crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,2446 @@
|
|
|
1
|
+
"""Query loop — the agentic turn loop, heart of the system.
|
|
2
|
+
|
|
3
|
+
Ported from src/query.ts. Sends messages to the API, processes tool calls,
|
|
4
|
+
and loops until the model stops using tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
|
+
from typing import Any, AsyncGenerator, Callable, Coroutine
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from crabcode_core.api.base import APIAdapter, ModelConfig, usage_int_field
|
|
18
|
+
from crabcode_core.usage import tracked_stream_message
|
|
19
|
+
from crabcode_core.api.network import certificate_failure, network_error_message
|
|
20
|
+
from crabcode_core.compact.compact import (
|
|
21
|
+
DEFAULT_COMPACT_BUFFER_TOKENS,
|
|
22
|
+
compact_conversation,
|
|
23
|
+
compact_summary_text,
|
|
24
|
+
compaction_input_limit,
|
|
25
|
+
conversation_turn_starts,
|
|
26
|
+
estimate_token_count,
|
|
27
|
+
)
|
|
28
|
+
from crabcode_core.compact.context_tokens import ContextTokenTracker, RequestSnapshot, TokenMeasurement
|
|
29
|
+
from crabcode_core.logging_utils import get_logger
|
|
30
|
+
from crabcode_core.prompts.blocks import SystemPrompt
|
|
31
|
+
from crabcode_core.tools.loading import ToolCatalog, ToolLoadingState
|
|
32
|
+
from crabcode_core.query.retry import ResponsesStreamRetryState, StreamRetry
|
|
33
|
+
from crabcode_core.query.computer_use_history import project_computer_use_history
|
|
34
|
+
from crabcode_core.types.config import ApiConfig, ToolLoadingSettings
|
|
35
|
+
from crabcode_core.types.event import (
|
|
36
|
+
CompactEvent,
|
|
37
|
+
CoreEvent,
|
|
38
|
+
ErrorEvent,
|
|
39
|
+
PermissionRequestEvent,
|
|
40
|
+
PermissionResponseEvent,
|
|
41
|
+
StreamModeEvent,
|
|
42
|
+
StreamRetryEvent,
|
|
43
|
+
StreamTextEvent,
|
|
44
|
+
SteeringAppliedEvent,
|
|
45
|
+
ThinkingEvent,
|
|
46
|
+
ToolResultEvent,
|
|
47
|
+
ToolUseEvent,
|
|
48
|
+
TurnCompleteEvent,
|
|
49
|
+
)
|
|
50
|
+
from crabcode_core.types.message import (
|
|
51
|
+
AssistantMessage,
|
|
52
|
+
ContentBlock,
|
|
53
|
+
Message,
|
|
54
|
+
TextBlock,
|
|
55
|
+
ThinkingBlock,
|
|
56
|
+
ToolResultBlock,
|
|
57
|
+
ToolUseBlock,
|
|
58
|
+
create_assistant_message,
|
|
59
|
+
create_tool_result_message,
|
|
60
|
+
create_user_message,
|
|
61
|
+
)
|
|
62
|
+
from crabcode_core.permissions.ai_reviewer import AiPermissionReviewer, AiReviewRequest
|
|
63
|
+
from crabcode_core.permissions.manager import PermissionMode
|
|
64
|
+
from crabcode_core.tools._input_schema import (
|
|
65
|
+
ToolInputNormalization,
|
|
66
|
+
normalize_tool_input,
|
|
67
|
+
)
|
|
68
|
+
from crabcode_core.types.tool import PermissionBehavior, PermissionResult, Tool, ToolContext, ToolResult
|
|
69
|
+
|
|
70
|
+
logger = get_logger(__name__)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _repair_unpaired_tool_calls(
|
|
74
|
+
messages: list[Message],
|
|
75
|
+
) -> list[tuple[ToolUseBlock, Message]]:
|
|
76
|
+
"""Normalize tool call/result ordering and close interrupted calls.
|
|
77
|
+
|
|
78
|
+
A provider requires every assistant ``tool_use`` to have a matching
|
|
79
|
+
``tool_result`` before another user message. Cancellation or a client
|
|
80
|
+
retry can leave results after an intervening user message, or leave the
|
|
81
|
+
assistant call without a result. Move existing result messages next to
|
|
82
|
+
their call and insert a deterministic failure result for any missing ID.
|
|
83
|
+
"""
|
|
84
|
+
result_messages: dict[str, Message] = {}
|
|
85
|
+
for message in messages:
|
|
86
|
+
content = message.content
|
|
87
|
+
if not isinstance(content, list):
|
|
88
|
+
continue
|
|
89
|
+
for block in content:
|
|
90
|
+
if isinstance(block, ToolResultBlock) and block.tool_use_id:
|
|
91
|
+
result_messages.setdefault(block.tool_use_id, message)
|
|
92
|
+
|
|
93
|
+
repairs_by_assistant: dict[str, list[ToolUseBlock]] = {}
|
|
94
|
+
known_call_ids: set[str] = set()
|
|
95
|
+
for message in messages:
|
|
96
|
+
if not isinstance(message, AssistantMessage):
|
|
97
|
+
continue
|
|
98
|
+
for block in message.tool_use_blocks:
|
|
99
|
+
if not block.id:
|
|
100
|
+
continue
|
|
101
|
+
known_call_ids.add(block.id)
|
|
102
|
+
if block.id not in result_messages:
|
|
103
|
+
repairs_by_assistant.setdefault(message.uuid, []).append(block)
|
|
104
|
+
|
|
105
|
+
if not known_call_ids:
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
repaired: list[tuple[ToolUseBlock, Message]] = []
|
|
109
|
+
rebuilt: list[Message] = []
|
|
110
|
+
emitted_result_messages: set[str] = set()
|
|
111
|
+
for message in messages:
|
|
112
|
+
content = message.content
|
|
113
|
+
if isinstance(content, list) and any(
|
|
114
|
+
isinstance(block, ToolResultBlock)
|
|
115
|
+
and block.tool_use_id in known_call_ids
|
|
116
|
+
for block in content
|
|
117
|
+
) and message.uuid in emitted_result_messages:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
rebuilt.append(message)
|
|
121
|
+
if isinstance(content, list) and any(
|
|
122
|
+
isinstance(block, ToolResultBlock)
|
|
123
|
+
and block.tool_use_id in known_call_ids
|
|
124
|
+
for block in content
|
|
125
|
+
) and not isinstance(message, AssistantMessage):
|
|
126
|
+
# A result that appeared before its assistant call is already in
|
|
127
|
+
# the right relative position; mark it so the assistant pass does
|
|
128
|
+
# not emit a duplicate copy later.
|
|
129
|
+
emitted_result_messages.add(message.uuid)
|
|
130
|
+
|
|
131
|
+
if isinstance(message, AssistantMessage):
|
|
132
|
+
for block in message.tool_use_blocks:
|
|
133
|
+
existing = result_messages.get(block.id)
|
|
134
|
+
if existing is not None:
|
|
135
|
+
if existing.uuid not in emitted_result_messages:
|
|
136
|
+
rebuilt.append(existing)
|
|
137
|
+
emitted_result_messages.add(existing.uuid)
|
|
138
|
+
continue
|
|
139
|
+
if block.id not in {
|
|
140
|
+
candidate.id
|
|
141
|
+
for candidate in repairs_by_assistant.get(message.uuid, [])
|
|
142
|
+
}:
|
|
143
|
+
continue
|
|
144
|
+
result = create_tool_result_message(
|
|
145
|
+
tool_use_id=block.id,
|
|
146
|
+
result="Tool call was interrupted before a result was produced.",
|
|
147
|
+
is_error=True,
|
|
148
|
+
source_tool_assistant_uuid=message.uuid,
|
|
149
|
+
)
|
|
150
|
+
rebuilt.append(result)
|
|
151
|
+
repaired.append((block, result))
|
|
152
|
+
|
|
153
|
+
messages[:] = rebuilt
|
|
154
|
+
return repaired
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _append_missing_tool_results(
|
|
158
|
+
messages: list[Message],
|
|
159
|
+
tool_use_blocks: list[ToolUseBlock],
|
|
160
|
+
assistant_uuid: str,
|
|
161
|
+
) -> list[tuple[ToolUseBlock, Message]]:
|
|
162
|
+
"""Close tool calls that were cancelled before their execution returned."""
|
|
163
|
+
result_ids = {
|
|
164
|
+
block.tool_use_id
|
|
165
|
+
for message in messages
|
|
166
|
+
if isinstance(message.content, list)
|
|
167
|
+
for block in message.content
|
|
168
|
+
if isinstance(block, ToolResultBlock)
|
|
169
|
+
}
|
|
170
|
+
repaired: list[tuple[ToolUseBlock, Message]] = []
|
|
171
|
+
for block in tool_use_blocks:
|
|
172
|
+
if not block.id or block.id in result_ids:
|
|
173
|
+
continue
|
|
174
|
+
result = create_tool_result_message(
|
|
175
|
+
tool_use_id=block.id,
|
|
176
|
+
result="Tool call was interrupted before a result was produced.",
|
|
177
|
+
is_error=True,
|
|
178
|
+
source_tool_assistant_uuid=assistant_uuid,
|
|
179
|
+
)
|
|
180
|
+
messages.append(result)
|
|
181
|
+
result_ids.add(block.id)
|
|
182
|
+
repaired.append((block, result))
|
|
183
|
+
return repaired
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _completed_tool_result(
|
|
187
|
+
messages: list[Message],
|
|
188
|
+
tool_use_id: str,
|
|
189
|
+
) -> ToolResultBlock | None:
|
|
190
|
+
"""Find a durable result so replayed call IDs never execute twice."""
|
|
191
|
+
if not tool_use_id:
|
|
192
|
+
return None
|
|
193
|
+
for message in reversed(messages):
|
|
194
|
+
if not isinstance(message.content, list):
|
|
195
|
+
continue
|
|
196
|
+
for block in message.content:
|
|
197
|
+
if (
|
|
198
|
+
isinstance(block, ToolResultBlock)
|
|
199
|
+
and block.tool_use_id == tool_use_id
|
|
200
|
+
):
|
|
201
|
+
return block
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _format_exception_message(exc: Exception) -> str:
|
|
206
|
+
"""Return an actionable message even when an exception has an empty str()."""
|
|
207
|
+
exception_name = type(exc).__name__
|
|
208
|
+
detail = str(exc).strip()
|
|
209
|
+
if detail:
|
|
210
|
+
return f"{exception_name}: {detail}"
|
|
211
|
+
return f"{exception_name}: no additional details"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _iter_exception_chain(exc: BaseException):
|
|
215
|
+
"""Yield an exception and the causes/contexts it wraps."""
|
|
216
|
+
pending: list[BaseException] = [exc]
|
|
217
|
+
seen: set[int] = set()
|
|
218
|
+
while pending:
|
|
219
|
+
current = pending.pop()
|
|
220
|
+
if id(current) in seen:
|
|
221
|
+
continue
|
|
222
|
+
seen.add(id(current))
|
|
223
|
+
yield current
|
|
224
|
+
for nested in (current.__cause__, current.__context__):
|
|
225
|
+
if nested is not None:
|
|
226
|
+
pending.append(nested)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _is_http_transport_error(exc: BaseException) -> bool:
|
|
230
|
+
"""Match httpx transport failures, including the parallel httpx2 client.
|
|
231
|
+
|
|
232
|
+
The OpenAI SDK raises ``APIConnectionError`` from ``httpx2.RemoteProtocolError``.
|
|
233
|
+
That class mirrors ``httpx.TransportError`` but is not a subclass of it.
|
|
234
|
+
"""
|
|
235
|
+
if isinstance(exc, httpx.TransportError):
|
|
236
|
+
return True
|
|
237
|
+
module = type(exc).__module__.split(".", 1)[0]
|
|
238
|
+
if module not in {"httpx", "httpx2"}:
|
|
239
|
+
return False
|
|
240
|
+
return any(base.__name__ == "TransportError" for base in type(exc).__mro__)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _is_http_connection_error(exc: BaseException) -> bool:
|
|
244
|
+
"""Match connect failures from httpx and httpx2."""
|
|
245
|
+
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
|
|
246
|
+
return True
|
|
247
|
+
module = type(exc).__module__.split(".", 1)[0]
|
|
248
|
+
if module not in {"httpx", "httpx2"}:
|
|
249
|
+
return False
|
|
250
|
+
return type(exc).__name__ in {"ConnectError", "ConnectTimeout"}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _is_recoverable_api_exception(exc: Exception) -> bool:
|
|
254
|
+
"""Identify transport/server failures that are safe for the caller to retry."""
|
|
255
|
+
if certificate_failure(exc):
|
|
256
|
+
return False
|
|
257
|
+
for current in _iter_exception_chain(exc):
|
|
258
|
+
if _is_http_transport_error(current) or isinstance(
|
|
259
|
+
current,
|
|
260
|
+
(asyncio.TimeoutError, json.JSONDecodeError, OSError),
|
|
261
|
+
):
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
status_code = getattr(current, "status_code", None)
|
|
265
|
+
if isinstance(status_code, int) and (
|
|
266
|
+
status_code in {408, 409, 429} or status_code >= 500
|
|
267
|
+
):
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _is_connection_api_exception(exc: Exception) -> bool:
|
|
274
|
+
"""Return whether the unbounded connection retry path applies."""
|
|
275
|
+
return any(_is_http_connection_error(current) for current in _iter_exception_chain(exc))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _retry_after_seconds_from_message(message: str) -> float | None:
|
|
279
|
+
"""Parse the server-advised delay used by Responses rate-limit errors."""
|
|
280
|
+
match = re.search(
|
|
281
|
+
r"(?:please\s+)?try\s+again\s+in\s+"
|
|
282
|
+
r"(\d+(?:\.\d+)?)\s*(ms|s|seconds?)\b",
|
|
283
|
+
message,
|
|
284
|
+
flags=re.IGNORECASE,
|
|
285
|
+
)
|
|
286
|
+
if not match:
|
|
287
|
+
return None
|
|
288
|
+
value = float(match.group(1))
|
|
289
|
+
unit = match.group(2).lower()
|
|
290
|
+
return value / 1000.0 if unit == "ms" else value
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _retry_after_seconds_from_exception(exc: Exception) -> float | None:
|
|
294
|
+
pending: list[BaseException] = [exc]
|
|
295
|
+
seen: set[int] = set()
|
|
296
|
+
while pending:
|
|
297
|
+
current = pending.pop()
|
|
298
|
+
if id(current) in seen:
|
|
299
|
+
continue
|
|
300
|
+
seen.add(id(current))
|
|
301
|
+
response = getattr(current, "response", None)
|
|
302
|
+
headers = getattr(response, "headers", None)
|
|
303
|
+
if headers is not None:
|
|
304
|
+
raw = headers.get("retry-after")
|
|
305
|
+
if raw is not None:
|
|
306
|
+
try:
|
|
307
|
+
return max(0.0, float(raw))
|
|
308
|
+
except (TypeError, ValueError):
|
|
309
|
+
pass
|
|
310
|
+
parsed = _retry_after_seconds_from_message(str(current))
|
|
311
|
+
if parsed is not None:
|
|
312
|
+
return parsed
|
|
313
|
+
for nested in (current.__cause__, current.__context__):
|
|
314
|
+
if nested is not None:
|
|
315
|
+
pending.append(nested)
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _is_recoverable_api_error_message(message: str) -> bool:
|
|
320
|
+
"""Recognize transient failures delivered as successful stream events."""
|
|
321
|
+
text = message.lower().strip()
|
|
322
|
+
if not text:
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
transient_phrases = (
|
|
326
|
+
"an error occurred while processing your request",
|
|
327
|
+
"you can retry your request",
|
|
328
|
+
"response failed",
|
|
329
|
+
"request timeout",
|
|
330
|
+
"too many requests",
|
|
331
|
+
"rate limit",
|
|
332
|
+
"internal server error",
|
|
333
|
+
"server error",
|
|
334
|
+
"bad gateway",
|
|
335
|
+
"service unavailable",
|
|
336
|
+
"gateway timeout",
|
|
337
|
+
"temporarily unavailable",
|
|
338
|
+
"server is overloaded",
|
|
339
|
+
"server overloaded",
|
|
340
|
+
"stream ended without a terminal event",
|
|
341
|
+
"invalid responses sse payload",
|
|
342
|
+
)
|
|
343
|
+
if any(phrase in text for phrase in transient_phrases):
|
|
344
|
+
return True
|
|
345
|
+
|
|
346
|
+
# Direct SSE adapters surface non-2xx responses as "HTTP <status>" text.
|
|
347
|
+
import re
|
|
348
|
+
|
|
349
|
+
match = re.search(r"\bhttp\s+(\d{3})\b", text)
|
|
350
|
+
if not match:
|
|
351
|
+
return False
|
|
352
|
+
status_code = int(match.group(1))
|
|
353
|
+
return status_code in {408, 409, 429} or status_code >= 500
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _merge_permission_results(
|
|
357
|
+
rule_perm: PermissionResult,
|
|
358
|
+
tool_perm: PermissionResult,
|
|
359
|
+
) -> PermissionResult:
|
|
360
|
+
updated_input = tool_perm.updated_input if tool_perm.updated_input is not None else rule_perm.updated_input
|
|
361
|
+
permission_key = tool_perm.permission_key or rule_perm.permission_key
|
|
362
|
+
|
|
363
|
+
if rule_perm.behavior == PermissionBehavior.DENY:
|
|
364
|
+
return PermissionResult(
|
|
365
|
+
behavior=PermissionBehavior.DENY,
|
|
366
|
+
reason=rule_perm.reason or tool_perm.reason,
|
|
367
|
+
updated_input=updated_input,
|
|
368
|
+
permission_key=permission_key,
|
|
369
|
+
)
|
|
370
|
+
if tool_perm.behavior == PermissionBehavior.DENY:
|
|
371
|
+
return PermissionResult(
|
|
372
|
+
behavior=PermissionBehavior.DENY,
|
|
373
|
+
reason=tool_perm.reason or rule_perm.reason,
|
|
374
|
+
updated_input=updated_input,
|
|
375
|
+
permission_key=permission_key,
|
|
376
|
+
)
|
|
377
|
+
if (
|
|
378
|
+
rule_perm.behavior == PermissionBehavior.ASK
|
|
379
|
+
or tool_perm.behavior == PermissionBehavior.ASK
|
|
380
|
+
):
|
|
381
|
+
reason = tool_perm.reason or rule_perm.reason
|
|
382
|
+
return PermissionResult(
|
|
383
|
+
behavior=PermissionBehavior.ASK,
|
|
384
|
+
reason=reason,
|
|
385
|
+
updated_input=updated_input,
|
|
386
|
+
permission_key=permission_key,
|
|
387
|
+
)
|
|
388
|
+
return PermissionResult(
|
|
389
|
+
behavior=PermissionBehavior.ALLOW,
|
|
390
|
+
reason=tool_perm.reason or rule_perm.reason,
|
|
391
|
+
updated_input=updated_input,
|
|
392
|
+
permission_key=permission_key,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@dataclass
|
|
397
|
+
class QueryParams:
|
|
398
|
+
messages: list[Message]
|
|
399
|
+
system_prompt: list[str]
|
|
400
|
+
user_context: dict[str, str]
|
|
401
|
+
system_context: dict[str, str]
|
|
402
|
+
tools: list[Tool]
|
|
403
|
+
tool_context: ToolContext
|
|
404
|
+
api_adapter: APIAdapter
|
|
405
|
+
max_turns: int = 0
|
|
406
|
+
permission_manager: Any = None
|
|
407
|
+
permission_queue: asyncio.Queue | None = None
|
|
408
|
+
hook_manager: Any = None
|
|
409
|
+
agent_mode: str = "agent" # "agent" | "plan"
|
|
410
|
+
api_config: ApiConfig | None = None # passed from session for ModelConfig
|
|
411
|
+
context_window: int = 0 # resolved context window size
|
|
412
|
+
context_token_tracker: ContextTokenTracker | None = None
|
|
413
|
+
ai_reviewer: AiPermissionReviewer | None = None
|
|
414
|
+
tool_call_timeout: float | None = None # seconds; None means no timeout
|
|
415
|
+
auto_compact_enabled: bool = True
|
|
416
|
+
compact_buffer_tokens: int = DEFAULT_COMPACT_BUFFER_TOKENS
|
|
417
|
+
compact_threshold: int | None = None
|
|
418
|
+
compact_prompt: str | None = None
|
|
419
|
+
reply_to_uuid: str | None = None
|
|
420
|
+
drain_peer_messages: Callable[[], list[str]] | None = None
|
|
421
|
+
# User guidance submitted while the foreground turn is running. These
|
|
422
|
+
# messages are drained only at model-request boundaries, after a complete
|
|
423
|
+
# tool-result batch has been appended to the conversation.
|
|
424
|
+
drain_steering_messages: Callable[[], list[Message]] | None = None
|
|
425
|
+
tool_loading: ToolLoadingSettings | None = None
|
|
426
|
+
tool_loading_state: ToolLoadingState | None = None
|
|
427
|
+
on_tools_loaded: Callable[[list[str]], None] | None = None
|
|
428
|
+
pinned_tools: tuple[str, ...] = ()
|
|
429
|
+
# Checked user prompts. Applied only to the model request, not the transcript.
|
|
430
|
+
user_append_prompts: list[str] = field(default_factory=list)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _plan_mode_is_active(params: QueryParams) -> bool:
|
|
434
|
+
"""Return whether this turn must enforce the read-only plan boundary.
|
|
435
|
+
|
|
436
|
+
``agent_mode`` is the immutable mode captured when the turn starts. The
|
|
437
|
+
live session check additionally closes the race where a client switches an
|
|
438
|
+
already-running agent turn into plan mode before its next tool call.
|
|
439
|
+
"""
|
|
440
|
+
if params.agent_mode == "plan":
|
|
441
|
+
return True
|
|
442
|
+
session = params.tool_context.session
|
|
443
|
+
return getattr(session, "agent_mode", None) == "plan"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _append_system_context(
|
|
447
|
+
system_prompt: list[str],
|
|
448
|
+
context: dict[str, str],
|
|
449
|
+
) -> list[str]:
|
|
450
|
+
"""Append system context entries to the end of system prompt."""
|
|
451
|
+
if not context:
|
|
452
|
+
return system_prompt
|
|
453
|
+
extra = "\n".join(f"{k}: {v}" for k, v in context.items() if v)
|
|
454
|
+
if extra:
|
|
455
|
+
if isinstance(system_prompt, SystemPrompt):
|
|
456
|
+
return system_prompt.with_dynamic(extra)
|
|
457
|
+
return [*system_prompt, extra]
|
|
458
|
+
return system_prompt
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _prepend_user_context(
|
|
462
|
+
messages: list[Message],
|
|
463
|
+
user_context: dict[str, str],
|
|
464
|
+
) -> list[Message]:
|
|
465
|
+
"""Prepare request-only history and prepend user context at the start."""
|
|
466
|
+
messages = project_computer_use_history(messages)
|
|
467
|
+
if not user_context:
|
|
468
|
+
return messages
|
|
469
|
+
|
|
470
|
+
ctx_parts = []
|
|
471
|
+
for key, value in user_context.items():
|
|
472
|
+
ctx_parts.append(f"# {key}\n{value}")
|
|
473
|
+
ctx_text = "\n".join(ctx_parts)
|
|
474
|
+
|
|
475
|
+
meta_msg = create_user_message(
|
|
476
|
+
content=(
|
|
477
|
+
f"<system-reminder>\n"
|
|
478
|
+
f"As you answer the user's questions, you can use the following context:\n"
|
|
479
|
+
f"{ctx_text}\n\n"
|
|
480
|
+
f"IMPORTANT: this context may or may not be relevant to your tasks. "
|
|
481
|
+
f"You should not respond to this context unless it is highly relevant "
|
|
482
|
+
f"to your task.\n</system-reminder>\n"
|
|
483
|
+
),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
return [meta_msg, *messages]
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _user_rules_block(prompts: list[str]) -> str:
|
|
490
|
+
lines = [text.strip() for text in prompts if text and text.strip()]
|
|
491
|
+
if not lines:
|
|
492
|
+
return ""
|
|
493
|
+
return "<user-rules>\n" + "\n\n".join(lines) + "\n</user-rules>"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _is_conversational_user_message(message: Message) -> bool:
|
|
497
|
+
role = getattr(message.role, "value", message.role)
|
|
498
|
+
if role != "user" or message.is_compact_summary:
|
|
499
|
+
return False
|
|
500
|
+
if message.origin in {"peer-message", "task-notification"}:
|
|
501
|
+
return False
|
|
502
|
+
content = message.content
|
|
503
|
+
if isinstance(content, str):
|
|
504
|
+
return True
|
|
505
|
+
if not content:
|
|
506
|
+
return False
|
|
507
|
+
return any(not isinstance(block, ToolResultBlock) for block in content)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _append_user_prompts(messages: list[Message], prompts: list[str]) -> list[Message]:
|
|
511
|
+
"""Append checked prompts to the latest user message in a request copy."""
|
|
512
|
+
block = _user_rules_block(prompts)
|
|
513
|
+
if not block:
|
|
514
|
+
return messages
|
|
515
|
+
index = next(
|
|
516
|
+
(
|
|
517
|
+
position
|
|
518
|
+
for position in range(len(messages) - 1, -1, -1)
|
|
519
|
+
if _is_conversational_user_message(messages[position])
|
|
520
|
+
),
|
|
521
|
+
None,
|
|
522
|
+
)
|
|
523
|
+
if index is None:
|
|
524
|
+
return messages
|
|
525
|
+
cloned = messages[index].model_copy(deep=True)
|
|
526
|
+
suffix = f"\n\n{block}"
|
|
527
|
+
if isinstance(cloned.content, str):
|
|
528
|
+
cloned.content = f"{cloned.content}{suffix}" if cloned.content else block
|
|
529
|
+
else:
|
|
530
|
+
blocks = list(cloned.content)
|
|
531
|
+
text_indexes = [position for position, item in enumerate(blocks) if isinstance(item, TextBlock)]
|
|
532
|
+
if text_indexes:
|
|
533
|
+
text_index = text_indexes[-1]
|
|
534
|
+
current = blocks[text_index]
|
|
535
|
+
blocks[text_index] = current.model_copy(update={"text": f"{current.text}{suffix}"})
|
|
536
|
+
else:
|
|
537
|
+
blocks.append(TextBlock(text=block))
|
|
538
|
+
cloned.content = blocks
|
|
539
|
+
copied = list(messages)
|
|
540
|
+
copied[index] = cloned
|
|
541
|
+
return copied
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _messages_for_model(messages: list[Message], params: QueryParams) -> list[Message]:
|
|
545
|
+
prepared = _prepend_user_context(messages, params.user_context)
|
|
546
|
+
return _append_user_prompts(prepared, params.user_append_prompts)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _find_tool(tools: list[Tool], name: str) -> Tool | None:
|
|
550
|
+
for tool in tools:
|
|
551
|
+
if tool.name == name:
|
|
552
|
+
return tool
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _normalize_and_record_tool_input(
|
|
557
|
+
tool: Tool,
|
|
558
|
+
block: ToolUseBlock,
|
|
559
|
+
) -> ToolInputNormalization:
|
|
560
|
+
normalization = normalize_tool_input(block.input, tool.input_schema)
|
|
561
|
+
if normalization.corrections:
|
|
562
|
+
logger.warning(
|
|
563
|
+
"telemetry tool_input_schema_normalized tool=%s tool_use_id=%s corrections=%s",
|
|
564
|
+
tool.name,
|
|
565
|
+
block.id,
|
|
566
|
+
json.dumps(
|
|
567
|
+
[
|
|
568
|
+
{
|
|
569
|
+
"path": correction.path,
|
|
570
|
+
"received": correction.received,
|
|
571
|
+
"expected": correction.expected,
|
|
572
|
+
}
|
|
573
|
+
for correction in normalization.corrections
|
|
574
|
+
],
|
|
575
|
+
separators=(",", ":"),
|
|
576
|
+
sort_keys=True,
|
|
577
|
+
),
|
|
578
|
+
)
|
|
579
|
+
return normalization
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
async def _run_tools(
|
|
583
|
+
tool_use_blocks: list[ToolUseBlock],
|
|
584
|
+
assistant_msg: AssistantMessage,
|
|
585
|
+
tools: list[Tool],
|
|
586
|
+
context: ToolContext,
|
|
587
|
+
hook_manager: Any = None,
|
|
588
|
+
tool_call_timeout: float | None = None,
|
|
589
|
+
) -> AsyncGenerator[tuple[list[Message], ToolResultEvent] | CoreEvent, None]:
|
|
590
|
+
"""Execute tool calls, yielding result events and mid-execution events.
|
|
591
|
+
|
|
592
|
+
Runs concurrency-safe tools in parallel, others sequentially.
|
|
593
|
+
Yields either (messages, ToolResultEvent) tuples or standalone
|
|
594
|
+
CoreEvents (e.g. ChoiceRequestEvent) emitted by tools during execution.
|
|
595
|
+
|
|
596
|
+
When a tool puts events into context.tool_event_queue (e.g. AskUserTool
|
|
597
|
+
emitting a ChoiceRequestEvent), this function monitors the queue concurrently
|
|
598
|
+
with the tool execution and yields those events immediately so the frontend
|
|
599
|
+
can respond (e.g. show a choice UI) while the tool is still awaiting input.
|
|
600
|
+
"""
|
|
601
|
+
import asyncio
|
|
602
|
+
|
|
603
|
+
safe: list[ToolUseBlock] = []
|
|
604
|
+
unsafe: list[ToolUseBlock] = []
|
|
605
|
+
for block in tool_use_blocks:
|
|
606
|
+
tool = _find_tool(tools, block.name)
|
|
607
|
+
if tool and tool.is_concurrency_safe:
|
|
608
|
+
safe.append(block)
|
|
609
|
+
else:
|
|
610
|
+
unsafe.append(block)
|
|
611
|
+
|
|
612
|
+
def _hook_feedback_messages(tag: str, feedbacks: list[str] | None) -> list[Message]:
|
|
613
|
+
if not feedbacks:
|
|
614
|
+
return []
|
|
615
|
+
msgs: list[Message] = []
|
|
616
|
+
for text in feedbacks:
|
|
617
|
+
if not text:
|
|
618
|
+
continue
|
|
619
|
+
msgs.append(
|
|
620
|
+
create_user_message(
|
|
621
|
+
content=f"<{tag}>\n{text}\n</{tag}>"
|
|
622
|
+
)
|
|
623
|
+
)
|
|
624
|
+
return msgs
|
|
625
|
+
|
|
626
|
+
async def execute_one(block: ToolUseBlock) -> tuple[list[Message], ToolResultEvent]:
|
|
627
|
+
tool = _find_tool(tools, block.name)
|
|
628
|
+
# Each invocation gets an isolated image sink. This is the same
|
|
629
|
+
# per-tool boundary Codex uses for ``emitImage`` output and prevents
|
|
630
|
+
# concurrent tools from sharing attachments.
|
|
631
|
+
call_context = replace(context, tool_use_id=block.id, emitted_images=[])
|
|
632
|
+
if not tool:
|
|
633
|
+
msg = create_tool_result_message(
|
|
634
|
+
tool_use_id=block.id,
|
|
635
|
+
result=f"Error: unknown tool '{block.name}'",
|
|
636
|
+
is_error=True,
|
|
637
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
638
|
+
)
|
|
639
|
+
event = ToolResultEvent(
|
|
640
|
+
tool_use_id=block.id,
|
|
641
|
+
tool_name=block.name,
|
|
642
|
+
result=f"Error: unknown tool '{block.name}'",
|
|
643
|
+
is_error=True,
|
|
644
|
+
tool_input=block.input,
|
|
645
|
+
)
|
|
646
|
+
return [msg], event
|
|
647
|
+
|
|
648
|
+
normalization = _normalize_and_record_tool_input(tool, block)
|
|
649
|
+
if normalization.error:
|
|
650
|
+
msg = create_tool_result_message(
|
|
651
|
+
tool_use_id=block.id,
|
|
652
|
+
result=f"Validation error: {normalization.error}",
|
|
653
|
+
is_error=True,
|
|
654
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
655
|
+
)
|
|
656
|
+
event = ToolResultEvent(
|
|
657
|
+
tool_use_id=block.id,
|
|
658
|
+
tool_name=block.name,
|
|
659
|
+
result=f"Validation error: {normalization.error}",
|
|
660
|
+
is_error=True,
|
|
661
|
+
tool_input=block.input,
|
|
662
|
+
)
|
|
663
|
+
return [msg], event
|
|
664
|
+
if normalization.corrections:
|
|
665
|
+
block = block.model_copy(update={"input": normalization.value}, deep=True)
|
|
666
|
+
|
|
667
|
+
validation_error = await tool.validate_input(block.input)
|
|
668
|
+
if validation_error:
|
|
669
|
+
msg = create_tool_result_message(
|
|
670
|
+
tool_use_id=block.id,
|
|
671
|
+
result=f"Validation error: {validation_error}",
|
|
672
|
+
is_error=True,
|
|
673
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
674
|
+
)
|
|
675
|
+
event = ToolResultEvent(
|
|
676
|
+
tool_use_id=block.id,
|
|
677
|
+
tool_name=block.name,
|
|
678
|
+
result=f"Validation error: {validation_error}",
|
|
679
|
+
is_error=True,
|
|
680
|
+
tool_input=block.input,
|
|
681
|
+
)
|
|
682
|
+
return [msg], event
|
|
683
|
+
|
|
684
|
+
extra_messages: list[Message] = []
|
|
685
|
+
if hook_manager:
|
|
686
|
+
pre_result = await hook_manager.run(
|
|
687
|
+
"pre_tool_call",
|
|
688
|
+
{
|
|
689
|
+
"tool_name": block.name,
|
|
690
|
+
"tool_input": block.input,
|
|
691
|
+
"tool_use_id": block.id,
|
|
692
|
+
"agent_id": context.agent_id,
|
|
693
|
+
},
|
|
694
|
+
cwd=context.cwd,
|
|
695
|
+
env=context.env,
|
|
696
|
+
)
|
|
697
|
+
extra_messages.extend(
|
|
698
|
+
_hook_feedback_messages(
|
|
699
|
+
"pre-tool-call-hook",
|
|
700
|
+
pre_result.feedback,
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
if pre_result.blocked:
|
|
704
|
+
reason = "; ".join(pre_result.details or []) or "blocked by pre_tool_call hook"
|
|
705
|
+
msg = create_tool_result_message(
|
|
706
|
+
tool_use_id=block.id,
|
|
707
|
+
result=f"Hook blocked tool call: {reason}",
|
|
708
|
+
is_error=True,
|
|
709
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
710
|
+
)
|
|
711
|
+
event = ToolResultEvent(
|
|
712
|
+
tool_use_id=block.id,
|
|
713
|
+
tool_name=block.name,
|
|
714
|
+
result=f"Hook blocked tool call: {reason}",
|
|
715
|
+
is_error=True,
|
|
716
|
+
tool_input=block.input,
|
|
717
|
+
)
|
|
718
|
+
return [*extra_messages, msg], event
|
|
719
|
+
|
|
720
|
+
timeout_enabled = tool_call_timeout is not None and tool_call_timeout > 0
|
|
721
|
+
try:
|
|
722
|
+
# Permissions/hooks may have awaited user input since dispatch.
|
|
723
|
+
# Recheck live availability, retaining the request's immutable exposure set.
|
|
724
|
+
catalog = call_context.tool_catalog
|
|
725
|
+
if catalog is not None and (
|
|
726
|
+
tool.name not in catalog.exposed_names or not tool.is_available(call_context)
|
|
727
|
+
):
|
|
728
|
+
raise PermissionError("Tool is not exposed or is no longer available")
|
|
729
|
+
if getattr(call_context.session, "agent_mode", None) == "plan" and not tool.is_read_only:
|
|
730
|
+
raise PermissionError("Plan mode: write operations are not allowed")
|
|
731
|
+
if timeout_enabled:
|
|
732
|
+
result = await asyncio.wait_for(
|
|
733
|
+
tool.call(block.input, call_context),
|
|
734
|
+
timeout=tool_call_timeout,
|
|
735
|
+
)
|
|
736
|
+
else:
|
|
737
|
+
result = await tool.call(block.input, call_context)
|
|
738
|
+
except asyncio.TimeoutError as e:
|
|
739
|
+
if timeout_enabled:
|
|
740
|
+
result = ToolResult(
|
|
741
|
+
result_for_model=f"Tool call timed out after {tool_call_timeout:g}s",
|
|
742
|
+
is_error=True,
|
|
743
|
+
)
|
|
744
|
+
else:
|
|
745
|
+
logger.exception("Tool execution timed out: %s", block.name)
|
|
746
|
+
result = ToolResult(
|
|
747
|
+
result_for_model=f"Error executing tool: {e}",
|
|
748
|
+
is_error=True,
|
|
749
|
+
)
|
|
750
|
+
except Exception as e:
|
|
751
|
+
logger.exception("Tool execution failed: %s", block.name)
|
|
752
|
+
result = ToolResult(
|
|
753
|
+
result_for_model=f"Error executing tool: {e}",
|
|
754
|
+
is_error=True,
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
if call_context.emitted_images:
|
|
758
|
+
result.images = [
|
|
759
|
+
*result.images,
|
|
760
|
+
*[
|
|
761
|
+
image
|
|
762
|
+
for image in call_context.emitted_images
|
|
763
|
+
if image not in result.images
|
|
764
|
+
],
|
|
765
|
+
]
|
|
766
|
+
|
|
767
|
+
if hook_manager:
|
|
768
|
+
post_result = await hook_manager.run(
|
|
769
|
+
"post_tool_call",
|
|
770
|
+
{
|
|
771
|
+
"tool_name": block.name,
|
|
772
|
+
"tool_input": block.input,
|
|
773
|
+
"tool_use_id": block.id,
|
|
774
|
+
"agent_id": context.agent_id,
|
|
775
|
+
"tool_result": result.result_for_model,
|
|
776
|
+
"tool_is_error": result.is_error,
|
|
777
|
+
},
|
|
778
|
+
cwd=context.cwd,
|
|
779
|
+
env=context.env,
|
|
780
|
+
)
|
|
781
|
+
extra_messages.extend(
|
|
782
|
+
_hook_feedback_messages(
|
|
783
|
+
"post-tool-call-hook",
|
|
784
|
+
post_result.feedback,
|
|
785
|
+
)
|
|
786
|
+
)
|
|
787
|
+
if post_result.blocked:
|
|
788
|
+
reason = "; ".join(post_result.details or []) or "post_tool_call hook failed"
|
|
789
|
+
result = ToolResult(
|
|
790
|
+
result_for_model=f"{result.result_for_model}\n\nPost hook error: {reason}",
|
|
791
|
+
result_for_display=result.result_for_display,
|
|
792
|
+
is_error=True,
|
|
793
|
+
images=result.images,
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
msg = create_tool_result_message(
|
|
797
|
+
tool_use_id=block.id,
|
|
798
|
+
result=result.result_for_model,
|
|
799
|
+
is_error=result.is_error,
|
|
800
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
801
|
+
images=result.images,
|
|
802
|
+
)
|
|
803
|
+
event = ToolResultEvent(
|
|
804
|
+
tool_use_id=block.id,
|
|
805
|
+
tool_name=block.name,
|
|
806
|
+
result=result.result_for_model,
|
|
807
|
+
is_error=result.is_error,
|
|
808
|
+
result_for_display=result.result_for_display,
|
|
809
|
+
tool_input=block.input,
|
|
810
|
+
images=result.images,
|
|
811
|
+
)
|
|
812
|
+
return [*extra_messages, msg], event
|
|
813
|
+
|
|
814
|
+
async def _run_with_event_drain(
|
|
815
|
+
coro: Coroutine[Any, Any, tuple[list[Message], ToolResultEvent]],
|
|
816
|
+
) -> AsyncGenerator[tuple[list[Message], ToolResultEvent] | CoreEvent, None]:
|
|
817
|
+
"""Run a tool coroutine while draining mid-execution events from the queue.
|
|
818
|
+
|
|
819
|
+
This solves the deadlock: a tool like AskUserTool puts a ChoiceRequestEvent
|
|
820
|
+
into tool_event_queue, then blocks on choice_queue. Without draining, the
|
|
821
|
+
event would never reach the frontend and the tool would wait forever.
|
|
822
|
+
"""
|
|
823
|
+
task: asyncio.Task[tuple[list[Message], ToolResultEvent]] = asyncio.create_task(coro)
|
|
824
|
+
queue = context.tool_event_queue
|
|
825
|
+
get_event: asyncio.Task[Any] | None = None
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
if not queue:
|
|
829
|
+
result = await task
|
|
830
|
+
yield result
|
|
831
|
+
return
|
|
832
|
+
|
|
833
|
+
while not task.done():
|
|
834
|
+
# Wait for either the tool to finish or an event to arrive.
|
|
835
|
+
# Keep a reference so an outer cancellation can clean up the
|
|
836
|
+
# queue waiter as well as the tool task.
|
|
837
|
+
get_event = asyncio.create_task(queue.get())
|
|
838
|
+
done, _ = await asyncio.wait(
|
|
839
|
+
{task, get_event},
|
|
840
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
if get_event in done:
|
|
844
|
+
yield get_event.result()
|
|
845
|
+
else:
|
|
846
|
+
get_event.cancel()
|
|
847
|
+
await asyncio.gather(get_event, return_exceptions=True)
|
|
848
|
+
get_event = None
|
|
849
|
+
|
|
850
|
+
# Drain any remaining events before checking task.
|
|
851
|
+
while True:
|
|
852
|
+
try:
|
|
853
|
+
yield queue.get_nowait()
|
|
854
|
+
except asyncio.QueueEmpty:
|
|
855
|
+
break
|
|
856
|
+
|
|
857
|
+
result = task.result()
|
|
858
|
+
# Final drain.
|
|
859
|
+
while True:
|
|
860
|
+
try:
|
|
861
|
+
yield queue.get_nowait()
|
|
862
|
+
except asyncio.QueueEmpty:
|
|
863
|
+
break
|
|
864
|
+
|
|
865
|
+
yield result
|
|
866
|
+
finally:
|
|
867
|
+
# Query/session cancellation must not leave a tool running against
|
|
868
|
+
# resources that are about to be closed, nor a queue waiter holding
|
|
869
|
+
# a reference to the session. Await both tasks so their exceptions
|
|
870
|
+
# are consumed and no "Task exception was never retrieved" warning
|
|
871
|
+
# is emitted.
|
|
872
|
+
pending: list[asyncio.Task[Any]] = []
|
|
873
|
+
if get_event is not None and not get_event.done():
|
|
874
|
+
get_event.cancel()
|
|
875
|
+
pending.append(get_event)
|
|
876
|
+
if not task.done():
|
|
877
|
+
task.cancel()
|
|
878
|
+
pending.append(task)
|
|
879
|
+
if pending:
|
|
880
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
881
|
+
|
|
882
|
+
# Safe tools run in parallel (they shouldn't emit mid-execution events)
|
|
883
|
+
if safe:
|
|
884
|
+
safe_results = await asyncio.gather(*(execute_one(b) for b in safe))
|
|
885
|
+
for item in safe_results:
|
|
886
|
+
yield item
|
|
887
|
+
|
|
888
|
+
# Unsafe tools run sequentially with event draining
|
|
889
|
+
for block in unsafe:
|
|
890
|
+
async for drained_item in _run_with_event_drain(execute_one(block)):
|
|
891
|
+
yield drained_item
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _parse_context_limit_from_error(error_msg: str) -> int | None:
|
|
895
|
+
"""Try to extract the model's actual context limit from an API error message.
|
|
896
|
+
|
|
897
|
+
Common patterns:
|
|
898
|
+
- "maximum context length is 202752 tokens"
|
|
899
|
+
- "context window of 128000"
|
|
900
|
+
"""
|
|
901
|
+
import re
|
|
902
|
+
m = re.search(r"(?:maximum context length|context window)[^\d]*(\d[\d,_]*)", error_msg, re.IGNORECASE)
|
|
903
|
+
if m:
|
|
904
|
+
return int(m.group(1).replace(",", "").replace("_", ""))
|
|
905
|
+
return None
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _is_request_size_error(error: str | Exception) -> bool:
|
|
909
|
+
"""Recognize byte-size rejections independently of model context limits."""
|
|
910
|
+
import re
|
|
911
|
+
|
|
912
|
+
status = getattr(error, "status_code", None)
|
|
913
|
+
if status is None:
|
|
914
|
+
status = getattr(getattr(error, "response", None), "status_code", None)
|
|
915
|
+
if status == 413:
|
|
916
|
+
return True
|
|
917
|
+
text = str(error).lower()
|
|
918
|
+
return bool(re.search(
|
|
919
|
+
r"(?:^|\bhttp(?:/[\d.]+)?\s+|\bstatus(?:_code)?\s*[:=]?\s*|\berror code:\s*)413\b",
|
|
920
|
+
text,
|
|
921
|
+
)) or any(phrase in text for phrase in (
|
|
922
|
+
"request entity too large",
|
|
923
|
+
"request body too large",
|
|
924
|
+
"payload too large",
|
|
925
|
+
"content too large",
|
|
926
|
+
"request_too_large",
|
|
927
|
+
"content_length_exceeded",
|
|
928
|
+
))
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _request_size_error_event(detail: str) -> ErrorEvent:
|
|
932
|
+
return ErrorEvent(
|
|
933
|
+
message=(
|
|
934
|
+
"The provider rejected the request body as too large. "
|
|
935
|
+
"Reduce attachment file sizes or send fewer images in a new conversation. "
|
|
936
|
+
f"Provider error: {detail}"
|
|
937
|
+
),
|
|
938
|
+
recoverable=True,
|
|
939
|
+
error_type="request_too_large",
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _is_context_overflow_error(error_msg: str) -> bool:
|
|
944
|
+
"""Recognize provider size-limit errors without matching arbitrary HTTP 400s."""
|
|
945
|
+
text = error_msg.lower()
|
|
946
|
+
phrases = (
|
|
947
|
+
"maximum context length",
|
|
948
|
+
"context length exceeded",
|
|
949
|
+
"context length limit exceeded",
|
|
950
|
+
"context window exceeded",
|
|
951
|
+
"exceeds the context window",
|
|
952
|
+
"context_length_exceeded",
|
|
953
|
+
"contextwindowexceeded",
|
|
954
|
+
"prompt is too long",
|
|
955
|
+
"input is too long",
|
|
956
|
+
"too many input tokens",
|
|
957
|
+
"input token count exceeds",
|
|
958
|
+
"exceeds the maximum number of tokens",
|
|
959
|
+
"request too large for model",
|
|
960
|
+
)
|
|
961
|
+
return any(phrase in text for phrase in phrases)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _truncate_to_fit_tokens(
|
|
965
|
+
messages: list[Message],
|
|
966
|
+
target_tokens: int,
|
|
967
|
+
system: list[str] | None = None,
|
|
968
|
+
tools: list[dict[str, Any]] | None = None,
|
|
969
|
+
) -> list[Message]:
|
|
970
|
+
"""Prune tool output, then drop only structurally complete old turns."""
|
|
971
|
+
if target_tokens <= 0:
|
|
972
|
+
return messages
|
|
973
|
+
|
|
974
|
+
if estimate_token_count(messages, system=system, tools=tools) <= target_tokens:
|
|
975
|
+
return messages
|
|
976
|
+
_trim_tool_results_to_fit(
|
|
977
|
+
messages,
|
|
978
|
+
target_tokens,
|
|
979
|
+
system=system,
|
|
980
|
+
tools=tools,
|
|
981
|
+
)
|
|
982
|
+
if estimate_token_count(messages, system=system, tools=tools) <= target_tokens:
|
|
983
|
+
return messages
|
|
984
|
+
|
|
985
|
+
# Preserve the compact summary (if any) and the newest real user turn.
|
|
986
|
+
starts = conversation_turn_starts(messages)
|
|
987
|
+
while (
|
|
988
|
+
estimate_token_count(messages, system=system, tools=tools) > target_tokens
|
|
989
|
+
and len(starts) > 1
|
|
990
|
+
):
|
|
991
|
+
start, end = starts[0], starts[1]
|
|
992
|
+
del messages[start:end]
|
|
993
|
+
starts = conversation_turn_starts(messages)
|
|
994
|
+
|
|
995
|
+
return messages
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _trim_tool_results_to_fit(
|
|
999
|
+
messages: list[Message],
|
|
1000
|
+
target_tokens: int,
|
|
1001
|
+
*,
|
|
1002
|
+
system: list[str] | None = None,
|
|
1003
|
+
tools: list[dict[str, Any]] | None = None,
|
|
1004
|
+
) -> bool:
|
|
1005
|
+
"""Prune oversized tool blocks before paying for a remote summary call."""
|
|
1006
|
+
changed = False
|
|
1007
|
+
|
|
1008
|
+
def estimated() -> int:
|
|
1009
|
+
return estimate_token_count(messages, system=system, tools=tools)
|
|
1010
|
+
|
|
1011
|
+
for max_chars in (2_000, 500):
|
|
1012
|
+
for msg in messages:
|
|
1013
|
+
if isinstance(msg.content, str):
|
|
1014
|
+
continue
|
|
1015
|
+
for block in msg.content:
|
|
1016
|
+
if not isinstance(block, ToolResultBlock) or len(block.content) <= max_chars:
|
|
1017
|
+
continue
|
|
1018
|
+
marker = "\n... [old tool output pruned to fit context] ...\n"
|
|
1019
|
+
keep = max(0, max_chars - len(marker))
|
|
1020
|
+
left = keep // 2
|
|
1021
|
+
block.content = (
|
|
1022
|
+
block.content[:left]
|
|
1023
|
+
+ marker
|
|
1024
|
+
+ block.content[-(keep - left) :]
|
|
1025
|
+
)
|
|
1026
|
+
changed = True
|
|
1027
|
+
if estimated() <= target_tokens:
|
|
1028
|
+
return changed
|
|
1029
|
+
return changed
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
_COMPACT_RESUME_PROMPT = (
|
|
1033
|
+
"[Conversation was compacted to fit the context window. "
|
|
1034
|
+
"Continue the current task from the summary and latest context "
|
|
1035
|
+
"without asking the user to repeat anything.]"
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
_COMPACT_EMPTY_RESPONSE_RETRY_PROMPT = (
|
|
1039
|
+
"[The previous attempt returned no content after compaction. "
|
|
1040
|
+
"Resume immediately from the compacted summary and latest context. "
|
|
1041
|
+
"Either continue the task or make the next tool call now.]"
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _compact_recovery_reasoning_effort(effort: str | None) -> str | None:
|
|
1046
|
+
"""Keep compact recovery calls from spending the whole response budget thinking."""
|
|
1047
|
+
if effort in {None, "none", "minimal", "low"}:
|
|
1048
|
+
return effort
|
|
1049
|
+
return "low"
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _append_compact_resume_prompt(messages: list[Message]) -> list[Message]:
|
|
1053
|
+
"""Append a lightweight resume prompt after emergency compaction."""
|
|
1054
|
+
if not messages:
|
|
1055
|
+
messages.append(create_user_message(content=_COMPACT_RESUME_PROMPT))
|
|
1056
|
+
return messages
|
|
1057
|
+
|
|
1058
|
+
last = messages[-1]
|
|
1059
|
+
if isinstance(last.content, str) and last.content == _COMPACT_RESUME_PROMPT:
|
|
1060
|
+
return messages
|
|
1061
|
+
|
|
1062
|
+
messages.append(create_user_message(content=_COMPACT_RESUME_PROMPT))
|
|
1063
|
+
return messages
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
async def query_loop(
|
|
1067
|
+
params: QueryParams,
|
|
1068
|
+
) -> AsyncGenerator[CoreEvent, None]:
|
|
1069
|
+
"""The main agentic turn loop.
|
|
1070
|
+
|
|
1071
|
+
Sends messages to the API, processes streaming response, executes
|
|
1072
|
+
tool calls, and loops until no more tool calls are made.
|
|
1073
|
+
"""
|
|
1074
|
+
# Work on a private deep copy so emergency pruning cannot mutate the durable
|
|
1075
|
+
# pre-turn state before a successful checkpoint/turn is committed. Keep the
|
|
1076
|
+
# caller-owned projection list itself as the single live container: tools
|
|
1077
|
+
# such as Checkpoint/Revert receive that list through ToolContext and must be
|
|
1078
|
+
# able to update the query loop immediately.
|
|
1079
|
+
params.messages[:] = [
|
|
1080
|
+
message.model_copy(deep=True) for message in params.messages
|
|
1081
|
+
]
|
|
1082
|
+
messages = params.messages
|
|
1083
|
+
params.tool_context.messages = messages
|
|
1084
|
+
turn_count = 0
|
|
1085
|
+
_context_retries = 0
|
|
1086
|
+
_MAX_CONTEXT_RETRIES = 1
|
|
1087
|
+
_compact_resume_retries = 0
|
|
1088
|
+
_MAX_COMPACT_RESUME_RETRIES = 1
|
|
1089
|
+
stream_retry_state = ResponsesStreamRetryState()
|
|
1090
|
+
pending_stream_retry: StreamRetry | None = None
|
|
1091
|
+
_awaiting_compact_resume = False
|
|
1092
|
+
usage_keys = (
|
|
1093
|
+
"input_tokens",
|
|
1094
|
+
"output_tokens",
|
|
1095
|
+
"total_input_tokens",
|
|
1096
|
+
"cache_read_tokens",
|
|
1097
|
+
"cache_write_tokens",
|
|
1098
|
+
)
|
|
1099
|
+
total_usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
|
|
1100
|
+
last_request_usage: dict[str, int] = {key: 0 for key in usage_keys}
|
|
1101
|
+
reported_usage_keys: set[str] = set()
|
|
1102
|
+
reported_total_usage_keys: set[str] = set()
|
|
1103
|
+
|
|
1104
|
+
def _compact_total_usage() -> dict[str, int]:
|
|
1105
|
+
return {
|
|
1106
|
+
key: value
|
|
1107
|
+
for key, value in total_usage.items()
|
|
1108
|
+
if key in {"input_tokens", "output_tokens"}
|
|
1109
|
+
or key in reported_total_usage_keys
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
# Frontends can answer a permission prompt after the turn has already
|
|
1113
|
+
# been interrupted, or can answer several prompts out of order. Keep only
|
|
1114
|
+
# responses for the current model response so a stale approval can never
|
|
1115
|
+
# authorize a later tool call.
|
|
1116
|
+
buffered_permission_responses: dict[str, PermissionResponseEvent] = {}
|
|
1117
|
+
|
|
1118
|
+
def _drain_steering_messages() -> list[Message]:
|
|
1119
|
+
if params.drain_steering_messages is None:
|
|
1120
|
+
return []
|
|
1121
|
+
steering_messages = params.drain_steering_messages()
|
|
1122
|
+
if not steering_messages:
|
|
1123
|
+
return []
|
|
1124
|
+
messages.extend(steering_messages)
|
|
1125
|
+
params.messages[:] = messages
|
|
1126
|
+
params.tool_context.messages = messages
|
|
1127
|
+
return steering_messages
|
|
1128
|
+
|
|
1129
|
+
repaired_tool_calls = _repair_unpaired_tool_calls(messages)
|
|
1130
|
+
if repaired_tool_calls:
|
|
1131
|
+
params.messages[:] = messages
|
|
1132
|
+
params.tool_context.messages = messages
|
|
1133
|
+
logger.warning(
|
|
1134
|
+
"Repaired %d unpaired tool call(s) before the next API request",
|
|
1135
|
+
len(repaired_tool_calls),
|
|
1136
|
+
)
|
|
1137
|
+
yield ErrorEvent(
|
|
1138
|
+
message=(
|
|
1139
|
+
"检测到上一次任务有工具调用未返回结果,已标记为中断并恢复会话。"
|
|
1140
|
+
),
|
|
1141
|
+
recoverable=True,
|
|
1142
|
+
error_type="history_repaired",
|
|
1143
|
+
)
|
|
1144
|
+
for block, _result in repaired_tool_calls:
|
|
1145
|
+
yield ToolResultEvent(
|
|
1146
|
+
tool_use_id=block.id,
|
|
1147
|
+
tool_name=block.name,
|
|
1148
|
+
result="Tool call was interrupted before a result was produced.",
|
|
1149
|
+
is_error=True,
|
|
1150
|
+
tool_input=block.input,
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
cfg = params.api_config
|
|
1154
|
+
adapter_config = getattr(params.api_adapter, "config", None)
|
|
1155
|
+
effective_model = (
|
|
1156
|
+
(cfg.model if cfg else None)
|
|
1157
|
+
or (getattr(adapter_config, "model", None))
|
|
1158
|
+
or "claude-sonnet-4-20250514"
|
|
1159
|
+
)
|
|
1160
|
+
effective_max_tokens = cfg.max_tokens if cfg else 16384
|
|
1161
|
+
params.tool_context.api_adapter = params.api_adapter
|
|
1162
|
+
params.tool_context.model = effective_model
|
|
1163
|
+
effective_thinking = cfg.thinking_enabled if cfg else True
|
|
1164
|
+
effective_thinking_budget = cfg.thinking_budget if cfg else 10000
|
|
1165
|
+
effective_reasoning_effort = (
|
|
1166
|
+
cfg.reasoning_effort
|
|
1167
|
+
if cfg and cfg.reasoning_effort is not None
|
|
1168
|
+
else getattr(adapter_config, "reasoning_effort", None)
|
|
1169
|
+
)
|
|
1170
|
+
effective_timeout = (
|
|
1171
|
+
cfg.timeout if cfg
|
|
1172
|
+
else getattr(adapter_config, "timeout", 300)
|
|
1173
|
+
)
|
|
1174
|
+
effective_max_retries = max(
|
|
1175
|
+
0,
|
|
1176
|
+
cfg.max_retries if cfg
|
|
1177
|
+
else getattr(adapter_config, "max_retries", 5),
|
|
1178
|
+
)
|
|
1179
|
+
effective_unbounded_connection_retries = bool(
|
|
1180
|
+
cfg.unbounded_connection_retries
|
|
1181
|
+
if cfg
|
|
1182
|
+
else getattr(adapter_config, "unbounded_connection_retries", False)
|
|
1183
|
+
)
|
|
1184
|
+
context_window = params.context_window
|
|
1185
|
+
token_tracker = params.context_token_tracker or ContextTokenTracker()
|
|
1186
|
+
count_available = True
|
|
1187
|
+
|
|
1188
|
+
def _request_config(max_tokens: int) -> ModelConfig:
|
|
1189
|
+
request_thinking = effective_thinking
|
|
1190
|
+
request_thinking_budget = effective_thinking_budget
|
|
1191
|
+
request_reasoning_effort = effective_reasoning_effort
|
|
1192
|
+
if _awaiting_compact_resume:
|
|
1193
|
+
request_reasoning_effort = _compact_recovery_reasoning_effort(
|
|
1194
|
+
request_reasoning_effort
|
|
1195
|
+
)
|
|
1196
|
+
if request_reasoning_effort is None:
|
|
1197
|
+
request_reasoning_effort = "low"
|
|
1198
|
+
if _compact_resume_retries > 0:
|
|
1199
|
+
request_thinking = False
|
|
1200
|
+
request_thinking_budget = 0
|
|
1201
|
+
elif request_thinking:
|
|
1202
|
+
request_thinking_budget = min(
|
|
1203
|
+
request_thinking_budget,
|
|
1204
|
+
max(1024, max_tokens // 4),
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
if request_thinking and request_thinking_budget >= max_tokens:
|
|
1208
|
+
request_thinking_budget = max_tokens - 1024
|
|
1209
|
+
if request_thinking_budget < 1024:
|
|
1210
|
+
request_thinking = False
|
|
1211
|
+
request_thinking_budget = 0
|
|
1212
|
+
|
|
1213
|
+
return ModelConfig(
|
|
1214
|
+
model=effective_model,
|
|
1215
|
+
max_tokens=max_tokens,
|
|
1216
|
+
thinking_enabled=request_thinking,
|
|
1217
|
+
thinking_budget=request_thinking_budget,
|
|
1218
|
+
timeout=effective_timeout,
|
|
1219
|
+
context_window=context_window,
|
|
1220
|
+
reasoning_effort=request_reasoning_effort,
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
def _schedule_stream_retry(
|
|
1224
|
+
error_message: str,
|
|
1225
|
+
*,
|
|
1226
|
+
connection_failed: bool = False,
|
|
1227
|
+
retry_after: float | None = None,
|
|
1228
|
+
) -> StreamRetry | None:
|
|
1229
|
+
# Unfinished thinking and text are discarded before the replay.
|
|
1230
|
+
# Tool calls that already closed stay in the checkpoint and are not
|
|
1231
|
+
# executed a second time, including adapters without response items.
|
|
1232
|
+
if "CERTIFICATE_VERIFY_FAILED" in error_message:
|
|
1233
|
+
return None
|
|
1234
|
+
fallback = getattr(params.api_adapter, "try_switch_fallback_transport", None)
|
|
1235
|
+
allow_unbounded = type(params.api_adapter).__name__ != "BedrockAdapter"
|
|
1236
|
+
return stream_retry_state.schedule(
|
|
1237
|
+
error=error_message,
|
|
1238
|
+
max_retries=effective_max_retries,
|
|
1239
|
+
connection_failed=connection_failed,
|
|
1240
|
+
retry_after=retry_after,
|
|
1241
|
+
unbounded_connection_retries=effective_unbounded_connection_retries,
|
|
1242
|
+
allow_unbounded_connection_retries=allow_unbounded,
|
|
1243
|
+
try_transport_fallback=fallback if callable(fallback) else None,
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
async def _perform_compaction(
|
|
1247
|
+
*,
|
|
1248
|
+
trigger: str,
|
|
1249
|
+
) -> CompactEvent | None:
|
|
1250
|
+
nonlocal _awaiting_compact_resume, _compact_resume_retries
|
|
1251
|
+
|
|
1252
|
+
old_count = len(messages)
|
|
1253
|
+
source_messages = list(messages)
|
|
1254
|
+
if params.hook_manager:
|
|
1255
|
+
pre = await params.hook_manager.run(
|
|
1256
|
+
"pre_compact",
|
|
1257
|
+
{
|
|
1258
|
+
"session_id": params.tool_context.session_id,
|
|
1259
|
+
"trigger": trigger,
|
|
1260
|
+
"custom_instructions": "",
|
|
1261
|
+
},
|
|
1262
|
+
cwd=params.tool_context.cwd,
|
|
1263
|
+
env=params.tool_context.env,
|
|
1264
|
+
)
|
|
1265
|
+
if pre.blocked:
|
|
1266
|
+
return None
|
|
1267
|
+
|
|
1268
|
+
result = await compact_conversation(
|
|
1269
|
+
messages,
|
|
1270
|
+
api_adapter=params.api_adapter,
|
|
1271
|
+
compact_prompt=params.compact_prompt,
|
|
1272
|
+
context_window=context_window,
|
|
1273
|
+
cwd=params.tool_context.cwd,
|
|
1274
|
+
session_id=params.tool_context.session_id,
|
|
1275
|
+
)
|
|
1276
|
+
if not result:
|
|
1277
|
+
return None
|
|
1278
|
+
|
|
1279
|
+
_append_compact_resume_prompt(result)
|
|
1280
|
+
if estimate_token_count(result) >= estimate_token_count(source_messages):
|
|
1281
|
+
logger.warning(
|
|
1282
|
+
"Compaction plus recovery prompt produced no token savings; "
|
|
1283
|
+
"original context was preserved"
|
|
1284
|
+
)
|
|
1285
|
+
return None
|
|
1286
|
+
|
|
1287
|
+
messages[:] = result
|
|
1288
|
+
token_tracker.reset()
|
|
1289
|
+
_awaiting_compact_resume = True
|
|
1290
|
+
_compact_resume_retries = 0
|
|
1291
|
+
params.tool_context.messages = messages
|
|
1292
|
+
|
|
1293
|
+
if params.hook_manager:
|
|
1294
|
+
await params.hook_manager.run(
|
|
1295
|
+
"post_compact",
|
|
1296
|
+
{
|
|
1297
|
+
"session_id": params.tool_context.session_id,
|
|
1298
|
+
"trigger": trigger,
|
|
1299
|
+
"compact_summary": compact_summary_text(messages[0]),
|
|
1300
|
+
},
|
|
1301
|
+
cwd=params.tool_context.cwd,
|
|
1302
|
+
env=params.tool_context.env,
|
|
1303
|
+
)
|
|
1304
|
+
return CompactEvent(
|
|
1305
|
+
summary=(
|
|
1306
|
+
"Conversation auto-compacted: context approaching limit"
|
|
1307
|
+
if trigger == "auto"
|
|
1308
|
+
else "Emergency compact after provider context overflow"
|
|
1309
|
+
),
|
|
1310
|
+
messages_before=old_count,
|
|
1311
|
+
messages_after=len(messages),
|
|
1312
|
+
trigger=trigger,
|
|
1313
|
+
source_messages=source_messages,
|
|
1314
|
+
checkpoint_messages=[message.model_copy(deep=True) for message in messages],
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
while True:
|
|
1318
|
+
if pending_stream_retry is not None:
|
|
1319
|
+
retry = pending_stream_retry
|
|
1320
|
+
pending_stream_retry = None
|
|
1321
|
+
logger.warning(
|
|
1322
|
+
"Transient API failure; %s in %.3fs: %s",
|
|
1323
|
+
retry.message,
|
|
1324
|
+
retry.delay_seconds,
|
|
1325
|
+
retry.error,
|
|
1326
|
+
)
|
|
1327
|
+
yield StreamRetryEvent(
|
|
1328
|
+
message=retry.message,
|
|
1329
|
+
error=retry.error,
|
|
1330
|
+
retry_count=retry.retry_count,
|
|
1331
|
+
max_retries=retry.max_retries,
|
|
1332
|
+
delay_seconds=retry.delay_seconds,
|
|
1333
|
+
unbounded=retry.unbounded,
|
|
1334
|
+
transport_fallback=retry.transport_fallback,
|
|
1335
|
+
discarded_text_chars=retry.discarded_text_chars,
|
|
1336
|
+
)
|
|
1337
|
+
if retry.delay_seconds > 0:
|
|
1338
|
+
await asyncio.sleep(retry.delay_seconds)
|
|
1339
|
+
reset_client = getattr(params.api_adapter, "reset_network_client", None)
|
|
1340
|
+
if callable(reset_client) and not retry.transport_fallback:
|
|
1341
|
+
try:
|
|
1342
|
+
await reset_client()
|
|
1343
|
+
except Exception as exc:
|
|
1344
|
+
yield ErrorEvent(message=network_error_message(exc, adapter_config), recoverable=True, error_type="network")
|
|
1345
|
+
return
|
|
1346
|
+
|
|
1347
|
+
turn_count += 1
|
|
1348
|
+
# Tools and permission reviewers must always observe the active
|
|
1349
|
+
# projection, including this turn's results.
|
|
1350
|
+
params.tool_context.messages = messages
|
|
1351
|
+
|
|
1352
|
+
# Follow-up guidance from the user is applied at the same safe
|
|
1353
|
+
# boundary as tool results: immediately before the next model request.
|
|
1354
|
+
# Keeping it out of _run_tools preserves provider requirements that a
|
|
1355
|
+
# complete tool-result batch directly follows its assistant tool call.
|
|
1356
|
+
steering_messages = _drain_steering_messages()
|
|
1357
|
+
if steering_messages:
|
|
1358
|
+
yield SteeringAppliedEvent(count=len(steering_messages))
|
|
1359
|
+
|
|
1360
|
+
# Peer text is injected only at an agent-loop boundary: never while a
|
|
1361
|
+
# tool is executing, but before the next model request. This lets a
|
|
1362
|
+
# session react after a completed tool batch without waiting for the
|
|
1363
|
+
# entire foreground turn to finish.
|
|
1364
|
+
if params.drain_peer_messages is not None:
|
|
1365
|
+
peer_messages = params.drain_peer_messages()
|
|
1366
|
+
for peer_text in peer_messages:
|
|
1367
|
+
messages.append(
|
|
1368
|
+
create_user_message(content=peer_text, origin="peer-message")
|
|
1369
|
+
)
|
|
1370
|
+
if peer_messages:
|
|
1371
|
+
params.messages[:] = messages
|
|
1372
|
+
params.tool_context.messages = messages
|
|
1373
|
+
|
|
1374
|
+
full_system = _append_system_context(
|
|
1375
|
+
params.system_prompt, params.system_context
|
|
1376
|
+
)
|
|
1377
|
+
messages_for_api = _messages_for_model(messages, params)
|
|
1378
|
+
|
|
1379
|
+
plan_mode_active = _plan_mode_is_active(params)
|
|
1380
|
+
available_tools = [
|
|
1381
|
+
t for t in params.tools
|
|
1382
|
+
if t.is_available(params.tool_context) and (not plan_mode_active or t.is_read_only)
|
|
1383
|
+
]
|
|
1384
|
+
directory = ""
|
|
1385
|
+
loading_mode = "eager"
|
|
1386
|
+
params.tool_context.tool_catalog = None
|
|
1387
|
+
if params.tool_loading is not None:
|
|
1388
|
+
if params.tool_loading_state is None:
|
|
1389
|
+
params.tool_loading_state = ToolLoadingState()
|
|
1390
|
+
catalog = ToolCatalog(
|
|
1391
|
+
params.tools, params.tool_context, params.tool_loading, params.tool_loading_state,
|
|
1392
|
+
plan_mode=plan_mode_active, on_change=params.on_tools_loaded, pinned=params.pinned_tools,
|
|
1393
|
+
)
|
|
1394
|
+
params.tool_context.tool_catalog = catalog
|
|
1395
|
+
loading_mode = catalog.mode
|
|
1396
|
+
available_tools = catalog.loaded
|
|
1397
|
+
directory = catalog.directory()
|
|
1398
|
+
full_system = _append_system_context(full_system, {
|
|
1399
|
+
"capabilities": directory, "toolInstructions": catalog.instructions(),
|
|
1400
|
+
})
|
|
1401
|
+
tool_schemas = [
|
|
1402
|
+
t.to_api_schema()
|
|
1403
|
+
for t in available_tools
|
|
1404
|
+
]
|
|
1405
|
+
exposed_names = frozenset(t.name for t in available_tools)
|
|
1406
|
+
prompt_budget = {
|
|
1407
|
+
"source": "estimated",
|
|
1408
|
+
"mode": loading_mode,
|
|
1409
|
+
"system_tokens": estimate_token_count([], system=full_system),
|
|
1410
|
+
"tool_tokens": estimate_token_count([], tools=tool_schemas),
|
|
1411
|
+
"directory_tokens": estimate_token_count([], system=[directory]) if directory else 0,
|
|
1412
|
+
"loaded_tools": len(tool_schemas),
|
|
1413
|
+
"loaded_names": [t.name for t in available_tools],
|
|
1414
|
+
"available_tools": sum(t.is_available(params.tool_context) and (not plan_mode_active or t.is_read_only) for t in params.tools),
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
max_tokens = effective_max_tokens
|
|
1418
|
+
|
|
1419
|
+
compact_limit = compaction_input_limit(
|
|
1420
|
+
context_window, max_tokens,
|
|
1421
|
+
buffer_tokens=params.compact_buffer_tokens,
|
|
1422
|
+
override=params.compact_threshold if params.auto_compact_enabled else None,
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
async def _measure_input(candidate: list[Message]) -> TokenMeasurement:
|
|
1426
|
+
nonlocal count_available
|
|
1427
|
+
count_config = _request_config(max_tokens)
|
|
1428
|
+
snapshot = RequestSnapshot.capture(
|
|
1429
|
+
params.api_adapter, count_config, candidate, full_system, tool_schemas,
|
|
1430
|
+
)
|
|
1431
|
+
measurement = token_tracker.measure(snapshot, candidate, full_system, tool_schemas)
|
|
1432
|
+
# Establish a baseline if none exists, then verify only near the
|
|
1433
|
+
# limit. Ordinary append-only turns reuse usage plus a small delta.
|
|
1434
|
+
needs_count = measurement.source == "estimated" or measurement.tokens >= compact_limit * 0.9
|
|
1435
|
+
counter = getattr(params.api_adapter, "count_input_tokens", None)
|
|
1436
|
+
if measurement.source != "server" and needs_count and count_available and callable(counter):
|
|
1437
|
+
try:
|
|
1438
|
+
count = await asyncio.wait_for(counter(
|
|
1439
|
+
candidate, full_system, tool_schemas, count_config,
|
|
1440
|
+
), timeout=3.0)
|
|
1441
|
+
if token_tracker.calibrate(snapshot, count):
|
|
1442
|
+
return TokenMeasurement(token_tracker.input_tokens, "server")
|
|
1443
|
+
count_available = False
|
|
1444
|
+
except Exception as exc:
|
|
1445
|
+
# Cancellation must propagate; counting failures must not.
|
|
1446
|
+
logger.debug("Input token counting unavailable (%s)", type(exc).__name__)
|
|
1447
|
+
count_available = False
|
|
1448
|
+
return measurement
|
|
1449
|
+
|
|
1450
|
+
# --- Final-request preflight (system + messages + tool schemas) ---
|
|
1451
|
+
if context_window > 0:
|
|
1452
|
+
measurement = await _measure_input(messages_for_api)
|
|
1453
|
+
estimated = measurement.tokens
|
|
1454
|
+
needs_compaction = params.auto_compact_enabled and estimated > compact_limit
|
|
1455
|
+
if needs_compaction:
|
|
1456
|
+
source_messages = list(messages)
|
|
1457
|
+
pruned_messages = [message.model_copy(deep=True) for message in messages]
|
|
1458
|
+
if _trim_tool_results_to_fit(
|
|
1459
|
+
pruned_messages,
|
|
1460
|
+
compact_limit,
|
|
1461
|
+
system=full_system,
|
|
1462
|
+
tools=tool_schemas,
|
|
1463
|
+
):
|
|
1464
|
+
pruned_for_api = _messages_for_model(pruned_messages, params)
|
|
1465
|
+
pruned_measurement = await _measure_input(pruned_for_api)
|
|
1466
|
+
if pruned_measurement.tokens <= compact_limit:
|
|
1467
|
+
messages[:] = pruned_messages
|
|
1468
|
+
params.tool_context.messages = messages
|
|
1469
|
+
messages_for_api = pruned_for_api
|
|
1470
|
+
measurement = pruned_measurement
|
|
1471
|
+
estimated = measurement.tokens
|
|
1472
|
+
needs_compaction = False
|
|
1473
|
+
logger.warning(
|
|
1474
|
+
"Pruned oversized tool output; estimated final input is now %d",
|
|
1475
|
+
estimated,
|
|
1476
|
+
)
|
|
1477
|
+
yield CompactEvent(
|
|
1478
|
+
summary="Oversized historical tool output was pruned",
|
|
1479
|
+
messages_before=len(source_messages),
|
|
1480
|
+
messages_after=len(messages),
|
|
1481
|
+
trigger="auto",
|
|
1482
|
+
source_messages=source_messages,
|
|
1483
|
+
checkpoint_messages=[
|
|
1484
|
+
message.model_copy(deep=True) for message in messages
|
|
1485
|
+
],
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
if needs_compaction:
|
|
1489
|
+
logger.warning(
|
|
1490
|
+
"Final input count (%d) exceeds compact threshold %d. "
|
|
1491
|
+
"Attempting auto compact.",
|
|
1492
|
+
estimated,
|
|
1493
|
+
compact_limit,
|
|
1494
|
+
)
|
|
1495
|
+
yield StreamModeEvent(mode="compacting")
|
|
1496
|
+
compact_event = await _perform_compaction(
|
|
1497
|
+
trigger="auto",
|
|
1498
|
+
)
|
|
1499
|
+
if compact_event:
|
|
1500
|
+
messages_for_api = _messages_for_model(messages, params)
|
|
1501
|
+
measurement = await _measure_input(messages_for_api)
|
|
1502
|
+
estimated = measurement.tokens
|
|
1503
|
+
yield compact_event
|
|
1504
|
+
|
|
1505
|
+
# Always validate input + output. If compaction was disabled or failed,
|
|
1506
|
+
# reduce output rather than knowingly sending an impossible request.
|
|
1507
|
+
available_output = context_window - estimated - 512
|
|
1508
|
+
if available_output < 256:
|
|
1509
|
+
params.messages[:] = messages
|
|
1510
|
+
yield ErrorEvent(
|
|
1511
|
+
message=(
|
|
1512
|
+
"The final request does not fit the model context window, and "
|
|
1513
|
+
"conversation compaction could not free enough space."
|
|
1514
|
+
),
|
|
1515
|
+
recoverable=True,
|
|
1516
|
+
error_type="context_overflow",
|
|
1517
|
+
)
|
|
1518
|
+
yield TurnCompleteEvent(
|
|
1519
|
+
reason="context_overflow",
|
|
1520
|
+
turn_count=turn_count,
|
|
1521
|
+
usage=_compact_total_usage(),
|
|
1522
|
+
context_used_tokens=estimated,
|
|
1523
|
+
context_token_source=measurement.source,
|
|
1524
|
+
context_window_tokens=context_window,
|
|
1525
|
+
context_remaining_tokens=max(0, context_window - estimated),
|
|
1526
|
+
context_used_percent=round(estimated / context_window * 100, 1),
|
|
1527
|
+
)
|
|
1528
|
+
return
|
|
1529
|
+
if max_tokens > available_output:
|
|
1530
|
+
max_tokens = max(256, available_output)
|
|
1531
|
+
logger.warning("Reducing max_tokens to %d to fit final request", max_tokens)
|
|
1532
|
+
|
|
1533
|
+
model_config = _request_config(max_tokens)
|
|
1534
|
+
request_snapshot = RequestSnapshot.capture(
|
|
1535
|
+
params.api_adapter, model_config, messages_for_api, full_system, tool_schemas,
|
|
1536
|
+
)
|
|
1537
|
+
logger.warning(
|
|
1538
|
+
"Sending API request: model=%s, max_tokens=%d, msgs=%d, context_window=%d",
|
|
1539
|
+
effective_model, max_tokens, len(messages_for_api), context_window,
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
last_request_usage = {key: 0 for key in usage_keys}
|
|
1543
|
+
reported_usage_keys = set()
|
|
1544
|
+
|
|
1545
|
+
def _set_request_usage(usage: dict[str, Any]) -> None:
|
|
1546
|
+
"""Record this API request's latest usage snapshot.
|
|
1547
|
+
|
|
1548
|
+
Providers can stream usage as partial snapshots, and Anthropic may
|
|
1549
|
+
repeat cumulative fields on message_delta. Keep total_usage in sync
|
|
1550
|
+
by applying only monotonic differences from the prior snapshot.
|
|
1551
|
+
"""
|
|
1552
|
+
for key in usage_keys:
|
|
1553
|
+
if key not in usage:
|
|
1554
|
+
continue
|
|
1555
|
+
value, valid = usage_int_field(usage, key)
|
|
1556
|
+
if not valid:
|
|
1557
|
+
continue
|
|
1558
|
+
previous = last_request_usage[key]
|
|
1559
|
+
if key in reported_usage_keys and value < previous:
|
|
1560
|
+
continue
|
|
1561
|
+
last_request_usage[key] = value
|
|
1562
|
+
reported_usage_keys.add(key)
|
|
1563
|
+
reported_total_usage_keys.add(key)
|
|
1564
|
+
total_usage[key] = total_usage.get(key, 0) + value - previous
|
|
1565
|
+
token_tracker.observe_usage(request_snapshot, {
|
|
1566
|
+
key: last_request_usage[key] for key in reported_usage_keys
|
|
1567
|
+
})
|
|
1568
|
+
|
|
1569
|
+
def _turn_complete_event(reason: str, snapshot: list[Message]) -> TurnCompleteEvent:
|
|
1570
|
+
context_messages = _messages_for_model(snapshot, params)
|
|
1571
|
+
context_snapshot = RequestSnapshot.capture(
|
|
1572
|
+
params.api_adapter, model_config, context_messages, full_system, tool_schemas,
|
|
1573
|
+
)
|
|
1574
|
+
measurement = token_tracker.measure(context_snapshot, context_messages, full_system, tool_schemas)
|
|
1575
|
+
context_used = measurement.tokens
|
|
1576
|
+
context_window_tokens = max(0, context_window or 0)
|
|
1577
|
+
context_remaining = (
|
|
1578
|
+
max(context_window_tokens - context_used, 0)
|
|
1579
|
+
if context_window_tokens
|
|
1580
|
+
else 0
|
|
1581
|
+
)
|
|
1582
|
+
context_percent = (
|
|
1583
|
+
round(context_used / context_window_tokens * 100, 1)
|
|
1584
|
+
if context_window_tokens
|
|
1585
|
+
else 0.0
|
|
1586
|
+
)
|
|
1587
|
+
assistant_uuid = next(
|
|
1588
|
+
(
|
|
1589
|
+
str(message.uuid)
|
|
1590
|
+
for message in reversed(snapshot)
|
|
1591
|
+
if getattr(getattr(message, "role", None), "value", getattr(message, "role", None)) == "assistant"
|
|
1592
|
+
),
|
|
1593
|
+
None,
|
|
1594
|
+
)
|
|
1595
|
+
logger.warning(
|
|
1596
|
+
"Query turn completed: session=%s, model=%s, reason=%s, "
|
|
1597
|
+
"context_tokens=%d, context_source=%s",
|
|
1598
|
+
params.tool_context.session_id, effective_model, reason,
|
|
1599
|
+
context_used, measurement.source,
|
|
1600
|
+
)
|
|
1601
|
+
return TurnCompleteEvent(
|
|
1602
|
+
reason=reason,
|
|
1603
|
+
turn_count=turn_count,
|
|
1604
|
+
usage=_compact_total_usage(),
|
|
1605
|
+
context_used_tokens=context_used,
|
|
1606
|
+
context_token_source=measurement.source,
|
|
1607
|
+
context_window_tokens=context_window_tokens,
|
|
1608
|
+
context_remaining_tokens=context_remaining,
|
|
1609
|
+
context_used_percent=context_percent,
|
|
1610
|
+
assistant_message_uuid=assistant_uuid,
|
|
1611
|
+
prompt_budget=prompt_budget,
|
|
1612
|
+
)
|
|
1613
|
+
|
|
1614
|
+
assistant_content: list[ContentBlock] = []
|
|
1615
|
+
tool_use_blocks: list[ToolUseBlock] = []
|
|
1616
|
+
current_text = ""
|
|
1617
|
+
current_thinking = ""
|
|
1618
|
+
current_tool: dict[str, str] = {}
|
|
1619
|
+
completed_content_count = 0
|
|
1620
|
+
announced_tool_use_ids: set[str] = set()
|
|
1621
|
+
item_aware_stream = bool(
|
|
1622
|
+
getattr(params.api_adapter, "emits_response_item_events", False)
|
|
1623
|
+
)
|
|
1624
|
+
emitted_mode: str = ""
|
|
1625
|
+
_retry_after_compact = False
|
|
1626
|
+
_retry_after_api_error = False
|
|
1627
|
+
_retry_after_completed_items = False
|
|
1628
|
+
provider_stop_reason = ""
|
|
1629
|
+
received_message_stop = False
|
|
1630
|
+
response_outcome = "interrupted"
|
|
1631
|
+
|
|
1632
|
+
def _flush_thinking_block() -> None:
|
|
1633
|
+
nonlocal current_thinking
|
|
1634
|
+
if not current_thinking:
|
|
1635
|
+
return
|
|
1636
|
+
assistant_content.append(ThinkingBlock(thinking=current_thinking))
|
|
1637
|
+
current_thinking = ""
|
|
1638
|
+
|
|
1639
|
+
def _has_response_evidence() -> bool:
|
|
1640
|
+
return bool(
|
|
1641
|
+
current_text
|
|
1642
|
+
or current_thinking
|
|
1643
|
+
or assistant_content
|
|
1644
|
+
or tool_use_blocks
|
|
1645
|
+
or current_tool
|
|
1646
|
+
)
|
|
1647
|
+
|
|
1648
|
+
def _restore_completed_response_items() -> None:
|
|
1649
|
+
"""Discard only the unfinished item before replaying the request."""
|
|
1650
|
+
nonlocal current_text, current_thinking, current_tool
|
|
1651
|
+
del assistant_content[completed_content_count:]
|
|
1652
|
+
tool_use_blocks[:] = [
|
|
1653
|
+
block
|
|
1654
|
+
for block in assistant_content
|
|
1655
|
+
if isinstance(block, ToolUseBlock)
|
|
1656
|
+
]
|
|
1657
|
+
current_text = ""
|
|
1658
|
+
current_thinking = ""
|
|
1659
|
+
current_tool = {}
|
|
1660
|
+
|
|
1661
|
+
def _discarded_response_text_chars() -> int:
|
|
1662
|
+
suffix = sum(
|
|
1663
|
+
len(block.text)
|
|
1664
|
+
for block in assistant_content[completed_content_count:]
|
|
1665
|
+
if isinstance(block, TextBlock)
|
|
1666
|
+
)
|
|
1667
|
+
return suffix + len(current_text)
|
|
1668
|
+
|
|
1669
|
+
yield StreamModeEvent(mode="requesting")
|
|
1670
|
+
|
|
1671
|
+
stream = None
|
|
1672
|
+
try:
|
|
1673
|
+
# Wrap stream with timeout to avoid hanging indefinitely
|
|
1674
|
+
async def _stream_with_timeout():
|
|
1675
|
+
source = tracked_stream_message(
|
|
1676
|
+
params.api_adapter,
|
|
1677
|
+
messages=messages_for_api,
|
|
1678
|
+
system=full_system,
|
|
1679
|
+
tools=tool_schemas,
|
|
1680
|
+
config=model_config,
|
|
1681
|
+
cwd=params.tool_context.cwd,
|
|
1682
|
+
session_id=params.tool_context.session_id,
|
|
1683
|
+
)
|
|
1684
|
+
try:
|
|
1685
|
+
async for chunk in source:
|
|
1686
|
+
yield chunk
|
|
1687
|
+
finally:
|
|
1688
|
+
await source.aclose()
|
|
1689
|
+
|
|
1690
|
+
stream = _stream_with_timeout()
|
|
1691
|
+
chunk = None
|
|
1692
|
+
while True:
|
|
1693
|
+
try:
|
|
1694
|
+
chunk = await asyncio.wait_for(
|
|
1695
|
+
stream.__anext__(),
|
|
1696
|
+
timeout=model_config.timeout,
|
|
1697
|
+
)
|
|
1698
|
+
except StopAsyncIteration:
|
|
1699
|
+
response_outcome = "exhausted"
|
|
1700
|
+
break
|
|
1701
|
+
except asyncio.TimeoutError:
|
|
1702
|
+
response_outcome = "timeout"
|
|
1703
|
+
error_message = (
|
|
1704
|
+
f"API request timed out after {model_config.timeout}s"
|
|
1705
|
+
)
|
|
1706
|
+
retry = _schedule_stream_retry(error_message)
|
|
1707
|
+
if retry is not None:
|
|
1708
|
+
retry = replace(
|
|
1709
|
+
retry,
|
|
1710
|
+
discarded_text_chars=_discarded_response_text_chars(),
|
|
1711
|
+
)
|
|
1712
|
+
pending_stream_retry = retry
|
|
1713
|
+
turn_count -= 1
|
|
1714
|
+
if completed_content_count > 0:
|
|
1715
|
+
_restore_completed_response_items()
|
|
1716
|
+
_retry_after_completed_items = True
|
|
1717
|
+
else:
|
|
1718
|
+
_retry_after_api_error = True
|
|
1719
|
+
break
|
|
1720
|
+
yield ErrorEvent(
|
|
1721
|
+
message=error_message,
|
|
1722
|
+
recoverable=True,
|
|
1723
|
+
error_type="network",
|
|
1724
|
+
)
|
|
1725
|
+
return
|
|
1726
|
+
if chunk.stop_reason:
|
|
1727
|
+
provider_stop_reason = chunk.stop_reason
|
|
1728
|
+
if chunk.type == "text":
|
|
1729
|
+
_flush_thinking_block()
|
|
1730
|
+
if emitted_mode != "responding":
|
|
1731
|
+
yield StreamModeEvent(mode="responding")
|
|
1732
|
+
emitted_mode = "responding"
|
|
1733
|
+
yield StreamTextEvent(text=chunk.text)
|
|
1734
|
+
current_text += chunk.text
|
|
1735
|
+
|
|
1736
|
+
elif chunk.type == "thinking":
|
|
1737
|
+
if emitted_mode != "thinking":
|
|
1738
|
+
yield StreamModeEvent(mode="thinking")
|
|
1739
|
+
emitted_mode = "thinking"
|
|
1740
|
+
yield ThinkingEvent(text=chunk.text)
|
|
1741
|
+
current_thinking += chunk.text
|
|
1742
|
+
|
|
1743
|
+
elif chunk.type == "tool_use_start":
|
|
1744
|
+
_flush_thinking_block()
|
|
1745
|
+
if current_text:
|
|
1746
|
+
assistant_content.append(TextBlock(text=current_text))
|
|
1747
|
+
current_text = ""
|
|
1748
|
+
if emitted_mode != "tool-input":
|
|
1749
|
+
yield StreamModeEvent(mode="tool-input")
|
|
1750
|
+
emitted_mode = "tool-input"
|
|
1751
|
+
current_tool = {
|
|
1752
|
+
"id": chunk.tool_use_id,
|
|
1753
|
+
"name": chunk.tool_name,
|
|
1754
|
+
"input_json": "",
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
elif chunk.type == "tool_use_delta":
|
|
1758
|
+
current_tool["input_json"] = current_tool.get("input_json", "") + chunk.tool_input_json
|
|
1759
|
+
|
|
1760
|
+
elif chunk.type == "tool_use_end":
|
|
1761
|
+
input_json = chunk.tool_input_json or current_tool.get("input_json", "{}")
|
|
1762
|
+
try:
|
|
1763
|
+
tool_input = json.loads(input_json)
|
|
1764
|
+
except json.JSONDecodeError:
|
|
1765
|
+
tool_input = {}
|
|
1766
|
+
|
|
1767
|
+
block = ToolUseBlock(
|
|
1768
|
+
id=chunk.tool_use_id or current_tool.get("id", ""),
|
|
1769
|
+
name=chunk.tool_name or current_tool.get("name", ""),
|
|
1770
|
+
input=tool_input,
|
|
1771
|
+
)
|
|
1772
|
+
assistant_content.append(block)
|
|
1773
|
+
tool_use_blocks.append(block)
|
|
1774
|
+
|
|
1775
|
+
# Function arguments can finish just before their enclosing
|
|
1776
|
+
# output item. Do not persist or execute the call until the
|
|
1777
|
+
# item is complete, so a dropped stream cannot run it early.
|
|
1778
|
+
if (
|
|
1779
|
+
not item_aware_stream
|
|
1780
|
+
and _completed_tool_result(messages, block.id) is None
|
|
1781
|
+
):
|
|
1782
|
+
completed_content_count = len(assistant_content)
|
|
1783
|
+
announced_tool_use_ids.add(block.id)
|
|
1784
|
+
yield ToolUseEvent(
|
|
1785
|
+
tool_name=block.name,
|
|
1786
|
+
tool_input=block.input,
|
|
1787
|
+
tool_use_id=block.id,
|
|
1788
|
+
)
|
|
1789
|
+
current_tool = {}
|
|
1790
|
+
|
|
1791
|
+
elif chunk.type == "response_item_done":
|
|
1792
|
+
_flush_thinking_block()
|
|
1793
|
+
if current_text:
|
|
1794
|
+
assistant_content.append(TextBlock(text=current_text))
|
|
1795
|
+
current_text = ""
|
|
1796
|
+
completed_content_count = len(assistant_content)
|
|
1797
|
+
for block in tool_use_blocks:
|
|
1798
|
+
if (
|
|
1799
|
+
block.id in announced_tool_use_ids
|
|
1800
|
+
or _completed_tool_result(messages, block.id) is not None
|
|
1801
|
+
):
|
|
1802
|
+
continue
|
|
1803
|
+
announced_tool_use_ids.add(block.id)
|
|
1804
|
+
yield ToolUseEvent(
|
|
1805
|
+
tool_name=block.name,
|
|
1806
|
+
tool_input=block.input,
|
|
1807
|
+
tool_use_id=block.id,
|
|
1808
|
+
)
|
|
1809
|
+
|
|
1810
|
+
elif chunk.type == "message_start":
|
|
1811
|
+
if chunk.usage:
|
|
1812
|
+
_set_request_usage(chunk.usage)
|
|
1813
|
+
|
|
1814
|
+
elif chunk.type == "message_delta":
|
|
1815
|
+
if chunk.usage:
|
|
1816
|
+
_set_request_usage(chunk.usage)
|
|
1817
|
+
|
|
1818
|
+
elif chunk.type == "message_stop":
|
|
1819
|
+
received_message_stop = True
|
|
1820
|
+
if chunk.usage:
|
|
1821
|
+
_set_request_usage(chunk.usage)
|
|
1822
|
+
_flush_thinking_block()
|
|
1823
|
+
if current_text:
|
|
1824
|
+
assistant_content.append(TextBlock(text=current_text))
|
|
1825
|
+
current_text = ""
|
|
1826
|
+
completed_content_count = len(assistant_content)
|
|
1827
|
+
for block in tool_use_blocks:
|
|
1828
|
+
if (
|
|
1829
|
+
block.id in announced_tool_use_ids
|
|
1830
|
+
or _completed_tool_result(messages, block.id) is not None
|
|
1831
|
+
):
|
|
1832
|
+
continue
|
|
1833
|
+
announced_tool_use_ids.add(block.id)
|
|
1834
|
+
yield ToolUseEvent(
|
|
1835
|
+
tool_name=block.name,
|
|
1836
|
+
tool_input=block.input,
|
|
1837
|
+
tool_use_id=block.id,
|
|
1838
|
+
)
|
|
1839
|
+
|
|
1840
|
+
elif chunk.type == "error":
|
|
1841
|
+
response_outcome = "error"
|
|
1842
|
+
if _is_request_size_error(chunk.error):
|
|
1843
|
+
yield _request_size_error_event(chunk.error)
|
|
1844
|
+
return
|
|
1845
|
+
has_response_evidence = _has_response_evidence()
|
|
1846
|
+
is_ctx_err = _is_context_overflow_error(chunk.error)
|
|
1847
|
+
if (
|
|
1848
|
+
is_ctx_err
|
|
1849
|
+
and not has_response_evidence
|
|
1850
|
+
and _context_retries < _MAX_CONTEXT_RETRIES
|
|
1851
|
+
):
|
|
1852
|
+
_context_retries += 1
|
|
1853
|
+
parsed_limit = _parse_context_limit_from_error(chunk.error)
|
|
1854
|
+
if parsed_limit and parsed_limit > 0:
|
|
1855
|
+
context_window = parsed_limit
|
|
1856
|
+
logger.info("Parsed context window from error: %d", context_window)
|
|
1857
|
+
logger.warning(
|
|
1858
|
+
"Context overflow before output (retry %d/%d), preparing recovery",
|
|
1859
|
+
_context_retries, _MAX_CONTEXT_RETRIES,
|
|
1860
|
+
)
|
|
1861
|
+
yield StreamModeEvent(mode="compacting")
|
|
1862
|
+
compact_event = await _perform_compaction(trigger="overflow")
|
|
1863
|
+
if not compact_event:
|
|
1864
|
+
yield ErrorEvent(
|
|
1865
|
+
message=chunk.error,
|
|
1866
|
+
recoverable=True,
|
|
1867
|
+
error_type="context_overflow",
|
|
1868
|
+
)
|
|
1869
|
+
return
|
|
1870
|
+
yield compact_event
|
|
1871
|
+
messages_for_api = _messages_for_model(messages, params)
|
|
1872
|
+
turn_count -= 1
|
|
1873
|
+
_retry_after_compact = True
|
|
1874
|
+
break
|
|
1875
|
+
is_recoverable = (
|
|
1876
|
+
chunk.retryable
|
|
1877
|
+
if chunk.retryable is not None
|
|
1878
|
+
else _is_recoverable_api_error_message(chunk.error)
|
|
1879
|
+
)
|
|
1880
|
+
retry = (
|
|
1881
|
+
_schedule_stream_retry(
|
|
1882
|
+
chunk.error,
|
|
1883
|
+
connection_failed=chunk.connection_failed,
|
|
1884
|
+
retry_after=(
|
|
1885
|
+
chunk.retry_after
|
|
1886
|
+
if chunk.retry_after is not None
|
|
1887
|
+
else _retry_after_seconds_from_message(chunk.error)
|
|
1888
|
+
),
|
|
1889
|
+
)
|
|
1890
|
+
if is_recoverable
|
|
1891
|
+
else None
|
|
1892
|
+
)
|
|
1893
|
+
if retry is not None:
|
|
1894
|
+
retry = replace(
|
|
1895
|
+
retry,
|
|
1896
|
+
discarded_text_chars=_discarded_response_text_chars(),
|
|
1897
|
+
)
|
|
1898
|
+
pending_stream_retry = retry
|
|
1899
|
+
turn_count -= 1
|
|
1900
|
+
if completed_content_count > 0:
|
|
1901
|
+
_restore_completed_response_items()
|
|
1902
|
+
_retry_after_completed_items = True
|
|
1903
|
+
else:
|
|
1904
|
+
_retry_after_api_error = True
|
|
1905
|
+
break
|
|
1906
|
+
yield ErrorEvent(
|
|
1907
|
+
message=chunk.error,
|
|
1908
|
+
recoverable=is_recoverable,
|
|
1909
|
+
error_type="network" if is_recoverable else "",
|
|
1910
|
+
)
|
|
1911
|
+
return
|
|
1912
|
+
|
|
1913
|
+
except Exception as e:
|
|
1914
|
+
response_outcome = "exception"
|
|
1915
|
+
error_str = _format_exception_message(e)
|
|
1916
|
+
if (
|
|
1917
|
+
_is_connection_api_exception(e)
|
|
1918
|
+
or certificate_failure(e)
|
|
1919
|
+
or any(_is_http_transport_error(item) for item in _iter_exception_chain(e))
|
|
1920
|
+
):
|
|
1921
|
+
error_str = network_error_message(e, adapter_config)
|
|
1922
|
+
if _is_request_size_error(e):
|
|
1923
|
+
yield _request_size_error_event(error_str)
|
|
1924
|
+
return
|
|
1925
|
+
has_response_evidence = _has_response_evidence()
|
|
1926
|
+
is_context_error = _is_context_overflow_error(error_str)
|
|
1927
|
+
if (
|
|
1928
|
+
is_context_error
|
|
1929
|
+
and not has_response_evidence
|
|
1930
|
+
and _context_retries < _MAX_CONTEXT_RETRIES
|
|
1931
|
+
):
|
|
1932
|
+
_context_retries += 1
|
|
1933
|
+
parsed_limit = _parse_context_limit_from_error(error_str)
|
|
1934
|
+
if parsed_limit and parsed_limit > 0:
|
|
1935
|
+
context_window = parsed_limit
|
|
1936
|
+
logger.info("Parsed context window from error: %d", context_window)
|
|
1937
|
+
logger.warning(
|
|
1938
|
+
"Context overflow before output (retry %d/%d), preparing recovery",
|
|
1939
|
+
_context_retries, _MAX_CONTEXT_RETRIES,
|
|
1940
|
+
)
|
|
1941
|
+
yield StreamModeEvent(mode="compacting")
|
|
1942
|
+
compact_event = await _perform_compaction(trigger="overflow")
|
|
1943
|
+
if not compact_event:
|
|
1944
|
+
yield ErrorEvent(
|
|
1945
|
+
message=error_str,
|
|
1946
|
+
recoverable=True,
|
|
1947
|
+
error_type="context_overflow",
|
|
1948
|
+
)
|
|
1949
|
+
return
|
|
1950
|
+
yield compact_event
|
|
1951
|
+
messages_for_api = _messages_for_model(messages, params)
|
|
1952
|
+
turn_count -= 1
|
|
1953
|
+
continue
|
|
1954
|
+
is_network_error = _is_recoverable_api_exception(e)
|
|
1955
|
+
retry = (
|
|
1956
|
+
_schedule_stream_retry(
|
|
1957
|
+
error_str,
|
|
1958
|
+
connection_failed=_is_connection_api_exception(e),
|
|
1959
|
+
retry_after=_retry_after_seconds_from_exception(e),
|
|
1960
|
+
)
|
|
1961
|
+
if is_network_error
|
|
1962
|
+
else None
|
|
1963
|
+
)
|
|
1964
|
+
if retry is not None:
|
|
1965
|
+
retry = replace(
|
|
1966
|
+
retry,
|
|
1967
|
+
discarded_text_chars=_discarded_response_text_chars(),
|
|
1968
|
+
)
|
|
1969
|
+
pending_stream_retry = retry
|
|
1970
|
+
turn_count -= 1
|
|
1971
|
+
if completed_content_count > 0:
|
|
1972
|
+
_restore_completed_response_items()
|
|
1973
|
+
_retry_after_completed_items = True
|
|
1974
|
+
else:
|
|
1975
|
+
continue
|
|
1976
|
+
else:
|
|
1977
|
+
if is_network_error or certificate_failure(e):
|
|
1978
|
+
logger.warning("Query network failure: %s", error_str)
|
|
1979
|
+
else:
|
|
1980
|
+
logger.exception("Query loop failed")
|
|
1981
|
+
yield ErrorEvent(
|
|
1982
|
+
message=error_str,
|
|
1983
|
+
recoverable=is_network_error,
|
|
1984
|
+
error_type="network" if is_network_error else "",
|
|
1985
|
+
)
|
|
1986
|
+
return
|
|
1987
|
+
finally:
|
|
1988
|
+
if stream is not None:
|
|
1989
|
+
await stream.aclose()
|
|
1990
|
+
# Metadata only: never log prompt text, tool arguments, images,
|
|
1991
|
+
# endpoint credentials or response contents.
|
|
1992
|
+
logger.warning(
|
|
1993
|
+
"API response finished: session=%s, model=%s, outcome=%s, "
|
|
1994
|
+
"stop_reason=%r, terminal_received=%s, completed_blocks=%d, "
|
|
1995
|
+
"tool_calls=%d, retry=%s, input_tokens=%s",
|
|
1996
|
+
params.tool_context.session_id, effective_model, response_outcome,
|
|
1997
|
+
provider_stop_reason, received_message_stop, completed_content_count,
|
|
1998
|
+
len(tool_use_blocks), pending_stream_retry is not None,
|
|
1999
|
+
last_request_usage.get("total_input_tokens")
|
|
2000
|
+
if "total_input_tokens" in reported_usage_keys
|
|
2001
|
+
else last_request_usage.get("input_tokens"),
|
|
2002
|
+
)
|
|
2003
|
+
|
|
2004
|
+
if _retry_after_api_error:
|
|
2005
|
+
continue
|
|
2006
|
+
|
|
2007
|
+
# A completed stream gives the next agent turn a fresh reconnect budget.
|
|
2008
|
+
if not _retry_after_completed_items:
|
|
2009
|
+
stream_retry_state = ResponsesStreamRetryState()
|
|
2010
|
+
|
|
2011
|
+
if _retry_after_compact:
|
|
2012
|
+
continue
|
|
2013
|
+
|
|
2014
|
+
_flush_thinking_block()
|
|
2015
|
+
if current_text:
|
|
2016
|
+
assistant_content.append(TextBlock(text=current_text))
|
|
2017
|
+
|
|
2018
|
+
has_visible_or_actionable_output = any(
|
|
2019
|
+
isinstance(block, (TextBlock, ToolUseBlock))
|
|
2020
|
+
for block in assistant_content
|
|
2021
|
+
)
|
|
2022
|
+
response_limit_error = None
|
|
2023
|
+
if provider_stop_reason in {"max_tokens", "length", "model_context_window_exceeded"}:
|
|
2024
|
+
response_limit_error = ErrorEvent(
|
|
2025
|
+
message=(
|
|
2026
|
+
f"Model response was cut short ({provider_stop_reason}); it did not finish the task."
|
|
2027
|
+
if provider_stop_reason == "model_context_window_exceeded"
|
|
2028
|
+
else (
|
|
2029
|
+
f"Model response reached the output token limit (max_tokens={max_tokens}, "
|
|
2030
|
+
f"reason={provider_stop_reason}); the reply is incomplete. "
|
|
2031
|
+
"Ask the model to continue, or increase the maximum output tokens in model settings."
|
|
2032
|
+
)
|
|
2033
|
+
),
|
|
2034
|
+
recoverable=True,
|
|
2035
|
+
error_type=(
|
|
2036
|
+
"context_overflow" if provider_stop_reason == "model_context_window_exceeded"
|
|
2037
|
+
else "output_limit"
|
|
2038
|
+
),
|
|
2039
|
+
)
|
|
2040
|
+
|
|
2041
|
+
if has_visible_or_actionable_output:
|
|
2042
|
+
assistant_usage = {
|
|
2043
|
+
key: last_request_usage[key]
|
|
2044
|
+
for key in reported_usage_keys
|
|
2045
|
+
}
|
|
2046
|
+
assistant_msg = create_assistant_message(
|
|
2047
|
+
content=assistant_content,
|
|
2048
|
+
parent_uuid=messages[-1].uuid if messages else None,
|
|
2049
|
+
reply_to_uuid=params.reply_to_uuid,
|
|
2050
|
+
usage=assistant_usage,
|
|
2051
|
+
)
|
|
2052
|
+
messages.append(assistant_msg)
|
|
2053
|
+
# Keep the caller-owned projection current at each completed model
|
|
2054
|
+
# response. A cancellation during a later tool turn can then
|
|
2055
|
+
# preserve this committed prefix without duplicating its text.
|
|
2056
|
+
params.messages[:] = messages
|
|
2057
|
+
_awaiting_compact_resume = False
|
|
2058
|
+
else:
|
|
2059
|
+
if _retry_after_completed_items:
|
|
2060
|
+
continue
|
|
2061
|
+
if _awaiting_compact_resume and _compact_resume_retries < _MAX_COMPACT_RESUME_RETRIES:
|
|
2062
|
+
_compact_resume_retries += 1
|
|
2063
|
+
logger.warning(
|
|
2064
|
+
"Empty response after compact, retrying (%d/%d)",
|
|
2065
|
+
_compact_resume_retries, _MAX_COMPACT_RESUME_RETRIES,
|
|
2066
|
+
)
|
|
2067
|
+
messages.append(
|
|
2068
|
+
create_user_message(content=_COMPACT_EMPTY_RESPONSE_RETRY_PROMPT)
|
|
2069
|
+
)
|
|
2070
|
+
params.messages[:] = messages
|
|
2071
|
+
turn_count -= 1
|
|
2072
|
+
continue
|
|
2073
|
+
if response_limit_error is not None:
|
|
2074
|
+
yield response_limit_error
|
|
2075
|
+
yield _turn_complete_event(provider_stop_reason, messages)
|
|
2076
|
+
params.messages[:] = messages
|
|
2077
|
+
return
|
|
2078
|
+
logger.warning("Empty response, ending turn (awaiting_resume=%s)", _awaiting_compact_resume)
|
|
2079
|
+
if _awaiting_compact_resume:
|
|
2080
|
+
yield ErrorEvent(
|
|
2081
|
+
message=(
|
|
2082
|
+
"Model returned no visible content after emergency compact; "
|
|
2083
|
+
"the compacted context was preserved."
|
|
2084
|
+
),
|
|
2085
|
+
recoverable=True,
|
|
2086
|
+
error_type="empty_response",
|
|
2087
|
+
)
|
|
2088
|
+
yield _turn_complete_event("empty_response", messages)
|
|
2089
|
+
params.messages[:] = messages
|
|
2090
|
+
return
|
|
2091
|
+
|
|
2092
|
+
if not tool_use_blocks:
|
|
2093
|
+
# A message can arrive while the model is streaming a text-only
|
|
2094
|
+
# response. Continue this foreground operation instead of ending
|
|
2095
|
+
# it and leaving that guidance stranded for a future prompt.
|
|
2096
|
+
steering_messages = _drain_steering_messages()
|
|
2097
|
+
if steering_messages:
|
|
2098
|
+
yield SteeringAppliedEvent(count=len(steering_messages))
|
|
2099
|
+
continue
|
|
2100
|
+
if _retry_after_completed_items:
|
|
2101
|
+
continue
|
|
2102
|
+
if response_limit_error is not None:
|
|
2103
|
+
yield response_limit_error
|
|
2104
|
+
yield _turn_complete_event(provider_stop_reason, messages)
|
|
2105
|
+
params.messages[:] = messages
|
|
2106
|
+
return
|
|
2107
|
+
yield _turn_complete_event("end_turn", messages)
|
|
2108
|
+
params.messages[:] = messages
|
|
2109
|
+
return
|
|
2110
|
+
|
|
2111
|
+
approved_blocks: list[ToolUseBlock] = []
|
|
2112
|
+
seen_tool_use_ids: set[str] = set()
|
|
2113
|
+
|
|
2114
|
+
for block in tool_use_blocks:
|
|
2115
|
+
if block.id in seen_tool_use_ids:
|
|
2116
|
+
logger.warning(
|
|
2117
|
+
"Suppressing duplicate tool call in one response: %s",
|
|
2118
|
+
block.id,
|
|
2119
|
+
)
|
|
2120
|
+
continue
|
|
2121
|
+
seen_tool_use_ids.add(block.id)
|
|
2122
|
+
|
|
2123
|
+
completed_result = _completed_tool_result(messages[:-1], block.id)
|
|
2124
|
+
if completed_result is not None:
|
|
2125
|
+
# A retried prompt includes already executed calls. Providers
|
|
2126
|
+
# can still replay the same call ID; copy its result into the
|
|
2127
|
+
# new protocol position without repeating
|
|
2128
|
+
# the side effect or surfacing a second tool lifecycle in UI.
|
|
2129
|
+
messages.append(
|
|
2130
|
+
create_tool_result_message(
|
|
2131
|
+
tool_use_id=block.id,
|
|
2132
|
+
result=completed_result.content,
|
|
2133
|
+
is_error=completed_result.is_error,
|
|
2134
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
2135
|
+
)
|
|
2136
|
+
)
|
|
2137
|
+
params.messages[:] = messages
|
|
2138
|
+
logger.warning(
|
|
2139
|
+
"Reused completed result for replayed tool call: %s",
|
|
2140
|
+
block.id,
|
|
2141
|
+
)
|
|
2142
|
+
continue
|
|
2143
|
+
|
|
2144
|
+
tool = _find_tool(params.tools, block.name)
|
|
2145
|
+
|
|
2146
|
+
if tool is not None:
|
|
2147
|
+
normalization = _normalize_and_record_tool_input(tool, block)
|
|
2148
|
+
if normalization.error:
|
|
2149
|
+
result = f"Validation error: {normalization.error}"
|
|
2150
|
+
msg = create_tool_result_message(
|
|
2151
|
+
tool_use_id=block.id,
|
|
2152
|
+
result=result,
|
|
2153
|
+
is_error=True,
|
|
2154
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
2155
|
+
)
|
|
2156
|
+
messages.append(msg)
|
|
2157
|
+
params.messages[:] = messages
|
|
2158
|
+
yield ToolResultEvent(
|
|
2159
|
+
tool_use_id=block.id,
|
|
2160
|
+
tool_name=block.name,
|
|
2161
|
+
result=result,
|
|
2162
|
+
is_error=True,
|
|
2163
|
+
tool_input=block.input,
|
|
2164
|
+
)
|
|
2165
|
+
continue
|
|
2166
|
+
if normalization.corrections:
|
|
2167
|
+
block = block.model_copy(
|
|
2168
|
+
update={"input": normalization.value},
|
|
2169
|
+
deep=True,
|
|
2170
|
+
)
|
|
2171
|
+
|
|
2172
|
+
# Tool-schema filtering is advisory: some providers can replay or
|
|
2173
|
+
# synthesize calls to tools that were not included in this request.
|
|
2174
|
+
# Enforce plan mode again at the final execution boundary, before
|
|
2175
|
+
# permission plumbing or remembered allow rules can approve it.
|
|
2176
|
+
if (
|
|
2177
|
+
tool is not None
|
|
2178
|
+
and _plan_mode_is_active(params)
|
|
2179
|
+
and not tool.is_read_only
|
|
2180
|
+
):
|
|
2181
|
+
reason = "Plan mode: write operations are not allowed"
|
|
2182
|
+
msg = create_tool_result_message(
|
|
2183
|
+
tool_use_id=block.id,
|
|
2184
|
+
result=f"Permission denied: {reason}",
|
|
2185
|
+
is_error=True,
|
|
2186
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
2187
|
+
)
|
|
2188
|
+
messages.append(msg)
|
|
2189
|
+
params.messages[:] = messages
|
|
2190
|
+
yield ToolResultEvent(
|
|
2191
|
+
tool_use_id=block.id,
|
|
2192
|
+
tool_name=block.name,
|
|
2193
|
+
result=f"Permission denied: {reason}",
|
|
2194
|
+
is_error=True,
|
|
2195
|
+
tool_input=block.input,
|
|
2196
|
+
)
|
|
2197
|
+
continue
|
|
2198
|
+
|
|
2199
|
+
# Semantic validation must happen before permission checks. Some
|
|
2200
|
+
# tools derive permission targets by parsing their input; treating
|
|
2201
|
+
# a parse failure as DENY makes malformed input look like a user or
|
|
2202
|
+
# filesystem permission failure instead of a validation error.
|
|
2203
|
+
if tool is not None and (block.name not in exposed_names or not tool.is_available(params.tool_context)):
|
|
2204
|
+
result = "Tool is not loaded or is unavailable. Use ToolSearch and wait for the next response before calling it."
|
|
2205
|
+
messages.append(create_tool_result_message(
|
|
2206
|
+
tool_use_id=block.id, result=result, is_error=True,
|
|
2207
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
2208
|
+
))
|
|
2209
|
+
params.messages[:] = messages
|
|
2210
|
+
yield ToolResultEvent(tool_use_id=block.id, tool_name=block.name,
|
|
2211
|
+
result=result, is_error=True, tool_input=block.input)
|
|
2212
|
+
continue
|
|
2213
|
+
if tool is not None:
|
|
2214
|
+
validation_error = await tool.validate_input(block.input)
|
|
2215
|
+
if validation_error:
|
|
2216
|
+
result = f"Validation error: {validation_error}"
|
|
2217
|
+
msg = create_tool_result_message(
|
|
2218
|
+
tool_use_id=block.id,
|
|
2219
|
+
result=result,
|
|
2220
|
+
is_error=True,
|
|
2221
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
2222
|
+
)
|
|
2223
|
+
messages.append(msg)
|
|
2224
|
+
params.messages[:] = messages
|
|
2225
|
+
yield ToolResultEvent(
|
|
2226
|
+
tool_use_id=block.id,
|
|
2227
|
+
tool_name=block.name,
|
|
2228
|
+
result=result,
|
|
2229
|
+
is_error=True,
|
|
2230
|
+
tool_input=block.input,
|
|
2231
|
+
)
|
|
2232
|
+
continue
|
|
2233
|
+
|
|
2234
|
+
if not params.permission_manager or not params.permission_queue:
|
|
2235
|
+
approved_blocks.append(block)
|
|
2236
|
+
continue
|
|
2237
|
+
|
|
2238
|
+
if not tool:
|
|
2239
|
+
approved_blocks.append(block)
|
|
2240
|
+
continue
|
|
2241
|
+
|
|
2242
|
+
effective_block = block.model_copy(deep=True)
|
|
2243
|
+
tool_perm = await tool.check_permissions(
|
|
2244
|
+
effective_block.input,
|
|
2245
|
+
params.tool_context,
|
|
2246
|
+
)
|
|
2247
|
+
if tool_perm.updated_input is not None:
|
|
2248
|
+
effective_block.input = tool_perm.updated_input
|
|
2249
|
+
|
|
2250
|
+
# Tool-specific normalization may expose derived permission targets
|
|
2251
|
+
# (for example, every path embedded in an ApplyPatch payload). Apply
|
|
2252
|
+
# policy rules to that normalized input rather than the model's raw
|
|
2253
|
+
# arguments so path-scoped rules cannot miss multi-file edits.
|
|
2254
|
+
rule_perm = params.permission_manager.check(tool, effective_block.input)
|
|
2255
|
+
|
|
2256
|
+
permission_key = tool_perm.permission_key or tool.get_permission_key(effective_block.input)
|
|
2257
|
+
|
|
2258
|
+
bypass_active = (
|
|
2259
|
+
hasattr(params.permission_manager, "mode")
|
|
2260
|
+
and params.permission_manager.mode == PermissionMode.BYPASS
|
|
2261
|
+
)
|
|
2262
|
+
if bypass_active and tool_perm.behavior == PermissionBehavior.ASK:
|
|
2263
|
+
tool_perm = PermissionResult(
|
|
2264
|
+
behavior=PermissionBehavior.ALLOW,
|
|
2265
|
+
updated_input=tool_perm.updated_input,
|
|
2266
|
+
permission_key=permission_key,
|
|
2267
|
+
)
|
|
2268
|
+
|
|
2269
|
+
merged_perm = _merge_permission_results(
|
|
2270
|
+
rule_perm,
|
|
2271
|
+
PermissionResult(
|
|
2272
|
+
behavior=tool_perm.behavior,
|
|
2273
|
+
reason=tool_perm.reason,
|
|
2274
|
+
updated_input=tool_perm.updated_input,
|
|
2275
|
+
permission_key=permission_key,
|
|
2276
|
+
),
|
|
2277
|
+
)
|
|
2278
|
+
|
|
2279
|
+
explicit_allow = params.permission_manager.has_explicit_allow(
|
|
2280
|
+
tool,
|
|
2281
|
+
effective_block.input,
|
|
2282
|
+
permission_key=permission_key,
|
|
2283
|
+
)
|
|
2284
|
+
if (
|
|
2285
|
+
explicit_allow
|
|
2286
|
+
and merged_perm.behavior != PermissionBehavior.DENY
|
|
2287
|
+
):
|
|
2288
|
+
approved_blocks.append(effective_block)
|
|
2289
|
+
continue
|
|
2290
|
+
|
|
2291
|
+
ai_review_active = (
|
|
2292
|
+
hasattr(params.permission_manager, "mode")
|
|
2293
|
+
and params.permission_manager.mode == PermissionMode.AI_REVIEW
|
|
2294
|
+
)
|
|
2295
|
+
if (
|
|
2296
|
+
ai_review_active
|
|
2297
|
+
and params.ai_reviewer is not None
|
|
2298
|
+
and merged_perm.behavior == PermissionBehavior.ASK
|
|
2299
|
+
and rule_perm.reason is None
|
|
2300
|
+
and tool_perm.behavior != PermissionBehavior.ASK
|
|
2301
|
+
):
|
|
2302
|
+
merged_perm = await params.ai_reviewer.review(
|
|
2303
|
+
AiReviewRequest(
|
|
2304
|
+
tool=tool,
|
|
2305
|
+
tool_input=effective_block.input,
|
|
2306
|
+
context=params.tool_context,
|
|
2307
|
+
permission_key=permission_key,
|
|
2308
|
+
reason=merged_perm.reason,
|
|
2309
|
+
)
|
|
2310
|
+
)
|
|
2311
|
+
|
|
2312
|
+
if merged_perm.behavior == PermissionBehavior.DENY:
|
|
2313
|
+
reason = merged_perm.reason or "denied by policy"
|
|
2314
|
+
msg = create_tool_result_message(
|
|
2315
|
+
tool_use_id=effective_block.id,
|
|
2316
|
+
result=f"Permission denied: {reason}",
|
|
2317
|
+
is_error=True,
|
|
2318
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
2319
|
+
)
|
|
2320
|
+
messages.append(msg)
|
|
2321
|
+
params.messages[:] = messages
|
|
2322
|
+
yield ToolResultEvent(
|
|
2323
|
+
tool_use_id=effective_block.id,
|
|
2324
|
+
tool_name=effective_block.name,
|
|
2325
|
+
result=f"Permission denied: {reason}",
|
|
2326
|
+
is_error=True,
|
|
2327
|
+
tool_input=effective_block.input,
|
|
2328
|
+
)
|
|
2329
|
+
elif merged_perm.behavior == PermissionBehavior.ASK:
|
|
2330
|
+
yield PermissionRequestEvent(
|
|
2331
|
+
tool_name=effective_block.name,
|
|
2332
|
+
tool_input=effective_block.input,
|
|
2333
|
+
tool_use_id=effective_block.id,
|
|
2334
|
+
reason=merged_perm.reason,
|
|
2335
|
+
permission_key=permission_key,
|
|
2336
|
+
)
|
|
2337
|
+
response = buffered_permission_responses.pop(effective_block.id, None)
|
|
2338
|
+
if response is None:
|
|
2339
|
+
expected_ids = {candidate.id for candidate in tool_use_blocks}
|
|
2340
|
+
try:
|
|
2341
|
+
while True:
|
|
2342
|
+
candidate = await params.permission_queue.get()
|
|
2343
|
+
if not isinstance(candidate, PermissionResponseEvent):
|
|
2344
|
+
continue
|
|
2345
|
+
if candidate.agent_id is not None:
|
|
2346
|
+
continue
|
|
2347
|
+
if candidate.tool_use_id == effective_block.id:
|
|
2348
|
+
response = candidate
|
|
2349
|
+
break
|
|
2350
|
+
# Preserve an answer for another tool in this same
|
|
2351
|
+
# assistant response; discard IDs from an older turn.
|
|
2352
|
+
if candidate.tool_use_id in expected_ids:
|
|
2353
|
+
buffered_permission_responses[candidate.tool_use_id] = candidate
|
|
2354
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
2355
|
+
interrupted = _append_missing_tool_results(
|
|
2356
|
+
messages,
|
|
2357
|
+
tool_use_blocks,
|
|
2358
|
+
assistant_msg.uuid,
|
|
2359
|
+
)
|
|
2360
|
+
if interrupted:
|
|
2361
|
+
params.messages[:] = messages
|
|
2362
|
+
raise
|
|
2363
|
+
assert response is not None
|
|
2364
|
+
if response.allowed:
|
|
2365
|
+
if response.always_allow:
|
|
2366
|
+
params.permission_manager.add_allow_rule(permission_key)
|
|
2367
|
+
approved_blocks.append(effective_block)
|
|
2368
|
+
else:
|
|
2369
|
+
if response.feedback:
|
|
2370
|
+
denial_msg = (
|
|
2371
|
+
f"Permission denied by user with feedback: {response.feedback}\n"
|
|
2372
|
+
"Adjust your approach based on this feedback."
|
|
2373
|
+
)
|
|
2374
|
+
else:
|
|
2375
|
+
denial_msg = (
|
|
2376
|
+
"Permission denied by user. Do not retry the same "
|
|
2377
|
+
"tool call. Explain what you wanted to do and ask "
|
|
2378
|
+
"for guidance."
|
|
2379
|
+
)
|
|
2380
|
+
msg = create_tool_result_message(
|
|
2381
|
+
tool_use_id=effective_block.id,
|
|
2382
|
+
result=denial_msg,
|
|
2383
|
+
is_error=True,
|
|
2384
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
2385
|
+
)
|
|
2386
|
+
messages.append(msg)
|
|
2387
|
+
params.messages[:] = messages
|
|
2388
|
+
yield ToolResultEvent(
|
|
2389
|
+
tool_use_id=effective_block.id,
|
|
2390
|
+
tool_name=effective_block.name,
|
|
2391
|
+
result=denial_msg,
|
|
2392
|
+
is_error=True,
|
|
2393
|
+
tool_input=effective_block.input,
|
|
2394
|
+
)
|
|
2395
|
+
else:
|
|
2396
|
+
approved_blocks.append(effective_block)
|
|
2397
|
+
|
|
2398
|
+
if approved_blocks:
|
|
2399
|
+
yield StreamModeEvent(mode="tool-running")
|
|
2400
|
+
should_end_turn_after_tools = False
|
|
2401
|
+
try:
|
|
2402
|
+
async for item in _run_tools(
|
|
2403
|
+
approved_blocks,
|
|
2404
|
+
assistant_msg,
|
|
2405
|
+
params.tools,
|
|
2406
|
+
params.tool_context,
|
|
2407
|
+
params.hook_manager,
|
|
2408
|
+
params.tool_call_timeout,
|
|
2409
|
+
):
|
|
2410
|
+
if isinstance(item, tuple):
|
|
2411
|
+
msgs, event = item
|
|
2412
|
+
messages.extend(msgs)
|
|
2413
|
+
params.messages[:] = messages
|
|
2414
|
+
if event.tool_name == "SwitchMode" and not event.is_error:
|
|
2415
|
+
should_end_turn_after_tools = True
|
|
2416
|
+
yield event
|
|
2417
|
+
else:
|
|
2418
|
+
# Mid-execution event (e.g. ChoiceRequestEvent)
|
|
2419
|
+
yield item
|
|
2420
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
2421
|
+
# A user interrupt or session shutdown can cancel a tool task
|
|
2422
|
+
# after the assistant call was committed but before its result
|
|
2423
|
+
# reached the projection. Close every remaining call so a
|
|
2424
|
+
# later Responses request cannot be rejected for an orphan.
|
|
2425
|
+
interrupted = _append_missing_tool_results(
|
|
2426
|
+
messages,
|
|
2427
|
+
approved_blocks,
|
|
2428
|
+
assistant_msg.uuid,
|
|
2429
|
+
)
|
|
2430
|
+
if interrupted:
|
|
2431
|
+
params.messages[:] = messages
|
|
2432
|
+
logger.warning(
|
|
2433
|
+
"Closed %d tool call(s) after tool execution cancellation",
|
|
2434
|
+
len(interrupted),
|
|
2435
|
+
)
|
|
2436
|
+
raise
|
|
2437
|
+
|
|
2438
|
+
if should_end_turn_after_tools:
|
|
2439
|
+
yield _turn_complete_event("mode_switch_requested", messages)
|
|
2440
|
+
params.messages[:] = messages
|
|
2441
|
+
return
|
|
2442
|
+
|
|
2443
|
+
if params.max_turns and turn_count >= params.max_turns:
|
|
2444
|
+
yield _turn_complete_event("max_turns_reached", messages)
|
|
2445
|
+
params.messages[:] = messages
|
|
2446
|
+
return
|