pi-ai-python 1.3.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 (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
pi_agent/agent_loop.py ADDED
@@ -0,0 +1,820 @@
1
+ """Agent loop that works with agent messages throughout.
2
+
3
+ Messages are converted to LLM messages only at the provider-call boundary.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import dataclasses
10
+ import time
11
+ from collections.abc import Awaitable, Callable, Sequence
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from pi_ai import (
16
+ AbortSignal,
17
+ AssistantMessage,
18
+ EventStream,
19
+ SystemMessage,
20
+ TextContent,
21
+ ToolCall,
22
+ ToolResultMessage,
23
+ ToolStateChanges,
24
+ get_current_tools,
25
+ get_tool_state_changes,
26
+ normalize_context,
27
+ now_ms,
28
+ to_tool_declaration,
29
+ validate_tool_arguments,
30
+ )
31
+ from pi_ai.utils.callbacks import maybe_await
32
+
33
+ from .stream_fn import get_default_stream_fn
34
+ from .types import (
35
+ AfterToolCallContext,
36
+ AfterToolCallResult,
37
+ AgentContext,
38
+ AgentEndEvent,
39
+ AgentEvent,
40
+ AgentLoopConfig,
41
+ AgentStartEvent,
42
+ AgentTool,
43
+ AgentToolCallOutcome,
44
+ AgentToolResult,
45
+ AgentTurnContext,
46
+ BeforeToolCallContext,
47
+ BeforeToolCallResult,
48
+ MessageEndEvent,
49
+ MessageStartEvent,
50
+ MessageUpdateEvent,
51
+ PrepareRequestContext,
52
+ StreamFn,
53
+ ToolExecutionEndEvent,
54
+ ToolExecutionStartEvent,
55
+ ToolExecutionUpdateEvent,
56
+ TurnEndEvent,
57
+ TurnStartEvent,
58
+ )
59
+
60
+ __all__ = [
61
+ "AgentEventSink",
62
+ "agent_loop",
63
+ "agent_loop_continue",
64
+ "run_agent_loop",
65
+ "run_agent_loop_continue",
66
+ "RunToolCallOptions",
67
+ "run_tool_call",
68
+ "ToolCallHooks",
69
+ ]
70
+
71
+ AgentEventSink = Callable[[AgentEvent], Awaitable[None] | None]
72
+
73
+ _UPDATE_EVENT_TYPES = frozenset(
74
+ {
75
+ "text_start",
76
+ "text_delta",
77
+ "text_end",
78
+ "thinking_start",
79
+ "thinking_delta",
80
+ "thinking_end",
81
+ "toolcall_start",
82
+ "toolcall_delta",
83
+ "toolcall_end",
84
+ }
85
+ )
86
+
87
+
88
+ def _create_agent_stream() -> EventStream[AgentEvent, list[Any]]:
89
+ return EventStream(
90
+ lambda event: event.type == "agent_end",
91
+ lambda event: event.messages if event.type == "agent_end" else [], # type: ignore[union-attr]
92
+ )
93
+
94
+
95
+ def agent_loop(
96
+ prompts: list[Any],
97
+ context: AgentContext,
98
+ config: AgentLoopConfig,
99
+ signal: AbortSignal | None = None,
100
+ stream_fn: StreamFn | None = None,
101
+ ) -> EventStream[AgentEvent, list[Any]]:
102
+ """Start an agent loop with new prompt messages and stream its events.
103
+
104
+ The returned stream is observational: it preserves event order but does not wait
105
+ for consumers before later loop phases continue. Use :class:`pi_agent.Agent` when
106
+ message processing must act as a barrier.
107
+ """
108
+ stream = _create_agent_stream()
109
+
110
+ async def run() -> None:
111
+ messages = await run_agent_loop(prompts, context, config, _push_sink(stream), signal, stream_fn)
112
+ stream.end(messages)
113
+
114
+ stream.start(run())
115
+ return stream
116
+
117
+
118
+ def agent_loop_continue(
119
+ context: AgentContext,
120
+ config: AgentLoopConfig,
121
+ signal: AbortSignal | None = None,
122
+ stream_fn: StreamFn | None = None,
123
+ ) -> EventStream[AgentEvent, list[Any]]:
124
+ """Continue from the current context without adding a message (e.g. retries).
125
+
126
+ The last message must convert to a ``user`` or ``toolResult`` message.
127
+ """
128
+ _check_continuable(context)
129
+ stream = _create_agent_stream()
130
+
131
+ async def run() -> None:
132
+ messages = await run_agent_loop_continue(context, config, _push_sink(stream), signal, stream_fn)
133
+ stream.end(messages)
134
+
135
+ stream.start(run())
136
+ return stream
137
+
138
+
139
+ def _push_sink(stream: EventStream[AgentEvent, list[Any]]) -> AgentEventSink:
140
+ def sink(event: AgentEvent) -> None:
141
+ stream.push(event)
142
+
143
+ return sink
144
+
145
+
146
+ def _check_continuable(context: AgentContext) -> None:
147
+ if not context.messages:
148
+ raise ValueError("Cannot continue: no messages in context")
149
+ if getattr(context.messages[-1], "role", None) == "assistant":
150
+ raise ValueError("Cannot continue from message role: assistant")
151
+
152
+
153
+ async def _emit(emit: AgentEventSink, event: AgentEvent) -> None:
154
+ result = emit(event)
155
+ if result is not None:
156
+ await result
157
+
158
+
159
+ async def run_agent_loop(
160
+ prompts: list[Any],
161
+ context: AgentContext,
162
+ config: AgentLoopConfig,
163
+ emit: AgentEventSink,
164
+ signal: AbortSignal | None = None,
165
+ stream_fn: StreamFn | None = None,
166
+ ) -> list[Any]:
167
+ """Run the loop for new prompt messages, emitting events through ``emit``."""
168
+ initial = _declare_tool_changes(context, prompts)
169
+ new_messages: list[Any] = list(initial)
170
+ current = AgentContext(messages=[*context.messages, *initial], tools=context.tools)
171
+ await _emit(emit, AgentStartEvent())
172
+ await _emit(emit, TurnStartEvent())
173
+ for message in initial:
174
+ await _emit(emit, MessageStartEvent(message))
175
+ await _emit(emit, MessageEndEvent(message))
176
+ await _run_loop(current, new_messages, config, signal, emit, stream_fn or get_default_stream_fn())
177
+ return new_messages
178
+
179
+
180
+ async def run_agent_loop_continue(
181
+ context: AgentContext,
182
+ config: AgentLoopConfig,
183
+ emit: AgentEventSink,
184
+ signal: AbortSignal | None = None,
185
+ stream_fn: StreamFn | None = None,
186
+ ) -> list[Any]:
187
+ """Run the loop from the existing context, emitting events through ``emit``."""
188
+ _check_continuable(context)
189
+ new_messages: list[Any] = []
190
+ current = AgentContext(messages=context.messages, tools=context.tools)
191
+ await _emit(emit, AgentStartEvent())
192
+ await _emit(emit, TurnStartEvent())
193
+ await _run_loop(current, new_messages, config, signal, emit, stream_fn or get_default_stream_fn())
194
+ return new_messages
195
+
196
+
197
+ def _apply_thinking_level(reasoning: Any, level: Any) -> Any:
198
+ if level is None:
199
+ return reasoning
200
+ return None if level == "off" else level
201
+
202
+
203
+ def _decision_action(decision: Any) -> str | None:
204
+ if decision is None:
205
+ return None
206
+ if isinstance(decision, str):
207
+ return decision
208
+ if isinstance(decision, dict):
209
+ return decision.get("action")
210
+ return getattr(decision, "action", None)
211
+
212
+
213
+ async def _run_loop(
214
+ initial_context: AgentContext,
215
+ new_messages: list[Any],
216
+ initial_config: AgentLoopConfig,
217
+ signal: AbortSignal | None,
218
+ emit: AgentEventSink,
219
+ stream_function: StreamFn,
220
+ ) -> None:
221
+ current = initial_context
222
+ config = initial_config
223
+ last_completed_turn: AgentTurnContext | None = None
224
+ explicit_continuation = False
225
+ pending: list[Any] = await _poll(config.get_steering_messages)
226
+
227
+ while True:
228
+ has_more_tool_calls = True
229
+ while has_more_tool_calls or pending:
230
+ prepared: list[Any] = []
231
+ if last_completed_turn is not None:
232
+ if config.prepare_next_turn is not None:
233
+ update = await maybe_await(config.prepare_next_turn(last_completed_turn))
234
+ if update is not None:
235
+ current = update.context or current
236
+ prepared = list(update.messages or [])
237
+ config = dataclasses.replace(
238
+ config,
239
+ model=update.model or config.model,
240
+ reasoning=_apply_thinking_level(config.reasoning, update.thinking_level),
241
+ )
242
+ # Preparation can be long-running (e.g. compaction): pick up steering queued meanwhile,
243
+ # unless the earlier poll already returned messages (one-at-a-time would deliver two).
244
+ if not pending:
245
+ pending = await _poll(config.get_steering_messages)
246
+ await _emit(emit, TurnStartEvent())
247
+
248
+ for message in _declare_tool_changes(current, [*prepared, *pending]):
249
+ await _emit(emit, MessageStartEvent(message))
250
+ await _emit(emit, MessageEndEvent(message))
251
+ current.messages.append(message)
252
+ new_messages.append(message)
253
+ pending = []
254
+
255
+ if config.prepare_request is not None:
256
+ request_update = await maybe_await(
257
+ config.prepare_request(
258
+ PrepareRequestContext(current, config.model, config.reasoning or "off"), signal
259
+ )
260
+ )
261
+ if request_update is not None:
262
+ current = request_update.context or current
263
+ config = dataclasses.replace(
264
+ config,
265
+ model=request_update.model or config.model,
266
+ reasoning=_apply_thinking_level(config.reasoning, request_update.thinking_level),
267
+ )
268
+
269
+ message = await _stream_assistant_response(current, config, signal, emit, stream_function)
270
+ new_messages.append(message)
271
+
272
+ if message.stop_reason in ("error", "aborted"):
273
+ last_completed_turn = AgentTurnContext(message, [], current, new_messages)
274
+ if config.finish_turn is not None:
275
+ await maybe_await(config.finish_turn(last_completed_turn, signal))
276
+ await _emit(emit, TurnEndEvent(message, []))
277
+ await _emit(emit, AgentEndEvent(new_messages))
278
+ return
279
+
280
+ tool_calls = [block for block in message.content if block.type == "toolCall"]
281
+ tool_results: list[ToolResultMessage] = []
282
+ has_more_tool_calls = False
283
+ if tool_calls:
284
+ # A "length" stop means the output was cut off, so every tool call may carry
285
+ # truncated arguments. Fail them all instead of executing borked calls.
286
+ if message.stop_reason == "length":
287
+ batch = await _fail_tool_calls_from_truncated_message(tool_calls, emit)
288
+ else:
289
+ batch = await _execute_tool_calls(current, message, config, signal, emit)
290
+ tool_results.extend(batch.messages)
291
+ has_more_tool_calls = not batch.terminate
292
+ for result in tool_results:
293
+ current.messages.append(result)
294
+ new_messages.append(result)
295
+
296
+ last_completed_turn = AgentTurnContext(message, tool_results, current, new_messages)
297
+ decision = None
298
+ if config.finish_turn is not None:
299
+ decision = await maybe_await(config.finish_turn(last_completed_turn, signal))
300
+ await _emit(emit, TurnEndEvent(message, tool_results))
301
+
302
+ action = _decision_action(decision)
303
+ if action == "end":
304
+ await _emit(emit, AgentEndEvent(new_messages))
305
+ return
306
+ explicit_continuation = action == "continue"
307
+ pending = await _poll(config.get_steering_messages)
308
+ if has_more_tool_calls or pending:
309
+ explicit_continuation = False
310
+
311
+ follow_ups = await _poll(config.get_follow_up_messages)
312
+ if follow_ups:
313
+ explicit_continuation = False
314
+ pending = follow_ups
315
+ continue
316
+ # No natural request was selected: fulfill the continuation decision with one context-only turn.
317
+ if explicit_continuation:
318
+ explicit_continuation = False
319
+ continue
320
+ break
321
+
322
+ await _emit(emit, AgentEndEvent(new_messages))
323
+
324
+
325
+ async def _poll(source: Callable[[], Awaitable[list[Any]]] | None) -> list[Any]:
326
+ if source is None:
327
+ return []
328
+ return list(await maybe_await(source()) or [])
329
+
330
+
331
+ _NO_CHANGES = ToolStateChanges([], [])
332
+
333
+
334
+ def _with_tool_changes(message: SystemMessage, changes: ToolStateChanges) -> SystemMessage:
335
+ return dataclasses.replace(
336
+ message,
337
+ tools_added=changes.tools_added or None,
338
+ tools_removed=changes.tools_removed or None,
339
+ )
340
+
341
+
342
+ def _declare_tool_changes(context: AgentContext, pending: list[Any]) -> list[Any]:
343
+ """Announce differences between the executable tools and the transcript's declared tools.
344
+
345
+ ``context.tools`` is what the runtime can execute; the transcript's system messages
346
+ declare what the model may call. A pending system message has its tool fields
347
+ replaced with the delta; otherwise a new system message is inserted before the
348
+ first non-system pending message.
349
+ """
350
+ system_index = -1
351
+ for index in range(len(pending) - 1, -1, -1):
352
+ if getattr(pending[index], "role", None) == "system":
353
+ system_index = index
354
+ break
355
+ pending_system: SystemMessage | None = pending[system_index] if system_index >= 0 else None
356
+ if pending_system is not None:
357
+ baseline = [
358
+ _with_tool_changes(message, _NO_CHANGES) if index == system_index else message
359
+ for index, message in enumerate(pending)
360
+ ]
361
+ else:
362
+ baseline = pending
363
+ changes = get_tool_state_changes(
364
+ get_current_tools([*context.messages, *baseline]),
365
+ [to_tool_declaration(tool) for tool in context.tools or []],
366
+ )
367
+ unchanged = not changes.tools_added and not changes.tools_removed
368
+
369
+ if pending_system is not None:
370
+ if unchanged and not pending_system.tools_added and not pending_system.tools_removed:
371
+ return pending
372
+ return [
373
+ _with_tool_changes(pending_system, changes) if index == system_index else message
374
+ for index, message in enumerate(baseline)
375
+ ]
376
+ if unchanged:
377
+ return pending
378
+ update = _with_tool_changes(SystemMessage("", timestamp=now_ms()), changes)
379
+ insert_at = next(
380
+ (index for index, message in enumerate(pending) if getattr(message, "role", None) != "system"), len(pending)
381
+ )
382
+ return [*pending[:insert_at], update, *pending[insert_at:]]
383
+
384
+
385
+ async def _stream_assistant_response(
386
+ context: AgentContext,
387
+ config: AgentLoopConfig,
388
+ signal: AbortSignal | None,
389
+ emit: AgentEventSink,
390
+ stream_function: StreamFn,
391
+ ) -> AssistantMessage:
392
+ messages = context.messages
393
+ if config.transform_context is not None:
394
+ messages = await config.transform_context(messages, signal)
395
+ llm_messages = await maybe_await(config.convert_to_llm(messages))
396
+ llm_context = normalize_context({"messages": llm_messages})
397
+
398
+ resolved_key = None
399
+ if config.get_api_key is not None:
400
+ resolved_key = await maybe_await(config.get_api_key(config.model.provider))
401
+ resolved_key = resolved_key or config.api_key
402
+
403
+ options: dict[str, Any] = dict(config.stream_options)
404
+ if config.reasoning is not None:
405
+ options["reasoning"] = config.reasoning
406
+ if resolved_key is not None:
407
+ options["api_key"] = resolved_key
408
+ if signal is not None:
409
+ options["signal"] = signal
410
+ response = await maybe_await(stream_function(config.model, llm_context, options))
411
+ thinking_level = config.reasoning or "off"
412
+
413
+ async def final_result() -> AssistantMessage:
414
+ message = await response.result()
415
+ message.thinking_level = thinking_level
416
+ return message
417
+
418
+ partial: AssistantMessage | None = None
419
+ added_partial = False
420
+ async for event in response:
421
+ kind = event.type
422
+ if kind == "start":
423
+ partial = event.partial # type: ignore[union-attr]
424
+ context.messages.append(partial)
425
+ added_partial = True
426
+ await _emit(emit, MessageStartEvent(partial.shallow_copy())) # type: ignore[union-attr]
427
+ elif kind in _UPDATE_EVENT_TYPES:
428
+ if partial is not None:
429
+ partial = event.partial # type: ignore[union-attr]
430
+ context.messages[-1] = partial
431
+ await _emit(emit, MessageUpdateEvent(partial.shallow_copy(), event)) # type: ignore[union-attr]
432
+ elif kind in ("done", "error"):
433
+ final = await final_result()
434
+ if added_partial:
435
+ context.messages[-1] = final
436
+ else:
437
+ context.messages.append(final)
438
+ await _emit(emit, MessageStartEvent(final.shallow_copy()))
439
+ await _emit(emit, MessageEndEvent(final))
440
+ return final
441
+
442
+ final = await final_result()
443
+ if added_partial:
444
+ context.messages[-1] = final
445
+ else:
446
+ context.messages.append(final)
447
+ await _emit(emit, MessageStartEvent(final.shallow_copy()))
448
+ await _emit(emit, MessageEndEvent(final))
449
+ return final
450
+
451
+
452
+ @dataclass(slots=True)
453
+ class _ExecutedToolCallBatch:
454
+ messages: list[ToolResultMessage]
455
+ terminate: bool
456
+
457
+
458
+ @dataclass(slots=True)
459
+ class _PreparedToolCall:
460
+ tool_call: ToolCall
461
+ tool: AgentTool
462
+ args: Any
463
+
464
+
465
+ @dataclass(slots=True)
466
+ class _ImmediateOutcome:
467
+ result: AgentToolResult
468
+ is_error: bool
469
+
470
+
471
+ @dataclass(slots=True)
472
+ class _ExecutedOutcome:
473
+ result: AgentToolResult
474
+ is_error: bool
475
+ duration_ms: int
476
+
477
+
478
+ async def _fail_tool_calls_from_truncated_message(
479
+ tool_calls: list[ToolCall], emit: AgentEventSink
480
+ ) -> _ExecutedToolCallBatch:
481
+ messages: list[ToolResultMessage] = []
482
+ for tool_call in tool_calls:
483
+ await _emit(emit, ToolExecutionStartEvent(tool_call.id, tool_call.name, tool_call.arguments))
484
+ finalized = AgentToolCallOutcome(
485
+ tool_call,
486
+ _error_result(
487
+ f'Tool call "{tool_call.name}" was not executed: the response hit the output token limit, '
488
+ "so its arguments may be truncated. Re-issue the tool call with complete arguments."
489
+ ),
490
+ True,
491
+ )
492
+ await _emit_tool_execution_end(finalized, emit)
493
+ result_message = _tool_result_message(finalized)
494
+ await _emit_tool_result_message(result_message, emit)
495
+ messages.append(result_message)
496
+ return _ExecutedToolCallBatch(messages, False)
497
+
498
+
499
+ async def _execute_tool_calls(
500
+ context: AgentContext,
501
+ assistant_message: AssistantMessage,
502
+ config: AgentLoopConfig,
503
+ signal: AbortSignal | None,
504
+ emit: AgentEventSink,
505
+ ) -> _ExecutedToolCallBatch:
506
+ tool_calls = [block for block in assistant_message.content if block.type == "toolCall"]
507
+ tools_by_name = {tool.name: tool for tool in context.tools or []}
508
+ has_sequential = any(
509
+ getattr(tools_by_name.get(call.name), "execution_mode", None) == "sequential" for call in tool_calls
510
+ )
511
+ if config.tool_execution == "sequential" or has_sequential:
512
+ return await _execute_sequential(context, assistant_message, tool_calls, config, signal, emit)
513
+ return await _execute_parallel(context, assistant_message, tool_calls, config, signal, emit)
514
+
515
+
516
+ async def _execute_sequential(
517
+ context: AgentContext,
518
+ assistant_message: AssistantMessage,
519
+ tool_calls: list[ToolCall],
520
+ config: AgentLoopConfig,
521
+ signal: AbortSignal | None,
522
+ emit: AgentEventSink,
523
+ ) -> _ExecutedToolCallBatch:
524
+ finalized_calls: list[AgentToolCallOutcome] = []
525
+ messages: list[ToolResultMessage] = []
526
+ for tool_call in tool_calls:
527
+ await _emit(emit, ToolExecutionStartEvent(tool_call.id, tool_call.name, tool_call.arguments))
528
+ preparation = await _prepare_tool_call(context, assistant_message, tool_call, config, signal)
529
+ if isinstance(preparation, _ImmediateOutcome):
530
+ finalized = AgentToolCallOutcome(tool_call, preparation.result, preparation.is_error)
531
+ else:
532
+ executed = await _execute_prepared(preparation, signal, _tool_update_sink(tool_call, emit))
533
+ finalized = await _finalize_executed(context, assistant_message, preparation, executed, config, signal)
534
+ await _emit_tool_execution_end(finalized, emit)
535
+ result_message = _tool_result_message(finalized)
536
+ await _emit_tool_result_message(result_message, emit)
537
+ finalized_calls.append(finalized)
538
+ messages.append(result_message)
539
+ if signal is not None and signal.aborted:
540
+ break
541
+ return _ExecutedToolCallBatch(messages, _should_terminate(finalized_calls))
542
+
543
+
544
+ async def _execute_parallel(
545
+ context: AgentContext,
546
+ assistant_message: AssistantMessage,
547
+ tool_calls: list[ToolCall],
548
+ config: AgentLoopConfig,
549
+ signal: AbortSignal | None,
550
+ emit: AgentEventSink,
551
+ ) -> _ExecutedToolCallBatch:
552
+ entries: list[AgentToolCallOutcome | Callable[[], Awaitable[AgentToolCallOutcome]]] = []
553
+ for tool_call in tool_calls:
554
+ await _emit(emit, ToolExecutionStartEvent(tool_call.id, tool_call.name, tool_call.arguments))
555
+ preparation = await _prepare_tool_call(context, assistant_message, tool_call, config, signal)
556
+ if isinstance(preparation, _ImmediateOutcome):
557
+ finalized = AgentToolCallOutcome(tool_call, preparation.result, preparation.is_error)
558
+ await _emit_tool_execution_end(finalized, emit)
559
+ entries.append(finalized)
560
+ if signal is not None and signal.aborted:
561
+ break
562
+ continue
563
+
564
+ def run(
565
+ prepared: _PreparedToolCall = preparation, call: ToolCall = tool_call
566
+ ) -> Awaitable[AgentToolCallOutcome]:
567
+ async def execute() -> AgentToolCallOutcome:
568
+ if signal is not None and signal.aborted:
569
+ aborted = AgentToolCallOutcome(call, _error_result("Operation aborted"), True)
570
+ await _emit_tool_execution_end(aborted, emit)
571
+ return aborted
572
+ executed = await _execute_prepared(prepared, signal, _tool_update_sink(call, emit))
573
+ outcome = await _finalize_executed(context, assistant_message, prepared, executed, config, signal)
574
+ await _emit_tool_execution_end(outcome, emit)
575
+ return outcome
576
+
577
+ return execute()
578
+
579
+ entries.append(run)
580
+ if signal is not None and signal.aborted:
581
+ break
582
+
583
+ ordered = await asyncio.gather(
584
+ *(entry() if callable(entry) else _completed(entry) for entry in entries) # type: ignore[arg-type]
585
+ )
586
+ messages: list[ToolResultMessage] = []
587
+ for finalized in ordered:
588
+ result_message = _tool_result_message(finalized)
589
+ await _emit_tool_result_message(result_message, emit)
590
+ messages.append(result_message)
591
+ return _ExecutedToolCallBatch(messages, _should_terminate(list(ordered)))
592
+
593
+
594
+ async def _completed(value: AgentToolCallOutcome) -> AgentToolCallOutcome:
595
+ return value
596
+
597
+
598
+ def _should_terminate(finalized: Sequence[AgentToolCallOutcome]) -> bool:
599
+ return bool(finalized) and all(outcome.result.terminate is True for outcome in finalized)
600
+
601
+
602
+ def _prepare_arguments(tool: AgentTool, tool_call: ToolCall) -> ToolCall:
603
+ if tool.prepare_arguments is None:
604
+ return tool_call
605
+ prepared = tool.prepare_arguments(tool_call.arguments)
606
+ if prepared is tool_call.arguments:
607
+ return tool_call
608
+ return dataclasses.replace(tool_call, arguments=prepared)
609
+
610
+
611
+ #: The ``before_tool_call`` and ``after_tool_call`` hooks (an ``AgentLoopConfig`` or ``RunToolCallOptions``).
612
+ ToolCallHooks = Any
613
+
614
+
615
+ async def _prepare_tool_call(
616
+ context: AgentContext,
617
+ assistant_message: AssistantMessage,
618
+ tool_call: ToolCall,
619
+ hooks: ToolCallHooks,
620
+ signal: AbortSignal | None,
621
+ tools: Sequence[AgentTool] | None = None,
622
+ ) -> _PreparedToolCall | _ImmediateOutcome:
623
+ available = tools if tools is not None else context.tools or []
624
+ tool = next((candidate for candidate in available if candidate.name == tool_call.name), None)
625
+ if tool is None:
626
+ return _ImmediateOutcome(_error_result(f"Tool {tool_call.name} not found"), True)
627
+ try:
628
+ prepared_call = _prepare_arguments(tool, tool_call)
629
+ validated = validate_tool_arguments(tool, prepared_call)
630
+ before = getattr(hooks, "before_tool_call", None)
631
+ if before is not None:
632
+ before_result: BeforeToolCallResult | None = await maybe_await(
633
+ before(BeforeToolCallContext(assistant_message, tool_call, validated, context), signal)
634
+ )
635
+ if signal is not None and signal.aborted:
636
+ return _ImmediateOutcome(_error_result("Operation aborted"), True)
637
+ if before_result is not None and _get(before_result, "block"):
638
+ result = _error_result(_get(before_result, "reason") or "Tool execution was blocked")
639
+ if _get(before_result, "terminate") is True:
640
+ result.terminate = True
641
+ return _ImmediateOutcome(result, True)
642
+ if signal is not None and signal.aborted:
643
+ return _ImmediateOutcome(_error_result("Operation aborted"), True)
644
+ return _PreparedToolCall(tool_call, tool, validated)
645
+ except Exception as error:
646
+ return _ImmediateOutcome(_error_result(_error_text(error)), True)
647
+
648
+
649
+ def _get(value: Any, name: str) -> Any:
650
+ if isinstance(value, dict):
651
+ return value.get(name)
652
+ return getattr(value, name, None)
653
+
654
+
655
+ def _tool_update_sink(tool_call: ToolCall, emit: AgentEventSink) -> Callable[[AgentToolResult], Any]:
656
+ def on_update(partial_result: AgentToolResult) -> Any:
657
+ return emit(ToolExecutionUpdateEvent(tool_call.id, tool_call.name, tool_call.arguments, partial_result))
658
+
659
+ return on_update
660
+
661
+
662
+ @dataclass(slots=True, kw_only=True)
663
+ class RunToolCallOptions:
664
+ """Options for :func:`run_tool_call`."""
665
+
666
+ #: Tools the call resolves against.
667
+ tools: Sequence[AgentTool]
668
+ #: Passed to the hooks as the message that issued the call.
669
+ assistant_message: AssistantMessage
670
+ #: Passed to the hooks as the current agent context.
671
+ context: AgentContext
672
+ signal: AbortSignal | None = None
673
+ on_update: Callable[[AgentToolResult], Any] | None = None
674
+ before_tool_call: Any = None
675
+ after_tool_call: Any = None
676
+
677
+
678
+ async def run_tool_call(tool_call: ToolCall, options: RunToolCallOptions) -> AgentToolCallOutcome:
679
+ """Run one tool call through the same steps as a model-issued call.
680
+
681
+ Argument preparation, schema validation, ``before_tool_call``, execution and
682
+ ``after_tool_call`` apply; no events are emitted and no messages added. Never
683
+ raises for tool failures: unknown tools, validation errors, blocked calls and
684
+ exceptions come back with ``is_error=True``.
685
+ """
686
+ preparation = await _prepare_tool_call(
687
+ options.context, options.assistant_message, tool_call, options, options.signal, options.tools
688
+ )
689
+ if isinstance(preparation, _ImmediateOutcome):
690
+ return AgentToolCallOutcome(tool_call, preparation.result, preparation.is_error)
691
+ executed = await _execute_prepared(preparation, options.signal, options.on_update or (lambda _result: None))
692
+ return await _finalize_executed(
693
+ options.context, options.assistant_message, preparation, executed, options, options.signal
694
+ )
695
+
696
+
697
+ async def _execute_prepared(
698
+ prepared: _PreparedToolCall,
699
+ signal: AbortSignal | None,
700
+ on_update: Callable[[AgentToolResult], Any],
701
+ ) -> _ExecutedOutcome:
702
+ update_events: list[Any] = []
703
+ accepting = True
704
+ started = time.perf_counter()
705
+
706
+ def elapsed() -> int:
707
+ return round((time.perf_counter() - started) * 1000)
708
+
709
+ def update(partial_result: AgentToolResult) -> None:
710
+ if not accepting:
711
+ return
712
+ pending = on_update(partial_result)
713
+ if pending is not None and asyncio.iscoroutine(pending):
714
+ update_events.append(asyncio.ensure_future(pending))
715
+ elif asyncio.isfuture(pending):
716
+ update_events.append(pending)
717
+
718
+ try:
719
+ result = await prepared.tool.execute(prepared.tool_call.id, prepared.args, signal, update)
720
+ duration = elapsed()
721
+ accepting = False
722
+ if update_events:
723
+ await asyncio.gather(*update_events)
724
+ if result is None:
725
+ result = AgentToolResult()
726
+ return _ExecutedOutcome(result, result.is_error is True, duration)
727
+ except Exception as error:
728
+ duration = elapsed()
729
+ accepting = False
730
+ if update_events:
731
+ await asyncio.gather(*update_events, return_exceptions=True)
732
+ return _ExecutedOutcome(_error_result(_error_text(error)), True, duration)
733
+ finally:
734
+ accepting = False
735
+
736
+
737
+ async def _finalize_executed(
738
+ context: AgentContext,
739
+ assistant_message: AssistantMessage,
740
+ prepared: _PreparedToolCall,
741
+ executed: _ExecutedOutcome,
742
+ hooks: ToolCallHooks,
743
+ signal: AbortSignal | None,
744
+ ) -> AgentToolCallOutcome:
745
+ result = executed.result
746
+ is_error = executed.is_error
747
+ after = getattr(hooks, "after_tool_call", None)
748
+ if after is not None:
749
+ try:
750
+ override: AfterToolCallResult | None = await maybe_await(
751
+ after(
752
+ AfterToolCallContext(
753
+ assistant_message, prepared.tool_call, prepared.args, result, is_error, context
754
+ ),
755
+ signal,
756
+ )
757
+ )
758
+ if override is not None:
759
+ content = _get(override, "content")
760
+ structured = _get(override, "structured_content")
761
+ if structured is None and content is None:
762
+ structured = result.structured_content
763
+ details = _get(override, "details")
764
+ usage = _get(override, "usage")
765
+ terminate = _get(override, "terminate")
766
+ result = AgentToolResult(
767
+ content if content is not None else result.content,
768
+ details if details is not None else result.details,
769
+ structured_content=structured,
770
+ usage=usage if usage is not None else result.usage,
771
+ is_error=result.is_error,
772
+ terminate=terminate if terminate is not None else result.terminate,
773
+ )
774
+ override_error = _get(override, "is_error")
775
+ if override_error is not None:
776
+ is_error = override_error
777
+ except Exception as error:
778
+ result = _error_result(_error_text(error))
779
+ is_error = True
780
+ return AgentToolCallOutcome(prepared.tool_call, result, is_error, executed.duration_ms)
781
+
782
+
783
+ def _error_text(error: BaseException) -> str:
784
+ return str(error) or type(error).__name__
785
+
786
+
787
+ def _error_result(message: str) -> AgentToolResult:
788
+ return AgentToolResult([TextContent(message)], {})
789
+
790
+
791
+ async def _emit_tool_execution_end(finalized: AgentToolCallOutcome, emit: AgentEventSink) -> None:
792
+ await _emit(
793
+ emit,
794
+ ToolExecutionEndEvent(
795
+ finalized.tool_call.id,
796
+ finalized.tool_call.name,
797
+ finalized.result,
798
+ finalized.is_error,
799
+ finalized.duration_ms,
800
+ ),
801
+ )
802
+
803
+
804
+ def _tool_result_message(finalized: AgentToolCallOutcome) -> ToolResultMessage:
805
+ return ToolResultMessage(
806
+ finalized.tool_call.id,
807
+ finalized.tool_call.name,
808
+ # Untyped tools can return results without content; never let None into the transcript.
809
+ list(finalized.result.content or []),
810
+ finalized.is_error,
811
+ details=finalized.result.details,
812
+ usage=finalized.result.usage,
813
+ duration_ms=finalized.duration_ms,
814
+ timestamp=now_ms(),
815
+ )
816
+
817
+
818
+ async def _emit_tool_result_message(message: ToolResultMessage, emit: AgentEventSink) -> None:
819
+ await _emit(emit, MessageStartEvent(message))
820
+ await _emit(emit, MessageEndEvent(message))