framework-agents 0.1.0__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.
- framework_agents/__init__.py +345 -0
- framework_agents/a2a_client.py +360 -0
- framework_agents/a2a_server.py +495 -0
- framework_agents/agent.py +920 -0
- framework_agents/checkpoint.py +231 -0
- framework_agents/config.py +200 -0
- framework_agents/conversation.py +249 -0
- framework_agents/errors.py +47 -0
- framework_agents/evals.py +127 -0
- framework_agents/mcp_client.py +477 -0
- framework_agents/memory.py +103 -0
- framework_agents/memory_compaction.py +63 -0
- framework_agents/middleware.py +1302 -0
- framework_agents/models/__init__.py +100 -0
- framework_agents/models/anthropic.py +339 -0
- framework_agents/models/capabilities.py +45 -0
- framework_agents/models/ollama.py +47 -0
- framework_agents/models/openai_compatible.py +409 -0
- framework_agents/observability.py +84 -0
- framework_agents/ontology_review.py +321 -0
- framework_agents/orchestration.py +169 -0
- framework_agents/pricing.py +63 -0
- framework_agents/prompts.py +132 -0
- framework_agents/rag.py +123 -0
- framework_agents/remote_sparql.py +213 -0
- framework_agents/runtime.py +88 -0
- framework_agents/sandbox.py +327 -0
- framework_agents/semantic.py +1614 -0
- framework_agents/serve.py +436 -0
- framework_agents/sessions.py +249 -0
- framework_agents/skills.py +213 -0
- framework_agents/state.py +89 -0
- framework_agents/streaming.py +74 -0
- framework_agents/swarm.py +318 -0
- framework_agents/tools.py +114 -0
- framework_agents/tracing.py +131 -0
- framework_agents/types.py +262 -0
- framework_agents/workflows.py +130 -0
- framework_agents-0.1.0.dist-info/METADATA +118 -0
- framework_agents-0.1.0.dist-info/RECORD +41 -0
- framework_agents-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
"""The ReAct loop. Parity: src/agent/index.ts (post-sweep semantics).
|
|
2
|
+
|
|
3
|
+
Pinned behaviors (each was a real bug in TS once; the python port starts
|
|
4
|
+
from the FIXED versions):
|
|
5
|
+
|
|
6
|
+
- Budgets fire after EVERY model call on CUMULATIVE usage; reasoning tokens
|
|
7
|
+
are a subset of output tokens so they are never added on top.
|
|
8
|
+
- Memory save at run end is CLEAR-then-SAVE (save() appends).
|
|
9
|
+
- Structured 'auto' resolves by provider capability — an unresolved 'auto'
|
|
10
|
+
made every run throw STRUCTURED_NO_MATCH in TS once.
|
|
11
|
+
- maxSteps exhaustion stores a synthesized assistant message (history never
|
|
12
|
+
ends on a dangling tool-call tail).
|
|
13
|
+
- Unknown tools / invalid args become model-visible feedback; loop continues.
|
|
14
|
+
- result.usage.cost_usd set only when a price is known — never guessed.
|
|
15
|
+
- Stream parity: token/thinking deltas batch per model call (as in TS), tool
|
|
16
|
+
events yield live, budget errors pass through unretried.
|
|
17
|
+
- Per-stream results: AgentStream captures its OWN AgentResult at the done
|
|
18
|
+
event; concurrent streams on one agent never cross-contaminate.
|
|
19
|
+
- beforeModel REPLACEMENTS feed the model call only: the loop's message list
|
|
20
|
+
stays authoritative for appends and the end-of-run save (TS parity).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import secrets
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from typing import Any, AsyncIterator, Callable, Literal, Optional, Type
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, ValidationError
|
|
33
|
+
|
|
34
|
+
from .checkpoint import PendingCall, ThreadCheckpoint
|
|
35
|
+
from .errors import AgentError
|
|
36
|
+
from .memory import InMemoryMemory, Memory
|
|
37
|
+
from .middleware import (
|
|
38
|
+
HITL_DECISIONS_KEY,
|
|
39
|
+
HookContext,
|
|
40
|
+
HitlInterrupt,
|
|
41
|
+
ModelRequest,
|
|
42
|
+
ToolCallRef,
|
|
43
|
+
ToolRejection,
|
|
44
|
+
compose_model_call,
|
|
45
|
+
compose_tool_call,
|
|
46
|
+
run_after_model,
|
|
47
|
+
run_after_tool,
|
|
48
|
+
run_before_model,
|
|
49
|
+
run_before_tool,
|
|
50
|
+
)
|
|
51
|
+
from .models import (
|
|
52
|
+
CompleteOptions,
|
|
53
|
+
ModelProvider,
|
|
54
|
+
ResponseFormatWire,
|
|
55
|
+
ToolWire,
|
|
56
|
+
)
|
|
57
|
+
from .pricing import ModelPrice, price_for, usage_cost
|
|
58
|
+
from .sessions import SessionManager, Snapshot, SnapshotData, select_snapshot_fields
|
|
59
|
+
from .state import AgentState
|
|
60
|
+
from .streaming import AgentStream, StreamEvent
|
|
61
|
+
from .tools import Tool, ToolContext, ToolResult, define_tool, parameters_schema
|
|
62
|
+
from .types import ChatMessage, ExecutedToolCall, TokenUsage
|
|
63
|
+
|
|
64
|
+
DEFAULT_SYSTEM = "You are a helpful agent. Use tools when needed. Be concise."
|
|
65
|
+
DEFAULT_MAX_STEPS = 6
|
|
66
|
+
|
|
67
|
+
TimelineKind = Literal["run", "edit", "branch", "resume", "snapshot"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _iso_now() -> str:
|
|
71
|
+
return datetime.now(timezone.utc).isoformat()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class TokenBudgets:
|
|
76
|
+
max_tokens: Optional[int] = None
|
|
77
|
+
max_cost_usd: Optional[float] = None
|
|
78
|
+
prices: Optional[ModelPrice] = None # price override for max_cost_usd
|
|
79
|
+
deadline_ms: Optional[float] = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ResponseFormat:
|
|
84
|
+
schema: Type[BaseModel]
|
|
85
|
+
name: str = "structured_response"
|
|
86
|
+
strategy: Literal["auto", "provider", "tool"] = "auto"
|
|
87
|
+
max_attempts: int = 2
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class AgentEvents:
|
|
92
|
+
on_step: Optional[Callable[[int], None]] = None
|
|
93
|
+
on_token: Optional[Callable[[str], None]] = None
|
|
94
|
+
on_thinking: Optional[Callable[[str], None]] = None
|
|
95
|
+
on_tool_call: Optional[Callable[[str, Any], None]] = None
|
|
96
|
+
on_tool_result: Optional[Callable[[str, Any], None]] = None
|
|
97
|
+
on_done: Optional[Callable[[str], None]] = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class AgentResult:
|
|
102
|
+
text: str
|
|
103
|
+
messages: list[ChatMessage]
|
|
104
|
+
tool_calls: list[ExecutedToolCall]
|
|
105
|
+
usage: TokenUsage
|
|
106
|
+
structured_response: Optional[Any] = None
|
|
107
|
+
invocation_state: Optional[dict[str, Any]] = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def provider_strategy(schema: Type[BaseModel], name: str = "structured_response") -> ResponseFormat:
|
|
111
|
+
return ResponseFormat(schema=schema, name=name, strategy="provider")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def tool_strategy(schema: Type[BaseModel], name: str = "structured_response") -> ResponseFormat:
|
|
115
|
+
return ResponseFormat(schema=schema, name=name, strategy="tool")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class AgentOptions:
|
|
120
|
+
model: ModelProvider
|
|
121
|
+
name: str = "agent"
|
|
122
|
+
description: str = ""
|
|
123
|
+
system_prompt: str = DEFAULT_SYSTEM
|
|
124
|
+
tools: list[Tool] = field(default_factory=list)
|
|
125
|
+
memory: Optional[Memory] = None
|
|
126
|
+
max_steps: int = DEFAULT_MAX_STEPS
|
|
127
|
+
max_tool_calls: Optional[int] = None
|
|
128
|
+
budgets: Optional[TokenBudgets] = None
|
|
129
|
+
response_format: Optional[ResponseFormat] = None
|
|
130
|
+
events: Optional[AgentEvents] = None
|
|
131
|
+
initial_messages: list[ChatMessage] = field(default_factory=list)
|
|
132
|
+
# Middleware hooks (see .middleware); contributed tools are merged at
|
|
133
|
+
# construction: PREPENDED, dedupe by name, first wins.
|
|
134
|
+
middleware: list[Any] = field(default_factory=list)
|
|
135
|
+
# Per-run state shared by reference with middleware hooks (never sent to
|
|
136
|
+
# the model); a run invocation_state kwarg overrides this.
|
|
137
|
+
invocation_state: Optional[dict[str, Any]] = None
|
|
138
|
+
# Agent key-value state (never sent to the model): AgentState or a plain
|
|
139
|
+
# dict (validated and converted at construction).
|
|
140
|
+
state: AgentState | dict[str, Any] | None = None
|
|
141
|
+
# Persist state + history across restarts. Restored on first run (BEFORE
|
|
142
|
+
# initial_messages seeding — the session wins over the seed), saved after
|
|
143
|
+
# each run.
|
|
144
|
+
session_manager: Optional[SessionManager] = None
|
|
145
|
+
# Output version: "v0" assistant turns carry plain text; "v1" also stores
|
|
146
|
+
# thinking as reasoning blocks on final turns.
|
|
147
|
+
output_version: Literal["v0", "v1"] = "v0"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Agent:
|
|
151
|
+
def __init__(self, options: AgentOptions) -> None:
|
|
152
|
+
self.options = options
|
|
153
|
+
# Middleware-contributed tools are PREPENDED to the agent's tool
|
|
154
|
+
# list (dedupe by name, first wins) so the model can plan with them.
|
|
155
|
+
seen: set[str] = set()
|
|
156
|
+
merged: list[Tool] = []
|
|
157
|
+
for tool in [
|
|
158
|
+
t
|
|
159
|
+
for mw in options.middleware
|
|
160
|
+
for t in (getattr(mw, "tools", None) or [])
|
|
161
|
+
] + list(options.tools):
|
|
162
|
+
if tool.name in seen:
|
|
163
|
+
continue
|
|
164
|
+
seen.add(tool.name)
|
|
165
|
+
merged.append(tool)
|
|
166
|
+
self._tools = merged
|
|
167
|
+
self._history: list[ChatMessage] = []
|
|
168
|
+
self._seeded = False
|
|
169
|
+
self._restored = False
|
|
170
|
+
self._last_result: Optional[AgentResult] = None
|
|
171
|
+
self._agent_state = (
|
|
172
|
+
options.state
|
|
173
|
+
if isinstance(options.state, AgentState)
|
|
174
|
+
else AgentState(options.state if isinstance(options.state, dict) else None)
|
|
175
|
+
)
|
|
176
|
+
self._revisions: list[dict[str, Any]] = []
|
|
177
|
+
self._instance_session_id: Optional[str] = None
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def name(self) -> str:
|
|
181
|
+
return self.options.name
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def messages(self) -> list[ChatMessage]:
|
|
185
|
+
return list(self._history)
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def state(self) -> AgentState:
|
|
189
|
+
"""Key-value state (never sent to the model)."""
|
|
190
|
+
return self._agent_state
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def session_id(self) -> str:
|
|
194
|
+
"""Session handle: the manager's id, or a generated per-instance id."""
|
|
195
|
+
if self.options.session_manager is not None:
|
|
196
|
+
return self.options.session_manager.session_id
|
|
197
|
+
if self._instance_session_id is None:
|
|
198
|
+
self._instance_session_id = secrets.token_hex(4)
|
|
199
|
+
return self._instance_session_id
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def timeline(self) -> list[dict[str, Any]]:
|
|
203
|
+
"""Version timeline: runs, edits, branches, resumes (append-only)."""
|
|
204
|
+
return list(self._revisions)
|
|
205
|
+
|
|
206
|
+
def _record(self, kind: TimelineKind, label: Optional[str] = None) -> None:
|
|
207
|
+
entry: dict[str, Any] = {"rev": len(self._revisions) + 1, "kind": kind}
|
|
208
|
+
if label:
|
|
209
|
+
entry["label"] = label
|
|
210
|
+
entry["at"] = _iso_now()
|
|
211
|
+
self._revisions.append(entry)
|
|
212
|
+
|
|
213
|
+
def with_tools(self, *extra: Tool) -> "Agent":
|
|
214
|
+
"""Clone with extra tools.
|
|
215
|
+
|
|
216
|
+
TS clone semantics: memory, session, and state are NOT carried over
|
|
217
|
+
(fresh per clone); model, tools, middleware, and formats are shared;
|
|
218
|
+
dedupe happens via the constructor (first occurrence wins).
|
|
219
|
+
"""
|
|
220
|
+
rt = self.options
|
|
221
|
+
return Agent(
|
|
222
|
+
AgentOptions(
|
|
223
|
+
model=rt.model,
|
|
224
|
+
name=rt.name,
|
|
225
|
+
description=rt.description,
|
|
226
|
+
system_prompt=rt.system_prompt,
|
|
227
|
+
tools=[*self._tools, *extra],
|
|
228
|
+
max_steps=rt.max_steps,
|
|
229
|
+
max_tool_calls=rt.max_tool_calls,
|
|
230
|
+
budgets=rt.budgets,
|
|
231
|
+
response_format=rt.response_format,
|
|
232
|
+
middleware=list(rt.middleware),
|
|
233
|
+
events=rt.events,
|
|
234
|
+
output_version=rt.output_version,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
async def edit_message(self, id_or_index: str | int, content: Any) -> ChatMessage:
|
|
239
|
+
"""Edit a message in the current history (by id or index) and record
|
|
240
|
+
the version in the timeline. History + memory stay in sync."""
|
|
241
|
+
if isinstance(id_or_index, int):
|
|
242
|
+
idx: Optional[int] = id_or_index
|
|
243
|
+
else:
|
|
244
|
+
idx = None
|
|
245
|
+
for i, message in enumerate(self._history):
|
|
246
|
+
if message.id == id_or_index:
|
|
247
|
+
idx = i
|
|
248
|
+
break
|
|
249
|
+
if idx is None or idx < 0 or idx >= len(self._history):
|
|
250
|
+
raise AgentError(
|
|
251
|
+
"MESSAGE_NOT_FOUND", f"No message {id_or_index} in history", retryable=False
|
|
252
|
+
)
|
|
253
|
+
message = self._history[idx]
|
|
254
|
+
# The role:"tool" messages that follow reference this turn's
|
|
255
|
+
# tool_call ids; replacing the content would orphan them (providers
|
|
256
|
+
# 400 on unpaired tool results).
|
|
257
|
+
if not isinstance(message.content, str):
|
|
258
|
+
blocks = message.blocks()
|
|
259
|
+
if any(b.model_dump().get("type") == "tool_call" for b in blocks):
|
|
260
|
+
raise AgentError(
|
|
261
|
+
"MESSAGE_NOT_EDITABLE",
|
|
262
|
+
f"Message {id_or_index} contains tool calls; branch() from a point before it instead",
|
|
263
|
+
retryable=False,
|
|
264
|
+
)
|
|
265
|
+
updated = message.model_copy(update={"content": content})
|
|
266
|
+
self._history = [*self._history[:idx], updated, *self._history[idx + 1 :]]
|
|
267
|
+
if self.options.memory is not None:
|
|
268
|
+
await self.options.memory.clear()
|
|
269
|
+
await self.options.memory.save([m for m in self._history if m.role != "system"])
|
|
270
|
+
self._record("edit", f"message {message.id or idx}")
|
|
271
|
+
return updated
|
|
272
|
+
|
|
273
|
+
async def branch(self, label: str, overrides: dict[str, Any] | None = None) -> "Agent":
|
|
274
|
+
"""Branch the current history into an independent agent.
|
|
275
|
+
|
|
276
|
+
Model, tools, middleware, and formats are shared; state is copied;
|
|
277
|
+
memory is fresh (seeded with this history); sessions are NOT carried
|
|
278
|
+
over (to avoid double-persist collisions). Both timelines record the
|
|
279
|
+
branch.
|
|
280
|
+
"""
|
|
281
|
+
rt = self.options
|
|
282
|
+
memory = InMemoryMemory()
|
|
283
|
+
await memory.save([m for m in self._history if m.role != "system"])
|
|
284
|
+
options: dict[str, Any] = {
|
|
285
|
+
"model": rt.model,
|
|
286
|
+
"name": rt.name,
|
|
287
|
+
"description": rt.description,
|
|
288
|
+
"system_prompt": rt.system_prompt,
|
|
289
|
+
"tools": list(self._tools),
|
|
290
|
+
"memory": memory,
|
|
291
|
+
"max_steps": rt.max_steps,
|
|
292
|
+
"max_tool_calls": rt.max_tool_calls,
|
|
293
|
+
"budgets": rt.budgets,
|
|
294
|
+
"response_format": rt.response_format,
|
|
295
|
+
"middleware": list(rt.middleware),
|
|
296
|
+
"events": rt.events,
|
|
297
|
+
"state": self.state.to_json(),
|
|
298
|
+
"output_version": rt.output_version,
|
|
299
|
+
}
|
|
300
|
+
options.update(overrides or {}) # overrides win (TS spread position)
|
|
301
|
+
child = Agent(AgentOptions(**options))
|
|
302
|
+
child._history = list(self._history)
|
|
303
|
+
child._revisions = list(self._revisions)
|
|
304
|
+
child._record("branch", label)
|
|
305
|
+
self._record("branch", label)
|
|
306
|
+
return child
|
|
307
|
+
|
|
308
|
+
async def pause(self, thread_id: str, checkpointer: Any) -> dict[str, Any]:
|
|
309
|
+
"""Pause mid-conversation: persist history + state under thread_id
|
|
310
|
+
without needing an interrupt. Pair with resume()."""
|
|
311
|
+
await checkpointer.save(
|
|
312
|
+
thread_id,
|
|
313
|
+
ThreadCheckpoint(
|
|
314
|
+
thread_id=thread_id,
|
|
315
|
+
saved_at=_iso_now(),
|
|
316
|
+
messages=list(self._history),
|
|
317
|
+
state=self.state.to_json(),
|
|
318
|
+
pending=[],
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
return {"thread_id": thread_id, "messages": len(self._history)}
|
|
322
|
+
|
|
323
|
+
def take_snapshot(self, opts: dict[str, Any] | None = None) -> Snapshot:
|
|
324
|
+
"""Manual checkpoint: messages + state (+ system prompt on request)."""
|
|
325
|
+
options = dict(opts or {})
|
|
326
|
+
preset = options.get("preset", "session")
|
|
327
|
+
data = select_snapshot_fields(
|
|
328
|
+
SnapshotData(
|
|
329
|
+
messages=list(self._history),
|
|
330
|
+
state=self.state.to_json(),
|
|
331
|
+
system_prompt=self.options.system_prompt,
|
|
332
|
+
),
|
|
333
|
+
preset=preset,
|
|
334
|
+
include=options.get("include"),
|
|
335
|
+
exclude=options.get("exclude"),
|
|
336
|
+
)
|
|
337
|
+
return Snapshot(
|
|
338
|
+
id=f"snapshot-{format(int(time.time() * 1000), 'x')}",
|
|
339
|
+
created_at=_iso_now(),
|
|
340
|
+
data=data,
|
|
341
|
+
app_data=options.get("appData") or options.get("app_data"),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
async def load_snapshot(self, snapshot: Snapshot) -> None:
|
|
345
|
+
"""Restore fields present in the snapshot; absent fields keep current
|
|
346
|
+
values (state merged key-by-key, messages replaced, memory synced)."""
|
|
347
|
+
data = snapshot.data
|
|
348
|
+
if data.state:
|
|
349
|
+
for key, value in data.state.items():
|
|
350
|
+
self.state.set(key, value)
|
|
351
|
+
if data.messages:
|
|
352
|
+
self._history = list(data.messages)
|
|
353
|
+
if self.options.memory is not None:
|
|
354
|
+
await self.options.memory.clear()
|
|
355
|
+
await self.options.memory.save([m for m in data.messages if m.role != "system"])
|
|
356
|
+
|
|
357
|
+
async def resume(
|
|
358
|
+
self,
|
|
359
|
+
thread_id: str,
|
|
360
|
+
decisions: Optional[dict[str, Any]] = None,
|
|
361
|
+
checkpointer: Any = None,
|
|
362
|
+
invocation_state: Optional[dict[str, Any]] = None,
|
|
363
|
+
) -> AgentResult:
|
|
364
|
+
"""Resume a suspended run: restore the checkpoint, seed human
|
|
365
|
+
decisions, and continue the loop WITHOUT a new user message."""
|
|
366
|
+
if checkpointer is None:
|
|
367
|
+
raise ValueError("resume() requires a checkpointer")
|
|
368
|
+
cp = await checkpointer.load(thread_id)
|
|
369
|
+
if cp is None:
|
|
370
|
+
raise AgentError(
|
|
371
|
+
"CHECKPOINT_MISSING", f'No checkpoint for thread "{thread_id}"', retryable=False
|
|
372
|
+
)
|
|
373
|
+
cp_state = dict(getattr(cp, "state", None) or {})
|
|
374
|
+
cp_messages = list(getattr(cp, "messages", None) or [])
|
|
375
|
+
if cp_state:
|
|
376
|
+
for key, value in cp_state.items():
|
|
377
|
+
self.state.set(key, value)
|
|
378
|
+
self._history = cp_messages
|
|
379
|
+
if self.options.memory is not None:
|
|
380
|
+
await self.options.memory.clear()
|
|
381
|
+
await self.options.memory.save([m for m in cp_messages if m.role != "system"])
|
|
382
|
+
invocation = dict(invocation_state or {})
|
|
383
|
+
invocation[HITL_DECISIONS_KEY] = decisions or {}
|
|
384
|
+
stream = AgentStream(self, self._continuation(invocation, checkpointer=checkpointer, thread_id=thread_id))
|
|
385
|
+
async for _ in stream:
|
|
386
|
+
pass
|
|
387
|
+
self._record("resume", thread_id)
|
|
388
|
+
result = stream.result
|
|
389
|
+
if result is None: # pragma: no cover - continuation sets it
|
|
390
|
+
raise AgentError("RUN_INCOMPLETE", "Resume ended without a result", retryable=False)
|
|
391
|
+
return result
|
|
392
|
+
|
|
393
|
+
async def run(
|
|
394
|
+
self,
|
|
395
|
+
input_text: str | list[Any],
|
|
396
|
+
invocation_state: Optional[dict[str, Any]] = None,
|
|
397
|
+
checkpointer: Any = None,
|
|
398
|
+
thread_id: Optional[str] = None,
|
|
399
|
+
) -> AgentResult:
|
|
400
|
+
"""Drain the stream (TS run() drains stream(); hooks fire identically)."""
|
|
401
|
+
stream = self.stream(input_text, invocation_state, checkpointer=checkpointer, thread_id=thread_id)
|
|
402
|
+
async for _ in stream:
|
|
403
|
+
pass
|
|
404
|
+
self._record("run", input_text[:60] if isinstance(input_text, str) else "blocks")
|
|
405
|
+
# Per-stream capture (NOT the agent-global _last_result): concurrent
|
|
406
|
+
# runs on one agent must each keep their own result.
|
|
407
|
+
result = stream.result
|
|
408
|
+
if result is None: # pragma: no cover - drain always sets it
|
|
409
|
+
raise AgentError("RUN_INCOMPLETE", "Stream drained without a result", retryable=False)
|
|
410
|
+
return result
|
|
411
|
+
|
|
412
|
+
def stream(
|
|
413
|
+
self,
|
|
414
|
+
input_text: str | list[Any],
|
|
415
|
+
invocation_state: Optional[dict[str, Any]] = None,
|
|
416
|
+
checkpointer: Any = None,
|
|
417
|
+
thread_id: Optional[str] = None,
|
|
418
|
+
) -> AsyncIterator[StreamEvent]:
|
|
419
|
+
# AgentStream wraps the generator so the finished AgentResult is
|
|
420
|
+
# reachable via .result (collect_stream) — python async generators
|
|
421
|
+
# cannot carry return values like the TS generator does.
|
|
422
|
+
return AgentStream(self, self._stream(input_text, invocation_state, checkpointer, thread_id))
|
|
423
|
+
|
|
424
|
+
@staticmethod
|
|
425
|
+
def _checkpoint_pair(
|
|
426
|
+
checkpointer: Any, thread_id: Optional[str]
|
|
427
|
+
) -> Optional[tuple[Any, str]]:
|
|
428
|
+
if checkpointer is not None and thread_id:
|
|
429
|
+
return (checkpointer, thread_id)
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
async def _stream(
|
|
433
|
+
self,
|
|
434
|
+
input_text: str | list[Any],
|
|
435
|
+
invocation_state: Optional[dict[str, Any]] = None,
|
|
436
|
+
checkpointer: Any = None,
|
|
437
|
+
thread_id: Optional[str] = None,
|
|
438
|
+
) -> AsyncIterator[StreamEvent]:
|
|
439
|
+
rt = self.options
|
|
440
|
+
# The kwarg wins over AgentOptions.invocation_state; hooks always see
|
|
441
|
+
# a live dict and AgentResult mirrors whichever one was in play.
|
|
442
|
+
state_provided = invocation_state is not None or rt.invocation_state is not None
|
|
443
|
+
invocation_state = (
|
|
444
|
+
invocation_state if invocation_state is not None else rt.invocation_state if rt.invocation_state is not None else {}
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Session restore wins over initialMessages: a persisted conversation
|
|
448
|
+
# must not be silently replaced by the seed on the first run.
|
|
449
|
+
if rt.session_manager is not None and not self._restored:
|
|
450
|
+
self._restored = True
|
|
451
|
+
latest = await rt.session_manager.load_latest()
|
|
452
|
+
if latest is not None:
|
|
453
|
+
if latest.state:
|
|
454
|
+
for key, value in latest.state.items():
|
|
455
|
+
self.state.set(key, value)
|
|
456
|
+
if latest.messages:
|
|
457
|
+
self._history = list(latest.messages)
|
|
458
|
+
if rt.memory is not None and not await rt.memory.load():
|
|
459
|
+
await rt.memory.save([m for m in latest.messages if m.role != "system"])
|
|
460
|
+
|
|
461
|
+
if not self._seeded and rt.initial_messages:
|
|
462
|
+
self._seeded = True
|
|
463
|
+
if rt.memory is not None:
|
|
464
|
+
# The session-restore path above ran FIRST: when it already
|
|
465
|
+
# filled memory, the seed stays out (session wins over seed).
|
|
466
|
+
if not await rt.memory.load():
|
|
467
|
+
await rt.memory.save([m for m in rt.initial_messages if m.role != "system"])
|
|
468
|
+
elif not self._history:
|
|
469
|
+
self._history = list(rt.initial_messages)
|
|
470
|
+
|
|
471
|
+
if rt.memory is not None:
|
|
472
|
+
history = list(await rt.memory.load())
|
|
473
|
+
else:
|
|
474
|
+
history = [m for m in self._history if m.role != "system"]
|
|
475
|
+
|
|
476
|
+
messages: list[ChatMessage] = [
|
|
477
|
+
ChatMessage(role="system", content=rt.system_prompt),
|
|
478
|
+
*[m for m in history if m.role != "system"],
|
|
479
|
+
ChatMessage(role="user", content=input_text),
|
|
480
|
+
]
|
|
481
|
+
async for event in self._run_loop(
|
|
482
|
+
messages, invocation_state, state_provided, self._checkpoint_pair(checkpointer, thread_id)
|
|
483
|
+
):
|
|
484
|
+
yield event
|
|
485
|
+
|
|
486
|
+
async def _continuation(
|
|
487
|
+
self,
|
|
488
|
+
invocation_state: dict[str, Any],
|
|
489
|
+
checkpointer: Any = None,
|
|
490
|
+
thread_id: Optional[str] = None,
|
|
491
|
+
state_provided: bool = True,
|
|
492
|
+
) -> AsyncIterator[StreamEvent]:
|
|
493
|
+
"""Continue the loop from restored history (no new user message)."""
|
|
494
|
+
rt = self.options
|
|
495
|
+
if rt.memory is not None:
|
|
496
|
+
history = list(await rt.memory.load())
|
|
497
|
+
else:
|
|
498
|
+
history = [m for m in self._history if m.role != "system"]
|
|
499
|
+
messages: list[ChatMessage] = [
|
|
500
|
+
ChatMessage(role="system", content=rt.system_prompt),
|
|
501
|
+
*[m for m in history if m.role != "system"],
|
|
502
|
+
]
|
|
503
|
+
async for event in self._run_loop(
|
|
504
|
+
messages, invocation_state, state_provided, self._checkpoint_pair(checkpointer, thread_id)
|
|
505
|
+
):
|
|
506
|
+
yield event
|
|
507
|
+
|
|
508
|
+
async def _run_loop(
|
|
509
|
+
self,
|
|
510
|
+
messages: list[ChatMessage],
|
|
511
|
+
invocation_state: dict[str, Any],
|
|
512
|
+
state_provided: bool,
|
|
513
|
+
checkpoint: Optional[tuple[Any, str]],
|
|
514
|
+
) -> AsyncIterator[StreamEvent]:
|
|
515
|
+
rt = self.options
|
|
516
|
+
mw = list(rt.middleware)
|
|
517
|
+
usage = TokenUsage()
|
|
518
|
+
started = time.monotonic()
|
|
519
|
+
executed: list[ExecutedToolCall] = []
|
|
520
|
+
structured_response: Any = None
|
|
521
|
+
has_structured = False
|
|
522
|
+
# Tools see the agent state + the LIVE invocation_state (by reference).
|
|
523
|
+
tool_ctx = ToolContext(agent_name=rt.name, state=self._agent_state, invocation_state=invocation_state)
|
|
524
|
+
|
|
525
|
+
# 'auto' resolves by capability; provider/tool stay explicit.
|
|
526
|
+
fmt = rt.response_format
|
|
527
|
+
strategy: str | None = None
|
|
528
|
+
if fmt is not None:
|
|
529
|
+
strategy = (
|
|
530
|
+
"provider" if fmt.strategy == "auto" and rt.model.capabilities.get("structured_output")
|
|
531
|
+
else "tool" if fmt.strategy == "auto"
|
|
532
|
+
else fmt.strategy
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
tools = list(self._tools)
|
|
536
|
+
if strategy == "tool" and fmt is not None:
|
|
537
|
+
tools = [*tools, self._structured_tool(fmt)]
|
|
538
|
+
|
|
539
|
+
text = ""
|
|
540
|
+
try:
|
|
541
|
+
for step in range(rt.max_steps):
|
|
542
|
+
rt.events and rt.events.on_step and rt.events.on_step(step)
|
|
543
|
+
yield {"type": "step", "step": step}
|
|
544
|
+
|
|
545
|
+
# Token/thinking deltas batch per model call (TS parity) while the
|
|
546
|
+
# events hooks fire live inside provider.complete.
|
|
547
|
+
pending: list[StreamEvent] = []
|
|
548
|
+
|
|
549
|
+
def on_token(delta: str) -> None:
|
|
550
|
+
if rt.events and rt.events.on_token:
|
|
551
|
+
rt.events.on_token(delta)
|
|
552
|
+
pending.append({"type": "token", "delta": delta})
|
|
553
|
+
|
|
554
|
+
def on_thinking(delta: str) -> None:
|
|
555
|
+
if rt.events and rt.events.on_thinking:
|
|
556
|
+
rt.events.on_thinking(delta)
|
|
557
|
+
pending.append({"type": "thinking", "delta": delta})
|
|
558
|
+
|
|
559
|
+
response_format = None
|
|
560
|
+
if strategy == "provider" and fmt is not None:
|
|
561
|
+
response_format = ResponseFormatWire(name=fmt.name, schema=fmt.schema.model_json_schema())
|
|
562
|
+
|
|
563
|
+
# Model hooks: before (forward order) -> wrap (first-registered
|
|
564
|
+
# outermost) -> after (reverse order). ctx is shared with the
|
|
565
|
+
# tool hooks below; invocation_state travels by reference.
|
|
566
|
+
ctx = HookContext(step=step, invocation_state=invocation_state)
|
|
567
|
+
|
|
568
|
+
async def _call_model(req: ModelRequest) -> Any:
|
|
569
|
+
return await rt.model.complete(
|
|
570
|
+
CompleteOptions(
|
|
571
|
+
messages=req.messages,
|
|
572
|
+
tools=req.tools,
|
|
573
|
+
response_format=req.response_format,
|
|
574
|
+
on_token=on_token,
|
|
575
|
+
on_thinking=on_thinking,
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
request = ModelRequest(
|
|
580
|
+
messages=messages,
|
|
581
|
+
tools=[
|
|
582
|
+
ToolWire(name=t.name, description=t.description, parameters_schema=parameters_schema(t))
|
|
583
|
+
for t in tools
|
|
584
|
+
],
|
|
585
|
+
response_format=response_format,
|
|
586
|
+
)
|
|
587
|
+
if mw:
|
|
588
|
+
request = await run_before_model(mw, request, ctx)
|
|
589
|
+
completion = await compose_model_call(mw, _call_model, ctx)(request)
|
|
590
|
+
completion = await run_after_model(mw, completion, request, ctx)
|
|
591
|
+
else:
|
|
592
|
+
completion = await _call_model(request)
|
|
593
|
+
# A before_model hook may have REPLACED the message list for
|
|
594
|
+
# this call: replacements feed the model call only; history is
|
|
595
|
+
# preserved (TS parity). The loop's `messages` list stays
|
|
596
|
+
# authoritative for appends and the end-of-run save — adopting
|
|
597
|
+
# a shortened replacement here once destroyed long-term memory.
|
|
598
|
+
for event in pending:
|
|
599
|
+
if event.get("delta"):
|
|
600
|
+
yield event
|
|
601
|
+
|
|
602
|
+
usage = usage + completion.usage
|
|
603
|
+
self._check_budgets(rt.budgets, usage, started, rt.model.id)
|
|
604
|
+
|
|
605
|
+
if not completion.tool_calls:
|
|
606
|
+
text = completion.text
|
|
607
|
+
if strategy == "provider" and fmt is not None and not has_structured:
|
|
608
|
+
try:
|
|
609
|
+
structured_response = fmt.schema.model_validate_json(_as_json(text))
|
|
610
|
+
has_structured = True
|
|
611
|
+
except ValidationError as err:
|
|
612
|
+
if step < rt.max_steps - 1:
|
|
613
|
+
messages.append(ChatMessage(role="assistant", content=text))
|
|
614
|
+
messages.append(
|
|
615
|
+
ChatMessage(
|
|
616
|
+
role="user",
|
|
617
|
+
content=f"Your last response was not valid for \"{fmt.name}\": {err}. Reply with corrected JSON only.",
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
continue
|
|
621
|
+
raise AgentError("STRUCTURED_INVALID", f"Invalid structured output: {err}", retryable=True) from err
|
|
622
|
+
if rt.output_version == "v1" and completion.thinking:
|
|
623
|
+
# v1: thinking is ALSO stored as a reasoning block
|
|
624
|
+
# (v0 keeps assistant turns as plain text).
|
|
625
|
+
messages.append(
|
|
626
|
+
ChatMessage(
|
|
627
|
+
role="assistant",
|
|
628
|
+
content=[
|
|
629
|
+
{"type": "reasoning", "reasoning": completion.thinking},
|
|
630
|
+
{"type": "text", "text": text},
|
|
631
|
+
],
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
else:
|
|
635
|
+
messages.append(ChatMessage(role="assistant", content=text))
|
|
636
|
+
break
|
|
637
|
+
|
|
638
|
+
messages.append(
|
|
639
|
+
ChatMessage(
|
|
640
|
+
role="assistant",
|
|
641
|
+
content=[
|
|
642
|
+
{"type": "tool_call", "id": c.id, "name": c.name, "args": c.args}
|
|
643
|
+
for c in completion.tool_calls
|
|
644
|
+
],
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
for call in completion.tool_calls:
|
|
648
|
+
if rt.max_tool_calls is not None and len(executed) >= rt.max_tool_calls:
|
|
649
|
+
raise AgentError("BUDGET_EXCEEDED", f"Exceeded maxToolCalls ({rt.max_tool_calls})", retryable=False)
|
|
650
|
+
tool = next((t for t in tools if t.name == call.name), None)
|
|
651
|
+
if tool is None:
|
|
652
|
+
messages.append(
|
|
653
|
+
ChatMessage(role="tool", content=f"Unknown tool: {call.name}", tool_call_id=call.id, tool_name=call.name)
|
|
654
|
+
)
|
|
655
|
+
continue
|
|
656
|
+
try:
|
|
657
|
+
args = tool.schema.model_validate(call.args if isinstance(call.args, dict) else {})
|
|
658
|
+
except ValidationError as err:
|
|
659
|
+
# Invalid args are repair feedback, not a crash.
|
|
660
|
+
messages.append(
|
|
661
|
+
ChatMessage(
|
|
662
|
+
role="tool",
|
|
663
|
+
content=f'Invalid args for tool "{tool.name}": {err}',
|
|
664
|
+
tool_call_id=call.id,
|
|
665
|
+
tool_name=tool.name,
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
continue
|
|
669
|
+
call_ref = ToolCallRef(name=call.name, args=call.args)
|
|
670
|
+
rejected: Optional[str] = None
|
|
671
|
+
if mw:
|
|
672
|
+
# beforeTool chain (registration order); ToolRejection is
|
|
673
|
+
# model-visible feedback and the run continues without
|
|
674
|
+
# executing the call (TS parity: rejections are recorded
|
|
675
|
+
# in result.toolCalls with output=feedback, afterTool
|
|
676
|
+
# applies, and to_result's content becomes the tool
|
|
677
|
+
# message).
|
|
678
|
+
try:
|
|
679
|
+
call_ref = await run_before_tool(mw, call_ref, ctx)
|
|
680
|
+
if call_ref.name != call.name:
|
|
681
|
+
tool = next((t for t in tools if t.name == call_ref.name), None)
|
|
682
|
+
if tool is None:
|
|
683
|
+
messages.append(
|
|
684
|
+
ChatMessage(
|
|
685
|
+
role="tool",
|
|
686
|
+
content=f"Unknown tool: {call_ref.name}",
|
|
687
|
+
tool_call_id=call.id,
|
|
688
|
+
tool_name=call_ref.name,
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
continue
|
|
692
|
+
# An edited call is validated against (possibly) the
|
|
693
|
+
# new tool schema before execution.
|
|
694
|
+
args = tool.schema.model_validate(call_ref.args if isinstance(call_ref.args, dict) else {})
|
|
695
|
+
except ValidationError as err:
|
|
696
|
+
messages.append(
|
|
697
|
+
ChatMessage(
|
|
698
|
+
role="tool",
|
|
699
|
+
content=f'Invalid args for tool "{tool.name}": {err}',
|
|
700
|
+
tool_call_id=call.id,
|
|
701
|
+
tool_name=tool.name,
|
|
702
|
+
)
|
|
703
|
+
)
|
|
704
|
+
continue
|
|
705
|
+
except ToolRejection as rejection:
|
|
706
|
+
rejected = rejection.feedback
|
|
707
|
+
if rejected is None:
|
|
708
|
+
rt.events and rt.events.on_tool_call and rt.events.on_tool_call(tool.name, args)
|
|
709
|
+
yield {"type": "tool_call", "name": tool.name, "args": args}
|
|
710
|
+
if mw:
|
|
711
|
+
async def _run_tool() -> Any:
|
|
712
|
+
return await tool.execute(args, tool_ctx)
|
|
713
|
+
|
|
714
|
+
if rejected is not None:
|
|
715
|
+
output = rejected
|
|
716
|
+
else:
|
|
717
|
+
try:
|
|
718
|
+
# aroundTool chain (first-registered outermost)
|
|
719
|
+
# runs around the RAW execution.
|
|
720
|
+
output = await compose_tool_call(mw, _run_tool, call_ref, ctx)()
|
|
721
|
+
except ToolRejection as rejection:
|
|
722
|
+
# AroundTool rejections become the tool output
|
|
723
|
+
# (feedback); the loop continues below.
|
|
724
|
+
output = rejection.feedback
|
|
725
|
+
except (AgentError, HitlInterrupt):
|
|
726
|
+
raise
|
|
727
|
+
except Exception as err: # noqa: BLE001 - tool/hook bugs wrap
|
|
728
|
+
raise AgentError("TOOL_EXECUTION", f'Tool "{tool.name}" failed: {err}', retryable=False) from err
|
|
729
|
+
elif rejected is not None:
|
|
730
|
+
output = rejected
|
|
731
|
+
else:
|
|
732
|
+
try:
|
|
733
|
+
output = await tool.execute(args, tool_ctx)
|
|
734
|
+
except AgentError:
|
|
735
|
+
raise
|
|
736
|
+
except Exception as err: # noqa: BLE001 - tool bugs wrap
|
|
737
|
+
raise AgentError("TOOL_EXECUTION", f'Tool "{tool.name}" failed: {err}', retryable=False) from err
|
|
738
|
+
# afterTool (reverse order) may transform the output of BOTH
|
|
739
|
+
# executed calls and rejections (TS runs it outside the
|
|
740
|
+
# execution try).
|
|
741
|
+
if mw:
|
|
742
|
+
output = await run_after_tool(mw, call_ref, output, ctx)
|
|
743
|
+
result = await tool.to_result(output)
|
|
744
|
+
executed.append(ExecutedToolCall(id=call.id, name=tool.name, args=args, output=output))
|
|
745
|
+
if strategy == "tool" and fmt is not None and tool.name == fmt.name and not has_structured:
|
|
746
|
+
try:
|
|
747
|
+
structured_response = fmt.schema.model_validate(output)
|
|
748
|
+
has_structured = True
|
|
749
|
+
except ValidationError:
|
|
750
|
+
pass # the tool content already carries repair feedback
|
|
751
|
+
rt.events and rt.events.on_tool_result and rt.events.on_tool_result(tool.name, output)
|
|
752
|
+
yield {"type": "tool_result", "name": tool.name, "output": output}
|
|
753
|
+
messages.append(ChatMessage(role="tool", content=result.content, tool_call_id=call.id, tool_name=tool.name))
|
|
754
|
+
|
|
755
|
+
if step == rt.max_steps - 1:
|
|
756
|
+
# Out of steps mid-tool-use: STORE a synthesized reply so the
|
|
757
|
+
# history never ends on a dangling tool call, and carry the
|
|
758
|
+
# LAST tool output so the caller has the answer (TS parity:
|
|
759
|
+
# `Last output: ${JSON.stringify(last.output)}`).
|
|
760
|
+
if executed:
|
|
761
|
+
text = (
|
|
762
|
+
f"Completed {len(executed)} tool call(s). "
|
|
763
|
+
f"Last output: {json.dumps(executed[-1].output, default=str)}"
|
|
764
|
+
)
|
|
765
|
+
else:
|
|
766
|
+
text = ""
|
|
767
|
+
messages.append(ChatMessage(role="assistant", content=text))
|
|
768
|
+
except HitlInterrupt as err:
|
|
769
|
+
# Checkpointed HITL: persist messages-up-to-the-interrupting
|
|
770
|
+
# assistant turn + state + the pending call before re-raising
|
|
771
|
+
# (TS agent/index.ts runLoop cut-search semantics).
|
|
772
|
+
if checkpoint is not None:
|
|
773
|
+
await self._save_hitl_checkpoint(checkpoint, messages, err)
|
|
774
|
+
raise
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
if fmt is not None and not has_structured:
|
|
778
|
+
raise AgentError(
|
|
779
|
+
"STRUCTURED_NO_MATCH", f'No valid "{fmt.name}" produced within {rt.max_steps} steps', retryable=False
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
self._history = messages
|
|
783
|
+
if rt.memory is not None:
|
|
784
|
+
# save() APPENDS: without the clear, every run duplicates history.
|
|
785
|
+
await rt.memory.clear()
|
|
786
|
+
await rt.memory.save([m for m in messages if m.role != "system"])
|
|
787
|
+
if rt.session_manager is not None:
|
|
788
|
+
await rt.session_manager.save_latest(
|
|
789
|
+
SnapshotData(messages=list(messages), state=self.state.to_json(), system_prompt=rt.system_prompt)
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
usage.cost_usd = usage_cost(usage, rt.model.id, rt.budgets.prices if rt.budgets else None)
|
|
793
|
+
# The result is recorded BEFORE the done event is yielded so a stream
|
|
794
|
+
# consumer captures its OWN result at the moment it observes done
|
|
795
|
+
# (AgentStream.result) — instance-global _last_result alone would let
|
|
796
|
+
# concurrent streams cross-contaminate or return a stale result.
|
|
797
|
+
self._last_result = AgentResult(
|
|
798
|
+
text=text,
|
|
799
|
+
messages=list(messages),
|
|
800
|
+
tool_calls=executed,
|
|
801
|
+
usage=usage,
|
|
802
|
+
structured_response=structured_response if has_structured else None,
|
|
803
|
+
invocation_state=invocation_state if state_provided else None,
|
|
804
|
+
)
|
|
805
|
+
rt.events and rt.events.on_done and rt.events.on_done(text)
|
|
806
|
+
yield {"type": "done", "text": text}
|
|
807
|
+
|
|
808
|
+
@staticmethod
|
|
809
|
+
def _interrupt_args(err: HitlInterrupt) -> Any:
|
|
810
|
+
"""Recover the interrupted call's args.
|
|
811
|
+
|
|
812
|
+
BaseException's ``args`` setter converts dict values via PySequence_Tuple
|
|
813
|
+
({"text": "prod"} becomes ('text',)), so ``err.args`` cannot be trusted.
|
|
814
|
+
The framework's HitlInterrupt carries the args inside its
|
|
815
|
+
``interrupt_id`` (hitl_key = f"{tool}:{json.dumps(args)}"); parse them
|
|
816
|
+
back from there and fall back to err.args for foreign interrupts.
|
|
817
|
+
"""
|
|
818
|
+
marker = f"{err.tool}:"
|
|
819
|
+
if err.interrupt_id.startswith(marker):
|
|
820
|
+
tail = err.interrupt_id[len(marker) :]
|
|
821
|
+
try:
|
|
822
|
+
return json.loads(tail)
|
|
823
|
+
except ValueError:
|
|
824
|
+
return err.args
|
|
825
|
+
return err.args
|
|
826
|
+
|
|
827
|
+
async def _save_hitl_checkpoint(
|
|
828
|
+
self, checkpoint: tuple[Any, str], messages: list[ChatMessage], err: HitlInterrupt
|
|
829
|
+
) -> None:
|
|
830
|
+
"""TS cut-search: drop the assistant turn holding the interrupted
|
|
831
|
+
tool_call (matched by tool name + JSON args equality) so a resumed
|
|
832
|
+
run re-issues the call from the prior state."""
|
|
833
|
+
checkpointer, thread_id = checkpoint
|
|
834
|
+
args = self._interrupt_args(err)
|
|
835
|
+
pending_args = json.dumps(args if args is not None else {}, default=str)
|
|
836
|
+
cut = len(messages)
|
|
837
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
838
|
+
message = messages[i]
|
|
839
|
+
if message.role != "assistant" or isinstance(message.content, str):
|
|
840
|
+
continue
|
|
841
|
+
matched = False
|
|
842
|
+
for block in message.content:
|
|
843
|
+
if not isinstance(block, dict) or block.get("type") != "tool_call":
|
|
844
|
+
continue
|
|
845
|
+
if block.get("name") == err.tool and json.dumps(block.get("args") or {}, default=str) == pending_args:
|
|
846
|
+
matched = True
|
|
847
|
+
break
|
|
848
|
+
if matched:
|
|
849
|
+
cut = i
|
|
850
|
+
break
|
|
851
|
+
await checkpointer.save(
|
|
852
|
+
thread_id,
|
|
853
|
+
ThreadCheckpoint(
|
|
854
|
+
thread_id=thread_id,
|
|
855
|
+
saved_at=_iso_now(),
|
|
856
|
+
messages=list(messages[:cut]),
|
|
857
|
+
state=self.state.to_json(),
|
|
858
|
+
pending=[PendingCall(interrupt_id=err.interrupt_id, tool=err.tool, args=args)],
|
|
859
|
+
),
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
def _structured_tool(self, fmt: ResponseFormat) -> Tool:
|
|
863
|
+
state = {"attempts": 0}
|
|
864
|
+
|
|
865
|
+
async def execute(args: Any, ctx: ToolContext | None) -> Any:
|
|
866
|
+
state["attempts"] += 1
|
|
867
|
+
try:
|
|
868
|
+
return fmt.schema.model_validate(args).model_dump()
|
|
869
|
+
except ValidationError as err:
|
|
870
|
+
if state["attempts"] >= fmt.max_attempts:
|
|
871
|
+
raise AgentError(
|
|
872
|
+
"STRUCTURED_NO_MATCH",
|
|
873
|
+
f'Invalid "{fmt.name}" after {state["attempts"]} attempts',
|
|
874
|
+
retryable=False,
|
|
875
|
+
) from err
|
|
876
|
+
return f"Error: invalid \"{fmt.name}\": {err}. Fix the arguments and call again."
|
|
877
|
+
|
|
878
|
+
async def to_result(output: Any) -> ToolResult:
|
|
879
|
+
return ToolResult(content=output if isinstance(output, str) else _as_json(output))
|
|
880
|
+
|
|
881
|
+
return define_tool(
|
|
882
|
+
name=fmt.name,
|
|
883
|
+
description=f'Return the final answer as structured "{fmt.name}". Call exactly once with valid arguments.',
|
|
884
|
+
schema=fmt.schema,
|
|
885
|
+
execute=execute,
|
|
886
|
+
to_result=to_result,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
@staticmethod
|
|
890
|
+
def _check_budgets(budgets: Optional[TokenBudgets], usage: TokenUsage, started: float, model_id: str) -> None:
|
|
891
|
+
if budgets is None:
|
|
892
|
+
return
|
|
893
|
+
# reasoning_tokens is a SUBSET of output_tokens: adding it would
|
|
894
|
+
# double-count and fire budgets early.
|
|
895
|
+
total = usage.input_tokens + usage.output_tokens
|
|
896
|
+
if budgets.max_tokens is not None and total > budgets.max_tokens:
|
|
897
|
+
raise AgentError("BUDGET_EXCEEDED", f"Exceeded maxTokens ({budgets.max_tokens}, used {total})", retryable=False)
|
|
898
|
+
if budgets.max_cost_usd is not None:
|
|
899
|
+
price = budgets.prices if budgets.prices is not None else price_for(model_id)
|
|
900
|
+
if price is None:
|
|
901
|
+
raise AgentError(
|
|
902
|
+
"BUDGET_NO_PRICE",
|
|
903
|
+
f'Cannot enforce maxCostUsd: no price for model "{model_id}" (set budgets.prices)',
|
|
904
|
+
retryable=False,
|
|
905
|
+
)
|
|
906
|
+
cost = usage_cost(usage, model_id, price)
|
|
907
|
+
if cost is not None and cost > budgets.max_cost_usd:
|
|
908
|
+
raise AgentError("BUDGET_EXCEEDED", f"Exceeded maxCostUsd (${budgets.max_cost_usd})", retryable=False)
|
|
909
|
+
if budgets.deadline_ms is not None and (time.monotonic() - started) * 1000 > budgets.deadline_ms:
|
|
910
|
+
raise AgentError("BUDGET_EXCEEDED", f"Exceeded deadline ({budgets.deadline_ms}ms)", retryable=False)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _as_json(value: Any) -> str:
|
|
914
|
+
if isinstance(value, str):
|
|
915
|
+
try:
|
|
916
|
+
json.loads(value)
|
|
917
|
+
return value
|
|
918
|
+
except ValueError:
|
|
919
|
+
return json.dumps(value)
|
|
920
|
+
return json.dumps(value, default=str)
|