aid-runtime-python 0.1.39__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aid_runtime/__init__.py +500 -0
- aid_runtime/agents/__init__.py +94 -0
- aid_runtime/agents/chat_model.py +351 -0
- aid_runtime/agents/deep_agents.py +600 -0
- aid_runtime/agents/engine.py +102 -0
- aid_runtime/agents/guardrails.py +519 -0
- aid_runtime/agents/ir.py +622 -0
- aid_runtime/agents/loop.py +1308 -0
- aid_runtime/agents/memory.py +241 -0
- aid_runtime/agents/policy.py +163 -0
- aid_runtime/agents/state.py +182 -0
- aid_runtime/cache.py +185 -0
- aid_runtime/concurrency.py +70 -0
- aid_runtime/control.py +329 -0
- aid_runtime/cost.py +168 -0
- aid_runtime/errors.py +337 -0
- aid_runtime/evals/__init__.py +72 -0
- aid_runtime/evals/dataset.py +77 -0
- aid_runtime/evals/gate.py +430 -0
- aid_runtime/evals/metrics.py +335 -0
- aid_runtime/evals/predicate.py +211 -0
- aid_runtime/evals/runner.py +224 -0
- aid_runtime/gateway.py +473 -0
- aid_runtime/pipeline.py +168 -0
- aid_runtime/prompts.py +185 -0
- aid_runtime/providers/__init__.py +20 -0
- aid_runtime/providers/base.py +57 -0
- aid_runtime/providers/fake.py +256 -0
- aid_runtime/providers/http.py +344 -0
- aid_runtime/py.typed +0 -0
- aid_runtime/retrieval/__init__.py +178 -0
- aid_runtime/retrieval/chunking.py +222 -0
- aid_runtime/retrieval/citations.py +131 -0
- aid_runtime/retrieval/connectors.py +131 -0
- aid_runtime/retrieval/embedding.py +293 -0
- aid_runtime/retrieval/gate.py +120 -0
- aid_runtime/retrieval/hybrid.py +164 -0
- aid_runtime/retrieval/metrics.py +213 -0
- aid_runtime/retrieval/pgvector.py +258 -0
- aid_runtime/retrieval/rerank.py +133 -0
- aid_runtime/retrieval/retriever.py +335 -0
- aid_runtime/retrieval/stores.py +161 -0
- aid_runtime/retrieval/types.py +433 -0
- aid_runtime/steps.py +284 -0
- aid_runtime/structured.py +150 -0
- aid_runtime/tools.py +271 -0
- aid_runtime/tracing.py +241 -0
- aid_runtime/types.py +332 -0
- aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
- aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
- aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
- aid_runtime_python-0.1.39.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|