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,600 @@
1
+ """The ``deepagents`` orchestration backend.
2
+
3
+ Deep Agents is a *backend*, not the IR and not the gateway. An agent that declares
4
+ ``engine: deepagents`` is driven by :func:`deepagents.create_deep_agent`; every other
5
+ agent keeps running on the native loop in :mod:`aid_runtime.agents.loop`, which is
6
+ unchanged. The two share the frame, the checkpoint, the ledger and the guardrail
7
+ engine -- the seam is a drive function, not a second definition of what an agent is.
8
+
9
+ Two boundaries are load-bearing, and they are why this module is small.
10
+
11
+ **The model boundary.** The harness is never handed a provider. It is handed
12
+ :class:`~aid_runtime.agents.chat_model.GatewayChatModel`, so fallback, budget, ledger
13
+ and spans keep applying inside the graph. Without that adapter the budget guarantee
14
+ would silently stop applying the moment an agent ran here -- the agent would look
15
+ fine and the ceiling would be gone.
16
+
17
+ **The capability boundary.** A mapping the harness cannot express is refused rather
18
+ than approximated. Transfer is the clear case: ``subagents`` are delegation with a
19
+ return, and there is no primitive that hands the conversation to another agent and
20
+ never comes back. An agent declaring ``handoffs`` therefore does not start at all
21
+ (see :func:`~aid_runtime.agents.engine.check_engine`). Silently degrading transfer
22
+ into delegation would still answer *something*, and the difference would only show
23
+ up in an eval baseline.
24
+
25
+ What this module deliberately does not do, and does not claim: filesystem, shell or
26
+ skill tools (the adapter's allow-list makes them unreachable, and they are not
27
+ enabled here), streaming (the gateway serves SSE for the HTTP layer, but the
28
+ deepagents engine consumes request/response through ``agenerate``/``ainvoke`` and
29
+ never an SSE byte stream, so the adapter's ``_astream`` keeps raising rather than
30
+ buffering into a non-stream), and LangSmith (our OTel
31
+ spans are the required trace; LangSmith is absent and off).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from typing import TYPE_CHECKING, Any
37
+
38
+ from deepagents import create_deep_agent
39
+ from langchain.agents.middleware import AgentMiddleware
40
+ from langchain_core.tools import StructuredTool
41
+ from langgraph.checkpoint.memory import InMemorySaver
42
+ from langgraph.errors import GraphRecursionError
43
+ from langgraph.types import Command
44
+ from opentelemetry.trace import Span, get_current_span
45
+
46
+ from aid_runtime.agents.chat_model import GatewayChatModel, to_langchain_messages
47
+ from aid_runtime.agents.engine import DEEPAGENTS, check_engine
48
+ from aid_runtime.agents.ir import AgentDefinition
49
+ from aid_runtime.agents.loop import _GUARDRAIL_INPUT, _Frame, _Outcome
50
+ from aid_runtime.agents.policy import Principal
51
+ from aid_runtime.agents.state import RunStatus, StepKind, StepRecord
52
+ from aid_runtime.cost import Budget
53
+ from aid_runtime.errors import DeepAgentsEngineError, DeepAgentsMaxStepsError
54
+ from aid_runtime.tracing import agent_span, record_hitl
55
+ from aid_runtime.types import Capability, Message, ToolCall
56
+
57
+ if TYPE_CHECKING:
58
+ from aid_runtime.agents.loop import AgentRuntime
59
+
60
+ __all__ = ["DeepAgentsEngine"]
61
+
62
+ #: Graph super-steps one model call can cost, used to size the recursion limit from
63
+ #: the agent's ``maxSteps``. The adapter's call counter is the real ceiling; this is
64
+ #: the backstop for a graph that cycles without spending a model call, so it is set
65
+ #: with room above the counter rather than exactly at it.
66
+ _RECURSION_PER_STEP = 4
67
+
68
+ #: The smallest recursion limit a run is built with: even ``maxSteps`` of 1 needs room
69
+ #: for the model call plus the tool super-step that follows it.
70
+ _MIN_RECURSION = 8
71
+
72
+ #: The harness's own delegation tool. It is the ``task`` builtin: a delegate is
73
+ #: called with a task and returns a result, which is delegation, not transfer.
74
+ _DELEGATE_TOOL = "task"
75
+
76
+
77
+ class _HaltError(Exception):
78
+ """A guardrail decided the run, from inside the graph.
79
+
80
+ Raised from middleware and caught by :meth:`DeepAgentsEngine.drive`. The harness
81
+ turns an ordinary tool error into a tool message and lets the graph continue,
82
+ which is right for a bad argument and wrong for a guardrail: a blocked call
83
+ delivered as an observation is a blocked call the model can route around. A
84
+ raise from middleware propagates out of ``ainvoke``, so this is the only shape in
85
+ which a tool-stage decision leaves the harness still a decision.
86
+ """
87
+
88
+ def __init__(self, outcome: _Outcome) -> None:
89
+ super().__init__(outcome.status.value)
90
+ self.outcome = outcome
91
+
92
+
93
+ class DeepAgentsEngine:
94
+ """Drives one frame on the Deep Agents harness.
95
+
96
+ Built per frame rather than held on the runtime, so no previous run's tools,
97
+ interrupt arming or transcript can survive into the next one.
98
+ """
99
+
100
+ def __init__(self, runtime: AgentRuntime) -> None:
101
+ self._runtime = runtime
102
+
103
+ async def drive(
104
+ self,
105
+ frame: _Frame,
106
+ *,
107
+ budget: Budget | None,
108
+ principal: Principal,
109
+ memory_key: str,
110
+ span: Span,
111
+ ) -> _Outcome:
112
+ """Run ``frame`` to a stop condition on the harness, reported in our terms."""
113
+ agent = frame.agent
114
+ check_engine(agent)
115
+ if not frame.input_checked:
116
+ frame.input_checked = True
117
+ paused = self._runtime._guardrail_text(
118
+ _GUARDRAIL_INPUT, frame, memory_key, span=span, rewrite=True
119
+ )
120
+ if paused is not None:
121
+ return paused
122
+
123
+ models: list[tuple[str, GatewayChatModel]] = []
124
+ graph = self._graph(frame, principal=principal, budget=budget, span=span, models=models)
125
+ config = self._config(agent, memory_key)
126
+ try:
127
+ result = await self._invoke(graph, frame, config, principal=principal)
128
+ except _HaltError as halt:
129
+ self._record_steps(frame, models)
130
+ self._runtime._record_status(span, halt.outcome.status.value, frame)
131
+ return halt.outcome
132
+ except DeepAgentsMaxStepsError:
133
+ self._record_steps(frame, models)
134
+ self._runtime._record_status(span, "max-steps", frame)
135
+ return self._runtime._outcome(RunStatus.MAX_STEPS, frame)
136
+ except GraphRecursionError:
137
+ # The backstop. A graph that cycled without spending a model call has
138
+ # still exhausted the agent's step budget; reporting it as a harness
139
+ # crash would hide an agent that never terminates.
140
+ self._record_steps(frame, models)
141
+ self._runtime._record_status(span, "max-steps", frame)
142
+ return self._runtime._outcome(RunStatus.MAX_STEPS, frame)
143
+
144
+ self._record_steps(frame, models)
145
+ paused = self._pause(frame, result, memory_key=memory_key, span=span)
146
+ if paused is not None:
147
+ return paused
148
+ return await self._complete(frame, result, memory_key=memory_key, span=span)
149
+
150
+ # -- graph -------------------------------------------------------------------
151
+
152
+ def _graph(
153
+ self,
154
+ frame: _Frame,
155
+ *,
156
+ principal: Principal,
157
+ budget: Budget | None,
158
+ span: Span,
159
+ models: list[tuple[str, GatewayChatModel]],
160
+ ) -> Any:
161
+ """Build the harness graph.
162
+
163
+ ``models`` is passed in and filled here because those adapters are the only
164
+ record of what the harness spent: every generation they hold came back
165
+ through the gateway, which is what makes the run's cost auditable.
166
+ """
167
+ agent = frame.agent
168
+ interrupts: dict[str, Any] | None = self._interrupts(agent)
169
+ subagents: Any = (
170
+ self._subagents(frame, principal=principal, budget=budget, span=span, models=models)
171
+ or None
172
+ )
173
+ return create_deep_agent(
174
+ model=self._model(agent, frame=frame, budget=budget, span=span, models=models),
175
+ tools=self._tools(agent, frame=frame, principal=principal, span=span),
176
+ system_prompt=self._runtime.spec.system_prompt(agent),
177
+ middleware=[
178
+ _Guardrails(
179
+ self,
180
+ frame,
181
+ principal=principal,
182
+ span=span,
183
+ memory_key=frame.memory_key,
184
+ )
185
+ ],
186
+ subagents=subagents,
187
+ interrupt_on=interrupts,
188
+ # Only a run that can be interrupted needs a checkpointer, and the pause
189
+ # it enables is a suspension point, never the decision itself.
190
+ checkpointer=InMemorySaver() if interrupts else None,
191
+ name=agent.id,
192
+ )
193
+
194
+ def _model(
195
+ self,
196
+ agent: AgentDefinition,
197
+ *,
198
+ frame: _Frame,
199
+ budget: Budget | None,
200
+ span: Span,
201
+ models: list[tuple[str, GatewayChatModel]],
202
+ ) -> GatewayChatModel:
203
+ """The adapter for one agent, with the capability check the loop also makes.
204
+
205
+ A model that cannot tool-call would answer from memory and look fine, and a
206
+ gateway fallback is chosen for a provider failure without re-negotiating
207
+ capabilities. Checking here is what keeps that guarantee inside the harness
208
+ instead of only on the native path.
209
+ """
210
+ required = frozenset(
211
+ {Capability.TOOL_CALLING} if agent.tools or agent.has_control_tools else set()
212
+ )
213
+ if required:
214
+ self._runtime.gateway.require(agent.model, required)
215
+ allowed = {self._runtime.spec.tools[tool_id].name for tool_id in agent.tools}
216
+ if agent.delegates:
217
+ # Delegation is the one harness tool this engine exposes on purpose, and
218
+ # only an agent that declares a delegate gets it. It carries no side
219
+ # effect of its own: the delegate's own grant governs what it may do.
220
+ allowed.add(_DELEGATE_TOOL)
221
+ model = GatewayChatModel(
222
+ gateway=self._runtime.gateway,
223
+ alias=agent.model,
224
+ fallbacks=self._runtime.spec.fallbacks_for(agent.model),
225
+ budget=budget,
226
+ allowed_tools=frozenset(allowed),
227
+ max_calls=max(agent.max_steps - frame.step, 0),
228
+ )
229
+ models.append((agent.id, model))
230
+ return model
231
+
232
+ def _subagents(
233
+ self,
234
+ frame: _Frame,
235
+ *,
236
+ principal: Principal,
237
+ budget: Budget | None,
238
+ span: Span,
239
+ models: list[tuple[str, GatewayChatModel]],
240
+ ) -> list[dict[str, Any]]:
241
+ """Map ``delegates`` onto ``subagents``: delegation with isolated context.
242
+
243
+ The harness gives a subagent only the task it was handed and returns its
244
+ result to the caller, which is what a delegate is. Each subagent gets its own
245
+ model and its own tool grant, so a delegate cannot inherit the caller's
246
+ allow-list or spend against the caller's step budget.
247
+
248
+ Each subagent also carries its own ``_Guardrails``, because the harness builds
249
+ a subagent's middleware list from the spec and nothing else: an omitted
250
+ ``middleware`` key means the delegate's graph has no tool-call guardrail at
251
+ all, and the caller's middleware does not reach inside it. That is the native
252
+ loop's semantic too -- ``_delegate`` opens a frame for the target and drives
253
+ it, so the *target's* ``stage_guardrails("tool-call")`` is what runs, and the
254
+ caller's does not cross the boundary.
255
+ """
256
+ built: list[dict[str, Any]] = []
257
+ for target_id in frame.agent.delegates:
258
+ target = self._runtime.spec.agent(target_id)
259
+ check_engine(target)
260
+ built.append(
261
+ {
262
+ "name": target.id,
263
+ "description": target.name or target.id,
264
+ "system_prompt": self._runtime.spec.system_prompt(target),
265
+ "model": self._model(
266
+ target,
267
+ frame=frame,
268
+ budget=budget,
269
+ span=span,
270
+ models=models,
271
+ ),
272
+ "tools": self._tools(target, frame=frame, principal=principal, span=span),
273
+ "middleware": [
274
+ _Guardrails(
275
+ self,
276
+ _delegate_frame(frame, target),
277
+ principal=principal,
278
+ # The delegate's span cannot exist yet: the harness runs
279
+ # it inline from the `task` call, and the caller's
280
+ # middleware opens that span around the dispatch. Resolving
281
+ # the then-current span is what keeps the delegate's records
282
+ # off the caller's span. See `_delegate_in_span`.
283
+ span=None,
284
+ memory_key=frame.memory_key,
285
+ )
286
+ ],
287
+ }
288
+ )
289
+ return built
290
+
291
+ def _tools(
292
+ self,
293
+ agent: AgentDefinition,
294
+ *,
295
+ frame: _Frame,
296
+ principal: Principal,
297
+ span: Span,
298
+ ) -> list[Any]:
299
+ """Wrap each declared tool as a harness tool whose body is our executor.
300
+
301
+ The body is deliberately thin: authorization and the tool-call guardrail run
302
+ in the ``_Guardrails`` middleware of whichever agent is calling -- the caller's
303
+ for the caller's tools, the delegate's own for the delegate's -- and execution
304
+ goes through the same ``ToolExecutor`` the native loop uses. That is what makes
305
+ a tool call under this engine the same event in the trace as one under the
306
+ native engine.
307
+ """
308
+ tools: list[Any] = []
309
+ for tool_id in agent.tools:
310
+ spec = self._runtime.spec.tools[tool_id]
311
+ definition = self._runtime._executor.definition(spec.name)
312
+
313
+ async def _call(
314
+ _agent: AgentDefinition = agent, _name: str = spec.name, **kwargs: Any
315
+ ) -> str:
316
+ # The id is derived, not random: the harness pairs a result with its
317
+ # call by id, and a random one would make two identical runs differ.
318
+ call = ToolCall(id=f"{_agent.id}:{_name}", name=_name, arguments=kwargs)
319
+ observations = await self._runtime._observe(_agent, [call], principal)
320
+ return observations[0].content
321
+
322
+ tools.append(
323
+ StructuredTool.from_function(
324
+ coroutine=_call,
325
+ name=spec.name,
326
+ description=definition.description or spec.name,
327
+ args_schema=definition.parameters,
328
+ )
329
+ )
330
+ return tools
331
+
332
+ def _interrupts(self, agent: AgentDefinition) -> dict[str, bool] | None:
333
+ """Which tools arm the harness's interrupt.
334
+
335
+ A tool the IR marks ``requiresConfirmation`` always arms it. When the agent
336
+ carries a ``humanInTheLoop.when`` policy, every tool arms it, and the policy
337
+ itself is then evaluated by us when the interrupt fires -- an interrupt the
338
+ policy does not cover is released immediately, so the pause a caller observes
339
+ is always our policy's and never the harness's default.
340
+ """
341
+ armed = {
342
+ self._runtime.spec.tools[tool_id].name
343
+ for tool_id in agent.tools
344
+ if self._runtime.spec.tools[tool_id].requires_confirmation
345
+ }
346
+ if agent.approval is not None:
347
+ armed |= {self._runtime.spec.tools[tool_id].name for tool_id in agent.tools}
348
+ return dict.fromkeys(sorted(armed), True) or None
349
+
350
+ @staticmethod
351
+ def _config(agent: AgentDefinition, memory_key: str) -> dict[str, Any]:
352
+ return {
353
+ "recursion_limit": max(agent.max_steps * _RECURSION_PER_STEP, _MIN_RECURSION),
354
+ "configurable": {"thread_id": f"{agent.id}:{memory_key}"},
355
+ }
356
+
357
+ async def _invoke(
358
+ self, graph: Any, frame: _Frame, config: dict[str, Any], *, principal: Principal
359
+ ) -> Any:
360
+ """Invoke the graph, releasing any interrupt our own policy does not hold."""
361
+ seeds = to_langchain_messages(
362
+ [message for message in frame.messages if message.role != "system"]
363
+ )
364
+ result = await graph.ainvoke({"messages": seeds}, config=config)
365
+ while True:
366
+ pending = result.get("__interrupt__")
367
+ if not pending:
368
+ return result
369
+ calls = self._interrupted(pending)
370
+ if self._runtime._needs_approval(frame.agent, calls, principal):
371
+ return result
372
+ result = await graph.ainvoke(
373
+ Command(resume={"decisions": [{"type": "approve"} for _ in calls]}),
374
+ config=config,
375
+ )
376
+
377
+ # -- outcomes ----------------------------------------------------------------
378
+
379
+ @staticmethod
380
+ def _interrupted(pending: Any) -> list[ToolCall]:
381
+ """The calls a pause is holding, as our own type.
382
+
383
+ The harness reports a description of each held action rather than the call it
384
+ composed, so the id is derived from position when none is supplied. It only
385
+ has to pair a resumed observation with its call, and a derived id is stable
386
+ across a replay where a random one would not be.
387
+ """
388
+ calls: list[ToolCall] = []
389
+ for index, interrupt in enumerate(pending):
390
+ value = getattr(interrupt, "value", interrupt)
391
+ requests = value.get("action_requests") if isinstance(value, dict) else None
392
+ for position, request in enumerate(requests or ()):
393
+ name = str(request.get("name", ""))
394
+ if not name:
395
+ continue
396
+ arguments = request.get("args")
397
+ calls.append(
398
+ ToolCall(
399
+ id=str(request.get("id") or f"interrupt-{index}-{position}"),
400
+ name=name,
401
+ arguments=dict(arguments) if isinstance(arguments, dict) else {},
402
+ )
403
+ )
404
+ return calls
405
+
406
+ def _pause(self, frame: _Frame, result: Any, *, memory_key: str, span: Span) -> _Outcome | None:
407
+ """Turn a held interrupt into our checkpoint, or ``None`` when nothing is held."""
408
+ pending = result.get("__interrupt__")
409
+ if not pending:
410
+ return None
411
+ calls = self._interrupted(pending)
412
+ if not calls:
413
+ raise DeepAgentsEngineError(
414
+ f"agent {frame.agent.id!r} paused on an interrupt that named no tool call; "
415
+ "the harness's interrupt payload has changed shape"
416
+ )
417
+ checkpoint = self._runtime._checkpoint(frame, calls, memory_key)
418
+ record_hitl(span, phase="pause", channel=checkpoint.channel, step=frame.step)
419
+ self._runtime._record_status(span, "paused", frame)
420
+ return _Outcome(
421
+ status=RunStatus.PAUSED,
422
+ agent_id=frame.agent.id,
423
+ steps=frame.steps,
424
+ path=frame.path,
425
+ checkpoint=checkpoint,
426
+ guardrails=tuple(frame.guardrails),
427
+ )
428
+
429
+ async def _complete(
430
+ self, frame: _Frame, result: Any, *, memory_key: str, span: Span
431
+ ) -> _Outcome:
432
+ """Apply the output guardrail, record memory, and report the answer."""
433
+ released = self._runtime._guardrail_answer(
434
+ frame, self._answer(result), memory_key, span=span
435
+ )
436
+ if isinstance(released, _Outcome):
437
+ return released
438
+ frame.messages.append(Message.assistant(released))
439
+ await self._runtime._remember(frame.agent, released, memory_key)
440
+ self._runtime._record_status(span, "completed", frame)
441
+ return self._runtime._outcome(RunStatus.COMPLETED, frame, answer=released)
442
+
443
+ @staticmethod
444
+ def _answer(result: Any) -> str:
445
+ """The last thing the model said, which is what a native run would report."""
446
+ for message in reversed(result.get("messages") or ()):
447
+ if getattr(message, "type", "") != "ai":
448
+ continue
449
+ content = getattr(message, "content", "")
450
+ if isinstance(content, str):
451
+ return content
452
+ return "".join(
453
+ block["text"]
454
+ for block in content
455
+ if isinstance(block, dict) and isinstance(block.get("text"), str)
456
+ )
457
+ return ""
458
+
459
+ def _record_steps(self, frame: _Frame, models: list[tuple[str, GatewayChatModel]]) -> None:
460
+ """Append one step per model call, in the order the gateway charged them.
461
+
462
+ The alias, cost and tokens come from the generation the gateway returned, so
463
+ a fallback appears in the step exactly as it would under the native loop.
464
+ """
465
+ for agent_id, model in models:
466
+ for generation in model.calls:
467
+ frame.step += 1
468
+ response = generation.response
469
+ frame.steps.append(
470
+ StepRecord(
471
+ index=frame.step,
472
+ agent_id=agent_id,
473
+ kind=StepKind.TOOLS if response.tool_calls else StepKind.ANSWER,
474
+ alias=generation.alias,
475
+ model=response.model,
476
+ cost_usd=generation.cost_usd,
477
+ total_tokens=generation.usage.total_tokens,
478
+ tool_names=tuple(call.name for call in response.tool_calls),
479
+ )
480
+ )
481
+ model.calls.clear()
482
+
483
+
484
+ class _Guardrails(AgentMiddleware):
485
+ """Our authorization and guardrail engine, at the tool-call boundary.
486
+
487
+ The harness has no guardrail concept, so this is where ours wraps it. It runs
488
+ before the tool body, which is the only point where a decision can still stop the
489
+ call, and it raises rather than returning an observation so the harness cannot
490
+ hand the decision back to the model as something to work around.
491
+
492
+ One instance is bound to one frame. The caller's is built with ``_graph``; a
493
+ delegate's is attached to its subagent spec by ``_subagents`` and bound to the
494
+ delegate's frame, which is the same split the native loop makes by driving each
495
+ frame with its own agent's guardrails.
496
+ """
497
+
498
+ def __init__(
499
+ self,
500
+ engine: DeepAgentsEngine,
501
+ frame: _Frame,
502
+ *,
503
+ principal: Principal,
504
+ span: Span | None,
505
+ memory_key: str,
506
+ ) -> None:
507
+ super().__init__()
508
+ self._engine = engine
509
+ self._frame = frame
510
+ self._principal = principal
511
+ self._span = span
512
+ self._memory_key = memory_key
513
+
514
+ @property
515
+ def _record_span(self) -> Span:
516
+ """The span this middleware's records belong to.
517
+
518
+ The caller's instance is handed the span its frame already opened. A
519
+ delegate's is not: it is built with the graph, before the harness has run
520
+ anything, so the delegate's span is opened later, around the ``task``
521
+ dispatch, and is simply the span current when the delegate's tool call runs.
522
+ Without that, a delegate's guardrail record would be attributed to the caller.
523
+ """
524
+ return self._span if self._span is not None else get_current_span()
525
+
526
+ async def awrap_tool_call(self, request: Any, handler: Any) -> Any:
527
+ call = _as_call(request)
528
+ runtime = self._engine._runtime
529
+ if call.name == _DELEGATE_TOOL:
530
+ # Delegation is the harness's own dispatch, not a declared tool: there is
531
+ # no IR entry to authorize or guard against. It is not unguarded, though.
532
+ # The subagent spec carries a `_Guardrails` bound to the delegate's frame,
533
+ # so the delegate's own guardrails decide its tool calls -- the caller's do
534
+ # not cross the boundary, exactly as the native loop's `_delegate` runs the
535
+ # target's frame instead of applying the caller's guardrails to it. This
536
+ # branch only has to open the delegate's span.
537
+ return await self._delegate_in_span(request, handler, call)
538
+ runtime._authorize(self._frame.agent, call, self._principal)
539
+ guarded, halted = runtime._guardrail_tool_calls(
540
+ self._frame, [call], self._memory_key, span=self._record_span
541
+ )
542
+ if halted is not None:
543
+ raise _HaltError(halted)
544
+ if guarded and guarded[0].arguments != call.arguments:
545
+ # A redaction rewrote the arguments; the tool must see the rewritten
546
+ # ones, not the model's originals.
547
+ request = request.override(
548
+ tool_call={**request.tool_call, "args": guarded[0].arguments}
549
+ )
550
+ return await handler(request)
551
+
552
+ async def _delegate_in_span(self, request: Any, handler: Any, call: ToolCall) -> Any:
553
+ """Run the harness's delegation inside the delegate's own span.
554
+
555
+ The harness runs a subagent inline rather than through ``AgentRuntime._drive``,
556
+ so this is the one place the delegate's agent span can be opened. Without it the
557
+ delegate's model spans and guardrail records would all be children of the
558
+ caller's span, and a decision the delegate made would be reported as one the
559
+ caller made. A malformed ``task`` call names no delegate and is left alone --
560
+ the harness rejects those before any subagent runs.
561
+ """
562
+ target_id = call.arguments.get("subagent_type")
563
+ target = (
564
+ self._engine._runtime.spec.agents.get(target_id) if isinstance(target_id, str) else None
565
+ )
566
+ if target is None:
567
+ return await handler(request)
568
+ with agent_span(
569
+ agent_id=target.id, engine=DEEPAGENTS, tracer=self._engine._runtime._tracer
570
+ ):
571
+ return await handler(request)
572
+
573
+
574
+ def _delegate_frame(caller: _Frame, target: AgentDefinition) -> _Frame:
575
+ """The frame a delegate's tool calls are decided under.
576
+
577
+ The harness keeps a subagent's transcript, so this frame carries no messages: it
578
+ exists because authorization and the guardrail engine need the target's own agent
579
+ definition, its position in the run's path and the run's memory key. It shares the
580
+ caller's ``guardrails`` list so a delegate's records reach the run's result, which
581
+ is what ``_delegate`` does on the native loop when it extends the caller's list.
582
+ """
583
+ return _Frame(
584
+ agent=target,
585
+ messages=[],
586
+ alias=target.model,
587
+ path=(*caller.path, target.id),
588
+ memory_key=caller.memory_key,
589
+ guardrails=caller.guardrails,
590
+ )
591
+
592
+
593
+ def _as_call(request: Any) -> ToolCall:
594
+ raw = request.tool_call
595
+ arguments = raw.get("args")
596
+ return ToolCall(
597
+ id=str(raw.get("id") or raw.get("name") or "tool-call"),
598
+ name=str(raw.get("name") or ""),
599
+ arguments=dict(arguments) if isinstance(arguments, dict) else {},
600
+ )