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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- 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))
|