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
aid_runtime/agents/ir.py
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
"""The IR seam: an emitted document in, resolved agent definitions out.
|
|
2
|
+
|
|
3
|
+
``pipeline.py`` consumes the IR's *pipeline* shapes and is deliberately unaware of
|
|
4
|
+
agents; this module is the equivalent seam for them. It reads only the collections
|
|
5
|
+
an agent can reach -- ``agents``, ``models``, ``prompts``, ``tools``, ``memory``,
|
|
6
|
+
``guardrails`` -- and resolves every reference at load time, so a loop never runs
|
|
7
|
+
against a dangling prompt id or an unpriced model.
|
|
8
|
+
|
|
9
|
+
Nothing here invents vocabulary. Field names are the IR's, ``instructions`` stays
|
|
10
|
+
the prompt *reference* the IR carries and is resolved to ``prompt.template`` here,
|
|
11
|
+
and the scopes are ``thread``/``user``/``global``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from aid_runtime.errors import ConfigurationError
|
|
21
|
+
from aid_runtime.types import (
|
|
22
|
+
Capability,
|
|
23
|
+
CostProfile,
|
|
24
|
+
ModelCacheConfig,
|
|
25
|
+
ModelSpec,
|
|
26
|
+
SemanticCacheConfig,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AgentDefinition",
|
|
31
|
+
"GuardrailDefinition",
|
|
32
|
+
"HumanApproval",
|
|
33
|
+
"MemoryDefinition",
|
|
34
|
+
"RuntimeSpec",
|
|
35
|
+
"ToolSpec",
|
|
36
|
+
"from_ir",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
#: IR model capability -> runtime capability. The IR's ``json-schema`` is what the
|
|
40
|
+
#: runtime calls structured output; capabilities with no runtime counterpart
|
|
41
|
+
#: (``vision``, ``reasoning``, ``prompt-cache``, ``audio``) are dropped rather than
|
|
42
|
+
#: invented, because nothing in the loop negotiates them.
|
|
43
|
+
_RUNTIME_CAPABILITY: dict[str, Capability] = {
|
|
44
|
+
"tool-calling": Capability.TOOL_CALLING,
|
|
45
|
+
"json-schema": Capability.STRUCTURED_OUTPUT,
|
|
46
|
+
"streaming": Capability.STREAMING,
|
|
47
|
+
"long-context": Capability.LONG_CONTEXT,
|
|
48
|
+
"embeddings": Capability.EMBEDDINGS,
|
|
49
|
+
"rerank": Capability.RERANK,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_ON_BUDGET_EXHAUSTED = frozenset({"fail", "degrade"})
|
|
53
|
+
|
|
54
|
+
#: The closed vocabularies from ``packages/ir/src/shapes.ts``. They are repeated
|
|
55
|
+
#: here on purpose: the build-time gates reject an unknown name, and the runtime
|
|
56
|
+
#: rejects it again rather than trusting that every document came through a gate.
|
|
57
|
+
_GUARDRAIL_STAGES = frozenset({"input", "output", "retrieval", "tool-call"})
|
|
58
|
+
_GUARDRAIL_ACTIONS = frozenset({"block", "redact", "flag", "escalate"})
|
|
59
|
+
_GUARDRAIL_POLICIES = frozenset(
|
|
60
|
+
{
|
|
61
|
+
"pii-detect",
|
|
62
|
+
"pii-redact",
|
|
63
|
+
"prompt-injection",
|
|
64
|
+
"jailbreak",
|
|
65
|
+
"moderation",
|
|
66
|
+
"toxicity",
|
|
67
|
+
"secret-scan",
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
_SIDE_EFFECTS = frozenset({"none", "read", "write", "destructive"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class ToolSpec:
|
|
76
|
+
"""An IR tool, as the loop needs it: name, side effects, and its auth policy."""
|
|
77
|
+
|
|
78
|
+
id: str
|
|
79
|
+
name: str
|
|
80
|
+
description: str
|
|
81
|
+
requires_confirmation: bool
|
|
82
|
+
side_effects: str
|
|
83
|
+
auth: Mapping[str, Any] | None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class GuardrailDefinition:
|
|
88
|
+
"""One resolved guardrail: where it runs, what it detects, and what it does.
|
|
89
|
+
|
|
90
|
+
``tools`` holds tool *ids*, because that is how the IR scopes a tool-call
|
|
91
|
+
guardrail; the loop translates a model's tool *name* to the id before asking.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
id: str
|
|
95
|
+
stages: tuple[str, ...]
|
|
96
|
+
policies: tuple[str, ...]
|
|
97
|
+
action: str
|
|
98
|
+
redact: tuple[str, ...]
|
|
99
|
+
tools: tuple[str, ...]
|
|
100
|
+
message: str
|
|
101
|
+
fallback_prompt: str | None
|
|
102
|
+
log: bool
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class MemoryDefinition:
|
|
107
|
+
"""One IR ``memory`` entry, resolved for the scope rules in ``agents.memory``."""
|
|
108
|
+
|
|
109
|
+
id: str
|
|
110
|
+
kind: str
|
|
111
|
+
scope: str
|
|
112
|
+
token_budget: int
|
|
113
|
+
backend: str | None
|
|
114
|
+
store: str | None
|
|
115
|
+
summarize_model: str | None
|
|
116
|
+
ttl: str | None
|
|
117
|
+
redact: tuple[str, ...]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class HumanApproval:
|
|
122
|
+
"""The parsed ``humanInTheLoop`` block: a policy, and a channel to ask on."""
|
|
123
|
+
|
|
124
|
+
when: Mapping[str, Any]
|
|
125
|
+
channel: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class AgentDefinition:
|
|
130
|
+
"""One agent, with every reference resolved."""
|
|
131
|
+
|
|
132
|
+
id: str
|
|
133
|
+
name: str
|
|
134
|
+
instructions: str
|
|
135
|
+
model: str
|
|
136
|
+
tools: tuple[str, ...]
|
|
137
|
+
memory: tuple[MemoryDefinition, ...]
|
|
138
|
+
max_steps: int
|
|
139
|
+
max_tokens_budget: int | None
|
|
140
|
+
max_cost_budget: float | None
|
|
141
|
+
on_budget_exhausted: str
|
|
142
|
+
handoffs: tuple[str, ...]
|
|
143
|
+
delegates: tuple[str, ...]
|
|
144
|
+
engine: str
|
|
145
|
+
# The pinned Deep Agents release, when the engine is ``deepagents``. Empty for
|
|
146
|
+
# ``native``; the IR gate refuses ``deepagents`` without a pin, so an empty
|
|
147
|
+
# value here means the document skipped the gate.
|
|
148
|
+
engine_version: str
|
|
149
|
+
approval: HumanApproval | None
|
|
150
|
+
guardrails: tuple[GuardrailDefinition, ...] = ()
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def has_control_tools(self) -> bool:
|
|
154
|
+
"""Whether this agent may move control at all (transfer *or* delegate)."""
|
|
155
|
+
return bool(self.handoffs or self.delegates)
|
|
156
|
+
|
|
157
|
+
def stage_guardrails(self, stage: str) -> tuple[GuardrailDefinition, ...]:
|
|
158
|
+
"""Every guardrail declared on this agent that runs at ``stage``.
|
|
159
|
+
|
|
160
|
+
Declaration order is the order of ``guardrails`` in the IR, and that is the
|
|
161
|
+
order the engine evaluates in. It has to come from the document: an order
|
|
162
|
+
the runtime invented would make the outcome of two conflicting guardrails
|
|
163
|
+
depend on which version of the runtime ran the spec.
|
|
164
|
+
"""
|
|
165
|
+
return tuple(guardrail for guardrail in self.guardrails if stage in guardrail.stages)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True, slots=True)
|
|
169
|
+
class RuntimeSpec:
|
|
170
|
+
"""Everything the loop needs, resolved from one IR document."""
|
|
171
|
+
|
|
172
|
+
models: Mapping[str, ModelSpec]
|
|
173
|
+
fallbacks: Mapping[str, tuple[str, ...]]
|
|
174
|
+
prompts: Mapping[str, str]
|
|
175
|
+
tools: Mapping[str, ToolSpec]
|
|
176
|
+
agents: Mapping[str, AgentDefinition]
|
|
177
|
+
guardrails: Mapping[str, GuardrailDefinition] = field(default_factory=dict)
|
|
178
|
+
#: The single concurrency cap, read from the IR's ``deployments[].concurrency``.
|
|
179
|
+
#: ``None`` means unbounded: the runtime only shares a limiter when a deployment
|
|
180
|
+
#: declares one, which keeps plain request/response usage free of surprises.
|
|
181
|
+
concurrency: int | None = None
|
|
182
|
+
|
|
183
|
+
def agent(self, agent_id: str) -> AgentDefinition:
|
|
184
|
+
try:
|
|
185
|
+
return self.agents[agent_id]
|
|
186
|
+
except KeyError:
|
|
187
|
+
raise ConfigurationError(
|
|
188
|
+
f"no agent {agent_id!r} in the IR; declared agents: {sorted(self.agents)}"
|
|
189
|
+
) from None
|
|
190
|
+
|
|
191
|
+
def system_prompt(self, agent: AgentDefinition) -> str:
|
|
192
|
+
try:
|
|
193
|
+
return self.prompts[agent.instructions]
|
|
194
|
+
except KeyError:
|
|
195
|
+
raise ConfigurationError(
|
|
196
|
+
f"agent {agent.id!r} names prompt {agent.instructions!r}, "
|
|
197
|
+
f"which the IR does not declare; declared prompts: {sorted(self.prompts)}"
|
|
198
|
+
) from None
|
|
199
|
+
|
|
200
|
+
def fallbacks_for(self, alias: str) -> tuple[str, ...]:
|
|
201
|
+
return self.fallbacks.get(alias, ())
|
|
202
|
+
|
|
203
|
+
def tool_specs(self, agent: AgentDefinition) -> tuple[ToolSpec, ...]:
|
|
204
|
+
return tuple(self.tools[tool_id] for tool_id in agent.tools)
|
|
205
|
+
|
|
206
|
+
def price(self, alias: str) -> float:
|
|
207
|
+
"""Combined price, the same number ``agents/budget-degrade`` compares.
|
|
208
|
+
|
|
209
|
+
The gate compares ``inputPerMTok + outputPerMTok``; keeping the identical
|
|
210
|
+
sum here means a spec the gate accepted cannot have the runtime pick a
|
|
211
|
+
"cheaper" alias that the gate would have called more expensive.
|
|
212
|
+
"""
|
|
213
|
+
spec = self.models[alias]
|
|
214
|
+
return spec.cost.input_per_1k_usd + spec.cost.output_per_1k_usd
|
|
215
|
+
|
|
216
|
+
def cheaper_fallback(self, alias: str) -> str | None:
|
|
217
|
+
"""The first declared fallback that is strictly cheaper than ``alias``.
|
|
218
|
+
|
|
219
|
+
One step, in the order the spec declared: this is the alias ladder's next
|
|
220
|
+
rung, not a search for the cheapest reachable model. ``None`` means there
|
|
221
|
+
is nowhere to step, which is what makes a ``degrade`` declaration fail
|
|
222
|
+
closed instead of pretending to degrade.
|
|
223
|
+
"""
|
|
224
|
+
budget = self.price(alias)
|
|
225
|
+
for candidate in self.fallbacks_for(alias):
|
|
226
|
+
if candidate in self.models and self.price(candidate) < budget:
|
|
227
|
+
return candidate
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def from_ir(document: Mapping[str, Any]) -> RuntimeSpec:
|
|
232
|
+
"""Resolve an emitted IR document into a :class:`RuntimeSpec`."""
|
|
233
|
+
raw_models = _by_id(document.get("models"), "models")
|
|
234
|
+
raw_prompts = _by_id(document.get("prompts"), "prompts")
|
|
235
|
+
raw_tools = _by_id(document.get("tools"), "tools")
|
|
236
|
+
raw_memory = _by_id(document.get("memory"), "memory")
|
|
237
|
+
raw_agents = _by_id(document.get("agents"), "agents")
|
|
238
|
+
|
|
239
|
+
prompts = {
|
|
240
|
+
prompt_id: _required_str(entry, "template", f"prompt {prompt_id!r}")
|
|
241
|
+
for prompt_id, entry in raw_prompts.items()
|
|
242
|
+
}
|
|
243
|
+
tools = {
|
|
244
|
+
tool_id: ToolSpec(
|
|
245
|
+
id=tool_id,
|
|
246
|
+
name=_required_str(entry, "name", f"tool {tool_id!r}"),
|
|
247
|
+
description=_str(entry.get("description")) or "",
|
|
248
|
+
requires_confirmation=entry.get("requiresConfirmation") is True,
|
|
249
|
+
side_effects=_side_effects(tool_id, entry.get("sideEffects")),
|
|
250
|
+
auth=_policy_expr(entry.get("auth"), f"tool {tool_id!r} auth"),
|
|
251
|
+
)
|
|
252
|
+
for tool_id, entry in raw_tools.items()
|
|
253
|
+
}
|
|
254
|
+
memory = {memory_id: _memory(memory_id, entry) for memory_id, entry in raw_memory.items()}
|
|
255
|
+
guardrails = {
|
|
256
|
+
guardrail_id: _guardrail(guardrail_id, entry, tools)
|
|
257
|
+
for guardrail_id, entry in _by_id(document.get("guardrails"), "guardrails").items()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fallbacks: dict[str, tuple[str, ...]] = {}
|
|
261
|
+
for model_id, entry in raw_models.items():
|
|
262
|
+
fallbacks[model_id] = tuple(_str_list(entry.get("fallbacks"), f"model {model_id!r}"))
|
|
263
|
+
|
|
264
|
+
agents = {
|
|
265
|
+
agent_id: _agent(agent_id, entry, prompts, tools, memory, guardrails)
|
|
266
|
+
for agent_id, entry in raw_agents.items()
|
|
267
|
+
}
|
|
268
|
+
_check_control_targets(agents)
|
|
269
|
+
|
|
270
|
+
reachable = _reachable_models(agents, fallbacks)
|
|
271
|
+
models = {model_id: _model(model_id, raw_models[model_id]) for model_id in sorted(reachable)}
|
|
272
|
+
|
|
273
|
+
return RuntimeSpec(
|
|
274
|
+
models=models,
|
|
275
|
+
fallbacks={alias: fallbacks.get(alias, ()) for alias in models},
|
|
276
|
+
prompts=prompts,
|
|
277
|
+
tools=tools,
|
|
278
|
+
agents=agents,
|
|
279
|
+
guardrails=guardrails,
|
|
280
|
+
concurrency=_concurrency(document.get("deployments")),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _agent(
|
|
285
|
+
agent_id: str,
|
|
286
|
+
entry: Mapping[str, Any],
|
|
287
|
+
prompts: Mapping[str, str],
|
|
288
|
+
tools: Mapping[str, ToolSpec],
|
|
289
|
+
memory: Mapping[str, MemoryDefinition],
|
|
290
|
+
guardrails: Mapping[str, GuardrailDefinition],
|
|
291
|
+
) -> AgentDefinition:
|
|
292
|
+
where = f"agent {agent_id!r}"
|
|
293
|
+
instructions = _str(entry.get("instructions")) or ""
|
|
294
|
+
if instructions not in prompts:
|
|
295
|
+
raise ConfigurationError(
|
|
296
|
+
f"{where} names prompt {instructions!r}, which the IR does not declare; "
|
|
297
|
+
f"declared prompts: {sorted(prompts)}"
|
|
298
|
+
)
|
|
299
|
+
tool_ids = tuple(_str_list(entry.get("tools"), f"{where} tools"))
|
|
300
|
+
missing = sorted(set(tool_ids) - set(tools))
|
|
301
|
+
if missing:
|
|
302
|
+
raise ConfigurationError(f"{where} names undeclared tools: {missing}")
|
|
303
|
+
memory_ids = tuple(_str_list(entry.get("memory"), f"{where} memory"))
|
|
304
|
+
missing_memory = sorted(set(memory_ids) - set(memory))
|
|
305
|
+
if missing_memory:
|
|
306
|
+
raise ConfigurationError(f"{where} names undeclared memory: {missing_memory}")
|
|
307
|
+
guardrail_ids = tuple(_str_list(entry.get("guardrails"), f"{where} guardrails"))
|
|
308
|
+
missing_guardrails = sorted(set(guardrail_ids) - set(guardrails))
|
|
309
|
+
if missing_guardrails:
|
|
310
|
+
raise ConfigurationError(f"{where} names undeclared guardrails: {missing_guardrails}")
|
|
311
|
+
|
|
312
|
+
engine = _str(entry.get("engine")) or "native"
|
|
313
|
+
engine_version = _str(entry.get("engineVersion")) or ""
|
|
314
|
+
on_budget = _str(entry.get("onBudgetExhausted")) or "fail"
|
|
315
|
+
if on_budget not in _ON_BUDGET_EXHAUSTED:
|
|
316
|
+
raise ConfigurationError(
|
|
317
|
+
f"{where} has onBudgetExhausted {on_budget!r}; expected one of "
|
|
318
|
+
f"{sorted(_ON_BUDGET_EXHAUSTED)}"
|
|
319
|
+
)
|
|
320
|
+
max_steps = entry.get("maxSteps")
|
|
321
|
+
if not isinstance(max_steps, int) or isinstance(max_steps, bool) or max_steps < 1:
|
|
322
|
+
raise ConfigurationError(f"{where} needs a positive maxSteps, got {max_steps!r}")
|
|
323
|
+
|
|
324
|
+
return AgentDefinition(
|
|
325
|
+
id=agent_id,
|
|
326
|
+
name=_str(entry.get("name")) or agent_id,
|
|
327
|
+
instructions=instructions,
|
|
328
|
+
model=_str(entry.get("model")) or "",
|
|
329
|
+
tools=tool_ids,
|
|
330
|
+
memory=tuple(memory[memory_id] for memory_id in memory_ids),
|
|
331
|
+
max_steps=max_steps,
|
|
332
|
+
max_tokens_budget=_optional_int(entry.get("maxTokensBudget")),
|
|
333
|
+
max_cost_budget=_optional_float(entry.get("maxCostBudget")),
|
|
334
|
+
on_budget_exhausted=on_budget,
|
|
335
|
+
handoffs=tuple(_str_list(entry.get("handoffs"), f"{where} handoffs")),
|
|
336
|
+
delegates=tuple(_str_list(entry.get("delegates"), f"{where} delegates")),
|
|
337
|
+
engine=engine,
|
|
338
|
+
engine_version=engine_version,
|
|
339
|
+
approval=_approval(where, entry.get("humanInTheLoop")),
|
|
340
|
+
guardrails=tuple(guardrails[guardrail_id] for guardrail_id in guardrail_ids),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _guardrail(
|
|
345
|
+
guardrail_id: str,
|
|
346
|
+
entry: Mapping[str, Any],
|
|
347
|
+
tools: Mapping[str, ToolSpec],
|
|
348
|
+
) -> GuardrailDefinition:
|
|
349
|
+
where = f"guardrail {guardrail_id!r}"
|
|
350
|
+
stages = tuple(_str_list(entry.get("stages"), f"{where} stages"))
|
|
351
|
+
unknown_stages = sorted(set(stages) - _GUARDRAIL_STAGES)
|
|
352
|
+
if unknown_stages:
|
|
353
|
+
raise ConfigurationError(
|
|
354
|
+
f"{where} names unknown stages {unknown_stages}; expected a subset of "
|
|
355
|
+
f"{sorted(_GUARDRAIL_STAGES)}"
|
|
356
|
+
)
|
|
357
|
+
policies = tuple(_str_list(entry.get("policies"), f"{where} policies"))
|
|
358
|
+
unknown_policies = sorted(set(policies) - _GUARDRAIL_POLICIES)
|
|
359
|
+
if unknown_policies:
|
|
360
|
+
raise ConfigurationError(
|
|
361
|
+
f"{where} names unknown policies {unknown_policies}; expected a subset of "
|
|
362
|
+
f"{sorted(_GUARDRAIL_POLICIES)}"
|
|
363
|
+
)
|
|
364
|
+
action = _str(entry.get("action")) or ""
|
|
365
|
+
if action not in _GUARDRAIL_ACTIONS:
|
|
366
|
+
raise ConfigurationError(
|
|
367
|
+
f"{where} has action {action!r}; expected one of {sorted(_GUARDRAIL_ACTIONS)}"
|
|
368
|
+
)
|
|
369
|
+
redact = tuple(_str_list(entry.get("redact"), f"{where} redact"))
|
|
370
|
+
if action == "redact" and not redact:
|
|
371
|
+
raise ConfigurationError(
|
|
372
|
+
f"{where} uses action 'redact' with no redact fields; a redaction that names "
|
|
373
|
+
"no field cannot redact anything"
|
|
374
|
+
)
|
|
375
|
+
tool_ids = tuple(_str_list(entry.get("tools"), f"{where} tools"))
|
|
376
|
+
missing = sorted(set(tool_ids) - set(tools))
|
|
377
|
+
if missing:
|
|
378
|
+
raise ConfigurationError(f"{where} names undeclared tools: {missing}")
|
|
379
|
+
|
|
380
|
+
on_fail = entry.get("onFail")
|
|
381
|
+
if not isinstance(on_fail, Mapping):
|
|
382
|
+
raise ConfigurationError(f"{where} needs an onFail object")
|
|
383
|
+
return GuardrailDefinition(
|
|
384
|
+
id=guardrail_id,
|
|
385
|
+
stages=stages,
|
|
386
|
+
policies=policies,
|
|
387
|
+
action=action,
|
|
388
|
+
redact=redact,
|
|
389
|
+
tools=tool_ids,
|
|
390
|
+
message=_required_str(on_fail, "message", f"{where} onFail"),
|
|
391
|
+
fallback_prompt=_str(on_fail.get("fallbackPrompt")),
|
|
392
|
+
log=on_fail.get("log") is True,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _side_effects(tool_id: str, raw: Any) -> str:
|
|
397
|
+
if raw is None:
|
|
398
|
+
return "none"
|
|
399
|
+
value = _str(raw)
|
|
400
|
+
if value not in _SIDE_EFFECTS:
|
|
401
|
+
raise ConfigurationError(
|
|
402
|
+
f"tool {tool_id!r} has sideEffects {raw!r}; expected one of {sorted(_SIDE_EFFECTS)}"
|
|
403
|
+
)
|
|
404
|
+
return value
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _policy_expr(raw: Any, where: str) -> Mapping[str, Any] | None:
|
|
408
|
+
if raw is None:
|
|
409
|
+
return None
|
|
410
|
+
if not isinstance(raw, Mapping):
|
|
411
|
+
raise ConfigurationError(
|
|
412
|
+
f"{where} must be a parsed policy object, got {type(raw).__name__}"
|
|
413
|
+
)
|
|
414
|
+
return raw
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _approval(where: str, raw: Any) -> HumanApproval | None:
|
|
418
|
+
if raw is None:
|
|
419
|
+
return None
|
|
420
|
+
if not isinstance(raw, Mapping):
|
|
421
|
+
raise ConfigurationError(f"{where} humanInTheLoop must be an object")
|
|
422
|
+
when = raw.get("when")
|
|
423
|
+
channel = _str(raw.get("channel")) or ""
|
|
424
|
+
if not isinstance(when, Mapping):
|
|
425
|
+
raise ConfigurationError(f"{where} humanInTheLoop needs a parsed 'when' policy")
|
|
426
|
+
if not channel:
|
|
427
|
+
raise ConfigurationError(
|
|
428
|
+
f"{where} declares humanInTheLoop with no channel; without a channel there "
|
|
429
|
+
"is nowhere to ask, so the loop refuses to run it"
|
|
430
|
+
)
|
|
431
|
+
return HumanApproval(when=when, channel=channel)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _check_control_targets(agents: Mapping[str, AgentDefinition]) -> None:
|
|
435
|
+
for agent in agents.values():
|
|
436
|
+
for field_name, targets in (("handoffs", agent.handoffs), ("delegates", agent.delegates)):
|
|
437
|
+
unknown = sorted(set(targets) - set(agents))
|
|
438
|
+
if unknown:
|
|
439
|
+
raise ConfigurationError(
|
|
440
|
+
f"agent {agent.id!r} {field_name} undeclared agents: {unknown}"
|
|
441
|
+
)
|
|
442
|
+
if agent.id in targets:
|
|
443
|
+
raise ConfigurationError(
|
|
444
|
+
f"agent {agent.id!r} {field_name} itself; a control edge must leave the agent"
|
|
445
|
+
)
|
|
446
|
+
both = sorted(set(agent.handoffs) & set(agent.delegates))
|
|
447
|
+
if both:
|
|
448
|
+
raise ConfigurationError(
|
|
449
|
+
f"agent {agent.id!r} lists {both} in both handoffs and delegates; "
|
|
450
|
+
"transfer and delegation are different semantics, so a target is one or the other"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _reachable_models(
|
|
455
|
+
agents: Mapping[str, AgentDefinition], fallbacks: Mapping[str, tuple[str, ...]]
|
|
456
|
+
) -> set[str]:
|
|
457
|
+
reachable: set[str] = set()
|
|
458
|
+
queue = [agent.model for agent in agents.values()]
|
|
459
|
+
while queue:
|
|
460
|
+
alias = queue.pop()
|
|
461
|
+
if alias in reachable:
|
|
462
|
+
continue
|
|
463
|
+
reachable.add(alias)
|
|
464
|
+
queue.extend(fallbacks.get(alias, ()))
|
|
465
|
+
return reachable
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _model(model_id: str, entry: Mapping[str, Any]) -> ModelSpec:
|
|
469
|
+
where = f"model {model_id!r}"
|
|
470
|
+
cost = entry.get("costProfile")
|
|
471
|
+
if not isinstance(cost, Mapping):
|
|
472
|
+
raise ConfigurationError(
|
|
473
|
+
f"{where} has no resolved costProfile; a budget cannot be enforced against "
|
|
474
|
+
"an unpriced model"
|
|
475
|
+
)
|
|
476
|
+
limits = entry.get("limits")
|
|
477
|
+
if not isinstance(limits, Mapping):
|
|
478
|
+
raise ConfigurationError(f"{where} has no limits")
|
|
479
|
+
context_tokens = limits.get("contextTokens")
|
|
480
|
+
if (
|
|
481
|
+
isinstance(context_tokens, bool)
|
|
482
|
+
or not isinstance(context_tokens, int)
|
|
483
|
+
or context_tokens < 1
|
|
484
|
+
):
|
|
485
|
+
raise ConfigurationError(f"{where} has no positive limits.contextTokens")
|
|
486
|
+
max_output = limits.get("maxOutputTokens")
|
|
487
|
+
if not isinstance(max_output, int) or isinstance(max_output, bool) or max_output < 1:
|
|
488
|
+
# The IR treats an output ceiling as optional; the runtime must still have
|
|
489
|
+
# one, so the context window is the only ceiling the IR guarantees.
|
|
490
|
+
max_output = context_tokens
|
|
491
|
+
|
|
492
|
+
capabilities = frozenset(
|
|
493
|
+
_RUNTIME_CAPABILITY[name]
|
|
494
|
+
for name in _str_list(entry.get("capabilities"), f"{where} capabilities")
|
|
495
|
+
if name in _RUNTIME_CAPABILITY
|
|
496
|
+
)
|
|
497
|
+
return ModelSpec(
|
|
498
|
+
name=_required_str(entry, "modelId", where),
|
|
499
|
+
provider=_required_str(entry, "provider", where),
|
|
500
|
+
cost=CostProfile(
|
|
501
|
+
input_per_1k_usd=float(cost.get("inputPerMTok", 0.0)) / 1000.0,
|
|
502
|
+
output_per_1k_usd=float(cost.get("outputPerMTok", 0.0)) / 1000.0,
|
|
503
|
+
),
|
|
504
|
+
context_window=context_tokens,
|
|
505
|
+
max_output_tokens=max_output,
|
|
506
|
+
capabilities=capabilities,
|
|
507
|
+
cache=_cache(where, entry.get("cache")),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _cache(where: str, raw: Any) -> ModelCacheConfig | None:
|
|
512
|
+
"""Parse the IR ``cache`` shape into a :class:`ModelCacheConfig`, or ``None``.
|
|
513
|
+
|
|
514
|
+
The IR carries ``cache: {prompt?: boolean, semantic?: {threshold, ttlSeconds}}``.
|
|
515
|
+
Semantic reuse is opt-in and carries an explicit threshold and TTL; there is no
|
|
516
|
+
default threshold, so a semantic entry missing either is a configuration error
|
|
517
|
+
rather than a silently lenient cache.
|
|
518
|
+
"""
|
|
519
|
+
if raw is None:
|
|
520
|
+
return None
|
|
521
|
+
if not isinstance(raw, Mapping):
|
|
522
|
+
raise ConfigurationError(f"{where} cache must be an object")
|
|
523
|
+
semantic = None
|
|
524
|
+
semantic_raw = raw.get("semantic")
|
|
525
|
+
if semantic_raw is not None:
|
|
526
|
+
if not isinstance(semantic_raw, Mapping):
|
|
527
|
+
raise ConfigurationError(f"{where} cache.semantic must be an object")
|
|
528
|
+
threshold = semantic_raw.get("threshold")
|
|
529
|
+
if isinstance(threshold, bool) or not isinstance(threshold, (int, float)):
|
|
530
|
+
raise ConfigurationError(f"{where} cache.semantic needs a threshold in [0, 1]")
|
|
531
|
+
threshold = float(threshold)
|
|
532
|
+
if not 0.0 <= threshold <= 1.0:
|
|
533
|
+
raise ConfigurationError(
|
|
534
|
+
f"{where} cache.semantic.threshold must be in [0, 1], got {threshold}"
|
|
535
|
+
)
|
|
536
|
+
ttl = semantic_raw.get("ttlSeconds")
|
|
537
|
+
if isinstance(ttl, bool) or not isinstance(ttl, int) or ttl < 1:
|
|
538
|
+
raise ConfigurationError(f"{where} cache.semantic needs a positive ttlSeconds")
|
|
539
|
+
semantic = SemanticCacheConfig(threshold=threshold, ttl_seconds=ttl)
|
|
540
|
+
return ModelCacheConfig(prompt=raw.get("prompt") is True, semantic=semantic)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _concurrency(raw: Any) -> int | None:
|
|
544
|
+
"""The single cap the IR's deployments declare, or ``None`` (unbounded).
|
|
545
|
+
|
|
546
|
+
``deployments`` is an array; the runtime shares one limiter, so the cap is the
|
|
547
|
+
highest ``concurrency`` any deployment declares. A spec that declares none gets
|
|
548
|
+
``None``, which means the gateway and executor stay unbounded by default.
|
|
549
|
+
"""
|
|
550
|
+
if not isinstance(raw, Sequence) or isinstance(raw, str):
|
|
551
|
+
return None
|
|
552
|
+
declared: list[int] = []
|
|
553
|
+
for entry in raw:
|
|
554
|
+
if not isinstance(entry, Mapping):
|
|
555
|
+
continue
|
|
556
|
+
value = entry.get("concurrency")
|
|
557
|
+
if isinstance(value, int) and not isinstance(value, bool) and value >= 1:
|
|
558
|
+
declared.append(value)
|
|
559
|
+
return max(declared) if declared else None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _memory(memory_id: str, entry: Mapping[str, Any]) -> MemoryDefinition:
|
|
563
|
+
where = f"memory {memory_id!r}"
|
|
564
|
+
token_budget = entry.get("tokenBudget")
|
|
565
|
+
if not isinstance(token_budget, int) or isinstance(token_budget, bool) or token_budget < 1:
|
|
566
|
+
raise ConfigurationError(f"{where} needs a positive tokenBudget")
|
|
567
|
+
return MemoryDefinition(
|
|
568
|
+
id=memory_id,
|
|
569
|
+
kind=_str(entry.get("kind")) or "buffer",
|
|
570
|
+
scope=_str(entry.get("scope")) or "thread",
|
|
571
|
+
token_budget=token_budget,
|
|
572
|
+
backend=_str(entry.get("backend")),
|
|
573
|
+
store=_str(entry.get("store")),
|
|
574
|
+
summarize_model=_str(entry.get("summarizeModel")),
|
|
575
|
+
ttl=_str(entry.get("ttl")),
|
|
576
|
+
redact=tuple(_str_list(entry.get("redact"), f"{where} redact")),
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _by_id(raw: Any, collection: str) -> dict[str, Mapping[str, Any]]:
|
|
581
|
+
if raw is None:
|
|
582
|
+
return {}
|
|
583
|
+
if not isinstance(raw, Sequence) or isinstance(raw, str):
|
|
584
|
+
raise ConfigurationError(f"IR {collection} must be an array")
|
|
585
|
+
resolved: dict[str, Mapping[str, Any]] = {}
|
|
586
|
+
for entry in raw:
|
|
587
|
+
if not isinstance(entry, Mapping):
|
|
588
|
+
raise ConfigurationError(f"IR {collection} contains a non-object entry")
|
|
589
|
+
entry_id = _str(entry.get("id"))
|
|
590
|
+
if not entry_id:
|
|
591
|
+
raise ConfigurationError(f"IR {collection} contains an entry with no id")
|
|
592
|
+
resolved[entry_id] = entry
|
|
593
|
+
return resolved
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _str(value: Any) -> str | None:
|
|
597
|
+
return value if isinstance(value, str) else None
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _required_str(entry: Mapping[str, Any], key: str, where: str) -> str:
|
|
601
|
+
value = _str(entry.get(key))
|
|
602
|
+
if not value:
|
|
603
|
+
raise ConfigurationError(f"{where} has no {key!r}")
|
|
604
|
+
return value
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _str_list(value: Any, where: str) -> list[str]:
|
|
608
|
+
if value is None:
|
|
609
|
+
return []
|
|
610
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
|
611
|
+
raise ConfigurationError(f"{where} must be an array of strings")
|
|
612
|
+
return [item for item in value if isinstance(item, str)]
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _optional_int(value: Any) -> int | None:
|
|
616
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _optional_float(value: Any) -> float | None:
|
|
620
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
621
|
+
return None
|
|
622
|
+
return float(value)
|