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.
Files changed (41) hide show
  1. framework_agents/__init__.py +345 -0
  2. framework_agents/a2a_client.py +360 -0
  3. framework_agents/a2a_server.py +495 -0
  4. framework_agents/agent.py +920 -0
  5. framework_agents/checkpoint.py +231 -0
  6. framework_agents/config.py +200 -0
  7. framework_agents/conversation.py +249 -0
  8. framework_agents/errors.py +47 -0
  9. framework_agents/evals.py +127 -0
  10. framework_agents/mcp_client.py +477 -0
  11. framework_agents/memory.py +103 -0
  12. framework_agents/memory_compaction.py +63 -0
  13. framework_agents/middleware.py +1302 -0
  14. framework_agents/models/__init__.py +100 -0
  15. framework_agents/models/anthropic.py +339 -0
  16. framework_agents/models/capabilities.py +45 -0
  17. framework_agents/models/ollama.py +47 -0
  18. framework_agents/models/openai_compatible.py +409 -0
  19. framework_agents/observability.py +84 -0
  20. framework_agents/ontology_review.py +321 -0
  21. framework_agents/orchestration.py +169 -0
  22. framework_agents/pricing.py +63 -0
  23. framework_agents/prompts.py +132 -0
  24. framework_agents/rag.py +123 -0
  25. framework_agents/remote_sparql.py +213 -0
  26. framework_agents/runtime.py +88 -0
  27. framework_agents/sandbox.py +327 -0
  28. framework_agents/semantic.py +1614 -0
  29. framework_agents/serve.py +436 -0
  30. framework_agents/sessions.py +249 -0
  31. framework_agents/skills.py +213 -0
  32. framework_agents/state.py +89 -0
  33. framework_agents/streaming.py +74 -0
  34. framework_agents/swarm.py +318 -0
  35. framework_agents/tools.py +114 -0
  36. framework_agents/tracing.py +131 -0
  37. framework_agents/types.py +262 -0
  38. framework_agents/workflows.py +130 -0
  39. framework_agents-0.1.0.dist-info/METADATA +118 -0
  40. framework_agents-0.1.0.dist-info/RECORD +41 -0
  41. 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)