aid-runtime-python 0.1.39__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. aid_runtime/__init__.py +500 -0
  2. aid_runtime/agents/__init__.py +94 -0
  3. aid_runtime/agents/chat_model.py +351 -0
  4. aid_runtime/agents/deep_agents.py +600 -0
  5. aid_runtime/agents/engine.py +102 -0
  6. aid_runtime/agents/guardrails.py +519 -0
  7. aid_runtime/agents/ir.py +622 -0
  8. aid_runtime/agents/loop.py +1308 -0
  9. aid_runtime/agents/memory.py +241 -0
  10. aid_runtime/agents/policy.py +163 -0
  11. aid_runtime/agents/state.py +182 -0
  12. aid_runtime/cache.py +185 -0
  13. aid_runtime/concurrency.py +70 -0
  14. aid_runtime/control.py +329 -0
  15. aid_runtime/cost.py +168 -0
  16. aid_runtime/errors.py +337 -0
  17. aid_runtime/evals/__init__.py +72 -0
  18. aid_runtime/evals/dataset.py +77 -0
  19. aid_runtime/evals/gate.py +430 -0
  20. aid_runtime/evals/metrics.py +335 -0
  21. aid_runtime/evals/predicate.py +211 -0
  22. aid_runtime/evals/runner.py +224 -0
  23. aid_runtime/gateway.py +473 -0
  24. aid_runtime/pipeline.py +168 -0
  25. aid_runtime/prompts.py +185 -0
  26. aid_runtime/providers/__init__.py +20 -0
  27. aid_runtime/providers/base.py +57 -0
  28. aid_runtime/providers/fake.py +256 -0
  29. aid_runtime/providers/http.py +344 -0
  30. aid_runtime/py.typed +0 -0
  31. aid_runtime/retrieval/__init__.py +178 -0
  32. aid_runtime/retrieval/chunking.py +222 -0
  33. aid_runtime/retrieval/citations.py +131 -0
  34. aid_runtime/retrieval/connectors.py +131 -0
  35. aid_runtime/retrieval/embedding.py +293 -0
  36. aid_runtime/retrieval/gate.py +120 -0
  37. aid_runtime/retrieval/hybrid.py +164 -0
  38. aid_runtime/retrieval/metrics.py +213 -0
  39. aid_runtime/retrieval/pgvector.py +258 -0
  40. aid_runtime/retrieval/rerank.py +133 -0
  41. aid_runtime/retrieval/retriever.py +335 -0
  42. aid_runtime/retrieval/stores.py +161 -0
  43. aid_runtime/retrieval/types.py +433 -0
  44. aid_runtime/steps.py +284 -0
  45. aid_runtime/structured.py +150 -0
  46. aid_runtime/tools.py +271 -0
  47. aid_runtime/tracing.py +241 -0
  48. aid_runtime/types.py +332 -0
  49. aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
  50. aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
  51. aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
  52. aid_runtime_python-0.1.39.dist-info/top_level.txt +1 -0
