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.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. 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