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": {},