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/cost.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Cost accounting and budget enforcement.
|
|
2
|
+
|
|
3
|
+
Cost is the budget this runtime enforces in Phase 0. A latency p95 budget is
|
|
4
|
+
deliberately absent: it is only meaningful next to a dashboard, and a gate nobody
|
|
5
|
+
reads is not a gate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
14
|
+
|
|
15
|
+
from aid_runtime.errors import BudgetExceededError
|
|
16
|
+
from aid_runtime.types import ModelSpec, TokenUsage
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Budget",
|
|
20
|
+
"CostLedger",
|
|
21
|
+
"LedgerEntry",
|
|
22
|
+
"SpendSummary",
|
|
23
|
+
"estimate_cost_usd",
|
|
24
|
+
"estimate_prompt_cost_usd",
|
|
25
|
+
"estimate_tokens",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Prices are quoted per 1k tokens, so a call costs fractions of a cent. Six
|
|
29
|
+
# decimals keeps a single call representable without letting float error
|
|
30
|
+
# accumulate visibly over a long run.
|
|
31
|
+
_USD_PRECISION = 6
|
|
32
|
+
|
|
33
|
+
NonNegativeUsd = Annotated[float, Field(ge=0.0)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Budget(BaseModel):
|
|
37
|
+
"""A spending ceiling for one scope (a run, a request, a nightly job)."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
40
|
+
|
|
41
|
+
max_usd: NonNegativeUsd | None = None
|
|
42
|
+
max_total_tokens: int | None = Field(default=None, gt=0)
|
|
43
|
+
scope: str = "run"
|
|
44
|
+
|
|
45
|
+
@model_validator(mode="after")
|
|
46
|
+
def _at_least_one_ceiling(self) -> Budget:
|
|
47
|
+
if self.max_usd is None and self.max_total_tokens is None:
|
|
48
|
+
raise ValueError("a budget must set max_usd, max_total_tokens, or both")
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class LedgerEntry:
|
|
54
|
+
"""One charged model call."""
|
|
55
|
+
|
|
56
|
+
alias: str
|
|
57
|
+
model: str
|
|
58
|
+
provider: str
|
|
59
|
+
usage: TokenUsage
|
|
60
|
+
cost_usd: float
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SpendSummary(BaseModel):
|
|
64
|
+
"""Aggregate spend, suitable for a log line or an OTel span attribute."""
|
|
65
|
+
|
|
66
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
67
|
+
|
|
68
|
+
calls: int = Field(ge=0)
|
|
69
|
+
input_tokens: int = Field(ge=0)
|
|
70
|
+
output_tokens: int = Field(ge=0)
|
|
71
|
+
cost_usd: NonNegativeUsd
|
|
72
|
+
by_model: dict[str, float] = Field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def total_tokens(self) -> int:
|
|
76
|
+
return self.input_tokens + self.output_tokens
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def estimate_tokens(text: str) -> int:
|
|
80
|
+
"""Four characters per token, the same over-estimate used before a call."""
|
|
81
|
+
return (len(text) + 3) // 4
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def estimate_cost_usd(spec: ModelSpec, usage: TokenUsage) -> float:
|
|
85
|
+
"""Price a call against a pinned model's published cost."""
|
|
86
|
+
return spec.cost.cost_usd(usage)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def estimate_prompt_cost_usd(spec: ModelSpec, text: str) -> float:
|
|
90
|
+
"""Rough pre-flight price for a prompt, used to fail before spending.
|
|
91
|
+
|
|
92
|
+
Four characters per token is the usual English approximation. It is
|
|
93
|
+
deliberately an over-estimate: refusing a call that would have fit is a
|
|
94
|
+
recoverable annoyance; overshooting a hard budget is not.
|
|
95
|
+
"""
|
|
96
|
+
estimated = TokenUsage(input_tokens=estimate_tokens(text), output_tokens=0)
|
|
97
|
+
return spec.cost.cost_usd(estimated)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CostLedger:
|
|
101
|
+
"""Accumulates spend and refuses to let a budget be crossed.
|
|
102
|
+
|
|
103
|
+
The ledger is intentionally not thread-safe and not async-safe: a gateway owns
|
|
104
|
+
one ledger per logical run, and sharing one across concurrent runs would make
|
|
105
|
+
"spent so far" meaningless.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self) -> None:
|
|
109
|
+
self._entries: list[LedgerEntry] = []
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def entries(self) -> tuple[LedgerEntry, ...]:
|
|
113
|
+
return tuple(self._entries)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def cost_usd(self) -> float:
|
|
117
|
+
return round(sum(entry.cost_usd for entry in self._entries), _USD_PRECISION)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def total_tokens(self) -> int:
|
|
121
|
+
return sum(entry.usage.total_tokens for entry in self._entries)
|
|
122
|
+
|
|
123
|
+
def check(self, budget: Budget) -> None:
|
|
124
|
+
"""Raise if current spend already violates ``budget``."""
|
|
125
|
+
self._check_usd(budget)
|
|
126
|
+
self._check_tokens(budget)
|
|
127
|
+
|
|
128
|
+
def charge(self, budget: Budget | None, entry: LedgerEntry) -> None:
|
|
129
|
+
"""Record a call, raising :class:`BudgetExceededError` if it overshoots.
|
|
130
|
+
|
|
131
|
+
The entry is recorded before the check so the ledger reflects what was
|
|
132
|
+
actually spent when the error propagates -- a caller that catches the
|
|
133
|
+
error still sees truthful accounting.
|
|
134
|
+
"""
|
|
135
|
+
self._entries.append(entry)
|
|
136
|
+
if budget is None:
|
|
137
|
+
return
|
|
138
|
+
self.check(budget)
|
|
139
|
+
|
|
140
|
+
def summary(self) -> SpendSummary:
|
|
141
|
+
by_model: dict[str, float] = {}
|
|
142
|
+
for entry in self._entries:
|
|
143
|
+
by_model[entry.model] = round(
|
|
144
|
+
by_model.get(entry.model, 0.0) + entry.cost_usd, _USD_PRECISION
|
|
145
|
+
)
|
|
146
|
+
return SpendSummary(
|
|
147
|
+
calls=len(self._entries),
|
|
148
|
+
input_tokens=sum(e.usage.input_tokens for e in self._entries),
|
|
149
|
+
output_tokens=sum(e.usage.output_tokens for e in self._entries),
|
|
150
|
+
cost_usd=self.cost_usd,
|
|
151
|
+
by_model=by_model,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _check_usd(self, budget: Budget) -> None:
|
|
155
|
+
if budget.max_usd is None:
|
|
156
|
+
return
|
|
157
|
+
spent = self.cost_usd
|
|
158
|
+
if spent > budget.max_usd:
|
|
159
|
+
raise BudgetExceededError(budget.scope, budget.max_usd, spent)
|
|
160
|
+
|
|
161
|
+
def _check_tokens(self, budget: Budget) -> None:
|
|
162
|
+
if budget.max_total_tokens is None:
|
|
163
|
+
return
|
|
164
|
+
spent_tokens = self.total_tokens
|
|
165
|
+
if spent_tokens > budget.max_total_tokens:
|
|
166
|
+
raise BudgetExceededError(
|
|
167
|
+
budget.scope, float(budget.max_total_tokens), float(spent_tokens), unit="tokens"
|
|
168
|
+
)
|
aid_runtime/errors.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Exception hierarchy for the AID Python runtime.
|
|
2
|
+
|
|
3
|
+
Every error this library raises derives from :class:`AidRuntimeError`, so an
|
|
4
|
+
application can distinguish "the framework refused to proceed" from "the network
|
|
5
|
+
was flaky" with a single `except`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AgentBudgetDegradedError",
|
|
14
|
+
"AgentHandoffUnsupportedError",
|
|
15
|
+
"AgentTransferCycleError",
|
|
16
|
+
"AidRuntimeError",
|
|
17
|
+
"BudgetExceededError",
|
|
18
|
+
"CapabilityMismatchError",
|
|
19
|
+
"ConfigurationError",
|
|
20
|
+
"DeepAgentsEngineError",
|
|
21
|
+
"DeepAgentsMaxStepsError",
|
|
22
|
+
"DeepAgentsUnavailableError",
|
|
23
|
+
"DeepAgentsVersionMismatchError",
|
|
24
|
+
"EvalError",
|
|
25
|
+
"FeatureDeferredError",
|
|
26
|
+
"GateConfigurationError",
|
|
27
|
+
"PipelineError",
|
|
28
|
+
"PolicyEvaluationError",
|
|
29
|
+
"PromptNotFoundError",
|
|
30
|
+
"PromptRenderError",
|
|
31
|
+
"ProviderError",
|
|
32
|
+
"ProviderResponseError",
|
|
33
|
+
"StructuredOutputError",
|
|
34
|
+
"ToolArgumentError",
|
|
35
|
+
"ToolExecutionError",
|
|
36
|
+
"ToolNotFoundError",
|
|
37
|
+
"UnknownModelAliasError",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AidRuntimeError(Exception):
|
|
42
|
+
"""Base class for every error raised by this library."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConfigurationError(AidRuntimeError):
|
|
46
|
+
"""The runtime was configured with something it cannot use."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UnknownModelAliasError(ConfigurationError):
|
|
50
|
+
"""An alias was requested that the gateway has no pinned model for.
|
|
51
|
+
|
|
52
|
+
Model pinning means the alias table is closed: an unknown alias is a
|
|
53
|
+
configuration error, never a silent pass-through to a provider default.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, alias: str, known: Sequence[str]) -> None:
|
|
57
|
+
listed = ", ".join(sorted(known)) or "<none>"
|
|
58
|
+
super().__init__(f"unknown model alias {alias!r}; pinned aliases: {listed}")
|
|
59
|
+
self.alias = alias
|
|
60
|
+
self.known = tuple(sorted(known))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CapabilityMismatchError(ConfigurationError):
|
|
64
|
+
"""A pinned model does not declare a capability the caller requires."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, alias: str, missing: Sequence[str]) -> None:
|
|
67
|
+
listed = ", ".join(sorted(missing))
|
|
68
|
+
super().__init__(f"model alias {alias!r} does not support: {listed}")
|
|
69
|
+
self.alias = alias
|
|
70
|
+
self.missing = tuple(sorted(missing))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BudgetExceededError(AidRuntimeError):
|
|
74
|
+
"""A cost or token budget was crossed.
|
|
75
|
+
|
|
76
|
+
Raised after a call is charged, so the ledger it reports has already recorded
|
|
77
|
+
the spend that crossed the line -- a caller that catches this still sees
|
|
78
|
+
truthful accounting rather than a pre-call estimate.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, scope: str, limit: float, spent: float, unit: str = "usd") -> None:
|
|
82
|
+
if unit == "usd":
|
|
83
|
+
limit_text = f"${limit:.6f}"
|
|
84
|
+
spent_text = f"${spent:.6f}"
|
|
85
|
+
over_text = f"${spent - limit:.6f}"
|
|
86
|
+
else:
|
|
87
|
+
limit_text = f"{limit:.0f} {unit}"
|
|
88
|
+
spent_text = f"{spent:.0f} {unit}"
|
|
89
|
+
over_text = f"{spent - limit:.0f} {unit}"
|
|
90
|
+
super().__init__(
|
|
91
|
+
f"budget {scope!r} exceeded: limit {limit_text}, spent {spent_text} "
|
|
92
|
+
f"(over by {over_text})"
|
|
93
|
+
)
|
|
94
|
+
self.scope = scope
|
|
95
|
+
self.limit = limit
|
|
96
|
+
self.spent = spent
|
|
97
|
+
self.unit = unit
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class FeatureDeferredError(AidRuntimeError):
|
|
101
|
+
"""A capability is declared for the IR contract but not implemented yet.
|
|
102
|
+
|
|
103
|
+
Raised for constructs the schema admits but the runtime does not yet serve:
|
|
104
|
+
TTL expiry and redaction on memory, several retrieval strategies and sources,
|
|
105
|
+
and the like. A generated app that declares one of these fails loudly at the
|
|
106
|
+
point of use instead of returning an approximate answer.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ProviderError(AidRuntimeError):
|
|
111
|
+
"""A provider call failed after exhausting retries."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ProviderResponseError(ProviderError):
|
|
115
|
+
"""The provider answered with a body we could not interpret."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, message: str, body: str | None = None) -> None:
|
|
118
|
+
super().__init__(message if body is None else f"{message}: {body[:500]}")
|
|
119
|
+
self.body = body
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class PromptNotFoundError(AidRuntimeError):
|
|
123
|
+
"""No prompt is registered under the requested name and version."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, name: str, version: str | None, known: Sequence[str]) -> None:
|
|
126
|
+
suffix = " (latest)" if version is None else f" version {version!r}"
|
|
127
|
+
listed = ", ".join(sorted(known)) or "<none>"
|
|
128
|
+
super().__init__(f"no prompt {name!r}{suffix}; registered: {listed}")
|
|
129
|
+
self.name = name
|
|
130
|
+
self.version = version
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class PromptRenderError(AidRuntimeError):
|
|
134
|
+
"""A prompt template could not be rendered from the supplied variables."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class StructuredOutputError(AidRuntimeError):
|
|
138
|
+
"""A model response could not be coerced into the requested output type."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, message: str, *, attempts: int, problems: Sequence[str] = ()) -> None:
|
|
141
|
+
detail = f" after {attempts} attempt(s)"
|
|
142
|
+
if problems:
|
|
143
|
+
detail += ": " + "; ".join(problems)
|
|
144
|
+
super().__init__(message + detail)
|
|
145
|
+
self.attempts = attempts
|
|
146
|
+
self.problems = tuple(problems)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ToolNotFoundError(AidRuntimeError):
|
|
150
|
+
"""A tool call named a tool the executor does not have."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, name: str, known: Sequence[str]) -> None:
|
|
153
|
+
listed = ", ".join(sorted(known)) or "<none>"
|
|
154
|
+
super().__init__(f"unknown tool {name!r}; registered: {listed}")
|
|
155
|
+
self.name = name
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ToolArgumentError(AidRuntimeError):
|
|
159
|
+
"""Tool call arguments failed schema validation."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, tool: str, problems: Sequence[str]) -> None:
|
|
162
|
+
super().__init__(f"invalid arguments for tool {tool!r}: {'; '.join(problems)}")
|
|
163
|
+
self.tool = tool
|
|
164
|
+
self.problems = tuple(problems)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ToolExecutionError(AidRuntimeError):
|
|
168
|
+
"""A tool handler raised."""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class PipelineError(AidRuntimeError):
|
|
172
|
+
"""A pipeline step failed or the pipeline was wired incorrectly."""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class PolicyEvaluationError(AidRuntimeError):
|
|
176
|
+
"""A parsed ``PolicyExpr`` referenced something the evaluator cannot decide.
|
|
177
|
+
|
|
178
|
+
The IR's policy grammar is closed, and a comparison between incompatible
|
|
179
|
+
values has no truth value. Failing closed here is what keeps a HITL gate from
|
|
180
|
+
silently deciding *not* to ask for approval.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class GuardrailBlockedError(AidRuntimeError):
|
|
185
|
+
"""A guardrail stopped the run before (or after) the guarded effect.
|
|
186
|
+
|
|
187
|
+
Raised by the ``block`` action, and by any guardrail the engine cannot
|
|
188
|
+
evaluate. An undecidable policy is a block, never an allow: the failure mode
|
|
189
|
+
this class exists to prevent is a check that quietly returns "no violation"
|
|
190
|
+
because its input did not match what it expected.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self, guardrail: str, stage: str, message: str) -> None:
|
|
194
|
+
super().__init__(f"guardrail {guardrail!r} blocked the {stage} stage: {message}")
|
|
195
|
+
self.guardrail = guardrail
|
|
196
|
+
self.stage = stage
|
|
197
|
+
self.message = message
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ToolPermissionError(AidRuntimeError):
|
|
201
|
+
"""An agent asked for a tool it was not granted, or one its auth policy denies.
|
|
202
|
+
|
|
203
|
+
Raised before the handler body runs. The check is deliberately not a tool
|
|
204
|
+
result: a model can repair a bad argument, but it cannot repair a missing
|
|
205
|
+
grant, so the caller is the party that has to fix this.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(self, agent: str, tool: str, reason: str) -> None:
|
|
209
|
+
super().__init__(f"agent {agent!r} may not call tool {tool!r}: {reason}")
|
|
210
|
+
self.agent = agent
|
|
211
|
+
self.tool = tool
|
|
212
|
+
self.reason = reason
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class AgentTransferCycleError(AidRuntimeError):
|
|
216
|
+
"""A transfer chain revisited an agent that already held control.
|
|
217
|
+
|
|
218
|
+
``agents/handoff-cycles`` rejects this at build time; the runtime refuses it
|
|
219
|
+
again rather than trusting that every caller came through the gate.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(self, path: Sequence[str]) -> None:
|
|
223
|
+
super().__init__(f"transfer cycle: {' -> '.join(path)}")
|
|
224
|
+
self.path = tuple(path)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class AgentBudgetDegradedError(AidRuntimeError):
|
|
228
|
+
"""A budget was crossed and there is no cheaper alias left to route to.
|
|
229
|
+
|
|
230
|
+
``ir/agents-budget-degrade`` rejects this at build time for declared specs;
|
|
231
|
+
this is the run-time counterpart for a hand-built IR.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class EvalError(AidRuntimeError):
|
|
236
|
+
"""An eval run could not be completed."""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class AgentHandoffUnsupportedError(AidRuntimeError):
|
|
240
|
+
"""An agent asked for ``handoffs`` on an engine that has no transfer primitive.
|
|
241
|
+
|
|
242
|
+
Deep Agents has ``subagents`` -- delegation that *returns* -- and no transfer:
|
|
243
|
+
control never moves, the caller always resumes and always authors the final
|
|
244
|
+
answer. Emulating transfer with a delegate would change who answers, so the
|
|
245
|
+
engine refuses instead of silently degrading ``handoffs`` into a delegate.
|
|
246
|
+
Transfer therefore stays native-only, and the IR gate plus this error are the
|
|
247
|
+
two places that say so.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def __init__(self, agent: str, targets: Sequence[str], engine: str) -> None:
|
|
251
|
+
super().__init__(
|
|
252
|
+
f"agent {agent!r} declares handoffs {list(targets)} but runs on the {engine!r} "
|
|
253
|
+
"engine, which has no transfer primitive; transfer is native-only, and "
|
|
254
|
+
"delegation (delegates) is the deepagents equivalent"
|
|
255
|
+
)
|
|
256
|
+
self.agent = agent
|
|
257
|
+
self.targets = tuple(targets)
|
|
258
|
+
self.engine = engine
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class DeepAgentsEngineError(AidRuntimeError):
|
|
262
|
+
"""The ``deepagents`` engine cannot be used as configured.
|
|
263
|
+
|
|
264
|
+
Base class so a caller can catch "the backend refused to start" without
|
|
265
|
+
enumerating the two reasons it can refuse.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class DeepAgentsUnavailableError(DeepAgentsEngineError):
|
|
270
|
+
"""The ``deepagents`` optional extra is not installed.
|
|
271
|
+
|
|
272
|
+
A named error rather than an ``ImportError`` because the fix is an install
|
|
273
|
+
instruction the caller can act on, and because an agent silently falling back
|
|
274
|
+
to the native loop would leave ``engine`` in the IR meaning nothing.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def __init__(self, extra: str, distribution: str) -> None:
|
|
278
|
+
super().__init__(
|
|
279
|
+
f"this agent runs on the 'deepagents' engine, but the {distribution!r} package "
|
|
280
|
+
f"is not installed; install the extra with: pip install '{extra}'"
|
|
281
|
+
)
|
|
282
|
+
self.extra = extra
|
|
283
|
+
self.distribution = distribution
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class DeepAgentsMaxStepsError(DeepAgentsEngineError):
|
|
287
|
+
"""The harness asked for one model call more than ``maxSteps`` allows.
|
|
288
|
+
|
|
289
|
+
Raised by the adapter rather than the harness, because the harness's own call
|
|
290
|
+
limiter does not fail: it appends a synthetic "model call limits exceeded"
|
|
291
|
+
answer and lets the run finish, which would report an exhausted agent as a
|
|
292
|
+
successful one. The engine translates this into ``RunStatus.MAX_STEPS``, the
|
|
293
|
+
same outcome the native loop reports for the same condition.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
def __init__(self, alias: str, max_steps: int) -> None:
|
|
297
|
+
super().__init__(
|
|
298
|
+
f"the deepagents harness requested model call {max_steps + 1} on alias "
|
|
299
|
+
f"{alias!r}, exceeding maxSteps {max_steps}; the run is exhausted"
|
|
300
|
+
)
|
|
301
|
+
self.alias = alias
|
|
302
|
+
self.max_steps = max_steps
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class DeepAgentsVersionMismatchError(DeepAgentsEngineError):
|
|
306
|
+
"""``engineVersion`` does not match the release this runtime is pinned to.
|
|
307
|
+
|
|
308
|
+
The IR gate refuses ``engine: deepagents`` without a pinned ``engineVersion``
|
|
309
|
+
precisely so that this comparison can exist: an unpinned engine is a backend
|
|
310
|
+
whose behaviour can change under a stored eval baseline. A mismatch is
|
|
311
|
+
therefore a hard failure, never a warning, and it fires in both directions --
|
|
312
|
+
a declared version this runtime is not built for, and a run-time that cannot
|
|
313
|
+
find the declaration at all.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
def __init__(self, agent: str, declared: str, pinned: str) -> None:
|
|
317
|
+
detail = (
|
|
318
|
+
"the IR declares no engineVersion"
|
|
319
|
+
if not declared
|
|
320
|
+
else f"the IR declares engineVersion {declared!r}"
|
|
321
|
+
)
|
|
322
|
+
super().__init__(
|
|
323
|
+
f"agent {agent!r}: {detail}, but this runtime is pinned to deepagents=={pinned}; "
|
|
324
|
+
f"set engineVersion to {pinned!r} or install the runtime that matches"
|
|
325
|
+
)
|
|
326
|
+
self.agent = agent
|
|
327
|
+
self.declared = declared
|
|
328
|
+
self.pinned = pinned
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class GateConfigurationError(EvalError):
|
|
332
|
+
"""The gate was configured in a way that cannot be trusted as a CI signal.
|
|
333
|
+
|
|
334
|
+
The load-bearing case: a threshold that references a metric whose result is
|
|
335
|
+
itself produced by a model. Gating on one source of non-determinism using
|
|
336
|
+
another is not a gate.
|
|
337
|
+
"""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Eval harness: datasets, metrics, N-run sampling, and the baseline gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from aid_runtime.evals.dataset import Dataset, EvalCase
|
|
6
|
+
from aid_runtime.evals.gate import (
|
|
7
|
+
BaselineGate,
|
|
8
|
+
EvalBaseline,
|
|
9
|
+
EvalRun,
|
|
10
|
+
GateResult,
|
|
11
|
+
MetricBaseline,
|
|
12
|
+
MetricGateResult,
|
|
13
|
+
MetricRun,
|
|
14
|
+
Threshold,
|
|
15
|
+
gate_from_config,
|
|
16
|
+
z_critical,
|
|
17
|
+
)
|
|
18
|
+
from aid_runtime.evals.metrics import (
|
|
19
|
+
AssertMetric,
|
|
20
|
+
ExactMatch,
|
|
21
|
+
JsonSchemaValid,
|
|
22
|
+
JudgeVerdict,
|
|
23
|
+
LlmJudge,
|
|
24
|
+
Metric,
|
|
25
|
+
MetricResult,
|
|
26
|
+
NormalizedContains,
|
|
27
|
+
default_metrics,
|
|
28
|
+
metric_names,
|
|
29
|
+
normalize,
|
|
30
|
+
)
|
|
31
|
+
from aid_runtime.evals.runner import (
|
|
32
|
+
CaseOutcome,
|
|
33
|
+
DatasetRun,
|
|
34
|
+
GatewayRunner,
|
|
35
|
+
Runner,
|
|
36
|
+
evaluate_once,
|
|
37
|
+
gateway_judge,
|
|
38
|
+
run_evaluation,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"AssertMetric",
|
|
43
|
+
"BaselineGate",
|
|
44
|
+
"CaseOutcome",
|
|
45
|
+
"Dataset",
|
|
46
|
+
"DatasetRun",
|
|
47
|
+
"EvalBaseline",
|
|
48
|
+
"EvalCase",
|
|
49
|
+
"EvalRun",
|
|
50
|
+
"ExactMatch",
|
|
51
|
+
"GateResult",
|
|
52
|
+
"GatewayRunner",
|
|
53
|
+
"JsonSchemaValid",
|
|
54
|
+
"JudgeVerdict",
|
|
55
|
+
"LlmJudge",
|
|
56
|
+
"Metric",
|
|
57
|
+
"MetricBaseline",
|
|
58
|
+
"MetricGateResult",
|
|
59
|
+
"MetricResult",
|
|
60
|
+
"MetricRun",
|
|
61
|
+
"NormalizedContains",
|
|
62
|
+
"Runner",
|
|
63
|
+
"Threshold",
|
|
64
|
+
"default_metrics",
|
|
65
|
+
"evaluate_once",
|
|
66
|
+
"gate_from_config",
|
|
67
|
+
"gateway_judge",
|
|
68
|
+
"metric_names",
|
|
69
|
+
"normalize",
|
|
70
|
+
"run_evaluation",
|
|
71
|
+
"z_critical",
|
|
72
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Eval datasets: the probabilistic analogue of a golden fixture.
|
|
2
|
+
|
|
3
|
+
A dataset is versioned and committed. The version matters as much as the cases:
|
|
4
|
+
comparing a run against a baseline recorded on a *different* dataset version is
|
|
5
|
+
the most common way an eval gate becomes noise, so the gate refuses it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
14
|
+
|
|
15
|
+
from aid_runtime.errors import EvalError
|
|
16
|
+
|
|
17
|
+
__all__ = ["Dataset", "EvalCase"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EvalCase(BaseModel):
|
|
21
|
+
"""One input, its expected output, and any tags the metrics key off."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
24
|
+
|
|
25
|
+
id: str = Field(min_length=1)
|
|
26
|
+
input: str
|
|
27
|
+
expected: str | None = None
|
|
28
|
+
tags: tuple[str, ...] = ()
|
|
29
|
+
metadata: dict[str, str] = Field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Dataset(BaseModel):
|
|
33
|
+
"""An ordered, versioned set of cases."""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
36
|
+
|
|
37
|
+
name: str = Field(min_length=1)
|
|
38
|
+
version: str = Field(min_length=1)
|
|
39
|
+
cases: tuple[EvalCase, ...] = Field(min_length=1)
|
|
40
|
+
|
|
41
|
+
@field_validator("cases", mode="before")
|
|
42
|
+
@classmethod
|
|
43
|
+
def _coerce_cases(cls, value: object) -> object:
|
|
44
|
+
if isinstance(value, list):
|
|
45
|
+
return tuple(value)
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
def __len__(self) -> int:
|
|
49
|
+
return len(self.cases)
|
|
50
|
+
|
|
51
|
+
def ids(self) -> tuple[str, ...]:
|
|
52
|
+
return tuple(case.id for case in self.cases)
|
|
53
|
+
|
|
54
|
+
def case(self, case_id: str) -> EvalCase:
|
|
55
|
+
for candidate in self.cases:
|
|
56
|
+
if candidate.id == case_id:
|
|
57
|
+
return candidate
|
|
58
|
+
raise EvalError(f"dataset {self.name!r} has no case {case_id!r}")
|
|
59
|
+
|
|
60
|
+
def by_tag(self, tag: str) -> tuple[EvalCase, ...]:
|
|
61
|
+
return tuple(case for case in self.cases if tag in case.tags)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_records(cls, name: str, version: str, records: list[dict[str, object]]) -> Dataset:
|
|
65
|
+
return cls.model_validate({"name": name, "version": version, "cases": records})
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_json(cls, payload: str) -> Dataset:
|
|
69
|
+
return cls.model_validate_json(payload)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_path(cls, path: Path | str) -> Dataset:
|
|
73
|
+
return cls.from_json(Path(path).read_text(encoding="utf-8"))
|
|
74
|
+
|
|
75
|
+
def to_json(self) -> str:
|
|
76
|
+
"""Canonical JSON: sorted keys, so a dataset diff is reviewable."""
|
|
77
|
+
return json.dumps(self.model_dump(mode="json"), indent=2, sort_keys=True) + "\n"
|