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,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
|
+
)
|