@@ -0,0 +1,102 @@
1
+ """The agent-engine seam: which backend runs an agent, and on exactly which release.
2
+
3
+ Deep Agents is an *orchestration backend* selected per agent, never the IR, never
4
+ the gateway, and never the only implementation. ``native`` stays the default; both
5
+ backends run the conformance corpus. This module owns the two facts that decide
6
+ whether a ``deepagents`` agent may start at all:
7
+
8
+ **The pin.** :data:`DEEPAGENTS_PIN` is the single authoritative record of the Deep
9
+ Agents release this runtime was built and measured against. It is a bare constant
10
+ rather than a range because a range is the thing pinning exists to prevent: the
11
+ harness's middleware stack, its recursion accounting and its interrupt payload all
12
+ move between releases, so an unpinned backend would change behaviour under a
13
+ stored eval baseline. ``pyproject.toml`` installs the same version, and
14
+ :func:`check_engine` compares the IR's ``engineVersion`` against *this* constant,
15
+ not against whatever wheel happens to be importable.
16
+
17
+ **Fail closed, in one place.** Every reason a ``deepagents`` agent cannot run --
18
+ unknown engine, transfer declared, missing pin, mismatched pin, missing extra --
19
+ is raised from :func:`check_engine`. Putting them together means there is one
20
+ function to read to know whether an engine is supported, and no second path that
21
+ could grow a silent fallback to the native loop. A runtime that quietly ran a
22
+ ``deepagents`` agent on the native engine would leave ``engine`` in the IR
23
+ meaning nothing at all.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from importlib.metadata import PackageNotFoundError
29
+ from importlib.metadata import version as distribution_version
30
+
31
+ from aid_runtime.agents.ir import AgentDefinition
32
+ from aid_runtime.errors import (
33
+ AgentHandoffUnsupportedError,
34
+ ConfigurationError,
35
+ DeepAgentsUnavailableError,
36
+ DeepAgentsVersionMismatchError,
37
+ )
38
+
39
+ __all__ = [
40
+ "DEEPAGENTS",
41
+ "DEEPAGENTS_PIN",
42
+ "ENGINES",
43
+ "NATIVE",
44
+ "check_engine",
45
+ "installed_deepagents_version",
46
+ ]
47
+
48
+ #: The default engine, and the one every existing agent keeps using.
49
+ NATIVE = "native"
50
+
51
+ #: The Deep Agents backend.
52
+ DEEPAGENTS = "deepagents"
53
+
54
+ #: The closed set of engines the IR may name.
55
+ ENGINES = frozenset({NATIVE, DEEPAGENTS})
56
+
57
+ #: The one authoritative pin. Measured from PyPI, mirrored in
58
+ #: ``pyproject.toml``'s ``deepagents`` extra, and compared against the IR's
59
+ #: ``engineVersion`` at run time.
60
+ DEEPAGENTS_PIN = "0.7.32"
61
+
62
+ #: The optional extra that installs the harness.
63
+ DEEPAGENTS_EXTRA = "aid-runtime-python[deepagents]"
64
+
65
+ #: The distribution the extra installs.
66
+ DEEPAGENTS_DISTRIBUTION = "deepagents"
67
+
68
+
69
+ def installed_deepagents_version() -> str | None:
70
+ """The installed ``deepagents`` version, or ``None`` when the extra is absent.
71
+
72
+ Deliberately read from installed metadata rather than by importing the
73
+ package: importing the harness would pull LangChain into every runtime that
74
+ merely *declares* the extra, and the version question is answerable without
75
+ paying for that.
76
+ """
77
+ try:
78
+ return distribution_version(DEEPAGENTS_DISTRIBUTION)
79
+ except PackageNotFoundError:
80
+ return None
81
+
82
+
83
+ def check_engine(agent: AgentDefinition) -> str:
84
+ """Validate this agent's engine, or raise the reason it cannot run.
85
+
86
+ Returns the validated engine name. ``native`` always passes; ``deepagents``
87
+ passes only when it declares no handoffs, pins ``engineVersion`` to the
88
+ version this runtime is built for, and the extra is actually installed.
89
+ """
90
+ if agent.engine == NATIVE:
91
+ return NATIVE
92
+ if agent.engine != DEEPAGENTS:
93
+ raise ConfigurationError(
94
+ f"agent {agent.id!r} declares engine {agent.engine!r}; the IR admits {sorted(ENGINES)}"
95
+ )
96
+ if agent.handoffs:
97
+ raise AgentHandoffUnsupportedError(agent.id, agent.handoffs, DEEPAGENTS)
98
+ if agent.engine_version != DEEPAGENTS_PIN:
99
+ raise DeepAgentsVersionMismatchError(agent.id, agent.engine_version, DEEPAGENTS_PIN)
100
+ if installed_deepagents_version() is None:
101
+ raise DeepAgentsUnavailableError(DEEPAGENTS_EXTRA, DEEPAGENTS_DISTRIBUTION)
102
+ return DEEPAGENTS
@@ -0,0 +1,519 @@
1
+ """The guardrails engine: the IR's ``guardrails`` collection, actually evaluated.
2
+
3
+ The IR already carries the *declaration* -- stages, policy kinds, an action, the
4
+ fields a redaction may touch. This module is the missing other half: something
5
+ that looks at text and decides. It is deliberately small and offline. Every
6
+ detector is a regular expression, so the answer for a given input does not depend
7
+ on a model, a network call, or a random seed, and a spec that passes here will
8
+ pass here again tomorrow.
9
+
10
+ Four stages, in the order a run meets them:
11
+
12
+ ``input``
13
+ before the model sees the user's turn.
14
+ ``retrieval``
15
+ around the chunks a retriever would splice into the prompt.
16
+ ``tool-call``
17
+ before a tool's handler body runs, over the call's arguments.
18
+ ``output``
19
+ after the model answers.
20
+
21
+ Three decisions this module owns, all of them recorded in ``docs/design.md`` §7.6:
22
+
23
+ **Undecidable is a block.** A guardrail naming a policy with no evaluator, or
24
+ whose ``redact`` fields do not cover the text it matched, raises
25
+ :class:`~aid_runtime.errors.GuardrailBlockedError`. There is no path from "the
26
+ check could not run" to "no violation found".
27
+
28
+ **Evaluation order is declaration order, except that ``block`` outranks it.** At
29
+ one stage the engine walks the agent's guardrails in the order the IR declared
30
+ them and collects every guardrail that fired. If *any* of them is a ``block``,
31
+ the first such guardrail decides -- a later ``flag`` cannot talk an earlier
32
+ ``block`` down. Otherwise the first guardrail that fired decides. Both halves are
33
+ pinned by tests: swapping the order changes which record is ``deciding`` while a
34
+ ``block`` keeps winning.
35
+
36
+ **``flag`` cannot be ignored, but it does not stop anything.** A flag is recorded
37
+ on the run (``RunResult.guardrails``) and on the ``aid.guardrail`` span event, and
38
+ the guarded text passes through unchanged. The consumer is whoever reads the run
39
+ result -- an eval, a log shipper, a reviewer. It is *not* an allow: a flag means a
40
+ detector matched, and the run carried on because the spec said the run may.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import re
46
+ from collections.abc import Mapping, Sequence
47
+ from dataclasses import dataclass, field
48
+ from typing import Any, Literal
49
+
50
+ from pydantic import BaseModel, ConfigDict
51
+
52
+ from aid_runtime.agents.ir import GuardrailDefinition
53
+ from aid_runtime.errors import ConfigurationError, GuardrailBlockedError
54
+
55
+ __all__ = [
56
+ "ACTION_BLOCK",
57
+ "ACTION_ESCALATE",
58
+ "ACTION_FLAG",
59
+ "ACTION_REDACT",
60
+ "DETECTORS",
61
+ "GUARDRAIL_ACTIONS",
62
+ "GUARDRAIL_STAGES",
63
+ "Detection",
64
+ "GuardrailEngine",
65
+ "GuardrailEscalation",
66
+ "GuardrailRecord",
67
+ "GuardrailVerdict",
68
+ "detect",
69
+ ]
70
+
71
+ GuardrailStage = Literal["input", "output", "retrieval", "tool-call"]
72
+ GuardrailAction = Literal["block", "redact", "flag", "escalate"]
73
+
74
+ ACTION_BLOCK: GuardrailAction = "block"
75
+ ACTION_REDACT: GuardrailAction = "redact"
76
+ ACTION_FLAG: GuardrailAction = "flag"
77
+ ACTION_ESCALATE: GuardrailAction = "escalate"
78
+
79
+ GUARDRAIL_STAGES: tuple[GuardrailStage, ...] = ("input", "retrieval", "tool-call", "output")
80
+ GUARDRAIL_ACTIONS: tuple[GuardrailAction, ...] = (
81
+ ACTION_BLOCK,
82
+ ACTION_REDACT,
83
+ ACTION_FLAG,
84
+ ACTION_ESCALATE,
85
+ )
86
+
87
+ #: What a redacted span is replaced with. Carrying the policy name means a log line
88
+ #: says *why* the text vanished, which a bare ``***`` does not.
89
+ _REDACTION = "[REDACTED:{policy}]"
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class Detection:
94
+ """One match: which policy fired, and the half-open span it fired on."""
95
+
96
+ policy: str
97
+ start: int
98
+ end: int
99
+
100
+
101
+ class GuardrailRecord(BaseModel):
102
+ """One guardrail that fired at one stage. The audit trail's unit."""
103
+
104
+ model_config = ConfigDict(frozen=True, extra="forbid")
105
+
106
+ guardrail: str
107
+ stage: GuardrailStage
108
+ action: GuardrailAction
109
+ policies: tuple[str, ...]
110
+ message: str
111
+
112
+
113
+ class GuardrailEscalation(BaseModel):
114
+ """A guardrail that entered human-in-the-loop instead of deciding by itself.
115
+
116
+ ``text`` is the guarded payload, kept so that approving an ``output``
117
+ escalation can release the model's answer without re-running the model -- and
118
+ so that a rejection can show the human what they rejected.
119
+ """
120
+
121
+ model_config = ConfigDict(frozen=True, extra="forbid")
122
+
123
+ guardrail: str
124
+ stage: GuardrailStage
125
+ message: str
126
+ channel: str = ""
127
+ text: str = ""
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class GuardrailVerdict:
132
+ """The engine's answer for one stage of one turn.
133
+
134
+ ``records`` is every guardrail that fired, not only the deciding one: a
135
+ ``flag`` next to a ``block`` still has to reach the audit trail. ``action`` and
136
+ ``deciding`` are the outcome; ``text`` and ``arguments`` are the payload after
137
+ any ``redact`` was applied.
138
+ """
139
+
140
+ stage: GuardrailStage
141
+ records: tuple[GuardrailRecord, ...] = ()
142
+ action: str | None = None
143
+ deciding: GuardrailRecord | None = None
144
+ text: str = ""
145
+ arguments: Mapping[str, Any] = field(default_factory=dict)
146
+
147
+ @property
148
+ def fired(self) -> bool:
149
+ """Whether any guardrail matched, whatever it decided to do about it."""
150
+ return bool(self.records)
151
+
152
+
153
+ def _phrase(*phrases: str) -> re.Pattern[str]:
154
+ joined = "|".join(re.escape(phrase) for phrase in phrases)
155
+ return re.compile(rf"\b(?:{joined})\b", re.IGNORECASE)
156
+
157
+
158
+ #: One entry per ``GuardrailPolicyKind`` in ``packages/ir/src/shapes.ts``. The keys
159
+ #: are closed on both sides: the IR gate rejects an unknown kind at build time and
160
+ #: :meth:`GuardrailEngine.check` rejects it again at run time.
161
+ #:
162
+ #: These are *heuristics*, and saying so is the honest description. They catch the
163
+ #: shapes a red-team corpus is built from -- an instruction-override phrase, a
164
+ #: well-formed email or card-like digit run, a jailbreak persona -- and they will
165
+ #: not catch a paraphrase. The corpus in ``tests/redteam`` measures that recall
166
+ #: against fixtures with recorded provenance instead of asserting it in prose.
167
+ DETECTORS: Mapping[str, tuple[re.Pattern[str], ...]] = {
168
+ "prompt-injection": (
169
+ _phrase(
170
+ "ignore previous instructions",
171
+ "ignore all previous instructions",
172
+ "ignore the previous instructions",
173
+ "ignore your instructions",
174
+ "disregard previous instructions",
175
+ "disregard your instructions",
176
+ "forget your instructions",
177
+ "override your instructions",
178
+ "ignore the system prompt",
179
+ "reveal the system prompt",
180
+ "reveal your system prompt",
181
+ "print the system prompt",
182
+ "show me your system prompt",
183
+ ),
184
+ re.compile(
185
+ r"\byou are now (?:a|an|the|in|no longer)\b",
186
+ re.IGNORECASE,
187
+ ),
188
+ re.compile(r"<\|\s*im_start\s*\|>", re.IGNORECASE),
189
+ ),
190
+ "jailbreak": (
191
+ _phrase(
192
+ "do anything now",
193
+ "developer mode",
194
+ "jailbreak mode",
195
+ "dan mode",
196
+ "without any restrictions",
197
+ "no ethical guidelines",
198
+ "no restrictions apply",
199
+ ),
200
+ re.compile(r"\bpretend (?:that )?you (?:have no|are not bound)", re.IGNORECASE),
201
+ re.compile(r"\bact as (?:an? )?(?:unrestricted|uncensored)\b", re.IGNORECASE),
202
+ ),
203
+ "pii-detect": (
204
+ re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
205
+ re.compile(r"(?<!\d)(?:\+?\d{1,3}[ -]?)?(?:\(\d{3}\)[ -]?|\d{3}[ -])\d{3}[ -]\d{4}(?!\d)"),
206
+ re.compile(r"(?<!\d)\d{3}-\d{2}-\d{4}(?!\d)"),
207
+ re.compile(r"(?<!\d)(?:\d[ -]?){13,19}(?!\d)"),
208
+ ),
209
+ "pii-redact": (
210
+ re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
211
+ re.compile(r"(?<!\d)(?:\+?\d{1,3}[ -]?)?(?:\(\d{3}\)[ -]?|\d{3}[ -])\d{3}[ -]\d{4}(?!\d)"),
212
+ re.compile(r"(?<!\d)\d{3}-\d{2}-\d{4}(?!\d)"),
213
+ re.compile(r"(?<!\d)(?:\d[ -]?){13,19}(?!\d)"),
214
+ ),
215
+ "secret-scan": (
216
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
217
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}\b"),
218
+ re.compile(r"\bsk-[A-Za-z0-9]{16,}\b"),
219
+ re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"),
220
+ re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
221
+ ),
222
+ "moderation": (
223
+ _phrase(
224
+ "how to build a bomb",
225
+ "how to make a bomb",
226
+ "build an explosive device",
227
+ "synthesize sarin",
228
+ "make nerve agent",
229
+ ),
230
+ ),
231
+ "toxicity": (
232
+ _phrase(
233
+ "kill yourself",
234
+ "go die",
235
+ "you should die",
236
+ "subhuman",
237
+ ),
238
+ ),
239
+ }
240
+
241
+ #: Policy kinds whose detection is about a *value* that a redaction can erase. The
242
+ #: others match intent, and masking an intent phrase would not make the request
243
+ #: safe -- which is why a ``redact`` guardrail over them fails closed below.
244
+ _REDACTABLE = frozenset({"pii-detect", "pii-redact", "secret-scan"})
245
+
246
+
247
+ def detect(policy: str, text: str) -> tuple[Detection, ...]:
248
+ """Every match of ``policy`` in ``text``, in document order.
249
+
250
+ Raise :class:`~aid_runtime.errors.ConfigurationError` for an unknown policy:
251
+ that is a caller bug, not untrusted input, and the engine reports it as a
252
+ block before it ever gets here.
253
+ """
254
+ patterns = DETECTORS.get(policy)
255
+ if patterns is None:
256
+ raise ConfigurationError(
257
+ f"no detector for guardrail policy {policy!r}; known policies: {sorted(DETECTORS)}"
258
+ )
259
+ hits: list[Detection] = []
260
+ for pattern in patterns:
261
+ hits.extend(
262
+ Detection(policy=policy, start=match.start(), end=match.end())
263
+ for match in pattern.finditer(text)
264
+ )
265
+ hits.sort(key=lambda hit: (hit.start, hit.end))
266
+ return tuple(hits)
267
+
268
+
269
+ def _mask(policy: str, text: str) -> str:
270
+ hits = detect(policy, text)
271
+ replacement = _REDACTION.format(policy=policy)
272
+ # Right to left, so an earlier replacement cannot move a later span's offsets.
273
+ for hit in reversed(hits):
274
+ text = f"{text[: hit.start]}{replacement}{text[hit.end :]}"
275
+ return text
276
+
277
+
278
+ def _texts(payload: Mapping[str, Any]) -> tuple[tuple[str, str], ...]:
279
+ """The ``(field, value)`` pairs a detector may look at.
280
+
281
+ Only strings: a detector that coerced an integer or a nested object to text
282
+ would fire on payloads a redaction cannot edit, and the redaction's fail-closed
283
+ re-check below would then block a run it could have repaired.
284
+ """
285
+ return tuple(
286
+ (name, value) for name, value in payload.items() if isinstance(value, str) and value
287
+ )
288
+
289
+
290
+ class GuardrailEngine:
291
+ """Evaluates an agent's guardrails at one stage.
292
+
293
+ Constructed once per runtime, from the document's resolved guardrail
294
+ definitions. It holds no state: the same engine answers the same question the
295
+ same way, which is what makes the corpus results reproducible.
296
+ """
297
+
298
+ def __init__(self, guardrails: Mapping[str, GuardrailDefinition] | None = None) -> None:
299
+ self._declared = dict(guardrails or {})
300
+
301
+ def declared(self, guardrail_id: str) -> GuardrailDefinition | None:
302
+ return self._declared.get(guardrail_id)
303
+
304
+ def check_input(
305
+ self,
306
+ guardrails: Sequence[GuardrailDefinition],
307
+ text: str,
308
+ ) -> GuardrailVerdict:
309
+ return self._check("input", guardrails, {"text": text}, tool_id=None)
310
+
311
+ def check_output(
312
+ self,
313
+ guardrails: Sequence[GuardrailDefinition],
314
+ text: str,
315
+ ) -> GuardrailVerdict:
316
+ return self._check("output", guardrails, {"text": text}, tool_id=None)
317
+
318
+ def check_retrieval(
319
+ self,
320
+ guardrails: Sequence[GuardrailDefinition],
321
+ results: Sequence[str],
322
+ ) -> GuardrailVerdict:
323
+ """Guard what a retriever would splice into the prompt.
324
+
325
+ Only the *results* are in the payload, not the query. The stage is about the
326
+ text a model is handed, and putting the user's question in the same payload
327
+ would let a ``redact`` guardrail over the ``text`` field fire on a violation
328
+ it cannot remove -- which fails closed as a block and hides the real cause.
329
+ The query's own checks belong to the ``input`` stage.
330
+ """
331
+ return self._check(
332
+ "retrieval",
333
+ guardrails,
334
+ {"text": "\n".join(results)},
335
+ tool_id=None,
336
+ )
337
+
338
+ def check_tool_call(
339
+ self,
340
+ guardrails: Sequence[GuardrailDefinition],
341
+ tool_id: str,
342
+ arguments: Mapping[str, Any],
343
+ ) -> GuardrailVerdict:
344
+ return self._check("tool-call", guardrails, dict(arguments), tool_id=tool_id)
345
+
346
+ def _check(
347
+ self,
348
+ stage: GuardrailStage,
349
+ guardrails: Sequence[GuardrailDefinition],
350
+ payload: Mapping[str, Any],
351
+ tool_id: str | None,
352
+ ) -> GuardrailVerdict:
353
+ applicable = [
354
+ guardrail
355
+ for guardrail in guardrails
356
+ if stage in guardrail.stages and _covers(guardrail, stage, tool_id)
357
+ ]
358
+ records: list[GuardrailRecord] = []
359
+ fired: list[GuardrailDefinition] = []
360
+ for guardrail in applicable:
361
+ policies = _firing_policies(guardrail, stage, payload)
362
+ if not policies:
363
+ continue
364
+ fired.append(guardrail)
365
+ records.append(
366
+ GuardrailRecord(
367
+ guardrail=guardrail.id,
368
+ stage=stage,
369
+ action=_action(guardrail),
370
+ policies=policies,
371
+ message=guardrail.message,
372
+ )
373
+ )
374
+
375
+ deciding = _decide(records)
376
+ if deciding is None:
377
+ return GuardrailVerdict(
378
+ stage=stage,
379
+ text=_text_of(payload),
380
+ arguments=dict(payload),
381
+ )
382
+
383
+ redacted = payload
384
+ if deciding.action == ACTION_REDACT:
385
+ redacted = _redact(
386
+ stage,
387
+ [guardrail for guardrail in fired if guardrail.action == ACTION_REDACT],
388
+ payload,
389
+ )
390
+ return GuardrailVerdict(
391
+ stage=stage,
392
+ records=tuple(records),
393
+ action=deciding.action,
394
+ deciding=deciding,
395
+ text=_text_of(redacted),
396
+ arguments=dict(redacted),
397
+ )
398
+
399
+
400
+ def _covers(guardrail: GuardrailDefinition, stage: str, tool_id: str | None) -> bool:
401
+ """Whether ``guardrail`` applies to this particular call.
402
+
403
+ ``tools`` is only meaningful on a ``tool-call`` guardrail, and an empty list
404
+ means "every tool that reaches the stage" -- absent and empty are the same
405
+ thing in the IR, and reading them differently here would be a second,
406
+ undocumented meaning for an empty array.
407
+ """
408
+ if stage != "tool-call" or not guardrail.tools:
409
+ return True
410
+ return tool_id is not None and tool_id in guardrail.tools
411
+
412
+
413
+ def _text_of(payload: Mapping[str, Any]) -> str:
414
+ value = payload.get("text")
415
+ return value if isinstance(value, str) else ""
416
+
417
+
418
+ def _firing_policies(
419
+ guardrail: GuardrailDefinition,
420
+ stage: str,
421
+ payload: Mapping[str, Any],
422
+ ) -> tuple[str, ...]:
423
+ texts = _texts(payload)
424
+ hits: list[str] = []
425
+ for policy in guardrail.policies:
426
+ if policy not in DETECTORS:
427
+ # Fail closed: a policy the engine cannot evaluate is a block, and it is
428
+ # reported with the guardrail that named it so an operator knows which
429
+ # declaration to fix.
430
+ raise GuardrailBlockedError(
431
+ guardrail.id,
432
+ stage,
433
+ f"policy {policy!r} has no evaluator in this runtime",
434
+ )
435
+ if any(detect(policy, text) for _, text in texts):
436
+ hits.append(policy)
437
+ return tuple(hits)
438
+
439
+
440
+ def _action(guardrail: GuardrailDefinition) -> GuardrailAction:
441
+ """The guardrail's action, refused if it is outside the closed vocabulary.
442
+
443
+ ``GuardrailDefinition.action`` is a ``str`` because the IR layer reads it out of
444
+ JSON, so this is where the closed set is enforced. It has to be enforced rather
445
+ than assumed: ``_decide`` looks for ``block`` by equality, so an unrecognised
446
+ action cannot decide a block and would otherwise fall through to whatever the
447
+ caller does with "not block" -- an allow nobody declared.
448
+ """
449
+ action = guardrail.action
450
+ if action not in GUARDRAIL_ACTIONS:
451
+ raise ConfigurationError(
452
+ f"guardrail {guardrail.id!r} declares unknown action {action!r}; "
453
+ f"expected one of {', '.join(GUARDRAIL_ACTIONS)}"
454
+ )
455
+ return action
456
+
457
+
458
+ def _decide(records: Sequence[GuardrailRecord]) -> GuardrailRecord | None:
459
+ """The deciding record: the first ``block``, else the first record declared.
460
+
461
+ ``block`` outranks declaration order deliberately. Ordering is about which of
462
+ two *comparable* actions wins, and "stop the run" is not comparable to "let it
463
+ continue with a note"; if declaration order could overrule a block, an author
464
+ could neuter a strict guardrail by declaring a permissive one first.
465
+
466
+ Only ``block`` is exempt. ``escalate``, ``redact`` and ``flag`` are comparable
467
+ with one another, so declaration order alone decides between them: a ``flag``
468
+ declared before an ``escalate`` flags and does not escalate, and a ``flag``
469
+ declared before a ``redact`` does not mask the payload -- ``_redact`` runs only
470
+ when the deciding action is ``redact``. That is intended: an unconditional
471
+ redaction pass would let a guardrail rewrite a payload under an action the
472
+ author did not select. The loser is still recorded, so a caller can see which
473
+ declaration decided and which merely fired.
474
+ """
475
+ for record in records:
476
+ if record.action == ACTION_BLOCK:
477
+ return record
478
+ return records[0] if records else None
479
+
480
+
481
+ def _redact(
482
+ stage: str,
483
+ guardrails: Sequence[GuardrailDefinition],
484
+ payload: Mapping[str, Any],
485
+ ) -> dict[str, Any]:
486
+ """Mask the declared fields, then prove nothing the detectors saw survived.
487
+
488
+ The re-check is the part that matters. A spec that declares ``redact: [answer]``
489
+ for a payload whose violation is in some other field would otherwise "redact"
490
+ successfully while shipping the violation, so this raises instead of returning
491
+ a payload it could not clean.
492
+ """
493
+ redacted = dict(payload)
494
+ for guardrail in guardrails:
495
+ for name in guardrail.redact:
496
+ value = redacted.get(name)
497
+ if not isinstance(value, str):
498
+ continue
499
+ for policy in guardrail.policies:
500
+ if policy not in _REDACTABLE:
501
+ raise GuardrailBlockedError(
502
+ guardrail.id,
503
+ stage,
504
+ f"policy {policy!r} detects intent, which masking field "
505
+ f"{name!r} cannot remove; use 'block', 'flag' or 'escalate'",
506
+ )
507
+ value = _mask(policy, value)
508
+ redacted[name] = value
509
+
510
+ for guardrail in guardrails:
511
+ remaining = _firing_policies(guardrail, stage, redacted)
512
+ if remaining:
513
+ raise GuardrailBlockedError(
514
+ guardrail.id,
515
+ stage,
516
+ f"redacting {list(guardrail.redact)} left a {remaining[0]!r} match; "
517
+ "the declared fields do not cover the payload",
518
+ )
519
+ return redacted