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,1308 @@
1
+ """The native agent engine: one loop, three ways to leave it.
2
+
3
+ The loop is a model call, its tool calls, the observations, repeat -- until the
4
+ model answers without a tool call, until ``maxSteps`` is spent, until a budget is
5
+ crossed, or until a human is needed. It consumes a :class:`RuntimeSpec` (the IR
6
+ seam in ``agents.ir``) and calls models through :class:`ModelGateway`, so fallback,
7
+ cost accounting, and tracing behave exactly as they do for a pipeline step.
8
+
9
+ Two control edges, and the difference is not cosmetic:
10
+
11
+ **transfer** -- IR ``handoffs``. Control *moves*. The caller does not resume; the
12
+ target owns the rest of the turn and its answer is the run's answer. The target
13
+ inherits the conversation but not the caller's instructions: a system prompt
14
+ belongs to an agent, not to a transcript. The runtime primitive is
15
+ :meth:`AgentRuntime._transfer`, and it never returns to the caller.
16
+
17
+ **delegate** -- IR ``delegates`` (Deep Agents ``subagents``). Control is *lent*.
18
+ The delegate gets an isolated context -- its own system prompt and the one message
19
+ it was handed, nothing else -- and returns exactly one observation. The caller
20
+ resumes, sees that observation as a tool result, and authors the final answer. The
21
+ runtime primitive is :meth:`AgentRuntime._delegate`.
22
+
23
+ So the observable difference is who produces the final answer, and whose transcript
24
+ contains the other's messages: after a transfer the answer is the target's and the
25
+ caller's steps are a prefix of the target's run; after a delegate the answer is the
26
+ caller's and the delegate's transcript is a separate, closed conversation.
27
+
28
+ Budgets are ratified decision D1: crossing one is a hard failure. An agent that
29
+ declares ``onBudgetExhausted: degrade`` instead steps **one** rung down its declared
30
+ alias ladder, **once**, and both the step record and the ledger show the cheaper
31
+ alias. It is never a retry loop: a degraded frame that still does not fit fails.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from collections.abc import Iterable, Mapping, Sequence
37
+ from dataclasses import dataclass, field
38
+ from typing import Any
39
+
40
+ from opentelemetry.trace import Span, Tracer
41
+
42
+ from aid_runtime.agents.engine import DEEPAGENTS, check_engine
43
+ from aid_runtime.agents.guardrails import (
44
+ ACTION_BLOCK,
45
+ ACTION_ESCALATE,
46
+ ACTION_REDACT,
47
+ GuardrailEngine,
48
+ GuardrailEscalation,
49
+ GuardrailRecord,
50
+ GuardrailVerdict,
51
+ )
52
+ from aid_runtime.agents.ir import (
53
+ AgentDefinition,
54
+ MemoryDefinition,
55
+ RuntimeSpec,
56
+ ToolSpec,
57
+ from_ir,
58
+ )
59
+ from aid_runtime.agents.memory import (
60
+ InProcessMemoryStore,
61
+ MemoryStore,
62
+ ThreadMemory,
63
+ check_memory_supported,
64
+ render_context,
65
+ )
66
+ from aid_runtime.agents.policy import (
67
+ Principal,
68
+ evaluate_policy,
69
+ policy_source,
70
+ )
71
+ from aid_runtime.agents.state import (
72
+ AgentCheckpoint,
73
+ HumanDecision,
74
+ RunResult,
75
+ RunStatus,
76
+ StepKind,
77
+ StepRecord,
78
+ )
79
+ from aid_runtime.concurrency import ConcurrencyLimiter
80
+ from aid_runtime.cost import Budget, CostLedger, LedgerEntry
81
+ from aid_runtime.errors import (
82
+ AgentBudgetDegradedError,
83
+ AgentTransferCycleError,
84
+ BudgetExceededError,
85
+ CapabilityMismatchError,
86
+ ConfigurationError,
87
+ GuardrailBlockedError,
88
+ PolicyEvaluationError,
89
+ ToolPermissionError,
90
+ )
91
+ from aid_runtime.gateway import ModelGateway
92
+ from aid_runtime.providers.base import Provider
93
+ from aid_runtime.tools import Tool, ToolExecutor
94
+ from aid_runtime.tracing import agent_span, record_attributes, record_guardrail, record_hitl
95
+ from aid_runtime.types import (
96
+ Capability,
97
+ CompletionRequest,
98
+ CompletionResponse,
99
+ GenerationResult,
100
+ Message,
101
+ TokenUsage,
102
+ ToolCall,
103
+ ToolDefinition,
104
+ )
105
+
106
+ __all__ = ["AgentRuntime"]
107
+
108
+ #: Namespacing for the control tools. A model cannot transfer by emitting prose,
109
+ #: and it cannot call a control tool the agent's IR did not declare.
110
+ TRANSFER_PREFIX = "transfer_to_"
111
+ DELEGATE_PREFIX = "delegate_to_"
112
+
113
+ #: The one argument a control tool takes: what the other agent needs to know.
114
+ CONTROL_ARGUMENT = "message"
115
+
116
+ _DEGRADE = "degrade"
117
+ _THREAD = "thread"
118
+ _GLOBAL = "global"
119
+ _IN_PROCESS = "in-process"
120
+ _RESTORED_ALIAS = "restored"
121
+ _TOOL_CONFIRMATION = "tool-confirmation"
122
+ _GUARDRAIL_CHANNEL = "guardrail"
123
+ _GUARDRAIL_INPUT = "input"
124
+ _GUARDRAIL_OUTPUT = "output"
125
+ _GUARDRAIL_TOOL = "tool-call"
126
+ #: Side effects that must be explicitly granted. ``read`` and ``none`` are not in
127
+ #: here on purpose: permissioning exists to gate effects, and gating reads would
128
+ #: make every hostile tool the interesting one.
129
+ _SIDE_EFFECTING = frozenset({"write", "destructive"})
130
+ _REJECTED = "rejected by the human approver"
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class _MemoryState:
135
+ """The memory context one agent turn resolved, and what it measured."""
136
+
137
+ thread: ThreadMemory | None = None
138
+ context: str = ""
139
+ scopes: tuple[str, ...] = ()
140
+ entries: int = 0
141
+ tokens: int = 0
142
+
143
+
144
+ @dataclass(slots=True)
145
+ class _Frame:
146
+ """One agent's turn in progress: its own transcript, budget anchors, and steps."""
147
+
148
+ agent: AgentDefinition
149
+ messages: list[Message]
150
+ alias: str
151
+ path: tuple[str, ...]
152
+ step: int = 0
153
+ degraded: bool = False
154
+ memory: _MemoryState = field(default_factory=_MemoryState)
155
+ memory_key: str = ""
156
+ cost_anchor: float = 0.0
157
+ token_anchor: int = 0
158
+ steps: list[StepRecord] = field(default_factory=list)
159
+ resumed_decision: HumanDecision | None = None
160
+ resumed_channel: str = ""
161
+ # `input` runs once per frame, on the message the frame started from. A
162
+ # transferred target re-checks the conversation it inherited (its guardrails are
163
+ # its own), while a resumed frame does not: the checkpoint only exists because
164
+ # the input stage already ran.
165
+ input_checked: bool = False
166
+ guardrails: list[GuardrailRecord] = field(default_factory=list)
167
+
168
+
169
+ @dataclass(slots=True)
170
+ class _Outcome:
171
+ """What a frame decided, before the run's totals are stamped on it."""
172
+
173
+ status: RunStatus
174
+ agent_id: str
175
+ answer: str = ""
176
+ steps: list[StepRecord] = field(default_factory=list)
177
+ path: tuple[str, ...] = ()
178
+ checkpoint: AgentCheckpoint | None = None
179
+ guardrails: tuple[GuardrailRecord, ...] = ()
180
+
181
+
182
+ class AgentRuntime:
183
+ """Runs the IR's agents on the native engine."""
184
+
185
+ def __init__(
186
+ self,
187
+ spec: RuntimeSpec,
188
+ gateway: ModelGateway,
189
+ *,
190
+ tools: Iterable[Tool] = (),
191
+ stores: Mapping[str, MemoryStore] | None = None,
192
+ memory_store: MemoryStore | None = None,
193
+ tracer: Tracer | None = None,
194
+ limiter: ConcurrencyLimiter | None = None,
195
+ ) -> None:
196
+ self._spec = spec
197
+ self._gateway = gateway
198
+ self._executor = ToolExecutor(tools, limiter=limiter)
199
+ self._stores = dict(stores or {})
200
+ self._default_store: MemoryStore = (
201
+ memory_store if memory_store is not None else InProcessMemoryStore()
202
+ )
203
+ self._tracer = tracer
204
+ self._guardrails = GuardrailEngine(spec.guardrails)
205
+ self._tools_by_name: dict[str, ToolSpec] = {
206
+ definition.name: definition for definition in spec.tools.values()
207
+ }
208
+ self._check_declarations()
209
+
210
+ @classmethod
211
+ def from_ir(
212
+ cls,
213
+ document: Mapping[str, Any],
214
+ *,
215
+ providers: Mapping[str, Provider],
216
+ tools: Iterable[Tool] = (),
217
+ stores: Mapping[str, MemoryStore] | None = None,
218
+ memory_store: MemoryStore | None = None,
219
+ ledger: CostLedger | None = None,
220
+ budget: Budget | None = None,
221
+ tracer: Tracer | None = None,
222
+ ) -> AgentRuntime:
223
+ """Build a runtime from an emitted IR document.
224
+
225
+ The IR's ``deployments[].concurrency`` becomes one limiter shared by the
226
+ gateway and the tool executor, so model calls, tool calls, and delegation
227
+ all draw from the same budget rather than running two independent pools.
228
+ """
229
+ spec = from_ir(document)
230
+ limiter = ConcurrencyLimiter(spec.concurrency) if spec.concurrency is not None else None
231
+ gateway = ModelGateway(
232
+ providers, spec.models, ledger=ledger, budget=budget, limiter=limiter
233
+ )
234
+ return cls(
235
+ spec,
236
+ gateway,
237
+ tools=tools,
238
+ stores=stores,
239
+ memory_store=memory_store,
240
+ tracer=tracer,
241
+ limiter=limiter,
242
+ )
243
+
244
+ @property
245
+ def spec(self) -> RuntimeSpec:
246
+ return self._spec
247
+
248
+ @property
249
+ def gateway(self) -> ModelGateway:
250
+ return self._gateway
251
+
252
+ async def run(
253
+ self,
254
+ agent_id: str,
255
+ text: str,
256
+ *,
257
+ principal: Principal | None = None,
258
+ memory_key: str | None = None,
259
+ conversation: Sequence[Message] = (),
260
+ ) -> RunResult:
261
+ """Run ``agent_id`` to a stop condition on one user message."""
262
+ agent = self._spec.agent(agent_id)
263
+ self._require_engine(agent)
264
+ key = memory_key or agent.id
265
+ frame = await self._open(
266
+ agent,
267
+ [
268
+ Message.system(self._spec.system_prompt(agent)),
269
+ *conversation,
270
+ Message.user(text),
271
+ ],
272
+ path=(agent.id,),
273
+ memory_key=key,
274
+ )
275
+ start_cost = self._gateway.ledger.cost_usd
276
+ start_tokens = self._gateway.ledger.total_tokens
277
+ outcome = await self._drive(
278
+ frame,
279
+ principal=principal if principal is not None else Principal(),
280
+ memory_key=key,
281
+ )
282
+ return self._wrap(outcome, start_cost, start_tokens)
283
+
284
+ async def resume(
285
+ self,
286
+ checkpoint: AgentCheckpoint,
287
+ decision: HumanDecision | str,
288
+ *,
289
+ principal: Principal | None = None,
290
+ ) -> RunResult:
291
+ """Continue a paused run from its checkpoint, with a human's answer."""
292
+ resolved = HumanDecision(decision)
293
+ self._restore_ledger(checkpoint)
294
+ start_cost = self._gateway.ledger.cost_usd
295
+ start_tokens = self._gateway.ledger.total_tokens
296
+ outcome = await self._resume(
297
+ checkpoint,
298
+ resolved,
299
+ principal if principal is not None else Principal(),
300
+ )
301
+ return self._wrap(outcome, start_cost, start_tokens)
302
+
303
+ async def _resume(
304
+ self,
305
+ checkpoint: AgentCheckpoint,
306
+ decision: HumanDecision,
307
+ principal: Principal,
308
+ ) -> _Outcome:
309
+ if checkpoint.resume_kind == "delegate":
310
+ raise ConfigurationError(
311
+ f"the checkpoint belongs to agent {checkpoint.agent_id!r}, which was waiting "
312
+ "inside a delegate; resume the checkpoint that carries the caller link"
313
+ )
314
+ agent = self._spec.agent(checkpoint.agent_id)
315
+ self._require_engine(agent)
316
+ frame = await self._frame_from(checkpoint)
317
+ frame.resumed_decision = decision
318
+ frame.resumed_channel = checkpoint.channel
319
+ escalation = checkpoint.escalation
320
+ if decision is HumanDecision.APPROVE:
321
+ if escalation is not None and escalation.stage == _GUARDRAIL_OUTPUT:
322
+ # The model already produced this answer and a person released it;
323
+ # re-generating would spend tokens to answer the same question and
324
+ # could produce a different answer than the one that was approved.
325
+ answer = escalation.text
326
+ frame.messages.append(Message.assistant(answer))
327
+ await self._remember(agent, answer, checkpoint.memory_key)
328
+ return self._guardrail_completed(frame, answer)
329
+ frame.messages.extend(await self._observe(agent, checkpoint.calls, principal))
330
+ elif escalation is not None:
331
+ # A person declined to release what the guardrail flagged. That is a
332
+ # decision, not an error, and the run stops here with the guardrail's
333
+ # own message as the answer rather than inventing one.
334
+ return _Outcome(
335
+ status=RunStatus.BLOCKED,
336
+ agent_id=agent.id,
337
+ answer=escalation.message,
338
+ steps=frame.steps,
339
+ path=frame.path,
340
+ guardrails=tuple(frame.guardrails),
341
+ )
342
+ else:
343
+ frame.messages.extend(
344
+ Message.tool(call.id, _REJECTED, name=call.name) for call in checkpoint.calls
345
+ )
346
+ outcome = await self._drive(frame, principal=principal, memory_key=checkpoint.memory_key)
347
+ if checkpoint.caller is None:
348
+ return outcome
349
+ if outcome.status is RunStatus.PAUSED and outcome.checkpoint is not None:
350
+ # The delegate paused again; the caller is still the one waiting, and the
351
+ # caller link has to survive the second pause or the run cannot continue.
352
+ outcome.checkpoint = outcome.checkpoint.model_copy(update={"caller": checkpoint.caller})
353
+ return outcome
354
+ return await self._resume_caller(checkpoint.caller, outcome, principal=principal)
355
+
356
+ async def _resume_caller(
357
+ self,
358
+ caller: AgentCheckpoint,
359
+ inner: _Outcome,
360
+ *,
361
+ principal: Principal,
362
+ ) -> _Outcome:
363
+ frame = await self._frame_from(caller)
364
+ call = caller.calls[0]
365
+ frame.messages.append(Message.tool(call.id, inner.answer, name=call.name))
366
+ frame.steps.extend(inner.steps)
367
+ frame.guardrails.extend(inner.guardrails)
368
+ return await self._drive(frame, principal=principal, memory_key=caller.memory_key)
369
+
370
+ def _guardrail_completed(self, frame: _Frame, answer: str) -> _Outcome:
371
+ """Complete a frame after an approved escalation released its answer."""
372
+ return _Outcome(
373
+ status=RunStatus.COMPLETED,
374
+ agent_id=frame.agent.id,
375
+ answer=answer,
376
+ steps=frame.steps,
377
+ path=frame.path,
378
+ guardrails=tuple(frame.guardrails),
379
+ )
380
+
381
+ async def _drive(
382
+ self,
383
+ frame: _Frame,
384
+ *,
385
+ principal: Principal,
386
+ memory_key: str,
387
+ ) -> _Outcome:
388
+ budget = self._budget(frame)
389
+ with agent_span(
390
+ agent_id=frame.agent.id, engine=frame.agent.engine, tracer=self._tracer
391
+ ) as span:
392
+ record_attributes(
393
+ span,
394
+ memory_scope=",".join(frame.memory.scopes) or None,
395
+ memory_entries=frame.memory.entries or None,
396
+ memory_tokens=frame.memory.tokens or None,
397
+ )
398
+ if frame.resumed_decision is not None:
399
+ record_hitl(
400
+ span,
401
+ phase="resume",
402
+ channel=frame.resumed_channel,
403
+ decision=frame.resumed_decision.value,
404
+ step=frame.step,
405
+ )
406
+ return await self._run(
407
+ frame, budget=budget, principal=principal, memory_key=memory_key, span=span
408
+ )
409
+
410
+ async def _run(
411
+ self,
412
+ frame: _Frame,
413
+ *,
414
+ budget: Budget | None,
415
+ principal: Principal,
416
+ memory_key: str,
417
+ span: Span,
418
+ ) -> _Outcome:
419
+ """Drive the frame on the engine its agent declared.
420
+
421
+ The native loop is the default and is unchanged. ``deepagents`` is a backend
422
+ selected per agent: the frame, checkpoint, budget and guardrail engine are
423
+ shared, so only the drive function differs. The import is deferred because the
424
+ harness is an optional extra -- an IR that never declares the engine must not
425
+ require it to be installed.
426
+ """
427
+ if frame.agent.engine == DEEPAGENTS:
428
+ from aid_runtime.agents.deep_agents import DeepAgentsEngine
429
+
430
+ return await DeepAgentsEngine(self).drive(
431
+ frame, budget=budget, principal=principal, memory_key=memory_key, span=span
432
+ )
433
+ return await self._run_loop(
434
+ frame, budget=budget, principal=principal, memory_key=memory_key, span=span
435
+ )
436
+
437
+ async def _run_loop(
438
+ self,
439
+ frame: _Frame,
440
+ *,
441
+ budget: Budget | None,
442
+ principal: Principal,
443
+ memory_key: str,
444
+ span: Span,
445
+ ) -> _Outcome:
446
+ agent = frame.agent
447
+ while True:
448
+ if frame.step >= agent.max_steps:
449
+ self._record_status(span, "max-steps", frame)
450
+ return self._outcome(RunStatus.MAX_STEPS, frame)
451
+
452
+ if not frame.input_checked:
453
+ frame.input_checked = True
454
+ paused = self._guardrail_text(
455
+ _GUARDRAIL_INPUT, frame, memory_key, span=span, rewrite=True
456
+ )
457
+ if paused is not None:
458
+ return paused
459
+
460
+ request = self._request(frame)
461
+ alias, degraded_from = self._route(frame, request, budget, span)
462
+ generation = await self._generate(agent, alias, request, budget=budget)
463
+ frame.step += 1
464
+ record = StepRecord(
465
+ index=frame.step,
466
+ agent_id=agent.id,
467
+ kind=StepKind.ANSWER,
468
+ alias=generation.alias,
469
+ model=generation.response.model,
470
+ cost_usd=generation.cost_usd,
471
+ total_tokens=generation.usage.total_tokens,
472
+ degraded_from=degraded_from,
473
+ )
474
+ frame.steps.append(record)
475
+ response = generation.response
476
+
477
+ if not response.tool_calls:
478
+ answer = self._guardrail_answer(frame, response.text, memory_key, span=span)
479
+ if isinstance(answer, _Outcome):
480
+ return answer
481
+ frame.messages.append(Message.assistant(answer))
482
+ await self._remember(agent, answer, memory_key)
483
+ self._record_status(span, "completed", frame)
484
+ return self._outcome(RunStatus.COMPLETED, frame, answer=answer)
485
+
486
+ control = self._control_call(response.tool_calls)
487
+ if control is not None:
488
+ moved = await self._control(
489
+ frame,
490
+ record,
491
+ control,
492
+ response,
493
+ principal=principal,
494
+ memory_key=memory_key,
495
+ budget=budget,
496
+ span=span,
497
+ )
498
+ if moved is not None:
499
+ return moved
500
+ continue
501
+
502
+ record.kind = StepKind.TOOLS
503
+ record.tool_names = tuple(call.name for call in response.tool_calls)
504
+ guarded, paused = self._guardrail_tool_calls(
505
+ frame, response.tool_calls, memory_key, span=span
506
+ )
507
+ if paused is not None:
508
+ return paused
509
+ frame.messages.append(Message.assistant_tool_calls(response.text, guarded))
510
+ if self._needs_approval(agent, guarded, principal):
511
+ checkpoint = self._checkpoint(frame, guarded, memory_key)
512
+ record_hitl(
513
+ span,
514
+ phase="pause",
515
+ channel=checkpoint.channel,
516
+ when=policy_source(agent.approval.when) if agent.approval else None,
517
+ step=frame.step,
518
+ )
519
+ self._record_status(span, "paused", frame)
520
+ return _Outcome(
521
+ status=RunStatus.PAUSED,
522
+ agent_id=agent.id,
523
+ steps=frame.steps,
524
+ path=frame.path,
525
+ checkpoint=checkpoint,
526
+ guardrails=tuple(frame.guardrails),
527
+ )
528
+
529
+ frame.messages.extend(await self._observe(agent, guarded, principal))
530
+
531
+ def _guardrail_text(
532
+ self,
533
+ stage: str,
534
+ frame: _Frame,
535
+ memory_key: str,
536
+ *,
537
+ span: Span,
538
+ rewrite: bool,
539
+ ) -> _Outcome | None:
540
+ """Run a whole-transcript stage (``input``) and apply its action.
541
+
542
+ ``rewrite`` is what ``redact`` does here: the last user message is replaced
543
+ with the masked text *in place*, so the model is called on the redacted turn
544
+ rather than on the original with a note attached.
545
+ """
546
+ index = _last_user_index(frame.messages)
547
+ text = "" if index is None else frame.messages[index].content
548
+ verdict = self._guardrails.check_input(frame.agent.stage_guardrails(stage), text)
549
+ self._record_guardrails(frame, verdict, span)
550
+ deciding = verdict.deciding
551
+ if verdict.action == ACTION_BLOCK and deciding is not None:
552
+ raise GuardrailBlockedError(deciding.guardrail, stage, deciding.message)
553
+ if verdict.action == ACTION_ESCALATE:
554
+ return self._guardrail_pause(frame, verdict, memory_key, (), span=span)
555
+ if rewrite and verdict.action == ACTION_REDACT and index is not None:
556
+ frame.messages[index] = Message.user(verdict.text)
557
+ return None
558
+
559
+ def _guardrail_answer(
560
+ self,
561
+ frame: _Frame,
562
+ text: str,
563
+ memory_key: str,
564
+ *,
565
+ span: Span,
566
+ ) -> str | _Outcome:
567
+ """Run the ``output`` stage over an answer, returning the text to ship.
568
+
569
+ An ``_Outcome`` means the answer is held: an escalation paused the run and
570
+ the checkpoint carries the text, so approving it releases this exact answer
571
+ without paying for a second generation.
572
+ """
573
+ verdict = self._guardrails.check_output(
574
+ frame.agent.stage_guardrails(_GUARDRAIL_OUTPUT), text
575
+ )
576
+ self._record_guardrails(frame, verdict, span)
577
+ deciding = verdict.deciding
578
+ if verdict.action == ACTION_BLOCK and deciding is not None:
579
+ raise GuardrailBlockedError(deciding.guardrail, _GUARDRAIL_OUTPUT, deciding.message)
580
+ if verdict.action == ACTION_ESCALATE:
581
+ return self._guardrail_pause(frame, verdict, memory_key, (), span=span, text=text)
582
+ return verdict.text if verdict.action == ACTION_REDACT else text
583
+
584
+ def _guardrail_tool_calls(
585
+ self,
586
+ frame: _Frame,
587
+ calls: Sequence[ToolCall],
588
+ memory_key: str,
589
+ *,
590
+ span: Span,
591
+ ) -> tuple[list[ToolCall], _Outcome | None]:
592
+ """Run the ``tool-call`` stage over every call in one step.
593
+
594
+ The guardrail scopes on tool *ids*, so each model name is resolved through
595
+ the IR before the engine sees it. A name the IR does not declare keeps its
596
+ own spelling as the scope key -- it will be refused later for a different
597
+ reason, and mapping it to some other tool's id would let a guardrail scoped
598
+ to one tool decide about another.
599
+ """
600
+ guarded: list[ToolCall] = []
601
+ escalation: GuardrailVerdict | None = None
602
+ for call in calls:
603
+ definition = self._tools_by_name.get(call.name)
604
+ tool_id = definition.id if definition is not None else call.name
605
+ verdict = self._guardrails.check_tool_call(
606
+ frame.agent.stage_guardrails(_GUARDRAIL_TOOL), tool_id, call.arguments
607
+ )
608
+ self._record_guardrails(frame, verdict, span)
609
+ deciding = verdict.deciding
610
+ if verdict.action == ACTION_BLOCK and deciding is not None:
611
+ raise GuardrailBlockedError(deciding.guardrail, _GUARDRAIL_TOOL, deciding.message)
612
+ if verdict.action == ACTION_ESCALATE and escalation is None:
613
+ escalation = verdict
614
+ if verdict.action == ACTION_REDACT:
615
+ call = call.model_copy(update={"arguments": dict(verdict.arguments)})
616
+ guarded.append(call)
617
+ if escalation is not None:
618
+ return guarded, self._guardrail_pause(frame, escalation, memory_key, guarded, span=span)
619
+ return guarded, None
620
+
621
+ def _record_guardrails(self, frame: _Frame, verdict: GuardrailVerdict, span: Span) -> None:
622
+ """Record what fired. Every record reaches the result, not only the deciding one."""
623
+ for record in verdict.records:
624
+ frame.guardrails.append(record)
625
+ record_guardrail(span, record)
626
+
627
+ def _guardrail_pause(
628
+ self,
629
+ frame: _Frame,
630
+ verdict: GuardrailVerdict,
631
+ memory_key: str,
632
+ calls: Sequence[ToolCall],
633
+ *,
634
+ span: Span,
635
+ text: str = "",
636
+ ) -> _Outcome:
637
+ """Enter HITL for an ``escalate``. A pause, never a failure."""
638
+ deciding = verdict.deciding
639
+ if deciding is None:
640
+ raise ConfigurationError(
641
+ "a guardrail verdict with no deciding record cannot be escalated"
642
+ )
643
+ approval = frame.agent.approval
644
+ escalation = GuardrailEscalation(
645
+ guardrail=deciding.guardrail,
646
+ stage=verdict.stage,
647
+ message=deciding.message,
648
+ channel=approval.channel if approval is not None else _GUARDRAIL_CHANNEL,
649
+ text=text,
650
+ )
651
+ checkpoint = self._checkpoint(frame, calls, memory_key, escalation=escalation)
652
+ record_hitl(
653
+ span,
654
+ phase="pause",
655
+ channel=escalation.channel,
656
+ when=f"guardrail:{deciding.guardrail}",
657
+ step=frame.step,
658
+ )
659
+ self._record_status(span, "paused", frame)
660
+ return _Outcome(
661
+ status=RunStatus.PAUSED,
662
+ agent_id=frame.agent.id,
663
+ steps=frame.steps,
664
+ path=frame.path,
665
+ checkpoint=checkpoint,
666
+ guardrails=tuple(frame.guardrails),
667
+ )
668
+
669
+ async def _control(
670
+ self,
671
+ frame: _Frame,
672
+ record: StepRecord,
673
+ call: ToolCall,
674
+ response: CompletionResponse,
675
+ *,
676
+ principal: Principal,
677
+ memory_key: str,
678
+ budget: Budget | None,
679
+ span: Span,
680
+ ) -> _Outcome | None:
681
+ """Act on a control call. ``None`` means the caller keeps control."""
682
+ transferring = call.name.startswith(TRANSFER_PREFIX)
683
+ prefix = TRANSFER_PREFIX if transferring else DELEGATE_PREFIX
684
+ target_id = call.name[len(prefix) :]
685
+ declared = frame.agent.handoffs if transferring else frame.agent.delegates
686
+ if target_id not in declared:
687
+ raise ConfigurationError(
688
+ f"agent {frame.agent.id!r} called control tool {call.name!r}, but does not "
689
+ f"declare it; declared handoffs {list(frame.agent.handoffs)} and "
690
+ f"delegates {list(frame.agent.delegates)}"
691
+ )
692
+ if target_id in frame.path:
693
+ raise AgentTransferCycleError([*frame.path, target_id])
694
+ message = _control_message(call, response)
695
+ if transferring:
696
+ record.kind = StepKind.TRANSFER
697
+ record.target = target_id
698
+ record_attributes(span, agent_transfer_to=target_id)
699
+ return await self._transfer(
700
+ frame, target_id, message, principal=principal, memory_key=memory_key
701
+ )
702
+ record.kind = StepKind.DELEGATE
703
+ record.target = target_id
704
+ record_attributes(span, agent_delegate_to=target_id)
705
+ return await self._delegate(
706
+ frame,
707
+ call,
708
+ response,
709
+ target_id,
710
+ message,
711
+ principal=principal,
712
+ memory_key=memory_key,
713
+ budget=budget,
714
+ span=span,
715
+ )
716
+
717
+ async def _transfer(
718
+ self,
719
+ frame: _Frame,
720
+ target_id: str,
721
+ message: str,
722
+ *,
723
+ principal: Principal,
724
+ memory_key: str,
725
+ ) -> _Outcome:
726
+ """Move control to another agent. The caller does not come back.
727
+
728
+ The target inherits the conversation -- every message except the caller's
729
+ system prompt, which is the caller's instructions and not the target's.
730
+ """
731
+ target = self._spec.agent(target_id)
732
+ frame.messages.append(Message.assistant(message))
733
+ history = [item for item in frame.messages if item.role != "system"]
734
+ target_frame = await self._open(
735
+ target,
736
+ [Message.system(self._spec.system_prompt(target)), *history],
737
+ path=(*frame.path, target.id),
738
+ memory_key=memory_key,
739
+ )
740
+ outcome = await self._drive(target_frame, principal=principal, memory_key=memory_key)
741
+ outcome.steps = [*frame.steps, *outcome.steps]
742
+ outcome.guardrails = (*frame.guardrails, *outcome.guardrails)
743
+ outcome.path = (*frame.path, target.id)
744
+ return outcome
745
+
746
+ async def _delegate(
747
+ self,
748
+ frame: _Frame,
749
+ call: ToolCall,
750
+ response: CompletionResponse,
751
+ target_id: str,
752
+ message: str,
753
+ *,
754
+ principal: Principal,
755
+ memory_key: str,
756
+ budget: Budget | None,
757
+ span: Span,
758
+ ) -> _Outcome | None:
759
+ """Lend control to another agent, then continue as the caller.
760
+
761
+ The delegate's context is isolated: its own system prompt and the single
762
+ message it was handed. Nothing else of the caller's transcript is visible,
763
+ which is what makes its result one observation rather than a continuation.
764
+ """
765
+ target = self._spec.agent(target_id)
766
+ frame.messages.append(Message.assistant_tool_calls(response.text, response.tool_calls))
767
+ delegate_frame = await self._open(
768
+ target,
769
+ [Message.system(self._spec.system_prompt(target)), Message.user(message)],
770
+ path=(*frame.path, target.id),
771
+ memory_key=memory_key,
772
+ )
773
+ inner = await self._drive(delegate_frame, principal=principal, memory_key=memory_key)
774
+ if inner.status is RunStatus.PAUSED:
775
+ inner.steps = [*frame.steps, *inner.steps]
776
+ inner.guardrails = (*frame.guardrails, *inner.guardrails)
777
+ if inner.checkpoint is not None:
778
+ caller = self._checkpoint(frame, (call,), memory_key).model_copy(
779
+ update={"resume_kind": "delegate", "channel": ""}
780
+ )
781
+ inner.checkpoint = inner.checkpoint.model_copy(update={"caller": caller})
782
+ return inner
783
+ if inner.status is not RunStatus.COMPLETED:
784
+ # The delegate ran out of steps or budget, so it has no result to hand
785
+ # back; the caller cannot borrow an answer that was never produced.
786
+ inner.steps = [*frame.steps, *inner.steps]
787
+ inner.guardrails = (*frame.guardrails, *inner.guardrails)
788
+ return inner
789
+ frame.steps.extend(inner.steps)
790
+ frame.guardrails.extend(inner.guardrails)
791
+ frame.messages.append(Message.tool(call.id, inner.answer, name=call.name))
792
+ return await self._run_loop(
793
+ frame, budget=budget, principal=principal, memory_key=memory_key, span=span
794
+ )
795
+
796
+ async def _observe(
797
+ self, agent: AgentDefinition, calls: Sequence[ToolCall], principal: Principal
798
+ ) -> list[Message]:
799
+ """Turn calls into observations, in the order the model asked for them.
800
+
801
+ A name this agent cannot call comes back as a tool error instead of
802
+ raising: the model composed the call, so the model is the party that can
803
+ correct it, exactly as for bad arguments. A missing *registration* is a
804
+ different failure and is refused when the runtime is built, where the
805
+ application can fix it.
806
+
807
+ Permission is the third case and it does raise, before any handler runs.
808
+ The grant is the agent's declared tool list, and a side-effecting tool
809
+ additionally needs an ``auth`` policy that holds for this principal. A
810
+ model cannot repair either: the spec is what is wrong.
811
+ """
812
+ known = [call for call in calls if call.name in self._executor.names]
813
+ for call in known:
814
+ self._authorize(agent, call, principal)
815
+ executed = {result.call_id: result for result in await self._executor.execute_all(known)}
816
+ observations: list[Message] = []
817
+ for call in calls:
818
+ result = executed.get(call.id)
819
+ if result is None:
820
+ observations.append(
821
+ Message.tool(
822
+ call.id,
823
+ f"error: this agent has no tool named {call.name!r}",
824
+ name=call.name,
825
+ )
826
+ )
827
+ continue
828
+ observations.append(Message.tool(result.call_id, result.content, name=result.name))
829
+ return observations
830
+
831
+ def _authorize(self, agent: AgentDefinition, call: ToolCall, principal: Principal) -> None:
832
+ """Refuse a call this agent may not make, before the handler body runs.
833
+
834
+ Deny by default, in three steps:
835
+
836
+ 1. the agent must declare the tool -- the IR's ``tools`` is the grant, and
837
+ an undeclared tool is refused even when the runtime has it registered;
838
+ 2. a ``write``/``destructive`` tool must declare an ``auth`` policy, because
839
+ a side effect with no authorization is an accident waiting to be called;
840
+ 3. that policy is evaluated here, against *this* principal, not at build
841
+ time -- ``auth`` is a question about the caller, and the caller is only
842
+ known now.
843
+
844
+ An undecidable policy is a denial. :class:`PolicyEvaluationError` means the
845
+ evaluator could not answer, and "could not answer" must never render as
846
+ "allowed".
847
+ """
848
+ granted = {self._spec.tools[tool_id].name for tool_id in agent.tools}
849
+ if call.name not in granted:
850
+ raise ToolPermissionError(
851
+ agent.id,
852
+ call.name,
853
+ "the agent does not declare it; the tool list is the grant, and there is "
854
+ "no implicit wildcard",
855
+ )
856
+ definition = self._tools_by_name.get(call.name)
857
+ if definition is None:
858
+ raise ToolPermissionError(
859
+ agent.id,
860
+ call.name,
861
+ "it is registered on this runtime but not declared in the IR, so its side "
862
+ "effects and auth policy are unknown",
863
+ )
864
+ if definition.auth is not None:
865
+ try:
866
+ allowed = evaluate_policy(definition.auth, principal)
867
+ except PolicyEvaluationError as exc:
868
+ raise ToolPermissionError(
869
+ agent.id,
870
+ call.name,
871
+ f"its auth policy {policy_source(definition.auth)} could not be decided "
872
+ f"({exc})",
873
+ ) from exc
874
+ if not allowed:
875
+ raise ToolPermissionError(
876
+ agent.id,
877
+ call.name,
878
+ f"its auth policy {policy_source(definition.auth)} denies this principal",
879
+ )
880
+ return
881
+ if definition.side_effects in _SIDE_EFFECTING:
882
+ raise ToolPermissionError(
883
+ agent.id,
884
+ call.name,
885
+ f"it has sideEffects {definition.side_effects!r} and declares no auth "
886
+ "policy; a side effect with no authorization is denied by default",
887
+ )
888
+
889
+ def _request(self, frame: _Frame) -> CompletionRequest:
890
+ messages = tuple(frame.messages)
891
+ if frame.memory.thread is not None:
892
+ messages = frame.memory.thread.trim(messages)
893
+ if frame.memory.context:
894
+ messages = (messages[0], Message.system(frame.memory.context), *messages[1:])
895
+ return CompletionRequest(messages=messages, tools=self._tool_definitions(frame.agent))
896
+
897
+ def _tool_definitions(self, agent: AgentDefinition) -> tuple[ToolDefinition, ...]:
898
+ definitions = [
899
+ self._executor.definition(self._spec.tools[tool_id].name) for tool_id in agent.tools
900
+ ]
901
+ for target in agent.handoffs:
902
+ definitions.append(_control_definition(TRANSFER_PREFIX, target))
903
+ for target in agent.delegates:
904
+ definitions.append(_control_definition(DELEGATE_PREFIX, target))
905
+ return tuple(definitions)
906
+
907
+ async def _generate(
908
+ self,
909
+ agent: AgentDefinition,
910
+ alias: str,
911
+ request: CompletionRequest,
912
+ *,
913
+ budget: Budget | None,
914
+ ) -> GenerationResult:
915
+ """Call the model, refusing a silent downgrade to one that cannot tool-call.
916
+
917
+ The pinned model must advertise ``tool-calling`` when the agent has tools or
918
+ control edges. A gateway fallback is chosen for a provider failure without
919
+ re-negotiating capabilities, so the answer's alias is checked too: an agent
920
+ that cannot call a tool would otherwise answer from memory and look fine.
921
+
922
+ The agent's ceiling goes to the gateway as the call's budget, so a breach is
923
+ the gateway's own :class:`~aid_runtime.errors.BudgetExceededError` -- hard
924
+ failure by default, with no second accounting path in the loop.
925
+ """
926
+ required = frozenset(
927
+ {Capability.TOOL_CALLING} if agent.tools or agent.has_control_tools else set()
928
+ )
929
+ if required:
930
+ self._gateway.require(alias, required)
931
+ generation = await self._gateway.generate(
932
+ alias, request, fallbacks=self._spec.fallbacks_for(alias), budget=budget
933
+ )
934
+ if required and not self._gateway.resolve(generation.alias).supports(required):
935
+ raise CapabilityMismatchError(
936
+ generation.alias, sorted(capability.value for capability in required)
937
+ )
938
+ return generation
939
+
940
+ def _route(
941
+ self,
942
+ frame: _Frame,
943
+ request: CompletionRequest,
944
+ budget: Budget | None,
945
+ span: Span,
946
+ ) -> tuple[str, str | None]:
947
+ """Pick the alias for this call, stepping down the ladder at most once."""
948
+ agent = frame.agent
949
+ if agent.on_budget_exhausted != _DEGRADE or frame.degraded:
950
+ return frame.alias, None
951
+ # Degrade steps down a *price* ladder, so it needs a dollar ceiling to step
952
+ # down from. A token-only ceiling cannot be made affordable by switching
953
+ # alias, and the gate refuses degrade without maxCostBudget, so there is
954
+ # nothing to route here. Returning keeps that hard failure with the ledger.
955
+ if budget is None or budget.max_usd is None:
956
+ return frame.alias, None
957
+ if self._gateway.fits(frame.alias, request, budget):
958
+ return frame.alias, None
959
+ cheaper = self._spec.cheaper_fallback(frame.alias)
960
+ if cheaper is None:
961
+ raise AgentBudgetDegradedError(
962
+ f"agent {agent.id!r} declares onBudgetExhausted: degrade, but alias "
963
+ f"{frame.alias!r} declares no cheaper fallback; ir/agents-budget-degrade "
964
+ "rejects this at build time, so this IR did not come through the gate"
965
+ )
966
+ if not self._gateway.fits(cheaper, request, budget):
967
+ # One rung was available and is still too dear: the budget is exhausted.
968
+ raise BudgetExceededError(budget.scope, budget.max_usd, self._gateway.ledger.cost_usd)
969
+ previous = frame.alias
970
+ frame.alias = cheaper
971
+ frame.degraded = True
972
+ record_attributes(
973
+ span, agent_budget_degraded=True, agent_budget_from=previous, agent_budget_to=cheaper
974
+ )
975
+ return cheaper, previous
976
+
977
+ def _budget(self, frame: _Frame) -> Budget | None:
978
+ """The agent's ceiling, expressed as absolute spend for one ledger.
979
+
980
+ The ledger is shared by the whole run, so a per-agent ceiling is anchored at
981
+ the spend the agent started from. That keeps a single accounting path
982
+ instead of a second ledger per agent, and it is what lets a cheaper alias
983
+ make the *same* ceiling affordable again.
984
+ """
985
+ agent = frame.agent
986
+ if agent.max_cost_budget is None and agent.max_tokens_budget is None:
987
+ return None
988
+ max_usd = (
989
+ None
990
+ if agent.max_cost_budget is None
991
+ else round(frame.cost_anchor + agent.max_cost_budget, 6)
992
+ )
993
+ max_tokens = (
994
+ None
995
+ if agent.max_tokens_budget is None
996
+ else frame.token_anchor + agent.max_tokens_budget
997
+ )
998
+ return Budget(max_usd=max_usd, max_total_tokens=max_tokens, scope=f"agent:{agent.id}")
999
+
1000
+ def _control_call(self, calls: Sequence[ToolCall]) -> ToolCall | None:
1001
+ controls = [
1002
+ call for call in calls if call.name.startswith((TRANSFER_PREFIX, DELEGATE_PREFIX))
1003
+ ]
1004
+ if not controls:
1005
+ return None
1006
+ if len(calls) > 1:
1007
+ raise ConfigurationError(
1008
+ "a control call must be the only call in its step, because it changes who "
1009
+ f"owns the rest of the turn; got {[call.name for call in calls]}"
1010
+ )
1011
+ return controls[0]
1012
+
1013
+ def _needs_approval(
1014
+ self, agent: AgentDefinition, calls: Sequence[ToolCall], principal: Principal
1015
+ ) -> bool:
1016
+ """Whether a person must answer before these calls run.
1017
+
1018
+ Two triggers, both from the IR: the tool declares ``requiresConfirmation``,
1019
+ or the agent's ``humanInTheLoop.when`` policy holds for this principal. The
1020
+ policy is evaluated against the caller, never against a step counter, which
1021
+ is why a pause cannot fire on "step 3" unless the IR's policy says so.
1022
+ """
1023
+ if any(
1024
+ self._tools_by_name[call.name].requires_confirmation
1025
+ for call in calls
1026
+ if call.name in self._tools_by_name
1027
+ ):
1028
+ return True
1029
+ return agent.approval is not None and evaluate_policy(agent.approval.when, principal)
1030
+
1031
+ def _checkpoint(
1032
+ self,
1033
+ frame: _Frame,
1034
+ calls: Sequence[ToolCall],
1035
+ memory_key: str,
1036
+ *,
1037
+ escalation: GuardrailEscalation | None = None,
1038
+ ) -> AgentCheckpoint:
1039
+ approval = frame.agent.approval
1040
+ if escalation is not None:
1041
+ channel = escalation.channel
1042
+ elif approval is not None:
1043
+ channel = approval.channel
1044
+ else:
1045
+ channel = _TOOL_CONFIRMATION
1046
+ return AgentCheckpoint(
1047
+ agent_id=frame.agent.id,
1048
+ step=frame.step,
1049
+ messages=tuple(frame.messages),
1050
+ steps=tuple(frame.steps),
1051
+ calls=tuple(calls),
1052
+ channel=channel,
1053
+ when=dict(approval.when) if approval is not None else {},
1054
+ cost_anchor_usd=frame.cost_anchor,
1055
+ token_anchor=frame.token_anchor,
1056
+ spent_usd=self._gateway.ledger.cost_usd,
1057
+ spent_tokens=self._gateway.ledger.total_tokens,
1058
+ degraded=frame.degraded,
1059
+ alias=frame.alias,
1060
+ path=frame.path,
1061
+ memory_key=memory_key,
1062
+ escalation=escalation,
1063
+ guardrails=tuple(frame.guardrails),
1064
+ )
1065
+
1066
+ def _restore_ledger(self, checkpoint: AgentCheckpoint) -> None:
1067
+ """Carry a pause's spend into this process's ledger before resuming.
1068
+
1069
+ A resume in a fresh process starts with an empty ledger, which would hand
1070
+ the agent a ceiling it has already partly spent. The restored amount is a
1071
+ real ledger entry under a distinct alias, so the spend stays auditable
1072
+ instead of becoming a number the loop quietly remembers.
1073
+ """
1074
+ ledger = self._gateway.ledger
1075
+ missing_usd = round(checkpoint.spent_usd - ledger.cost_usd, 6)
1076
+ missing_tokens = checkpoint.spent_tokens - ledger.total_tokens
1077
+ if missing_usd <= 0 and missing_tokens <= 0:
1078
+ return
1079
+ ledger.charge(
1080
+ None,
1081
+ LedgerEntry(
1082
+ alias=_RESTORED_ALIAS,
1083
+ model=_RESTORED_ALIAS,
1084
+ provider=_RESTORED_ALIAS,
1085
+ usage=TokenUsage(input_tokens=max(missing_tokens, 0), output_tokens=0),
1086
+ cost_usd=max(missing_usd, 0.0),
1087
+ ),
1088
+ )
1089
+
1090
+ async def _open(
1091
+ self,
1092
+ agent: AgentDefinition,
1093
+ messages: Sequence[Message],
1094
+ *,
1095
+ path: tuple[str, ...],
1096
+ memory_key: str,
1097
+ ) -> _Frame:
1098
+ self._require_engine(agent)
1099
+ memory = await self._memory_state(agent, memory_key)
1100
+ ledger = self._gateway.ledger
1101
+ return _Frame(
1102
+ agent=agent,
1103
+ messages=list(messages),
1104
+ alias=agent.model,
1105
+ path=path,
1106
+ memory=memory,
1107
+ memory_key=memory_key,
1108
+ cost_anchor=ledger.cost_usd,
1109
+ token_anchor=ledger.total_tokens,
1110
+ )
1111
+
1112
+ async def _frame_from(self, checkpoint: AgentCheckpoint) -> _Frame:
1113
+ agent = self._spec.agent(checkpoint.agent_id)
1114
+ memory = await self._memory_state(agent, checkpoint.memory_key)
1115
+ return _Frame(
1116
+ agent=agent,
1117
+ messages=list(checkpoint.messages),
1118
+ alias=checkpoint.alias or agent.model,
1119
+ path=checkpoint.path,
1120
+ step=checkpoint.step,
1121
+ degraded=checkpoint.degraded,
1122
+ memory=memory,
1123
+ memory_key=checkpoint.memory_key,
1124
+ cost_anchor=checkpoint.cost_anchor_usd,
1125
+ token_anchor=checkpoint.token_anchor,
1126
+ steps=list(checkpoint.steps),
1127
+ # A checkpoint exists only after the input stage ran, so a resumed frame
1128
+ # must not re-check the message it already passed -- and must not re-apply
1129
+ # a redaction to text that is already masked.
1130
+ input_checked=True,
1131
+ guardrails=list(checkpoint.guardrails),
1132
+ )
1133
+
1134
+ async def _memory_state(self, agent: AgentDefinition, memory_key: str) -> _MemoryState:
1135
+ thread: ThreadMemory | None = None
1136
+ blocks: list[str] = []
1137
+ scopes: list[str] = []
1138
+ entries = 0
1139
+ tokens = 0
1140
+ for definition in agent.memory:
1141
+ check_memory_supported(
1142
+ memory_id=definition.id,
1143
+ kind=definition.kind,
1144
+ summarize_model=definition.summarize_model,
1145
+ ttl=definition.ttl,
1146
+ redact=definition.redact,
1147
+ )
1148
+ scopes.append(definition.scope)
1149
+ if definition.scope == _THREAD:
1150
+ if thread is None or definition.token_budget < _budget_of(thread):
1151
+ thread = ThreadMemory(definition.token_budget)
1152
+ continue
1153
+ values = await self._store_for(definition).read(
1154
+ scope=definition.scope, key=_storage_key(definition, memory_key)
1155
+ )
1156
+ text, used = render_context(values, definition.token_budget)
1157
+ if text:
1158
+ blocks.append(f"[{definition.id}]\n{text}")
1159
+ entries += len(values)
1160
+ tokens += used
1161
+ return _MemoryState(
1162
+ thread=thread,
1163
+ context="\n\n".join(blocks),
1164
+ scopes=tuple(scopes),
1165
+ entries=entries,
1166
+ tokens=tokens,
1167
+ )
1168
+
1169
+ async def _remember(self, agent: AgentDefinition, answer: str, memory_key: str) -> None:
1170
+ """Record the answer in every memory that outlives the run."""
1171
+ if not answer:
1172
+ return
1173
+ for definition in agent.memory:
1174
+ if definition.scope == _THREAD:
1175
+ continue
1176
+ await self._store_for(definition).append(
1177
+ scope=definition.scope,
1178
+ key=_storage_key(definition, memory_key),
1179
+ value=answer,
1180
+ )
1181
+
1182
+ def _store_for(self, definition: MemoryDefinition) -> MemoryStore:
1183
+ backend = definition.backend
1184
+ if backend is None or backend == _IN_PROCESS:
1185
+ return self._default_store
1186
+ try:
1187
+ return self._stores[backend]
1188
+ except KeyError:
1189
+ raise ConfigurationError(
1190
+ f"memory {definition.id!r} names backend {backend!r}, which is not "
1191
+ f"registered here; registered stores: {sorted(self._stores) or '<none>'}"
1192
+ ) from None
1193
+
1194
+ def _check_declarations(self) -> None:
1195
+ for agent in self._spec.agents.values():
1196
+ declared = {self._spec.tools[tool_id].name for tool_id in agent.tools}
1197
+ missing = sorted(declared - set(self._executor.names))
1198
+ if missing:
1199
+ raise ConfigurationError(
1200
+ f"agent {agent.id!r} declares tools that are not registered on this "
1201
+ f"runtime: {missing}; registered tools: {list(self._executor.names)}"
1202
+ )
1203
+ control = {TRANSFER_PREFIX + target for target in agent.handoffs}
1204
+ control |= {DELEGATE_PREFIX + target for target in agent.delegates}
1205
+ shadowed = sorted(control & declared)
1206
+ if shadowed:
1207
+ raise ConfigurationError(
1208
+ f"agent {agent.id!r} declares a tool named like a control tool "
1209
+ f"({shadowed}); transfer and delegation would be silently shadowed"
1210
+ )
1211
+
1212
+ @staticmethod
1213
+ def _require_engine(agent: AgentDefinition) -> None:
1214
+ """Refuse an engine this runtime cannot run, before any work is done.
1215
+
1216
+ The validation itself lives in :func:`aid_runtime.agents.engine.check_engine`
1217
+ so there is one place to read to know whether an engine is supported. A
1218
+ runtime that quietly ran a ``deepagents`` agent on the native loop would leave
1219
+ ``engine`` in the IR meaning nothing at all.
1220
+ """
1221
+ check_engine(agent)
1222
+
1223
+ @staticmethod
1224
+ def _record_status(span: Span, status: str, frame: _Frame) -> None:
1225
+ record_attributes(span, agent_status=status, agent_steps=frame.step)
1226
+
1227
+ @staticmethod
1228
+ def _outcome(status: RunStatus, frame: _Frame, *, answer: str = "") -> _Outcome:
1229
+ return _Outcome(
1230
+ status=status,
1231
+ agent_id=frame.agent.id,
1232
+ answer=answer,
1233
+ steps=frame.steps,
1234
+ path=frame.path,
1235
+ guardrails=tuple(frame.guardrails),
1236
+ )
1237
+
1238
+ def _wrap(self, outcome: _Outcome, start_cost: float, start_tokens: int) -> RunResult:
1239
+ ledger = self._gateway.ledger
1240
+ return RunResult(
1241
+ status=outcome.status,
1242
+ agent_id=outcome.agent_id,
1243
+ answer=outcome.answer,
1244
+ steps=tuple(outcome.steps),
1245
+ path=outcome.path,
1246
+ cost_usd=round(max(ledger.cost_usd - start_cost, 0.0), 6),
1247
+ total_tokens=max(ledger.total_tokens - start_tokens, 0),
1248
+ checkpoint=outcome.checkpoint,
1249
+ guardrails=outcome.guardrails,
1250
+ )
1251
+
1252
+
1253
+ def _budget_of(thread: ThreadMemory) -> int:
1254
+ return thread.token_budget if thread.token_budget is not None else 0
1255
+
1256
+
1257
+ def _last_user_index(messages: Sequence[Message]) -> int | None:
1258
+ """The turn the input stage guards: the most recent user message."""
1259
+ for index in range(len(messages) - 1, -1, -1):
1260
+ if messages[index].role == "user":
1261
+ return index
1262
+ return None
1263
+
1264
+
1265
+ def _storage_key(definition: MemoryDefinition, memory_key: str) -> str:
1266
+ """Global scope is shared by every caller; user scope is per key."""
1267
+ if definition.scope == _GLOBAL:
1268
+ return definition.id
1269
+ return f"{memory_key}:{definition.id}"
1270
+
1271
+
1272
+ def _control_message(call: ToolCall, response: CompletionResponse) -> str:
1273
+ raw = call.arguments.get(CONTROL_ARGUMENT)
1274
+ if isinstance(raw, str) and raw:
1275
+ return raw
1276
+ return response.text or f"control passed to {call.name}"
1277
+
1278
+
1279
+ def _control_definition(prefix: str, target: str) -> ToolDefinition:
1280
+ transferring = prefix == TRANSFER_PREFIX
1281
+ description = (
1282
+ f"Transfer the conversation to agent {target!r}. Control moves: you will not "
1283
+ "answer again, and the other agent produces the final answer."
1284
+ if transferring
1285
+ else (
1286
+ f"Delegate a self-contained task to agent {target!r}. Control returns to you "
1287
+ "with its result as an observation; you still produce the final answer."
1288
+ )
1289
+ )
1290
+ return ToolDefinition(
1291
+ name=f"{prefix}{target}",
1292
+ description=description,
1293
+ parameters={
1294
+ "type": "object",
1295
+ "properties": {
1296
+ CONTROL_ARGUMENT: {
1297
+ "type": "string",
1298
+ "description": (
1299
+ "Where to take the conversation from here."
1300
+ if transferring
1301
+ else "The whole task; the other agent cannot see this conversation."
1302
+ ),
1303
+ }
1304
+ },
1305
+ "required": [CONTROL_ARGUMENT],
1306
+ "additionalProperties": False,
1307
+ },
1308
+ )