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,241 @@
|
|
|
1
|
+
"""Agent memory: thread scope, and one durable adapter.
|
|
2
|
+
|
|
3
|
+
Ratified decision D5 draws the line this module implements: in-process memory is
|
|
4
|
+
the default, exactly one store-backed adapter ships, and there is no plugin zoo.
|
|
5
|
+
A ``backend`` naming a declared store is served by whatever store the caller
|
|
6
|
+
registered under that id; with none registered, the in-process store answers.
|
|
7
|
+
|
|
8
|
+
``thread`` scope is the checkpointer unit -- the conversation the loop is already
|
|
9
|
+
carrying -- so it needs no store and no key. ``user`` and ``global`` scope are the
|
|
10
|
+
cross-run facts, keyed by a caller-supplied key and stored behind
|
|
11
|
+
:class:`MemoryStore`. The IR vocabulary is ``thread``/``user``/``global``; the
|
|
12
|
+
``session`` spelling L1 removed is not reintroduced here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import tempfile
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
from aid_runtime.cost import estimate_tokens
|
|
26
|
+
from aid_runtime.errors import ConfigurationError, FeatureDeferredError
|
|
27
|
+
from aid_runtime.types import Message
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"IMPLEMENTED_MEMORY_KINDS",
|
|
31
|
+
"MEMORY_SCOPES",
|
|
32
|
+
"FileMemoryStore",
|
|
33
|
+
"InProcessMemoryStore",
|
|
34
|
+
"MemoryStore",
|
|
35
|
+
"ThreadMemory",
|
|
36
|
+
"check_memory_supported",
|
|
37
|
+
"render_context",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
#: The IR's scope vocabulary. ``session`` is deliberately absent.
|
|
41
|
+
MEMORY_SCOPES = ("thread", "user", "global")
|
|
42
|
+
|
|
43
|
+
#: The kinds served without a summarizer or an embedding model. The rest are
|
|
44
|
+
#: declared in the IR and refused by name, never silently downgraded.
|
|
45
|
+
IMPLEMENTED_MEMORY_KINDS = ("buffer", "window")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class MemoryStore(Protocol):
|
|
50
|
+
"""Append-only, scoped storage behind the memory scopes that outlive a run."""
|
|
51
|
+
|
|
52
|
+
async def read(self, *, scope: str, key: str) -> tuple[str, ...]:
|
|
53
|
+
"""Every value recorded for ``(scope, key)``, oldest first."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
async def append(self, *, scope: str, key: str, value: str) -> None:
|
|
57
|
+
"""Record one value. Append-only is what makes replay deterministic."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InProcessMemoryStore:
|
|
62
|
+
"""The default: a dict, lost when the process ends.
|
|
63
|
+
|
|
64
|
+
That is the honest default for a library that must not decide where a host
|
|
65
|
+
application keeps its data. A durable adapter is opt-in, and there is one.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self._values: dict[tuple[str, str], list[str]] = {}
|
|
70
|
+
|
|
71
|
+
async def read(self, *, scope: str, key: str) -> tuple[str, ...]:
|
|
72
|
+
return tuple(self._values.get((scope, key), ()))
|
|
73
|
+
|
|
74
|
+
async def append(self, *, scope: str, key: str, value: str) -> None:
|
|
75
|
+
self._values.setdefault((scope, key), []).append(value)
|
|
76
|
+
|
|
77
|
+
def clear(self) -> None:
|
|
78
|
+
self._values.clear()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class FileMemoryStore:
|
|
82
|
+
"""One JSON file, one lock-free writer.
|
|
83
|
+
|
|
84
|
+
The file is rewritten through a temporary file and an atomic replace so a crash
|
|
85
|
+
mid-write cannot leave a half-file that a later read would parse as truth.
|
|
86
|
+
Writes are serialized by an ``asyncio.Lock``; the file I/O itself runs in a
|
|
87
|
+
worker thread so the loop is not blocked by disk.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, path: str | Path) -> None:
|
|
91
|
+
self._path = Path(path)
|
|
92
|
+
self._lock = asyncio.Lock()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def path(self) -> Path:
|
|
96
|
+
return self._path
|
|
97
|
+
|
|
98
|
+
async def read(self, *, scope: str, key: str) -> tuple[str, ...]:
|
|
99
|
+
data = await asyncio.to_thread(self._load)
|
|
100
|
+
return tuple(data.get(scope, {}).get(key, ()))
|
|
101
|
+
|
|
102
|
+
async def append(self, *, scope: str, key: str, value: str) -> None:
|
|
103
|
+
async with self._lock:
|
|
104
|
+
data = await asyncio.to_thread(self._load)
|
|
105
|
+
data.setdefault(scope, {}).setdefault(key, []).append(value)
|
|
106
|
+
await asyncio.to_thread(self._save, data)
|
|
107
|
+
|
|
108
|
+
def _load(self) -> dict[str, dict[str, list[str]]]:
|
|
109
|
+
if not self._path.exists():
|
|
110
|
+
return {}
|
|
111
|
+
try:
|
|
112
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
113
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
114
|
+
raise ConfigurationError(f"memory store {self._path} is unreadable: {exc}") from exc
|
|
115
|
+
if not isinstance(raw, dict):
|
|
116
|
+
raise ConfigurationError(f"memory store {self._path} is not a JSON object")
|
|
117
|
+
return {
|
|
118
|
+
str(scope): {str(key): [str(value) for value in values] for key, values in keys.items()}
|
|
119
|
+
for scope, keys in raw.items()
|
|
120
|
+
if isinstance(keys, dict)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def _save(self, data: dict[str, dict[str, list[str]]]) -> None:
|
|
124
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
handle, temporary = tempfile.mkstemp(
|
|
126
|
+
dir=self._path.parent, prefix=f".{self._path.name}.", suffix=".tmp"
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
|
130
|
+
json.dump(data, stream, sort_keys=True, separators=(",", ":"))
|
|
131
|
+
Path(temporary).replace(self._path)
|
|
132
|
+
except OSError:
|
|
133
|
+
Path(temporary).unlink(missing_ok=True)
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_memory_supported(
|
|
138
|
+
*,
|
|
139
|
+
memory_id: str,
|
|
140
|
+
kind: str,
|
|
141
|
+
summarize_model: str | None,
|
|
142
|
+
ttl: str | None,
|
|
143
|
+
redact: Sequence[str],
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Refuse a declared memory the runtime cannot honour exactly.
|
|
146
|
+
|
|
147
|
+
``summary``, ``semantic``, ``episodic`` and ``vector`` would each need a model
|
|
148
|
+
or an index that L2 does not have, and so would ``summarizeModel``; ``ttl``
|
|
149
|
+
needs a clock and ``redact`` needs redaction on the tracing path. Serving them
|
|
150
|
+
approximately would make the declaration lie, so they are refused by name.
|
|
151
|
+
"""
|
|
152
|
+
if kind not in IMPLEMENTED_MEMORY_KINDS:
|
|
153
|
+
raise FeatureDeferredError(
|
|
154
|
+
f"memory {memory_id!r} asks for kind {kind!r}; L2 implements "
|
|
155
|
+
f"{', '.join(IMPLEMENTED_MEMORY_KINDS)} and refuses the rest by name"
|
|
156
|
+
)
|
|
157
|
+
if summarize_model is not None:
|
|
158
|
+
raise FeatureDeferredError(
|
|
159
|
+
f"memory {memory_id!r} names summarizeModel {summarize_model!r}; "
|
|
160
|
+
"no summarizer is implemented, so an unbounded buffer is served instead"
|
|
161
|
+
)
|
|
162
|
+
if ttl is not None:
|
|
163
|
+
raise FeatureDeferredError(
|
|
164
|
+
f"memory {memory_id!r} declares ttl {ttl!r}; expiry enforcement is not implemented"
|
|
165
|
+
)
|
|
166
|
+
if redact:
|
|
167
|
+
raise FeatureDeferredError(
|
|
168
|
+
f"memory {memory_id!r} declares redaction rules; memory redaction is not implemented"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ThreadMemory:
|
|
173
|
+
"""Bounds the transcript a single agent turn may replay.
|
|
174
|
+
|
|
175
|
+
``thread`` scope is the conversation the loop is already carrying, so this is
|
|
176
|
+
a trimmer rather than a store: when the transcript exceeds ``token_budget``,
|
|
177
|
+
whole tool-call groups are dropped oldest-first. A group is an assistant turn
|
|
178
|
+
with its tool observations, because dropping one without the other would
|
|
179
|
+
replay a tool result whose call is gone.
|
|
180
|
+
|
|
181
|
+
A message larger than the whole budget is kept rather than truncated: half a
|
|
182
|
+
tool observation is worse than an over-budget one, and the budget is a target
|
|
183
|
+
for what to *replay*, not a hard accounting ceiling.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(self, token_budget: int | None = None) -> None:
|
|
187
|
+
if token_budget is not None and token_budget < 1:
|
|
188
|
+
raise ConfigurationError("thread memory tokenBudget must be positive")
|
|
189
|
+
self._token_budget = token_budget
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def token_budget(self) -> int | None:
|
|
193
|
+
return self._token_budget
|
|
194
|
+
|
|
195
|
+
def tokens(self, messages: Sequence[Message]) -> int:
|
|
196
|
+
return sum(estimate_tokens(message.content) for message in messages)
|
|
197
|
+
|
|
198
|
+
def trim(self, messages: Sequence[Message]) -> tuple[Message, ...]:
|
|
199
|
+
if self._token_budget is None:
|
|
200
|
+
return tuple(messages)
|
|
201
|
+
kept = list(messages)
|
|
202
|
+
total = self.tokens(kept)
|
|
203
|
+
start = next(
|
|
204
|
+
(index for index, message in enumerate(kept) if message.tool_calls),
|
|
205
|
+
len(kept),
|
|
206
|
+
)
|
|
207
|
+
while total > self._token_budget and start < len(kept):
|
|
208
|
+
end = start + 1
|
|
209
|
+
while end < len(kept) and kept[end].role == "tool":
|
|
210
|
+
end += 1
|
|
211
|
+
total -= self.tokens(kept[start:end])
|
|
212
|
+
del kept[start:end]
|
|
213
|
+
while total > self._token_budget:
|
|
214
|
+
victim = next(
|
|
215
|
+
(
|
|
216
|
+
index
|
|
217
|
+
for index, message in enumerate(kept)
|
|
218
|
+
if message.role != "system" and index < len(kept) - 1
|
|
219
|
+
),
|
|
220
|
+
None,
|
|
221
|
+
)
|
|
222
|
+
if victim is None:
|
|
223
|
+
break
|
|
224
|
+
total -= estimate_tokens(kept[victim].content)
|
|
225
|
+
del kept[victim]
|
|
226
|
+
return tuple(kept)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def render_context(entries: Sequence[str], token_budget: int) -> tuple[str, int]:
|
|
230
|
+
"""Fit stored entries into ``token_budget``, newest first. Returns text, tokens."""
|
|
231
|
+
chosen: list[str] = []
|
|
232
|
+
tokens = 0
|
|
233
|
+
for entry in reversed(entries):
|
|
234
|
+
cost = estimate_tokens(entry) + 1
|
|
235
|
+
if chosen and tokens + cost > token_budget:
|
|
236
|
+
break
|
|
237
|
+
chosen.append(entry)
|
|
238
|
+
tokens += cost
|
|
239
|
+
if tokens >= token_budget:
|
|
240
|
+
break
|
|
241
|
+
return "\n".join(reversed(chosen)), tokens
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Evaluation of the IR's ``PolicyExpr`` AST.
|
|
2
|
+
|
|
3
|
+
The grammar is the one ``packages/ir/src/policy`` parses, and the AST is plain
|
|
4
|
+
JSON precisely so a target does not need a second parser:
|
|
5
|
+
|
|
6
|
+
expression := or
|
|
7
|
+
or := and ('or' and)*
|
|
8
|
+
and := comparison ('and' comparison)*
|
|
9
|
+
comparison := unary (('==' | '!=' | '<' | '<=' | '>' | '>=') unary)?
|
|
10
|
+
unary := 'not' unary | primary
|
|
11
|
+
primary := 'true' | 'false' | string | number | call | '(' expression ')'
|
|
12
|
+
call := builtin '(' (operand (',' operand)*)? ')'
|
|
13
|
+
|
|
14
|
+
The builtins are the closed set ``hasRole``, ``hasScope``, ``isAuthenticated``, so
|
|
15
|
+
the context this evaluates against is a :class:`Principal` -- who is asking --
|
|
16
|
+
and not loop state such as a step counter. Anything the evaluator cannot decide
|
|
17
|
+
raises: for a human-in-the-loop gate, "could not decide" must never degrade into
|
|
18
|
+
"do not ask".
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from aid_runtime.errors import PolicyEvaluationError
|
|
28
|
+
|
|
29
|
+
__all__ = ["Principal", "evaluate_policy", "policy_source"]
|
|
30
|
+
|
|
31
|
+
_COMPARISONS = frozenset({"==", "!=", "<", "<=", ">", ">="})
|
|
32
|
+
_LOGICAL = frozenset({"and", "or"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Principal:
|
|
37
|
+
"""Who a policy is evaluated for.
|
|
38
|
+
|
|
39
|
+
Defaults are the anonymous-but-authenticated caller an app with no auth layer
|
|
40
|
+
has: ``isAuthenticated()`` is true and both role and scope sets are empty, so
|
|
41
|
+
``hasRole('admin')`` is false rather than undefined.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
roles: frozenset[str] = frozenset()
|
|
45
|
+
scopes: frozenset[str] = frozenset()
|
|
46
|
+
authenticated: bool = True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def evaluate_policy(node: Mapping[str, Any], principal: Principal) -> bool:
|
|
50
|
+
"""Evaluate a parsed policy against ``principal``. Never returns "maybe"."""
|
|
51
|
+
value = _evaluate(node, principal)
|
|
52
|
+
if not isinstance(value, bool):
|
|
53
|
+
raise PolicyEvaluationError(
|
|
54
|
+
f"policy did not evaluate to a boolean, got {type(value).__name__}: "
|
|
55
|
+
f"{policy_source(node)}"
|
|
56
|
+
)
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def policy_source(node: Mapping[str, Any]) -> str:
|
|
61
|
+
"""Render an AST back to source, so a trace or error can quote the policy."""
|
|
62
|
+
kind = node.get("kind")
|
|
63
|
+
if kind in {"string", "number", "boolean"}:
|
|
64
|
+
return _literal_source(node.get("value"))
|
|
65
|
+
if kind == "call":
|
|
66
|
+
arguments = ", ".join(policy_source(argument) for argument in node.get("args", ()))
|
|
67
|
+
return f"{node.get('name')}({arguments})"
|
|
68
|
+
if kind == "comparison":
|
|
69
|
+
return f"{policy_source(node['left'])} {node.get('op')} {policy_source(node['right'])}"
|
|
70
|
+
if kind in _LOGICAL:
|
|
71
|
+
return f"({policy_source(node['left'])} {kind} {policy_source(node['right'])})"
|
|
72
|
+
if kind == "not":
|
|
73
|
+
return f"not {policy_source(node['operand'])}"
|
|
74
|
+
raise PolicyEvaluationError(f"unsupported policy node kind {kind!r}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _literal_source(value: Any) -> str:
|
|
78
|
+
if isinstance(value, bool):
|
|
79
|
+
return "true" if value else "false"
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
return f'"{value}"'
|
|
82
|
+
return str(value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _evaluate(node: Mapping[str, Any], principal: Principal) -> Any:
|
|
86
|
+
kind = node.get("kind")
|
|
87
|
+
if kind in {"string", "number", "boolean"}:
|
|
88
|
+
return node.get("value")
|
|
89
|
+
if kind == "call":
|
|
90
|
+
return _call(node, principal)
|
|
91
|
+
if kind == "comparison":
|
|
92
|
+
return _compare(node, principal)
|
|
93
|
+
if kind in _LOGICAL:
|
|
94
|
+
return _logic(kind, node, principal)
|
|
95
|
+
if kind == "not":
|
|
96
|
+
return not _as_bool(_evaluate(node["operand"], principal), node)
|
|
97
|
+
raise PolicyEvaluationError(f"unsupported policy node kind {kind!r}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _call(node: Mapping[str, Any], principal: Principal) -> bool:
|
|
101
|
+
name = node.get("name")
|
|
102
|
+
arguments = [_evaluate(argument, principal) for argument in node.get("args", ())]
|
|
103
|
+
if name == "isAuthenticated":
|
|
104
|
+
if arguments:
|
|
105
|
+
raise PolicyEvaluationError("isAuthenticated() takes no arguments")
|
|
106
|
+
return principal.authenticated
|
|
107
|
+
if name == "hasRole":
|
|
108
|
+
return _membership(name, arguments, principal.roles)
|
|
109
|
+
if name == "hasScope":
|
|
110
|
+
return _membership(name, arguments, principal.scopes)
|
|
111
|
+
raise PolicyEvaluationError(f"unknown policy builtin {name!r}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _membership(name: str, arguments: list[Any], held: frozenset[str]) -> bool:
|
|
115
|
+
if len(arguments) != 1:
|
|
116
|
+
raise PolicyEvaluationError(f"{name}() takes exactly one argument")
|
|
117
|
+
(wanted,) = arguments
|
|
118
|
+
if not isinstance(wanted, str):
|
|
119
|
+
raise PolicyEvaluationError(f"{name}() argument must be a string, got {wanted!r}")
|
|
120
|
+
return wanted in held
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _compare(node: Mapping[str, Any], principal: Principal) -> bool:
|
|
124
|
+
operator = node.get("op")
|
|
125
|
+
if operator not in _COMPARISONS:
|
|
126
|
+
raise PolicyEvaluationError(f"unknown comparison operator {operator!r}")
|
|
127
|
+
left = _evaluate(node["left"], principal)
|
|
128
|
+
right = _evaluate(node["right"], principal)
|
|
129
|
+
if operator == "==":
|
|
130
|
+
return bool(left == right)
|
|
131
|
+
if operator == "!=":
|
|
132
|
+
return bool(left != right)
|
|
133
|
+
try:
|
|
134
|
+
if operator == "<":
|
|
135
|
+
return bool(left < right)
|
|
136
|
+
if operator == "<=":
|
|
137
|
+
return bool(left <= right)
|
|
138
|
+
if operator == ">":
|
|
139
|
+
return bool(left > right)
|
|
140
|
+
return bool(left >= right)
|
|
141
|
+
except TypeError as exc:
|
|
142
|
+
raise PolicyEvaluationError(
|
|
143
|
+
f"cannot compare {type(left).__name__} {operator} {type(right).__name__}: "
|
|
144
|
+
f"{policy_source(node)}"
|
|
145
|
+
) from exc
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _logic(kind: str, node: Mapping[str, Any], principal: Principal) -> bool:
|
|
149
|
+
left = _as_bool(_evaluate(node["left"], principal), node)
|
|
150
|
+
# ``and``/``or`` short-circuit on the left operand's truth value.
|
|
151
|
+
if kind == "and" and not left:
|
|
152
|
+
return False
|
|
153
|
+
if kind == "or" and left:
|
|
154
|
+
return True
|
|
155
|
+
return _as_bool(_evaluate(node["right"], principal), node)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _as_bool(value: Any, node: Mapping[str, Any]) -> bool:
|
|
159
|
+
if not isinstance(value, bool):
|
|
160
|
+
raise PolicyEvaluationError(
|
|
161
|
+
f"expected a boolean operand in {policy_source(node)}, got {value!r}"
|
|
162
|
+
)
|
|
163
|
+
return value
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The value types of a run: its steps, its result, and its pause.
|
|
2
|
+
|
|
3
|
+
These live together because they are one shape: a checkpoint *is* a result that
|
|
4
|
+
stopped early, and a resumed result is a checkpoint plus more steps. Keeping them
|
|
5
|
+
in one module is also what lets ``agents.loop`` and a caller share the types
|
|
6
|
+
without an import cycle.
|
|
7
|
+
|
|
8
|
+
:class:`AgentCheckpoint` carries the steps completed so far, so a run that paused
|
|
9
|
+
and resumed reports the whole transcript of steps rather than only the part that
|
|
10
|
+
happened after the resume -- a trace that forgets the pre-pause steps is not a
|
|
11
|
+
trace of the run.
|
|
12
|
+
|
|
13
|
+
``caller`` is the frame that was awaiting a *delegated* result when the pause
|
|
14
|
+
happened inside the delegate. Transfer has no caller to return to, which is the
|
|
15
|
+
distinction the loop is built on; a transfer checkpoint never has one.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from enum import StrEnum
|
|
21
|
+
from typing import Any, Literal
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
24
|
+
|
|
25
|
+
from aid_runtime.agents.guardrails import GuardrailEscalation, GuardrailRecord
|
|
26
|
+
from aid_runtime.types import Message, ToolCall
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AgentCheckpoint",
|
|
30
|
+
"HumanDecision",
|
|
31
|
+
"PendingApproval",
|
|
32
|
+
"RunResult",
|
|
33
|
+
"RunStatus",
|
|
34
|
+
"StepKind",
|
|
35
|
+
"StepRecord",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RunStatus(StrEnum):
|
|
40
|
+
"""How a run ended.
|
|
41
|
+
|
|
42
|
+
``PAUSED`` is an outcome, not an error. So is ``BLOCKED``: a human rejected an
|
|
43
|
+
escalation the guardrails raised, which is a decision the run respected rather
|
|
44
|
+
than a failure it suffered.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
COMPLETED = "completed"
|
|
48
|
+
PAUSED = "paused"
|
|
49
|
+
MAX_STEPS = "max-steps"
|
|
50
|
+
BLOCKED = "blocked"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class StepKind(StrEnum):
|
|
54
|
+
"""What one model step did with its output."""
|
|
55
|
+
|
|
56
|
+
ANSWER = "answer"
|
|
57
|
+
TOOLS = "tools"
|
|
58
|
+
TRANSFER = "transfer"
|
|
59
|
+
DELEGATE = "delegate"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class StepRecord(BaseModel):
|
|
63
|
+
"""One model call, and what was done with its output.
|
|
64
|
+
|
|
65
|
+
``degraded_from`` is set only on the step that stepped down the alias ladder,
|
|
66
|
+
so "did this run degrade?" is a question the trace answers without arithmetic.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(extra="forbid")
|
|
70
|
+
|
|
71
|
+
index: int = Field(ge=1)
|
|
72
|
+
agent_id: str
|
|
73
|
+
kind: StepKind
|
|
74
|
+
alias: str
|
|
75
|
+
model: str
|
|
76
|
+
cost_usd: float = Field(ge=0.0)
|
|
77
|
+
total_tokens: int = Field(ge=0)
|
|
78
|
+
tool_names: tuple[str, ...] = ()
|
|
79
|
+
target: str | None = None
|
|
80
|
+
degraded_from: str | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class HumanDecision(StrEnum):
|
|
84
|
+
"""What a human answered. Rejection is a normal answer, not an error."""
|
|
85
|
+
|
|
86
|
+
APPROVE = "approve"
|
|
87
|
+
REJECT = "reject"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AgentCheckpoint(BaseModel):
|
|
91
|
+
"""Everything a paused agent needs to continue, and nothing that is derived."""
|
|
92
|
+
|
|
93
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
94
|
+
|
|
95
|
+
agent_id: str = Field(min_length=1)
|
|
96
|
+
step: int = Field(ge=0)
|
|
97
|
+
messages: tuple[Message, ...]
|
|
98
|
+
steps: tuple[StepRecord, ...] = ()
|
|
99
|
+
calls: tuple[ToolCall, ...] = ()
|
|
100
|
+
channel: str = ""
|
|
101
|
+
when: dict[str, Any] = Field(default_factory=dict)
|
|
102
|
+
# `approve`: `calls` are awaiting a person. `delegate`: `calls` holds the one
|
|
103
|
+
# control call whose delegate paused, and the resumed frame must append the
|
|
104
|
+
# delegate's answer rather than execute anything.
|
|
105
|
+
resume_kind: Literal["approve", "delegate"] = "approve"
|
|
106
|
+
cost_anchor_usd: float = Field(ge=0.0)
|
|
107
|
+
token_anchor: int = Field(ge=0)
|
|
108
|
+
spent_usd: float = Field(ge=0.0)
|
|
109
|
+
spent_tokens: int = Field(ge=0)
|
|
110
|
+
degraded: bool = False
|
|
111
|
+
# The alias the paused frame was actually calling. A resumed run has to keep
|
|
112
|
+
# it: `degraded` records that this run already stepped down, and resuming on
|
|
113
|
+
# the dear alias would silently undo a bounded decision the trace still shows.
|
|
114
|
+
alias: str = ""
|
|
115
|
+
path: tuple[str, ...] = ()
|
|
116
|
+
memory_key: str = ""
|
|
117
|
+
caller: AgentCheckpoint | None = None
|
|
118
|
+
# Set when a guardrail escalated. Its `stage` tells the resume path what to do:
|
|
119
|
+
# an `input` escalation has already been checked, an `output` one holds the
|
|
120
|
+
# answer to release on approval, a `tool-call` one holds `calls` to execute.
|
|
121
|
+
escalation: GuardrailEscalation | None = None
|
|
122
|
+
guardrails: tuple[GuardrailRecord, ...] = ()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class PendingApproval(BaseModel):
|
|
126
|
+
"""The question a pause is asking, separated from the resume state."""
|
|
127
|
+
|
|
128
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
129
|
+
|
|
130
|
+
agent_id: str
|
|
131
|
+
channel: str
|
|
132
|
+
step: int
|
|
133
|
+
calls: tuple[ToolCall, ...]
|
|
134
|
+
when: dict[str, Any] = Field(default_factory=dict)
|
|
135
|
+
path: tuple[str, ...] = ()
|
|
136
|
+
escalation: GuardrailEscalation | None = None
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_checkpoint(cls, checkpoint: AgentCheckpoint) -> PendingApproval:
|
|
140
|
+
return cls(
|
|
141
|
+
agent_id=checkpoint.agent_id,
|
|
142
|
+
channel=checkpoint.channel,
|
|
143
|
+
step=checkpoint.step,
|
|
144
|
+
calls=checkpoint.calls,
|
|
145
|
+
when=checkpoint.when,
|
|
146
|
+
path=checkpoint.path,
|
|
147
|
+
escalation=checkpoint.escalation,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class RunResult(BaseModel):
|
|
152
|
+
"""What a run produced -- including when it produced no answer."""
|
|
153
|
+
|
|
154
|
+
model_config = ConfigDict(extra="forbid")
|
|
155
|
+
|
|
156
|
+
status: RunStatus
|
|
157
|
+
agent_id: str
|
|
158
|
+
answer: str = ""
|
|
159
|
+
steps: tuple[StepRecord, ...] = ()
|
|
160
|
+
path: tuple[str, ...] = ()
|
|
161
|
+
cost_usd: float = Field(default=0.0, ge=0.0)
|
|
162
|
+
total_tokens: int = Field(default=0, ge=0)
|
|
163
|
+
checkpoint: AgentCheckpoint | None = None
|
|
164
|
+
guardrails: tuple[GuardrailRecord, ...] = ()
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def completed(self) -> bool:
|
|
168
|
+
return self.status is RunStatus.COMPLETED
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def degraded(self) -> tuple[tuple[str, str], ...]:
|
|
172
|
+
"""``(from, to)`` for every step that stepped down the alias ladder."""
|
|
173
|
+
pairs: list[tuple[str, str]] = []
|
|
174
|
+
for step in self.steps:
|
|
175
|
+
if step.degraded_from is not None:
|
|
176
|
+
pairs.append((step.degraded_from, step.alias))
|
|
177
|
+
return tuple(pairs)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def approval(self) -> PendingApproval | None:
|
|
181
|
+
"""The question a paused run is asking, or ``None`` when it did not pause."""
|
|
182
|
+
return None if self.checkpoint is None else PendingApproval.from_checkpoint(self.checkpoint)
|