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.
- aid_runtime/__init__.py +500 -0
- aid_runtime/agents/__init__.py +94 -0
- aid_runtime/agents/chat_model.py +351 -0
- aid_runtime/agents/deep_agents.py +600 -0
- aid_runtime/agents/engine.py +102 -0
- aid_runtime/agents/guardrails.py +519 -0
- aid_runtime/agents/ir.py +622 -0
- aid_runtime/agents/loop.py +1308 -0
- aid_runtime/agents/memory.py +241 -0
- aid_runtime/agents/policy.py +163 -0
- aid_runtime/agents/state.py +182 -0
- aid_runtime/cache.py +185 -0
- aid_runtime/concurrency.py +70 -0
- aid_runtime/control.py +329 -0
- aid_runtime/cost.py +168 -0
- aid_runtime/errors.py +337 -0
- aid_runtime/evals/__init__.py +72 -0
- aid_runtime/evals/dataset.py +77 -0
- aid_runtime/evals/gate.py +430 -0
- aid_runtime/evals/metrics.py +335 -0
- aid_runtime/evals/predicate.py +211 -0
- aid_runtime/evals/runner.py +224 -0
- aid_runtime/gateway.py +473 -0
- aid_runtime/pipeline.py +168 -0
- aid_runtime/prompts.py +185 -0
- aid_runtime/providers/__init__.py +20 -0
- aid_runtime/providers/base.py +57 -0
- aid_runtime/providers/fake.py +256 -0
- aid_runtime/providers/http.py +344 -0
- aid_runtime/py.typed +0 -0
- aid_runtime/retrieval/__init__.py +178 -0
- aid_runtime/retrieval/chunking.py +222 -0
- aid_runtime/retrieval/citations.py +131 -0
- aid_runtime/retrieval/connectors.py +131 -0
- aid_runtime/retrieval/embedding.py +293 -0
- aid_runtime/retrieval/gate.py +120 -0
- aid_runtime/retrieval/hybrid.py +164 -0
- aid_runtime/retrieval/metrics.py +213 -0
- aid_runtime/retrieval/pgvector.py +258 -0
- aid_runtime/retrieval/rerank.py +133 -0
- aid_runtime/retrieval/retriever.py +335 -0
- aid_runtime/retrieval/stores.py +161 -0
- aid_runtime/retrieval/types.py +433 -0
- aid_runtime/steps.py +284 -0
- aid_runtime/structured.py +150 -0
- aid_runtime/tools.py +271 -0
- aid_runtime/tracing.py +241 -0
- aid_runtime/types.py +332 -0
- aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
- aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
- aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
- 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
|