diff --git a/docs/docs/development/resource-architecture.md b/docs/docs/development/resource-architecture.md
index f9ea27b5..284f928f 100644
--- a/docs/docs/development/resource-architecture.md
+++ b/docs/docs/development/resource-architecture.md
@@ -45,12 +45,15 @@ The last two are not peers of the first four. Agent configuration is the leaf la
| `rules.txt` | Global behavioral instructions |
| `personality.yaml` | Tone and manner |
| `role.yaml` | Who the agent is and what it is for |
+| `guardrails.yaml` | Checks that constrain what the agent can say or do |
Rules are **always present in the prompt**, on every turn. They are not retrieved and not conditional, which makes them the right home for instructions that are unconditionally true — "always confirm the booking reference before making changes" — and the wrong home for facts, which would consume prompt space even when irrelevant to the current turn.
`personality.yaml` and `role.yaml` are narrower than rules: they accept only `{{attr:}}` and `{{vrbl:}}` references. Behavioral references such as `{{fn:}}` and `{{ho:}}` belong in `rules.txt`.
-See [agent settings](../reference/resources/agent_settings.md).
+`guardrails.yaml` covers the same ground as rules from the other side. A rule is an instruction in the prompt, which the model can still be talked out of; a guardrail is a check evaluated against the conversation, with its own action when it trips. That makes them easy to confuse — "never give medical advice" is a plausible entry in either. Write it as a rule first, and add a guardrail when testing shows the rule alone isn't holding. The platform also ships a fixed catalog of guardrails you can only toggle, covering the failure modes no prompt reliably prevents on its own, such as jailbreak attempts.
+
+See [agent settings](../reference/resources/agent_settings.md) and [guardrails](../reference/resources/guardrails.md).
### Knowledge base
@@ -234,6 +237,7 @@ See [voice settings](../reference/resources/voice_settings.md), [chat settings](
|---|---|
| A new FAQ, policy, or factual answer | Topic (`topics/`) |
| A global behavioral rule (always do X, never do Y) | `agent_settings/rules.txt` |
+| Enforcement for a rule the model keeps working around | Guardrail (`agent_settings/guardrails.yaml`) |
| Agent identity and tone | `agent_settings/personality.yaml` and `role.yaml` |
| A multi-step guided conversation | Flow (`flows/`) |
| Structured data collection from the caller | Entity + flow |
diff --git a/docs/docs/reference/resources.md b/docs/docs/reference/resources.md
index f6405631..9490d55b 100644
--- a/docs/docs/reference/resources.md
+++ b/docs/docs/reference/resources.md
@@ -16,6 +16,7 @@ Every resource here follows the same sync process, including [permission-gated v
| Resource | Configures | File |
|---|---|---|
| [Agent settings](./resources/agent_settings.md) | Personality, role, and global rules | `agent_settings/` |
+| [Guardrails](./resources/guardrails.md) | Platform and custom checks that constrain agent behavior | `agent_settings/guardrails.yaml` |
| [Languages](./resources/languages.md) | Supported languages for a multilingual agent | `agent_settings/languages.yaml` |
| [Experimental config](./resources/experimental_config.md) | Opt-in experimental platform features | `agent_settings/experimental_config.json` |
diff --git a/docs/docs/reference/resources/agent_settings.md b/docs/docs/reference/resources/agent_settings.md
index ce1cb724..d71bb51e 100644
--- a/docs/docs/reference/resources/agent_settings.md
+++ b/docs/docs/reference/resources/agent_settings.md
@@ -25,6 +25,7 @@ agent_settings/
├── personality.yaml
├── role.yaml
├── rules.txt
+├── guardrails.yaml # Optional
├── safety_filters.yaml # Optional
└── experimental_config.json # Optional
~~~
@@ -51,6 +52,12 @@ agent_settings/
Provides plain-text instructions the agent should follow on every turn.
+- **Guardrails**
+
+ ---
+
+ Platform and custom checks that constrain agent behavior at runtime.
+
- **Languages**
---
@@ -215,6 +222,12 @@ That kind of logic belongs in flows and Python functions.
- concise instructions that apply broadly
- deterministic logic handled in code or flow transitions
+## Guardrails
+
+The optional `guardrails.yaml` file configures platform and custom guardrails — runtime checks that constrain what the agent can say or do.
+
+See the [Guardrails reference](./guardrails.md) for full field descriptions, validation rules, and examples.
+
## Languages
The optional `languages.yaml` file configures which languages the agent supports. When present, it defines the default language and any additional languages.
@@ -245,6 +258,13 @@ See the [Safety filters reference](./safety_filters.md) for field descriptions,
Learn how referenced global functions are defined and used.
[Open functions](./functions.md)
+- **Guardrails**
+
+ ---
+
+ Configure platform and custom guardrails that constrain agent behavior.
+ [Open guardrails](./guardrails.md)
+
- **Languages**
---
diff --git a/docs/docs/reference/resources/guardrails.md b/docs/docs/reference/resources/guardrails.md
new file mode 100644
index 00000000..065af7a7
--- /dev/null
+++ b/docs/docs/reference/resources/guardrails.md
@@ -0,0 +1,142 @@
+---
+title: Guardrails
+description: Configure platform and custom guardrails that constrain agent behavior during a conversation.
+---
+
+# Guardrails
+
+
+Guardrails are runtime checks that constrain what the agent can say or do, catching problems a prompt or rule alone can't reliably prevent.
+
+
+There are two kinds: a fixed catalog of **platform guardrails** you can only toggle on or off, and **custom guardrails** you define yourself with a trigger condition and an action.
+
+## Location
+
+Both kinds of guardrail live in a single optional file:
+
+~~~text
+agent_settings/
+└── guardrails.yaml # Optional
+~~~
+
+## What guardrails control
+
+
+
+- **Platform guardrails**
+
+ ---
+
+ A fixed set of platform-provided checks. Only the `enabled` toggle can be changed.
+
+- **Custom guardrails**
+
+ ---
+
+ Your own rules: a prompt describing when the guardrail should trigger, and an action describing what happens when it does.
+
+
+
+## Platform guardrails
+
+!!! note "Fixed catalog — enable or disable only"
+ The catalog of platform guardrails is fixed by the platform. You can enable or disable each one, but you cannot create a new platform guardrail or delete an existing one via the ADK.
+
+### The catalog
+
+| Name | Description |
+|---|---|
+| `ai_identity` | Has the agent disclose that it's an AI when asked. |
+| `emergency_escalation` | Detects emergencies and escalates instead of continuing the conversation normally. |
+| `hallucination_control` | Reduces factually unsupported or made-up responses. |
+| `jailbreak_defence` | Detects and blocks attempts to override the agent's instructions or persona. |
+| `tool_call_integrity` | Checks that the agent's function/tool calls are well-formed and intended. |
+
+### Fields
+
+| Field | Description |
+|---|---|
+| `name` | One of the fixed catalog names above. |
+| `enabled` | `true` or `false`. Default: `true`. |
+
+### Example
+
+~~~yaml
+platform_guardrails:
+ - name: jailbreak_defence
+ enabled: true
+ - name: hallucination_control
+ enabled: false
+~~~
+
+## Custom guardrails
+
+Custom guardrails live under an optional `custom_guardrails` list in the same file. Unlike platform guardrails, they can be created, updated, and deleted via the ADK.
+
+### Fields
+
+| Field | Description |
+|---|---|
+| `name` | Display name for the guardrail. |
+| `prompt` | Describes the condition that triggers the guardrail. Free text — references are not evaluated here. |
+| `action` | Describes what the agent should do when the guardrail triggers, for example `warn`, or an instruction that calls a function, handoff, or SMS template. |
+| `enabled` | `true` or `false`. Default: `true`. |
+
+### Supported references in `action`
+
+`action` is the only field scanned for references — a reference written in `prompt` is treated as plain text.
+
+It accepts every prefix in the [resource references table](../../development/resource-architecture.md#resource-references) except two: flow transition functions (`{{ft:...}}`) and entities (`{{entity:...}}`) fail validation in a guardrail action.
+
+### Example
+
+~~~yaml
+custom_guardrails:
+ - name: No medical advice
+ enabled: true
+ action: warn
+ prompt: Never give medical advice. Offer to transfer the caller to a human instead.
+~~~
+
+## Validation
+
+Validation rejects a `guardrails.yaml` that doesn't satisfy these rules:
+
+- Every platform guardrail's `name` must be one of the fixed catalog names; anything else is rejected with the list of valid names.
+- Every platform guardrail in the fixed catalog must be present in the file — none can be missing, though any can be `enabled: false`.
+- Every platform and custom guardrail's `enabled` must be a boolean (`true`/`false`, unquoted).
+- A custom guardrail's `name`, `prompt`, and `action` are all required.
+- Any `{{prefix:name}}` reference in a custom guardrail's `action` must use one of the supported prefixes above, and must resolve to a resource that actually exists.
+
+## Best practices
+
+- Keep `prompt` focused on the trigger condition and `action` focused on the response; don't fold both into one field.
+- Disable a platform or custom guardrail with `enabled: false` instead of deleting it, so it's easy to re-enable later.
+
+## Related pages
+
+
+
+- **Safety filters**
+
+ ---
+
+ Content filtering on user input and agent output, configured per channel.
+ [Open safety filters](./safety_filters.md)
+
+- **Agent settings**
+
+ ---
+
+ Personality, role, and rules — the other resources that shape agent behavior.
+ [Open agent settings](./agent_settings.md)
+
+- **Functions**
+
+ ---
+
+ Global functions that a custom guardrail's action can call.
+ [Open functions](./functions.md)
+
+
diff --git a/docs/docs/reference/resources/safety_filters.md b/docs/docs/reference/resources/safety_filters.md
index 6573c3db..e0370490 100644
--- a/docs/docs/reference/resources/safety_filters.md
+++ b/docs/docs/reference/resources/safety_filters.md
@@ -185,6 +185,13 @@ The same settings can be configured in the Agent Studio UI. The platform docs co
Configure personality, role, and rules alongside project-level safety filters.
[Open agent settings](./agent_settings.md)
+- **Guardrails**
+
+ ---
+
+ Runtime checks that constrain agent behavior, rather than filtering content.
+ [Open guardrails](./guardrails.md)
+
- **Voice settings**
---
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 07c05455..95403fcf 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -142,6 +142,7 @@ nav:
- Resource reference:
- Overview: reference/resources.md
- Agent settings: reference/resources/agent_settings.md
+ - Guardrails: reference/resources/guardrails.md
- Topics: reference/resources/topics.md
- Functions: reference/resources/functions.md
- Flows: reference/resources/flows.md
diff --git a/src/poly/resources/__init__.py b/src/poly/resources/__init__.py
index 0023646a..4e448544 100644
--- a/src/poly/resources/__init__.py
+++ b/src/poly/resources/__init__.py
@@ -41,6 +41,7 @@
FunctionParameters,
FunctionType,
)
+from poly.resources.guardrails import CustomGuardrail, PlatformGuardrail
from poly.resources.handoff import (
Handoff,
HandoffSipConfig,
diff --git a/src/poly/resources/guardrails.py b/src/poly/resources/guardrails.py
new file mode 100644
index 00000000..a8f31fef
--- /dev/null
+++ b/src/poly/resources/guardrails.py
@@ -0,0 +1,342 @@
+"""Handling and managing Agent Studio Guardrails (platform and custom)
+
+Copyright PolyAI Limited
+"""
+
+import logging
+import os
+from dataclasses import dataclass
+from typing import ClassVar
+
+from google.protobuf.message import Message
+
+import poly.resources.resource_utils as utils
+from poly.handlers.protobuf.guardrails_pb2 import (
+ Guardrail,
+ GuardrailName,
+ Guardrails_CreateCustomGuardrail,
+ Guardrails_DeleteCustomGuardrail,
+ Guardrails_UpdateCustomGuardrail,
+ Guardrails_UpdateGuardrails,
+)
+from poly.resources.resource import MultiResourceYamlResource, ResourceMapping, register_resource
+
+GUARDRAILS_FILE = os.path.join("agent_settings", "guardrails.yaml")
+
+logger = logging.getLogger(__name__)
+
+CUSTOM_GUARDRAIL_REFERENCES = [
+ "global_functions",
+ "sms",
+ "handoff",
+ "attributes",
+ "variables",
+ "translations",
+]
+
+# Guardrail.name is a fixed platform enum (GuardrailName), not a free string.
+# Map it to/from a short lowercase form for the YAML/CLI-facing "id", e.g.
+# GUARDRAIL_NAME_JAILBREAK_DEFENCE <-> "jailbreak_defence".
+_GUARDRAIL_NAME_PREFIX = "GUARDRAIL_NAME_"
+
+
+def _guardrail_name_to_yaml(proto_name: str) -> str:
+ """Convert a GuardrailName enum string to its short YAML form."""
+ return proto_name.removeprefix(_GUARDRAIL_NAME_PREFIX).lower()
+
+
+def _guardrail_name_from_yaml(yaml_name: str) -> str:
+ """Convert a short YAML guardrail name back to its GuardrailName enum string."""
+ return f"{_GUARDRAIL_NAME_PREFIX}{yaml_name.upper()}"
+
+
+# The fixed catalog of real platform guardrails, as full GuardrailName enum
+# strings, excluding the GUARDRAIL_NAME_UNSPECIFIED sentinel. Computed once
+# here so every consumer agrees on what counts as a valid guardrail.
+_GUARDRAIL_CATALOG: tuple[str, ...] = tuple(
+ value.name
+ for value in GuardrailName.DESCRIPTOR.values
+ if value.name != "GUARDRAIL_NAME_UNSPECIFIED"
+)
+
+
+class _GuardrailYamlResource(MultiResourceYamlResource):
+ """Shared base for the guardrail resources stored in ``GUARDRAILS_FILE``.
+
+ Platform and custom guardrails live as separate top-level lists in the
+ same file, keyed by ``top_level_name`` — discovery is otherwise identical.
+ """
+
+ @classmethod
+ def discover_resources(cls, base_path: str) -> list[str]:
+ """Discover resources of this type in the given base path."""
+ yaml_path = os.path.join(base_path, GUARDRAILS_FILE)
+ discovered: list[str] = []
+
+ if not os.path.exists(yaml_path):
+ return discovered
+
+ yaml_dict = cls._get_top_level_data(yaml_path)
+ guardrails: list[dict] = yaml_dict.get(cls.top_level_name, []) if yaml_dict else []
+
+ for guardrail in guardrails:
+ name = guardrail.get("name")
+ if not name:
+ continue
+ clean_name = utils.clean_name(name, lowercase=False)
+ discovered.append(os.path.join(yaml_path, cls.top_level_name, clean_name))
+
+ return discovered
+
+
+@register_resource("platform_guardrails")
+@dataclass
+class PlatformGuardrail(_GuardrailYamlResource):
+ """Dataclass representing an Agent Studio platform guardrail's toggle state.
+
+ Platform guardrails are provided by the platform (the catalog of possible
+ guardrails is fixed) — only the ``enabled`` toggle can be updated locally.
+ """
+
+ enabled: bool = True
+ top_level_name: ClassVar[str] = "platform_guardrails"
+
+ @classmethod
+ def from_projection(cls, projection: dict) -> dict[str, "PlatformGuardrail"]:
+ """Parse platform guardrails from a projection dict.
+
+ guardrails.guardrails is a map keyed by the short GuardrailName enum
+ suffix (e.g. "JAILBREAK_DEFENCE", without the "GUARDRAIL_NAME_"
+ prefix), each value an object carrying an explicit `enabled` toggle.
+ Emit one resource per entry in the fixed catalog, defaulting to
+ enabled if the platform hasn't reported a state for it.
+ """
+ guardrails_section = projection.get("guardrails")
+ if not guardrails_section:
+ return {}
+ guardrails_map = guardrails_section.get("guardrails") or {}
+
+ guardrails = {}
+ for proto_name in _GUARDRAIL_CATALOG:
+ short_proto_name = proto_name.removeprefix(_GUARDRAIL_NAME_PREFIX)
+ entry = guardrails_map.get(short_proto_name, {})
+ if not isinstance(entry, dict):
+ logger.warning(
+ "Skipping platform guardrail projection entry of unexpected shape "
+ "(expected an object, got %s): %r",
+ type(entry).__name__,
+ entry,
+ )
+ entry = {}
+ name = _guardrail_name_to_yaml(proto_name)
+ guardrails[name] = cls(
+ resource_id=name,
+ name=name,
+ enabled=entry.get("enabled", True),
+ )
+ return guardrails
+
+ @property
+ def file_path(self) -> str:
+ """Get the file path for the platform guardrail."""
+ clean_name = utils.clean_name(self.name, lowercase=False)
+ return os.path.join(GUARDRAILS_FILE, self.top_level_name, clean_name)
+
+ def to_yaml_dict(self) -> dict:
+ """Return a dictionary suitable for YAML serialization."""
+ return {
+ "name": self.name,
+ "enabled": self.enabled,
+ }
+
+ @classmethod
+ def from_yaml_dict(
+ cls, yaml_dict: dict, resource_id: str, name: str, **kwargs
+ ) -> "PlatformGuardrail":
+ """Create an instance from YAML data and identity fields."""
+ resolved_name = yaml_dict.get("name") or name
+ return cls(
+ resource_id=resource_id,
+ name=resolved_name,
+ enabled=yaml_dict.get("enabled", True),
+ )
+
+ def validate(self, **kwargs) -> None:
+ """Validate the platform guardrail resource."""
+ if not self.name:
+ raise ValueError("Name is required")
+ if not isinstance(self.name, str):
+ raise ValueError(f"Invalid value {self.name!r} for 'name'. Must be a string.")
+ if not isinstance(self.enabled, bool):
+ raise ValueError(
+ f"Invalid value {self.enabled!r} for 'enabled'. Must be true or false (unquoted)."
+ )
+
+ proto_name = _guardrail_name_from_yaml(self.name)
+ if proto_name not in _GUARDRAIL_CATALOG:
+ valid_names = sorted(_guardrail_name_to_yaml(n) for n in _GUARDRAIL_CATALOG)
+ raise ValueError(
+ f"Unrecognised platform guardrail '{self.name}'. "
+ f"Must be one of: {', '.join(valid_names)}"
+ )
+
+ @classmethod
+ def validate_collection(cls, resources: dict[str, "PlatformGuardrail"]) -> None:
+ """Ensure every guardrail in the fixed platform catalog is present locally.
+
+ The catalog is fixed by the platform, so a missing entry means the local
+ file has drifted (e.g. a line was deleted by hand) rather than reflecting
+ a real platform state.
+ """
+ present_names = {guardrail.name for guardrail in resources.values()}
+ catalog_names = {_guardrail_name_to_yaml(n) for n in _GUARDRAIL_CATALOG}
+ missing = sorted(catalog_names - present_names)
+ if missing:
+ raise ValueError(
+ f"Missing platform guardrail(s) in {GUARDRAILS_FILE}: {', '.join(missing)}. "
+ "Run 'poly pull' to sync the full guardrail catalog."
+ )
+
+ @property
+ def command_type(self) -> str:
+ """Get the update type for updating the resource."""
+ return "guardrails"
+
+ def build_update_proto(self) -> Guardrails_UpdateGuardrails:
+ """Create a proto for updating the resource."""
+ return Guardrails_UpdateGuardrails(
+ guardrails=[Guardrail(name=_guardrail_name_from_yaml(self.name), enabled=self.enabled)]
+ )
+
+ def build_create_proto(self) -> Message:
+ """Create a proto for creating the resource."""
+ raise NotImplementedError("Create operation not supported for platform guardrails.")
+
+ def build_delete_proto(self) -> Message:
+ """Create a proto for deleting the resource."""
+ raise NotImplementedError("Delete operation not supported for platform guardrails.")
+
+
+@register_resource("custom_guardrails")
+@dataclass
+class CustomGuardrail(_GuardrailYamlResource):
+ """Dataclass representing an Agent Studio custom guardrail.
+
+ Stored as an optional ``custom_guardrails`` list in the same
+ ``agent_settings/guardrails.yaml`` file used by ``PlatformGuardrail``.
+ """
+
+ prompt: str
+ action: str
+ enabled: bool = True
+ top_level_name: ClassVar[str] = "custom_guardrails"
+
+ @classmethod
+ def from_projection(cls, projection: dict) -> dict[str, "CustomGuardrail"]:
+ """Parse custom guardrails from a projection dict."""
+ custom_guardrails = {}
+ for guardrail_id, guardrail in (
+ projection.get("guardrails", {}).get("customGuardrails", {}).get("entities", {}).items()
+ ):
+ if not isinstance(guardrail, dict):
+ logger.warning(
+ "Skipping custom guardrail projection entry of unexpected shape "
+ "(expected an object, got %s): %r",
+ type(guardrail).__name__,
+ guardrail,
+ )
+ continue
+ custom_guardrails[guardrail_id] = cls(
+ resource_id=guardrail_id,
+ name=guardrail.get("name", ""),
+ prompt=guardrail.get("prompt", ""),
+ action=guardrail.get("action", ""),
+ enabled=guardrail.get("enabled", True),
+ )
+ return custom_guardrails
+
+ @property
+ def file_path(self) -> str:
+ """Get the file path for the custom guardrail."""
+ clean_name = utils.clean_name(self.name, lowercase=False)
+ return os.path.join(GUARDRAILS_FILE, self.top_level_name, clean_name)
+
+ def to_yaml_dict(self) -> dict:
+ """Return a dictionary suitable for YAML serialization."""
+ return {
+ "name": self.name,
+ "enabled": self.enabled,
+ "action": self.action,
+ "prompt": self.prompt,
+ }
+
+ @classmethod
+ def to_pretty_dict(
+ cls, d: dict, resource_mappings: list[ResourceMapping] = None, **kwargs
+ ) -> dict:
+ """Return the pretty dictionary."""
+ d["action"] = utils.replace_resource_ids_with_names(d["action"], resource_mappings or [])
+ return d
+
+ @classmethod
+ def from_yaml_dict(
+ cls, yaml_dict: dict, resource_id: str, name: str, **kwargs
+ ) -> "CustomGuardrail":
+ """Create an instance from YAML data and identity fields."""
+ resolved_name = yaml_dict.get("name") or name
+ return cls(
+ resource_id=resource_id,
+ name=resolved_name,
+ prompt=yaml_dict.get("prompt", ""),
+ action=yaml_dict.get("action", ""),
+ enabled=yaml_dict.get("enabled", True),
+ )
+
+ def validate(self, resource_mappings: list = None, **kwargs) -> None:
+ """Validate the custom guardrail resource."""
+ if not self.name:
+ raise ValueError("Name is required")
+ if not self.prompt:
+ raise ValueError("Prompt is required")
+ if not self.action:
+ raise ValueError("Action is required")
+
+ references = utils.get_references_from_prompt(
+ self.action, CUSTOM_GUARDRAIL_REFERENCES, raise_on_invalid=True
+ )
+ valid, invalid_references = utils.validate_references(references, resource_mappings)
+ if not valid:
+ raise ValueError(f"Invalid references: {invalid_references}")
+
+ def build_create_proto(self) -> Guardrails_CreateCustomGuardrail:
+ """Create a proto for creating the resource."""
+ references = utils.get_references_from_prompt(self.action, CUSTOM_GUARDRAIL_REFERENCES)
+ return Guardrails_CreateCustomGuardrail(
+ id=self.resource_id,
+ name=self.name,
+ prompt=self.prompt,
+ action=self.action,
+ enabled=self.enabled,
+ references=references,
+ )
+
+ def build_update_proto(self) -> Guardrails_UpdateCustomGuardrail:
+ """Create a proto for updating the resource."""
+ references = utils.get_references_from_prompt(self.action, CUSTOM_GUARDRAIL_REFERENCES)
+ return Guardrails_UpdateCustomGuardrail(
+ id=self.resource_id,
+ name=self.name,
+ prompt=self.prompt,
+ action=self.action,
+ enabled=self.enabled,
+ references=references,
+ )
+
+ def build_delete_proto(self) -> Guardrails_DeleteCustomGuardrail:
+ """Create a proto for deleting the resource."""
+ return Guardrails_DeleteCustomGuardrail(id=self.resource_id)
+
+ @property
+ def command_type(self) -> str:
+ """Get the update type for updating the resource."""
+ return "custom_guardrail"
diff --git a/src/poly/tests/resources_test.py b/src/poly/tests/resources_test.py
index 7d9a1aaa..a43a7d46 100644
--- a/src/poly/tests/resources_test.py
+++ b/src/poly/tests/resources_test.py
@@ -61,6 +61,7 @@
FunctionParameters,
FunctionType,
)
+from poly.resources.guardrails import CustomGuardrail, PlatformGuardrail
from poly.resources.handoff import Handoff
from poly.resources.keyphrase_boosting import KeyphraseBoosting
from poly.resources.languages import (
@@ -3757,7 +3758,6 @@ def test_validate_entity(self):
self.assertIsNone(entity_without_config.validate())
-
TEST_FUNCTION_STEP_CODE = """def process_data(conv: Conversation, flow: Flow):
\"\"\"Process some data.\"\"\"
return "processed"
@@ -8404,6 +8404,773 @@ def test_validate_duplicate_with_default_raises(self):
self.assertIn("Duplicate language code", str(cm.exception))
+class PlatformGuardrailTests(unittest.TestCase):
+ """Tests for the PlatformGuardrail resource (toggles for platform-provided guardrails)."""
+
+ def setUp(self):
+ MultiResourceYamlResource._file_cache.clear()
+
+ def test_from_projection_reads_explicit_enabled_per_entry(self):
+ """guardrails.guardrails is a map keyed by short suffix, each an explicit toggle."""
+ projection = {
+ "guardrails": {
+ "guardrails": {
+ "JAILBREAK_DEFENCE": {"enabled": False},
+ "HALLUCINATION_CONTROL": {"enabled": True},
+ }
+ }
+ }
+ guardrails = PlatformGuardrail.from_projection(projection)
+ self.assertFalse(guardrails["jailbreak_defence"].enabled)
+ self.assertTrue(guardrails["hallucination_control"].enabled)
+
+ def test_from_projection_matches_the_real_account_payload(self):
+ """Regression test pinned to an actual observed projection payload."""
+ projection = {
+ "guardrails": {
+ "guardrails": {
+ "JAILBREAK_DEFENCE": {"enabled": False},
+ "HALLUCINATION_CONTROL": {"enabled": True},
+ "AI_IDENTITY": {"enabled": False},
+ "EMERGENCY_ESCALATION": {"enabled": True},
+ "TOOL_CALL_INTEGRITY": {"enabled": True},
+ }
+ }
+ }
+ guardrails = PlatformGuardrail.from_projection(projection)
+ self.assertEqual(
+ {name: g.enabled for name, g in guardrails.items()},
+ {
+ "jailbreak_defence": False,
+ "hallucination_control": True,
+ "ai_identity": False,
+ "emergency_escalation": True,
+ "tool_call_integrity": True,
+ },
+ )
+
+ def test_from_projection_emits_the_full_catalog(self):
+ """Every known guardrail gets a resource, even if absent from the map."""
+ projection = {"guardrails": {"guardrails": {"AI_IDENTITY": {"enabled": False}}}}
+ guardrails = PlatformGuardrail.from_projection(projection)
+ self.assertEqual(
+ set(guardrails),
+ {
+ "jailbreak_defence",
+ "hallucination_control",
+ "ai_identity",
+ "emergency_escalation",
+ "tool_call_integrity",
+ },
+ )
+ self.assertFalse(guardrails["ai_identity"].enabled)
+
+ def test_from_projection_defaults_missing_entries_to_enabled(self):
+ """A guardrail absent from the map defaults to enabled."""
+ projection = {"guardrails": {"guardrails": {"AI_IDENTITY": {"enabled": False}}}}
+ guardrails = PlatformGuardrail.from_projection(projection)
+ self.assertTrue(guardrails["jailbreak_defence"].enabled)
+
+ def test_from_projection_skips_non_object_entries_without_raising(self):
+ """A malformed (non-dict) map value is logged and skipped, defaulting to enabled."""
+ projection = {
+ "guardrails": {
+ "guardrails": {
+ "JAILBREAK_DEFENCE": "unexpected-bare-string",
+ "AI_IDENTITY": {"enabled": False},
+ }
+ }
+ }
+ with self.assertLogs("poly.resources.guardrails", level="WARNING"):
+ guardrails = PlatformGuardrail.from_projection(projection)
+ self.assertTrue(guardrails["jailbreak_defence"].enabled)
+ self.assertFalse(guardrails["ai_identity"].enabled)
+
+ def test_from_projection_no_guardrails_section_yields_nothing(self):
+ """When the projection has no guardrails section at all, no resources are emitted."""
+ self.assertEqual(PlatformGuardrail.from_projection({}), {})
+
+ def test_from_projection_empty_map_yields_all_enabled(self):
+ """An empty guardrails map still yields the full catalog, all enabled."""
+ projection = {"guardrails": {"guardrails": {}}}
+ guardrails = PlatformGuardrail.from_projection(projection)
+ self.assertEqual(len(guardrails), 5)
+ self.assertTrue(all(g.enabled for g in guardrails.values()))
+
+ def test_to_yaml_dict_from_yaml_dict_roundtrip(self):
+ """to_yaml_dict then from_yaml_dict preserves the name and toggle state."""
+ guardrail = PlatformGuardrail(
+ resource_id="hallucination_control", name="hallucination_control", enabled=False
+ )
+ yaml_dict = guardrail.to_yaml_dict()
+ self.assertEqual(yaml_dict, {"name": "hallucination_control", "enabled": False})
+
+ restored = PlatformGuardrail.from_yaml_dict(
+ yaml_dict, resource_id="hallucination_control", name="hallucination_control"
+ )
+ self.assertEqual(restored.name, guardrail.name)
+ self.assertEqual(restored.enabled, guardrail.enabled)
+
+ def test_from_yaml_dict_falls_back_to_identity_name(self):
+ """When the YAML has no name field, the identity name is used."""
+ guardrail = PlatformGuardrail.from_yaml_dict(
+ {"enabled": True}, resource_id="ai_identity", name="ai_identity"
+ )
+ self.assertEqual(guardrail.name, "ai_identity")
+
+ def test_file_path(self):
+ """All platform guardrails live in agent_settings/guardrails.yaml."""
+ guardrail = PlatformGuardrail(resource_id="jailbreak_defence", name="jailbreak_defence")
+ expected = os.path.join(
+ "agent_settings", "guardrails.yaml", "platform_guardrails", "jailbreak_defence"
+ )
+ self.assertEqual(guardrail.file_path, expected)
+
+ def test_command_type(self):
+ guardrail = PlatformGuardrail(resource_id="jailbreak_defence", name="jailbreak_defence")
+ self.assertEqual(guardrail.command_type, "guardrails")
+
+ def test_validate_passes_for_a_known_guardrail_name(self):
+ guardrail = PlatformGuardrail(
+ resource_id="emergency_escalation", name="emergency_escalation", enabled=False
+ )
+ self.assertIsNone(guardrail.validate())
+
+ def test_validate_unrecognised_name_raises_and_lists_valid_names(self):
+ """An unknown guardrail name is rejected with the list of valid options."""
+ guardrail = PlatformGuardrail(resource_id="made_up", name="made_up")
+ with self.assertRaises(ValueError) as cm:
+ guardrail.validate()
+ self.assertIn("Unrecognised platform guardrail 'made_up'", str(cm.exception))
+ self.assertIn("jailbreak_defence", str(cm.exception))
+
+ def test_validate_unspecified_sentinel_name_raises(self):
+ """The GUARDRAIL_NAME_UNSPECIFIED sentinel is not a real guardrail, so it is rejected."""
+ guardrail = PlatformGuardrail(resource_id="unspecified", name="unspecified", enabled=True)
+ with self.assertRaises(ValueError) as cm:
+ guardrail.validate()
+ self.assertIn("Unrecognised platform guardrail 'unspecified'", str(cm.exception))
+ # The sentinel is also absent from the list of valid options offered to the user.
+ valid_names = str(cm.exception).split("Must be one of: ")[1].split(", ")
+ self.assertNotIn("unspecified", valid_names)
+
+ def test_validate_empty_name_raises(self):
+ guardrail = PlatformGuardrail(resource_id="", name="")
+ with self.assertRaises(ValueError) as cm:
+ guardrail.validate()
+ self.assertIn("Name is required", str(cm.exception))
+
+ def test_validate_non_string_name_raises(self):
+ """An unquoted numeric name (e.g. `name: 123`) is rejected as a ValueError, not a crash."""
+ guardrail = PlatformGuardrail(resource_id="123", name=123, enabled=True)
+ with self.assertRaises(ValueError) as cm:
+ guardrail.validate()
+ self.assertIn("Invalid value 123 for 'name'", str(cm.exception))
+ self.assertIn("Must be a string", str(cm.exception))
+
+ def test_validate_quoted_enabled_raises(self):
+ """A YAML-quoted boolean ('true') is rejected with an actionable message."""
+ guardrail = PlatformGuardrail(resource_id="ai_identity", name="ai_identity", enabled="true")
+ with self.assertRaises(ValueError) as cm:
+ guardrail.validate()
+ self.assertIn("Must be true or false (unquoted)", str(cm.exception))
+
+ @staticmethod
+ def _catalog_names() -> set[str]:
+ """The fixed platform guardrail catalog, derived from the GuardrailName proto enum.
+
+ e.g. GUARDRAIL_NAME_JAILBREAK_DEFENCE -> "jailbreak_defence".
+ """
+ from poly.handlers.protobuf.guardrails_pb2 import GuardrailName
+
+ return {
+ value.name.removeprefix("GUARDRAIL_NAME_").lower()
+ for value in GuardrailName.DESCRIPTOR.values
+ if value.name != "GUARDRAIL_NAME_UNSPECIFIED"
+ }
+
+ @classmethod
+ def _full_collection(cls) -> dict:
+ """A complete local collection: one PlatformGuardrail per catalog entry."""
+ return {
+ name: PlatformGuardrail(resource_id=name, name=name) for name in cls._catalog_names()
+ }
+
+ def test_validate_collection_passes_when_whole_catalog_is_present(self):
+ """A collection covering every catalog guardrail is valid."""
+ self.assertIsNone(PlatformGuardrail.validate_collection(self._full_collection()))
+
+ def test_validate_collection_missing_one_guardrail_raises_naming_it(self):
+ """Deleting a single guardrail from the file is reported by name, with a fix."""
+ collection = self._full_collection()
+ self.assertIn("ai_identity", collection)
+ del collection["ai_identity"]
+
+ with self.assertRaises(ValueError) as cm:
+ PlatformGuardrail.validate_collection(collection)
+ message = str(cm.exception)
+ self.assertIn("Missing platform guardrail(s)", message)
+ self.assertIn("ai_identity", message)
+ self.assertIn("poly pull", message)
+
+ def test_validate_collection_missing_several_guardrails_names_all_of_them(self):
+ """Every missing guardrail is listed, not just the first one found."""
+ collection = self._full_collection()
+ for name in ("ai_identity", "jailbreak_defence"):
+ self.assertIn(name, collection)
+ del collection[name]
+
+ with self.assertRaises(ValueError) as cm:
+ PlatformGuardrail.validate_collection(collection)
+ message = str(cm.exception)
+ self.assertIn("ai_identity", message)
+ self.assertIn("jailbreak_defence", message)
+
+ def test_validate_collection_empty_raises_listing_the_full_catalog(self):
+ """An empty collection means the whole catalog has drifted away locally."""
+ with self.assertRaises(ValueError) as cm:
+ PlatformGuardrail.validate_collection({})
+ message = str(cm.exception)
+ for name in self._catalog_names():
+ self.assertIn(name, message)
+
+ def test_build_update_proto_maps_short_name_back_to_enum(self):
+ """The update proto carries a single Guardrail with the platform enum name."""
+ from poly.handlers.protobuf.guardrails_pb2 import GuardrailName
+
+ guardrail = PlatformGuardrail(
+ resource_id="jailbreak_defence", name="jailbreak_defence", enabled=False
+ )
+ proto = guardrail.build_update_proto()
+ self.assertEqual(len(proto.guardrails), 1)
+ self.assertEqual(proto.guardrails[0].name, GuardrailName.GUARDRAIL_NAME_JAILBREAK_DEFENCE)
+ self.assertFalse(proto.guardrails[0].enabled)
+
+ def test_build_create_proto_not_supported(self):
+ """Platform guardrails cannot be created — the catalog is fixed."""
+ guardrail = PlatformGuardrail(resource_id="ai_identity", name="ai_identity")
+ with self.assertRaises(NotImplementedError):
+ guardrail.build_create_proto()
+
+ def test_build_delete_proto_not_supported(self):
+ """Platform guardrails cannot be deleted — the catalog is fixed."""
+ guardrail = PlatformGuardrail(resource_id="ai_identity", name="ai_identity")
+ with self.assertRaises(NotImplementedError):
+ guardrail.build_delete_proto()
+
+ def test_discover_resources(self):
+ """discover_resources returns one path per entry in agent_settings/guardrails.yaml."""
+ base_path = os.path.join(os.path.dirname(__file__), "test_projects", "test_project")
+ discovered = PlatformGuardrail.discover_resources(base_path)
+ self.assertCountEqual(
+ discovered,
+ [
+ os.path.join(
+ base_path,
+ "agent_settings",
+ "guardrails.yaml",
+ "platform_guardrails",
+ name,
+ )
+ for name in (
+ "jailbreak_defence",
+ "hallucination_control",
+ "ai_identity",
+ "emergency_escalation",
+ "tool_call_integrity",
+ )
+ ],
+ )
+
+ def test_discover_resources_missing_file(self):
+ self.assertEqual(PlatformGuardrail.discover_resources("/nonexistent"), [])
+
+ def test_discover_resources_skips_nameless_entries(self):
+ """Entries without a name are skipped rather than producing an unnamed path."""
+ yaml_content = """platform_guardrails:
+- name: jailbreak_defence
+ enabled: true
+- enabled: false
+"""
+ base_path = "."
+ yaml_path = os.path.join(base_path, "agent_settings", "guardrails.yaml")
+
+ def exists_gr(p):
+ return yaml_path in str(p) or os.path.exists(p)
+
+ def isfile_gr(p):
+ return yaml_path in str(p) or os.path.isfile(p)
+
+ def getmtime_gr(p):
+ return 1.0 if yaml_path in str(p) else os.path.getmtime(p)
+
+ with mock_read_from_file({yaml_path: yaml_content}):
+ with (
+ unittest.mock.patch(
+ "poly.resources.guardrails.os.path.exists", side_effect=exists_gr
+ ),
+ unittest.mock.patch(
+ "poly.resources.resource.os.path.exists", side_effect=exists_gr
+ ),
+ unittest.mock.patch(
+ "poly.resources.resource.os.path.isfile", side_effect=isfile_gr
+ ),
+ unittest.mock.patch(
+ "poly.resources.resource.os.path.getmtime", side_effect=getmtime_gr
+ ),
+ ):
+ discovered = PlatformGuardrail.discover_resources(base_path)
+ self.assertEqual(len(discovered), 1)
+ self.assertIn("jailbreak_defence", discovered[0])
+
+
+class CustomGuardrailTests(unittest.TestCase):
+ """Tests for the CustomGuardrail resource.
+
+ Custom guardrails share agent_settings/guardrails.yaml with platform guardrails,
+ living under an optional ``custom_guardrails`` top-level list.
+ """
+
+ def setUp(self):
+ MultiResourceYamlResource._file_cache.clear()
+
+ @staticmethod
+ def _sample_guardrail() -> CustomGuardrail:
+ return CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-no_medical_advice",
+ name="No medical advice",
+ prompt="Never give medical advice. Offer to transfer the caller to a human instead.",
+ action="warn",
+ )
+
+ @staticmethod
+ def _discover_from_yaml(yaml_content: str) -> list[str]:
+ """Run discover_resources against an in-memory agent_settings/guardrails.yaml."""
+ yaml_path = os.path.join(".", "agent_settings", "guardrails.yaml")
+ with mock_read_from_file({yaml_path: yaml_content}):
+ with (
+ unittest.mock.patch("poly.resources.guardrails.os.path.exists", return_value=True),
+ unittest.mock.patch("poly.resources.resource.os.path.exists", return_value=True),
+ unittest.mock.patch("poly.resources.resource.os.path.isfile", return_value=True),
+ unittest.mock.patch("poly.resources.resource.os.path.getmtime", return_value=1.0),
+ ):
+ return CustomGuardrail.discover_resources(".")
+
+ def test_from_projection_parses_all_fields(self):
+ """Custom guardrails are keyed by their entity-map id, like topics/entities."""
+ projection = {
+ "guardrails": {
+ "customGuardrails": {
+ "entities": {
+ "CUSTOM_GUARDRAILS-1": {
+ "name": "No medical advice",
+ "prompt": "Never give medical advice.",
+ "action": "warn",
+ "enabled": False,
+ }
+ }
+ }
+ }
+ }
+ guardrails = CustomGuardrail.from_projection(projection)
+ guardrail = guardrails["CUSTOM_GUARDRAILS-1"]
+ self.assertEqual(guardrail.resource_id, "CUSTOM_GUARDRAILS-1")
+ self.assertEqual(guardrail.name, "No medical advice")
+ self.assertEqual(guardrail.prompt, "Never give medical advice.")
+ self.assertEqual(guardrail.action, "warn")
+ self.assertFalse(guardrail.enabled)
+
+ def test_from_projection_matches_the_real_account_payload(self):
+ """Regression test pinned to an actual observed customGuardrails payload.
+
+ Also includes the 'ids' sibling key the real payload carries alongside
+ 'entities' — it should be ignored, not treated as an entry.
+ """
+ projection = {
+ "guardrails": {
+ "customGuardrails": {
+ "ids": ["5ee46d81-99bc-4fc9-8046-e517948134a4"],
+ "entities": {
+ "5ee46d81-99bc-4fc9-8046-e517948134a4": {
+ "id": "5ee46d81-99bc-4fc9-8046-e517948134a4",
+ "name": "Customer Information",
+ "prompt": (
+ "Triggerswhenever you are about to repeat customer information"
+ ),
+ "action": "Call {{fn:default-function}}",
+ "enabled": True,
+ "references": {
+ "sms": {},
+ "handoff": {},
+ "attributes": {},
+ "globalFunctions": {"default-function": True},
+ "variables": {},
+ "translations": {},
+ },
+ "createdAt": "2026-08-18T14:54:52.431Z",
+ "createdBy": "",
+ "updatedAt": "2026-08-18T14:54:52.431Z",
+ "updatedBy": "",
+ }
+ },
+ }
+ }
+ }
+ guardrails = CustomGuardrail.from_projection(projection)
+ self.assertEqual(list(guardrails), ["5ee46d81-99bc-4fc9-8046-e517948134a4"])
+ guardrail = guardrails["5ee46d81-99bc-4fc9-8046-e517948134a4"]
+ self.assertEqual(guardrail.name, "Customer Information")
+ self.assertEqual(guardrail.action, "Call {{fn:default-function}}")
+ self.assertTrue(guardrail.enabled)
+
+ def test_from_projection_parses_multiple_entities(self):
+ """Each key in the entities map becomes its own guardrail resource."""
+ projection = {
+ "guardrails": {
+ "customGuardrails": {
+ "entities": {
+ "CUSTOM_GUARDRAILS-1": {"name": "First"},
+ "CUSTOM_GUARDRAILS-2": {"name": "Second"},
+ }
+ }
+ }
+ }
+ guardrails = CustomGuardrail.from_projection(projection)
+ self.assertEqual(set(guardrails), {"CUSTOM_GUARDRAILS-1", "CUSTOM_GUARDRAILS-2"})
+
+ def test_from_projection_defaults_missing_fields(self):
+ """Fields absent from the projection fall back to empty strings and enabled=True."""
+ projection = {"guardrails": {"customGuardrails": {"entities": {"CUSTOM_GUARDRAILS-1": {}}}}}
+ guardrail = CustomGuardrail.from_projection(projection)["CUSTOM_GUARDRAILS-1"]
+ self.assertEqual(guardrail.name, "")
+ self.assertEqual(guardrail.prompt, "")
+ self.assertEqual(guardrail.action, "")
+ self.assertTrue(guardrail.enabled)
+
+ def test_from_projection_skips_non_object_entries_without_raising(self):
+ """A malformed (non-dict) entity value is logged and skipped, not a crash."""
+ projection = {
+ "guardrails": {
+ "customGuardrails": {
+ "entities": {
+ "CUSTOM_GUARDRAILS-BAD": "unexpected-bare-string",
+ "CUSTOM_GUARDRAILS-1": {"name": "Kept"},
+ }
+ }
+ }
+ }
+ with self.assertLogs("poly.resources.guardrails", level="WARNING"):
+ guardrails = CustomGuardrail.from_projection(projection)
+ self.assertEqual(list(guardrails), ["CUSTOM_GUARDRAILS-1"])
+
+ def test_from_projection_empty_projection_yields_no_guardrails(self):
+ self.assertEqual(CustomGuardrail.from_projection({}), {})
+
+ def test_to_yaml_dict_from_yaml_dict_roundtrip(self):
+ """to_yaml_dict then from_yaml_dict preserves every field."""
+ guardrail = self._sample_guardrail()
+ yaml_dict = guardrail.to_yaml_dict()
+ self.assertEqual(yaml_dict["name"], "No medical advice")
+ self.assertEqual(yaml_dict["action"], "warn")
+ self.assertTrue(yaml_dict["enabled"])
+
+ restored = CustomGuardrail.from_yaml_dict(
+ yaml_dict,
+ resource_id="CUSTOM_GUARDRAILS-no_medical_advice",
+ name="No medical advice",
+ )
+ self.assertEqual(restored.name, guardrail.name)
+ self.assertEqual(restored.prompt, guardrail.prompt)
+ self.assertEqual(restored.action, guardrail.action)
+ self.assertEqual(restored.enabled, guardrail.enabled)
+
+ def test_to_pretty_replaces_a_function_id_in_action_with_its_name(self):
+ """On pull, the raw ID in 'action' is swapped for the human-readable name."""
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="Escalate",
+ prompt="Trigger when the caller asks for a doctor.",
+ action="Call {{fn:func-123}}",
+ )
+ resource_mappings = [
+ ResourceMapping(
+ resource_id="func-123",
+ resource_name="escalate",
+ resource_type=Function,
+ file_path="functions/escalate.py",
+ flow_name=None,
+ resource_prefix="fn",
+ )
+ ]
+ pretty_content = guardrail.to_pretty(resource_mappings=resource_mappings)
+ self.assertIn("{{fn:escalate}}", pretty_content)
+ self.assertNotIn("{{fn:func-123}}", pretty_content)
+
+ def test_to_pretty_with_no_resource_mappings_leaves_ids_unchanged(self):
+ """With nothing to map against, the action passes through verbatim."""
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="Escalate",
+ prompt="Trigger when the caller asks for a doctor.",
+ action="Call {{fn:func-123}}",
+ )
+ pretty_content = guardrail.to_pretty(resource_mappings=[])
+ self.assertIn("{{fn:func-123}}", pretty_content)
+
+ def test_to_pretty_leaves_the_prompt_field_untouched(self):
+ """Only 'action' carries references, so a reference-shaped token in
+ 'prompt' keeps its raw ID even when that ID is mapped."""
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="Escalate",
+ prompt="This mentions {{fn:func-123}} but it's just prose.",
+ action="warn",
+ )
+ resource_mappings = [
+ ResourceMapping(
+ resource_id="func-123",
+ resource_name="escalate",
+ resource_type=Function,
+ file_path="functions/escalate.py",
+ flow_name=None,
+ resource_prefix="fn",
+ )
+ ]
+ pretty_content = guardrail.to_pretty(resource_mappings=resource_mappings)
+ self.assertIn("{{fn:func-123}} but it's just prose.", pretty_content)
+
+ def test_to_pretty_from_pretty_roundtrip_restores_the_raw_yaml(self):
+ """Names written on pull are turned back into IDs on push."""
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="Escalate",
+ prompt="Trigger when the caller asks for a doctor.",
+ action="Call {{fn:func-123}}",
+ )
+ resource_mappings = [
+ ResourceMapping(
+ resource_id="func-123",
+ resource_name="escalate",
+ resource_type=Function,
+ file_path="functions/escalate.py",
+ flow_name=None,
+ resource_prefix="fn",
+ )
+ ]
+ pretty_content = guardrail.to_pretty(resource_mappings=resource_mappings)
+ reverted = CustomGuardrail.from_pretty(pretty_content, resource_mappings=resource_mappings)
+ self.assertEqual(reverted, guardrail.raw)
+
+ def test_file_path_and_command_type(self):
+ """Custom guardrails address a named entry inside the shared guardrails.yaml."""
+ guardrail = self._sample_guardrail()
+ expected = os.path.join(
+ "agent_settings", "guardrails.yaml", "custom_guardrails", "No_medical_advice"
+ )
+ self.assertEqual(guardrail.file_path, expected)
+ self.assertEqual(guardrail.command_type, "custom_guardrail")
+
+ def test_validate_passes_with_no_references(self):
+ self.assertIsNone(self._sample_guardrail().validate(resource_mappings=[]))
+
+ def test_validate_missing_name_raises(self):
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1", name="", prompt="A prompt", action="warn"
+ )
+ with self.assertRaises(ValueError) as cm:
+ guardrail.validate(resource_mappings=[])
+ self.assertIn("Name is required", str(cm.exception))
+
+ def test_validate_missing_prompt_raises(self):
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1", name="No medical advice", prompt="", action="warn"
+ )
+ with self.assertRaises(ValueError) as cm:
+ guardrail.validate(resource_mappings=[])
+ self.assertIn("Prompt is required", str(cm.exception))
+
+ def test_validate_missing_action_raises(self):
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="No medical advice",
+ prompt="A prompt",
+ action="",
+ )
+ with self.assertRaises(ValueError) as cm:
+ guardrail.validate(resource_mappings=[])
+ self.assertIn("Action is required", str(cm.exception))
+
+ def test_validate_passes_with_a_known_function_reference(self):
+ """References only ever live in 'action', never 'prompt'."""
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="Escalate",
+ prompt="Trigger when the caller asks for a doctor.",
+ action="Call {{fn:func-123}}",
+ )
+ resource_mappings = [
+ ResourceMapping(
+ resource_id="func-123",
+ resource_name="escalate",
+ resource_type=Function,
+ file_path="functions/escalate.py",
+ flow_name=None,
+ resource_prefix="fn",
+ )
+ ]
+ self.assertIsNone(guardrail.validate(resource_mappings=resource_mappings))
+
+ def test_validate_unknown_function_reference_raises(self):
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="Escalate",
+ prompt="Trigger when the caller asks for a doctor.",
+ action="Call {{fn:func-missing}}",
+ )
+ with self.assertRaises(ValueError) as cm:
+ guardrail.validate(resource_mappings=[])
+ self.assertIn("Invalid references: ['global_functions: func-missing']", str(cm.exception))
+
+ def test_validate_transition_function_reference_type_raises(self):
+ """Flow transition functions ({{ft:...}}) are not valid in a guardrail action."""
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="Escalate",
+ prompt="Trigger when the caller asks for a doctor.",
+ action="Go to {{ft:step-1}}",
+ )
+ with self.assertRaises(ValueError) as cm:
+ guardrail.validate(resource_mappings=[])
+ self.assertIn("Invalid reference type: transition_functions", str(cm.exception))
+
+ def test_validate_ignores_reference_syntax_in_the_prompt_field(self):
+ """A reference-shaped token in 'prompt' is never scanned — only 'action' is.
+
+ The prompt below embeds a reference to a function that ISN'T in
+ resource_mappings; if prompt were scanned this would raise. It doesn't,
+ because only 'action' (whose own reference IS mapped) is scanned.
+ """
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="Escalate",
+ prompt="This mentions {{fn:not-a-real-function}} but it's just prose.",
+ action="Call {{fn:func-123}}",
+ )
+ resource_mappings = [
+ ResourceMapping(
+ resource_id="func-123",
+ resource_name="escalate",
+ resource_type=Function,
+ file_path="functions/escalate.py",
+ flow_name=None,
+ resource_prefix="fn",
+ )
+ ]
+ self.assertIsNone(guardrail.validate(resource_mappings=resource_mappings))
+
+ def test_build_create_proto_includes_fields_and_references(self):
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="No medical advice",
+ prompt="Never give medical advice.",
+ action="Call {{fn:func-123}} instead of giving advice.",
+ enabled=False,
+ )
+ proto = guardrail.build_create_proto()
+ self.assertEqual(proto.id, "CUSTOM_GUARDRAILS-1")
+ self.assertEqual(proto.name, "No medical advice")
+ self.assertEqual(proto.prompt, "Never give medical advice.")
+ self.assertEqual(proto.action, "Call {{fn:func-123}} instead of giving advice.")
+ self.assertFalse(proto.enabled)
+ self.assertTrue(proto.references.global_functions["func-123"])
+
+ def test_build_update_proto_includes_fields_and_references(self):
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="No medical advice",
+ prompt="Apologise first.",
+ action="Use {{tn:TN-greeting}} to apologise first.",
+ )
+ proto = guardrail.build_update_proto()
+ self.assertEqual(proto.id, "CUSTOM_GUARDRAILS-1")
+ self.assertEqual(proto.action, "Use {{tn:TN-greeting}} to apologise first.")
+ self.assertTrue(proto.enabled)
+ self.assertTrue(proto.references.translations["TN-greeting"])
+
+ def test_build_create_proto_includes_a_reference_from_the_action_field(self):
+ """Regression test: a reference living only in 'action' is still sent."""
+ guardrail = CustomGuardrail(
+ resource_id="CUSTOM_GUARDRAILS-1",
+ name="Customer Information",
+ prompt="Triggers whenever you are about to repeat customer information",
+ action="Call {{fn:default-function}}",
+ )
+ proto = guardrail.build_create_proto()
+ self.assertTrue(proto.references.global_functions["default-function"])
+
+ def test_build_delete_proto_only_sets_the_id(self):
+ proto = self._sample_guardrail().build_delete_proto()
+ self.assertEqual(proto.id, "CUSTOM_GUARDRAILS-no_medical_advice")
+
+ def test_read_local_resource_reads_the_named_entry_from_the_shared_file(self):
+ """Reading picks the custom_guardrails entry whose name matches the path segment."""
+ base_path = os.path.join(os.path.dirname(__file__), "test_projects", "test_project")
+ file_path = os.path.join(
+ base_path, "agent_settings", "guardrails.yaml", "custom_guardrails", "No_medical_advice"
+ )
+ guardrail = CustomGuardrail.read_local_resource(
+ file_path=file_path,
+ resource_id="CUSTOM_GUARDRAILS-no_medical_advice",
+ resource_name="No medical advice",
+ )
+ self.assertEqual(guardrail.name, "No medical advice")
+ self.assertEqual(guardrail.action, "warn")
+ self.assertTrue(guardrail.enabled)
+ self.assertIn("Never give medical advice", guardrail.prompt)
+
+ def test_discover_resources(self):
+ """discover_resources returns one path per custom_guardrails entry in the shared file."""
+ base_path = os.path.join(os.path.dirname(__file__), "test_projects", "test_project")
+ discovered = CustomGuardrail.discover_resources(base_path)
+ self.assertEqual(
+ discovered,
+ [
+ os.path.join(
+ base_path,
+ "agent_settings",
+ "guardrails.yaml",
+ "custom_guardrails",
+ "No_medical_advice",
+ )
+ ],
+ )
+
+ def test_discover_resources_missing_file(self):
+ self.assertEqual(CustomGuardrail.discover_resources("/nonexistent"), [])
+
+ def test_discover_resources_file_without_custom_guardrails_section(self):
+ """A guardrails.yaml holding only platform guardrails yields no custom guardrails."""
+ yaml_content = """platform_guardrails:
+- name: jailbreak_defence
+ enabled: true
+"""
+ self.assertEqual(self._discover_from_yaml(yaml_content), [])
+
+ def test_discover_resources_skips_nameless_entries(self):
+ """Entries without a name are skipped rather than producing an unnamed path."""
+ yaml_content = """custom_guardrails:
+- name: No medical advice
+ enabled: true
+ action: warn
+ prompt: Never give medical advice.
+- action: warn
+ prompt: A guardrail someone forgot to name.
+"""
+ discovered = self._discover_from_yaml(yaml_content)
+ self.assertEqual(len(discovered), 1)
+ self.assertIn("No_medical_advice", discovered[0])
+
+
class ValidateWebchatSiblingsTests(unittest.TestCase):
"""Tests for validate_webchat_siblings in resource_utils."""
@@ -8823,9 +9590,7 @@ def test_skips_document_without_content(self):
def test_keeps_document_with_empty_content(self):
"""An empty 'content' is a readable but empty document, not a permission failure."""
projection = {
- "documents": {
- "documents": {"entities": {"DOC-1": {"path": "empty.md", "content": ""}}}
- }
+ "documents": {"documents": {"entities": {"DOC-1": {"path": "empty.md", "content": ""}}}}
}
documents = Document.from_projection(projection)
self.assertEqual(list(documents), ["DOC-1"])
diff --git a/src/poly/tests/test_projects/test_project/agent_settings/guardrails.yaml b/src/poly/tests/test_projects/test_project/agent_settings/guardrails.yaml
new file mode 100644
index 00000000..5b694e13
--- /dev/null
+++ b/src/poly/tests/test_projects/test_project/agent_settings/guardrails.yaml
@@ -0,0 +1,16 @@
+platform_guardrails:
+- name: jailbreak_defence
+ enabled: true
+- name: hallucination_control
+ enabled: false
+- name: ai_identity
+ enabled: true
+- name: emergency_escalation
+ enabled: true
+- name: tool_call_integrity
+ enabled: false
+custom_guardrails:
+- name: No medical advice
+ enabled: true
+ action: warn
+ prompt: Never give medical advice. Offer to transfer the caller to a human instead.
diff --git a/src/poly/tests/test_projects/test_project/test_project.json b/src/poly/tests/test_projects/test_project/test_project.json
index 25f02077..1fc17613 100644
--- a/src/poly/tests/test_projects/test_project/test_project.json
+++ b/src/poly/tests/test_projects/test_project/test_project.json
@@ -1305,6 +1305,42 @@
"path": "test_document.md",
"contents": "This is a test document.\nIt has multiple lines.\n"
}
+ },
+ "platform_guardrails": {
+ "jailbreak_defence": {
+ "resource_id": "jailbreak_defence",
+ "name": "jailbreak_defence",
+ "enabled": true
+ },
+ "hallucination_control": {
+ "resource_id": "hallucination_control",
+ "name": "hallucination_control",
+ "enabled": false
+ },
+ "ai_identity": {
+ "resource_id": "ai_identity",
+ "name": "ai_identity",
+ "enabled": true
+ },
+ "emergency_escalation": {
+ "resource_id": "emergency_escalation",
+ "name": "emergency_escalation",
+ "enabled": true
+ },
+ "tool_call_integrity": {
+ "resource_id": "tool_call_integrity",
+ "name": "tool_call_integrity",
+ "enabled": false
+ }
+ },
+ "custom_guardrails": {
+ "CUSTOM_GUARDRAILS-no_medical_advice": {
+ "resource_id": "CUSTOM_GUARDRAILS-no_medical_advice",
+ "name": "No medical advice",
+ "prompt": "Never give medical advice. Offer to transfer the caller to a human instead.",
+ "action": "warn",
+ "enabled": true
+ }
}
},
"file_structure_info": {},