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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. aid_runtime/__init__.py +500 -0
  2. aid_runtime/agents/__init__.py +94 -0
  3. aid_runtime/agents/chat_model.py +351 -0
  4. aid_runtime/agents/deep_agents.py +600 -0
  5. aid_runtime/agents/engine.py +102 -0
  6. aid_runtime/agents/guardrails.py +519 -0
  7. aid_runtime/agents/ir.py +622 -0
  8. aid_runtime/agents/loop.py +1308 -0
  9. aid_runtime/agents/memory.py +241 -0
  10. aid_runtime/agents/policy.py +163 -0
  11. aid_runtime/agents/state.py +182 -0
  12. aid_runtime/cache.py +185 -0
  13. aid_runtime/concurrency.py +70 -0
  14. aid_runtime/control.py +329 -0
  15. aid_runtime/cost.py +168 -0
  16. aid_runtime/errors.py +337 -0
  17. aid_runtime/evals/__init__.py +72 -0
  18. aid_runtime/evals/dataset.py +77 -0
  19. aid_runtime/evals/gate.py +430 -0
  20. aid_runtime/evals/metrics.py +335 -0
  21. aid_runtime/evals/predicate.py +211 -0
  22. aid_runtime/evals/runner.py +224 -0
  23. aid_runtime/gateway.py +473 -0
  24. aid_runtime/pipeline.py +168 -0
  25. aid_runtime/prompts.py +185 -0
  26. aid_runtime/providers/__init__.py +20 -0
  27. aid_runtime/providers/base.py +57 -0
  28. aid_runtime/providers/fake.py +256 -0
  29. aid_runtime/providers/http.py +344 -0
  30. aid_runtime/py.typed +0 -0
  31. aid_runtime/retrieval/__init__.py +178 -0
  32. aid_runtime/retrieval/chunking.py +222 -0
  33. aid_runtime/retrieval/citations.py +131 -0
  34. aid_runtime/retrieval/connectors.py +131 -0
  35. aid_runtime/retrieval/embedding.py +293 -0
  36. aid_runtime/retrieval/gate.py +120 -0
  37. aid_runtime/retrieval/hybrid.py +164 -0
  38. aid_runtime/retrieval/metrics.py +213 -0
  39. aid_runtime/retrieval/pgvector.py +258 -0
  40. aid_runtime/retrieval/rerank.py +133 -0
  41. aid_runtime/retrieval/retriever.py +335 -0
  42. aid_runtime/retrieval/stores.py +161 -0
  43. aid_runtime/retrieval/types.py +433 -0
  44. aid_runtime/steps.py +284 -0
  45. aid_runtime/structured.py +150 -0
  46. aid_runtime/tools.py +271 -0
  47. aid_runtime/tracing.py +241 -0
  48. aid_runtime/types.py +332 -0
  49. aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
  50. aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
  51. aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
  52. aid_runtime_python-0.1.39.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)