monkeybot 2.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2374 @@
1
+ """Owned agent loop: provider streaming, inspectors, tool dispatch, event emission."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import dataclasses
7
+ import json
8
+ import logging
9
+ import os
10
+ import shutil
11
+ from collections.abc import AsyncIterator, Sequence
12
+ from contextlib import aclosing
13
+ from pathlib import Path
14
+ from typing import Any, Protocol, cast, runtime_checkable
15
+
16
+ from monkeybot.core.attachments.catalog import SessionAttachmentCatalog
17
+ from monkeybot.core.attachments.freeze import freeze_attachments_in_history
18
+ from monkeybot.core.attachments.store import AttachmentStore
19
+ from monkeybot.core.context import (
20
+ MCP_REGISTRY_MUTATING_TOOLS,
21
+ TurnContext,
22
+ refresh_memory_index,
23
+ refresh_tools_after_mcp_change,
24
+ )
25
+ from monkeybot.core.context.epoch import ContextEpochTracker, EpochAdmit, fingerprint_text
26
+ from monkeybot.core.context.memory_prompt import (
27
+ MemoryPromptSelection,
28
+ memory_index_fingerprint,
29
+ prepare_memory_for_prompt,
30
+ )
31
+ from monkeybot.core.context.tool_output_policy import resolve_tool_budget
32
+ from monkeybot.core.context.tool_result_ingress import summarize_tool_result_text
33
+ from monkeybot.core.context.tool_shapers import (
34
+ exceeds_tool_output_budget,
35
+ shape_tool_text,
36
+ )
37
+ from monkeybot.core.hooks import HookEvent, HookManager, HookPayload
38
+ from monkeybot.core.llm.provider import (
39
+ Done,
40
+ Message,
41
+ Provider,
42
+ ProviderCallHints,
43
+ TextDelta,
44
+ ToolCall,
45
+ UsageEvent,
46
+ cache_retention_from_env,
47
+ provider_count_input_tokens,
48
+ provider_stream,
49
+ )
50
+ from monkeybot.core.llm.usage import Usage
51
+ from monkeybot.core.logging_utils import kv
52
+ from monkeybot.core.messages import convert_to_provider, transform_context
53
+ from monkeybot.core.persistence.backends import HistoryStore
54
+ from monkeybot.core.persistence.transcript import TranscriptWriter
55
+ from monkeybot.core.prompts.prompt import (
56
+ RUNTIME_NOTES_HEADING,
57
+ compose_stable_baseline,
58
+ compose_volatile_tail,
59
+ compose_volatile_tail_parts,
60
+ latest_user_message_text,
61
+ )
62
+ from monkeybot.core.runtime.context_budget import (
63
+ ContextBudgeter,
64
+ compute_context_pressure_tier,
65
+ summarization_trigger_ratio_from_env,
66
+ )
67
+ from monkeybot.core.runtime.provider_stream_mapper import ProviderStreamMapper
68
+ from monkeybot.core.tools.inspector import InspectorToolCall, ToolInspector
69
+ from monkeybot.core.tools.permission import remember_always_approval, resource_for_call
70
+ from monkeybot.core.tools.types import ToolExecutionResult
71
+ from monkeybot.core.types.content_blocks import (
72
+ ContentBlock,
73
+ File,
74
+ Image,
75
+ Text,
76
+ ToolRequest,
77
+ ToolResponse,
78
+ )
79
+ from monkeybot.core.types.content_blocks import Thinking as ThinkingBlock
80
+ from monkeybot.core.types.types_tools import ToolDef
81
+ from monkeybot.providers.pricing import estimate_cost
82
+
83
+ from .events import (
84
+ AgentEvent,
85
+ AttachmentDescriptorEvent,
86
+ ContextEpochStarted,
87
+ ContextSummarized,
88
+ ContextSummarizing,
89
+ Error,
90
+ ImageBlock,
91
+ SystemContextUpdated,
92
+ SystemPromptSnapshot,
93
+ Thinking,
94
+ ToolCallResult,
95
+ ToolCallStarted,
96
+ ToolConfirmationRequestEvent,
97
+ TurnComplete,
98
+ UsageTotals,
99
+ UserSteered,
100
+ )
101
+ from .input_admission import InputAdmission, preview_text
102
+
103
+ logger = logging.getLogger("monkeybot.core.runtime.loop")
104
+
105
+
106
+ async def _await_history_write(task: asyncio.Task[None] | None) -> None:
107
+ """Await a backgrounded history write, logging (never raising) on failure.
108
+
109
+ The final assistant message is persisted off the token-streaming path, but it
110
+ MUST land before any later ``history.load``/``reset`` (e.g. attachment freeze)
111
+ so the row is not lost. Callers await this at the turn tail.
112
+ """
113
+ if task is None:
114
+ return
115
+ try:
116
+ await task
117
+ except Exception:
118
+ logger.exception("background assistant history write failed")
119
+
120
+
121
+ def _effective_max_turns(max_turns: int | None) -> int:
122
+ if max_turns is not None:
123
+ return max_turns
124
+ return int(os.getenv("MAX_TURNS", "50"))
125
+
126
+
127
+ def _effective_doom_loop_threshold() -> int:
128
+ """Consecutive identical tool calls before doom-loop recovery.
129
+
130
+ Default 3. Set ``DOOM_LOOP_THRESHOLD=0`` to disable. Applies whether the
131
+ calls succeed or fail — identical name+args with no progress is the signal.
132
+ """
133
+ raw = os.getenv("DOOM_LOOP_THRESHOLD")
134
+ if raw is None or raw.strip() == "":
135
+ return 3
136
+ try:
137
+ return max(0, int(raw))
138
+ except ValueError:
139
+ logger.warning(
140
+ "invalid DOOM_LOOP_THRESHOLD %s; using default 3",
141
+ kv(value=raw),
142
+ )
143
+ return 3
144
+
145
+
146
+ def _tool_call_fingerprint(name: str, args: dict[str, Any]) -> str:
147
+ return f"{name}:{json.dumps(args, sort_keys=True, default=str)}"
148
+
149
+
150
+ def _doom_loop_texts(tool_name: str, threshold: int) -> tuple[str, str]:
151
+ """Return ``(error_event_text, recovery_system_note)``."""
152
+ error = (
153
+ f"Doom loop detected: tool {tool_name!r} called identically "
154
+ f"{threshold} times"
155
+ )
156
+ note = (
157
+ f"[Harness] {error}. Do not repeat the same call. Explain the situation "
158
+ "to the user and take a different approach (different tool or args)."
159
+ )
160
+ return error, note
161
+
162
+
163
+ def _should_reject_tool_batch(
164
+ calls: Sequence[ToolCall],
165
+ *,
166
+ truncated: bool,
167
+ ) -> bool:
168
+ """True when no tool in this provider turn is safe to execute.
169
+
170
+ A length/max-tokens stop means every tool call may carry silently
171
+ incomplete args. Also reject when every call already has ``parse_error``
172
+ (incomplete JSON).
173
+ """
174
+ if not calls:
175
+ return False
176
+ if truncated:
177
+ return True
178
+ return all(c.parse_error for c in calls)
179
+
180
+
181
+ def _rejected_tool_batch_error(
182
+ call: ToolCall,
183
+ *,
184
+ truncated: bool,
185
+ ) -> str:
186
+ if truncated:
187
+ return (
188
+ f'Tool call "{call.name}" was not executed: the response hit the '
189
+ "output token limit, so its arguments may be truncated. Re-issue "
190
+ "the tool call with complete arguments."
191
+ )
192
+ return call.parse_error or (
193
+ f'Tool call "{call.name}" was not executed: incomplete tool arguments '
194
+ "in this batch. Re-issue with complete arguments."
195
+ )
196
+
197
+
198
+ def _doom_loop_exempt_names(tools: Sequence[ToolDef]) -> frozenset[str]:
199
+ """Tool names marked ``doom_loop_exempt`` (identical-args polling is expected)."""
200
+ return frozenset(t.name for t in tools if t.doom_loop_exempt)
201
+
202
+
203
+ @dataclasses.dataclass
204
+ class _DoomLoopTracker:
205
+ """Tracks consecutive identical tool calls within one user message.
206
+
207
+ Fingerprint is tool name + args. Outcome (ok vs error) does not reset the
208
+ streak — successful no-progress loops (e.g. repeated screenshots) must trip
209
+ the same guard as repeated failures.
210
+
211
+ Tools in ``exempt_names`` (from ``ToolDef.doom_loop_exempt``) are ignored so
212
+ legitimate polling (e.g. ``loop_status``) does not force a recovery turn.
213
+
214
+ After a recovery turn is consumed, the tracker re-arms so a later streak in
215
+ the same user message can trigger again. While ``triggered`` is set (between
216
+ detection and ``consume_recovery``), further ``record`` calls are ignored so
217
+ the same streak cannot re-fire mid-batch.
218
+ """
219
+
220
+ threshold: int
221
+ exempt_names: frozenset[str] = dataclasses.field(default_factory=frozenset)
222
+ streak_fp: str | None = None
223
+ streak_count: int = 0
224
+ triggered: bool = False
225
+ force_no_tools: bool = False
226
+ recovery_note: str | None = None
227
+ triggered_tool: str | None = None
228
+ _pending_error: str | None = None
229
+
230
+ def record(self, name: str, args: dict[str, Any]) -> None:
231
+ if self.threshold <= 0 or self.triggered or name in self.exempt_names:
232
+ return
233
+ fp = _tool_call_fingerprint(name, args)
234
+ if fp == self.streak_fp:
235
+ self.streak_count += 1
236
+ else:
237
+ self.streak_fp = fp
238
+ self.streak_count = 1
239
+ if self.streak_count < self.threshold:
240
+ return
241
+ self.triggered = True
242
+ self.triggered_tool = name
243
+ error, note = _doom_loop_texts(name, self.threshold)
244
+ self._pending_error = error
245
+ self.force_no_tools = True
246
+ self.recovery_note = note
247
+
248
+ def take_error(self) -> str | None:
249
+ msg = self._pending_error
250
+ self._pending_error = None
251
+ return msg
252
+
253
+ def consume_recovery(self) -> tuple[bool, str | None]:
254
+ force = self.force_no_tools
255
+ note = self.recovery_note
256
+ self.force_no_tools = False
257
+ self.recovery_note = None
258
+ if force:
259
+ # Re-arm so a second identical streak later in this user message
260
+ # can trigger another recovery turn.
261
+ self.triggered = False
262
+ self.streak_fp = None
263
+ self.streak_count = 0
264
+ self.triggered_tool = None
265
+ return force, note
266
+
267
+
268
+ def _usage_to_totals(u: Usage) -> UsageTotals:
269
+ return UsageTotals(
270
+ input_tokens=u.input_tokens,
271
+ output_tokens=u.output_tokens,
272
+ cached_tokens=u.cached_tokens,
273
+ cache_read_tokens=u.cache_read_tokens,
274
+ cache_creation_tokens=u.cache_creation_tokens,
275
+ cost_usd=u.cost_usd,
276
+ duration_ms=u.duration_ms,
277
+ estimated_prompt_tokens=u.estimated_prompt_tokens,
278
+ )
279
+
280
+
281
+ def _merge_usage_event(usage: Usage, ev: UsageEvent) -> None:
282
+ usage.input_tokens += ev.input_tokens
283
+ usage.output_tokens += ev.output_tokens
284
+ usage.cached_tokens += ev.cached_tokens
285
+ usage.cache_read_tokens += ev.cache_read_tokens
286
+ usage.cache_creation_tokens += ev.cache_creation_tokens
287
+
288
+
289
+ def _normalize_user_content(user_content: str | list[ContentBlock]) -> list[ContentBlock]:
290
+ if isinstance(user_content, str):
291
+ return [Text(text=user_content)]
292
+ return list(user_content)
293
+
294
+
295
+ def _user_text_from_content(blocks: Sequence[ContentBlock]) -> str:
296
+ return " ".join(
297
+ b.text.strip() for b in blocks if isinstance(b, Text) and b.text.strip()
298
+ )
299
+
300
+
301
+ def _blocks_to_sse_summary(blocks: Sequence[ContentBlock]) -> str:
302
+ parts: list[str] = []
303
+ for block in blocks:
304
+ if isinstance(block, Text):
305
+ parts.append(block.text)
306
+ elif isinstance(block, Image):
307
+ meta = block.metadata or {}
308
+ att_id = meta.get("attachment_id", "")
309
+ parts.append(f"[loaded image {att_id}]" if att_id else "[loaded image]")
310
+ elif isinstance(block, File):
311
+ parts.append("[loaded pdf]")
312
+ return "\n".join(parts)
313
+
314
+
315
+ def _system_message_from_text(body: str) -> Message:
316
+ return Message(role="system", content=[Text(text=body)])
317
+
318
+
319
+ def _admit_system_context(
320
+ epoch: ContextEpochTracker,
321
+ ctx: TurnContext,
322
+ chat_messages: Sequence[Message],
323
+ *,
324
+ memory_selection: MemoryPromptSelection | None = None,
325
+ attachment_catalog: SessionAttachmentCatalog | None = None,
326
+ ) -> EpochAdmit:
327
+ """Compose stable/volatile tails and reconcile against the current context epoch."""
328
+ catalog = (
329
+ attachment_catalog.list_records() if attachment_catalog is not None else None
330
+ )
331
+ stable = compose_stable_baseline(ctx, attachment_catalog=catalog)
332
+ volatile_parts = compose_volatile_tail_parts(
333
+ ctx, chat_messages=chat_messages, memory_selection=memory_selection
334
+ )
335
+ volatile = "".join(volatile_parts.values())
336
+ return epoch.reconcile(
337
+ stable_baseline=stable,
338
+ volatile_text=volatile,
339
+ stable_fingerprint=fingerprint_text(stable),
340
+ volatile_fingerprint=fingerprint_text(volatile),
341
+ volatile_part_fingerprints={
342
+ name: fingerprint_text(text) for name, text in volatile_parts.items()
343
+ },
344
+ )
345
+
346
+
347
+ def _messages_for_provider(
348
+ system: Message,
349
+ history: Sequence[Message],
350
+ *,
351
+ mid_conversation_update: str = "",
352
+ ) -> list[Message]:
353
+ """Leading system + history + optional chronological system-context update.
354
+
355
+ The update is user-role so all providers accept mid-conversation updates
356
+ (system is leading-only for Anthropic/Gemini/OpenAI adapters). When history
357
+ already ends in a ``user`` row (e.g. a tool-response turn), the update is
358
+ folded into that same message instead of appended as a new one — Anthropic
359
+ and Gemini both reject (or, for Anthropic, only sometimes silently coalesce)
360
+ two consecutive same-role messages.
361
+ """
362
+ out: list[Message] = [system, *list(history)]
363
+ update = mid_conversation_update.strip()
364
+ if not update:
365
+ return out
366
+ update_block = Text(text=mid_conversation_update)
367
+ if out[-1].role == "user":
368
+ out[-1] = Message(role="user", content=[*out[-1].content, update_block])
369
+ else:
370
+ out.append(Message(role="user", content=[update_block]))
371
+ return out
372
+
373
+
374
+ async def _load_agent_chat_history(history: HistoryStore, thread_id: str) -> list[Message]:
375
+ """Load transcript rows and apply agent-facing transforms (integrity + strip UI)."""
376
+ return transform_context(await history.load(thread_id))
377
+
378
+
379
+ def _epoch_events(
380
+ admit: EpochAdmit,
381
+ *,
382
+ request_id: str,
383
+ thread_id: str,
384
+ ) -> list[AgentEvent]:
385
+ if admit.kind == "unchanged":
386
+ return []
387
+ logger.debug(
388
+ "context epoch %s",
389
+ kv(
390
+ request_id=request_id,
391
+ thread_id=thread_id,
392
+ kind=admit.kind,
393
+ epoch_id=admit.epoch_id,
394
+ changed_sources=",".join(admit.changed_sources),
395
+ ),
396
+ )
397
+ if admit.kind == "new_epoch":
398
+ return [
399
+ ContextEpochStarted(
400
+ request_id=request_id,
401
+ epoch_id=admit.epoch_id,
402
+ changed_sources=list(admit.changed_sources),
403
+ )
404
+ ]
405
+ return [
406
+ SystemContextUpdated(
407
+ request_id=request_id,
408
+ epoch_id=admit.epoch_id,
409
+ changed_sources=list(admit.changed_sources),
410
+ )
411
+ ]
412
+
413
+
414
+ def _provider_messages_prompt_summary(messages: Sequence[Message]) -> str:
415
+ """Compact prompt text for observability (Langfuse ``gen_ai.prompt`` / observation input)."""
416
+ lines: list[str] = []
417
+ for msg in messages:
418
+ parts: list[str] = []
419
+ for block in msg.content:
420
+ if isinstance(block, Text):
421
+ parts.append(block.text)
422
+ elif isinstance(block, ToolRequest):
423
+ parts.append(f"[tool_call {block.name}]")
424
+ elif isinstance(block, ToolResponse):
425
+ parts.append(f"[tool_result {block.tool_name}]")
426
+ text = " ".join(parts).strip()
427
+ if text:
428
+ lines.append(f"{msg.role}: {text}")
429
+ return "\n".join(lines)
430
+
431
+
432
+ _HOOK_READ_TIMEOUT_S = 2.0
433
+ _HOOK_PRE_TOOL_TIMEOUT_S = 1.5
434
+ _HOOK_SETTLEMENT_TIMEOUT_S = 2.0
435
+
436
+
437
+ def _settlement_timeout_s() -> float:
438
+ raw = os.environ.get("MONKEYBOT_HOOK_SETTLEMENT_TIMEOUT_S")
439
+ if raw is None or raw.strip() == "":
440
+ return _HOOK_SETTLEMENT_TIMEOUT_S
441
+ try:
442
+ return max(0.0, float(raw))
443
+ except ValueError:
444
+ return _HOOK_SETTLEMENT_TIMEOUT_S
445
+
446
+
447
+ async def _drain_hook_settlement(hook_manager: HookManager | None) -> None:
448
+ """Wait for fire-and-forget hooks before the next provider call / TurnComplete."""
449
+ if hook_manager is None:
450
+ return
451
+ await hook_manager.drain_settlement(timeout_s=_settlement_timeout_s())
452
+
453
+
454
+ def _record_tool_hook_span_event(phase: str, tool_name: str) -> None:
455
+ try:
456
+ from monkeybot.observability.instrumentation import add_tool_hook_span_event
457
+
458
+ if phase == "pre_tool":
459
+ add_tool_hook_span_event(phase="pre_tool", tool_name=tool_name)
460
+ else:
461
+ add_tool_hook_span_event(phase="post_tool", tool_name=tool_name)
462
+ except ImportError:
463
+ return
464
+
465
+
466
+ async def _fire_hook(
467
+ hook_manager: HookManager | None,
468
+ *,
469
+ event: HookEvent,
470
+ ctx: TurnContext,
471
+ timeout_s: float,
472
+ user_message: str | None = None,
473
+ tool_name: str | None = None,
474
+ tool_args: dict[str, Any] | None = None,
475
+ tool_result: str | None = None,
476
+ tool_error: str | None = None,
477
+ tools: list[ToolDef] | None = None,
478
+ provider_messages: list[Message] | None = None,
479
+ inner_turn: int | None = None,
480
+ assistant_text: str | None = None,
481
+ thinking_text: str | None = None,
482
+ tool_requests: list[dict[str, Any]] | None = None,
483
+ usage: dict[str, int] | None = None,
484
+ provider_error: str | None = None,
485
+ ) -> HookPayload | None:
486
+ """Construct + fire a payload when ``hook_manager`` is configured; else return ``None``."""
487
+ if hook_manager is None:
488
+ return None
489
+ payload = HookPayload(
490
+ event=event,
491
+ thread_id=ctx.thread_id,
492
+ request_id=ctx.request_id,
493
+ ctx=ctx,
494
+ user_message=user_message,
495
+ tool_name=tool_name,
496
+ tool_args=tool_args,
497
+ tool_result=tool_result,
498
+ tool_error=tool_error,
499
+ tools=tools,
500
+ provider_messages=provider_messages,
501
+ inner_turn=inner_turn,
502
+ assistant_text=assistant_text,
503
+ thinking_text=thinking_text,
504
+ tool_requests=tool_requests,
505
+ usage=usage,
506
+ provider_error=provider_error,
507
+ )
508
+ return await hook_manager.fire(payload, timeout_s=timeout_s)
509
+
510
+
511
+ def _apply_before_provider_hook(
512
+ payload: HookPayload | None,
513
+ provider_messages: list[Message],
514
+ turn_tools: Sequence[ToolDef],
515
+ ) -> tuple[list[Message], Sequence[ToolDef], bool]:
516
+ """Return (messages, tools, tools_replaced) after ``BEFORE_PROVIDER_REQUEST``."""
517
+ if payload is None:
518
+ return provider_messages, turn_tools, False
519
+ messages = (
520
+ list(payload.provider_messages)
521
+ if payload.provider_messages is not None
522
+ else provider_messages
523
+ )
524
+ if payload.tools is None:
525
+ return messages, turn_tools, False
526
+ return messages, list(payload.tools), True
527
+
528
+
529
+ async def _fire_after_provider_response(
530
+ hook_manager: HookManager | None,
531
+ *,
532
+ ctx: TurnContext,
533
+ inner_turn: int,
534
+ assistant_text: str | None = None,
535
+ thinking_text: str | None = None,
536
+ tool_requests: list[dict[str, Any]] | None = None,
537
+ usage: dict[str, int] | None = None,
538
+ provider_error: str | None = None,
539
+ ) -> None:
540
+ await _fire_hook(
541
+ hook_manager,
542
+ event=HookEvent.AFTER_PROVIDER_RESPONSE,
543
+ ctx=ctx,
544
+ timeout_s=0,
545
+ inner_turn=inner_turn,
546
+ assistant_text=assistant_text,
547
+ thinking_text=thinking_text,
548
+ tool_requests=tool_requests,
549
+ usage=usage,
550
+ provider_error=provider_error,
551
+ )
552
+
553
+
554
+ def _append_extra_system_text(system: Message, extra: str | None) -> Message:
555
+ """Return a new system Message with ``extra`` under a ``## Runtime notes`` section.
556
+
557
+ Uses the same markdown heading style as the rest of the composed system prompt
558
+ (``## Memory index``, harness sections). When ``extra`` is empty/None the original
559
+ message is returned unchanged.
560
+ """
561
+ if not extra:
562
+ return system
563
+ base = "".join(b.text for b in system.content if isinstance(b, Text))
564
+ wrapped = f"{base}{RUNTIME_NOTES_HEADING}\n{extra.strip()}\n"
565
+ return Message(role="system", content=[Text(text=wrapped)])
566
+
567
+
568
+ def _combine_extras(*parts: str | None) -> str | None:
569
+ """Join non-empty hook-injected fragments with blank lines; ``None`` if all empty."""
570
+ kept = [p.strip() for p in parts if p and p.strip()]
571
+ if not kept:
572
+ return None
573
+ return "\n\n".join(kept)
574
+
575
+
576
+ # Max concurrent ``task`` (subagent) subprocesses per single model tool batch.
577
+ _MAX_CONCURRENT_SUBAGENTS = 10
578
+ _MAX_CONCURRENT_PARALLEL_TOOLS = 10
579
+
580
+
581
+ def _parallel_tool_concurrency() -> int:
582
+ raw = os.environ.get("MONKEYBOT_PARALLEL_TOOL_CONCURRENCY")
583
+ if raw is None or raw.strip() == "":
584
+ return _MAX_CONCURRENT_PARALLEL_TOOLS
585
+ try:
586
+ return max(1, int(raw))
587
+ except ValueError:
588
+ return _MAX_CONCURRENT_PARALLEL_TOOLS
589
+
590
+
591
+ def _parallel_safe_names(tools: Sequence[ToolDef]) -> frozenset[str]:
592
+ """Names marked ``parallel_safe`` (read-only / concurrent-safe tools)."""
593
+ return frozenset(t.name for t in tools if t.parallel_safe)
594
+
595
+ _SPILL_REL = Path(".monkeybot") / "spill"
596
+ SUMMARY_TRIGGER_RATIO = summarization_trigger_ratio_from_env()
597
+ """Same ratio as pre-stream summarization check (``preflight_prompt_tokens >= cap``)."""
598
+ _SUMMARY_TRIGGER_RATIO = SUMMARY_TRIGGER_RATIO
599
+ _SUMMARY_KEEP_HEAD = 1
600
+ _SUMMARY_KEEP_TAIL = 6
601
+
602
+ # Fixed Markdown template for middle-history compression.
603
+ _COMPACTION_SUMMARY_SYSTEM = """\
604
+ Output exactly the Markdown structure shown inside <template> and keep the \
605
+ section order unchanged. Do not include the <template> tags in your response.
606
+ <template>
607
+ ## Objective
608
+ - [one or two brief sentences describing what the user is trying to accomplish]
609
+
610
+ ## Important Details
611
+ - [constraints/preferences, decisions and why, important facts/assumptions, \
612
+ exact context needed to continue, or "(none)"]
613
+
614
+ ## Work State
615
+ ### Completed
616
+ - [finished work, verified facts, or changes made; otherwise "(none)"]
617
+
618
+ ### Active
619
+ - [current work, partial changes, or investigation state; otherwise "(none)"]
620
+
621
+ ### Blocked
622
+ - [blockers, failing commands, or unknowns; otherwise "(none)"]
623
+
624
+ ## Next Move
625
+ 1. [immediate concrete action, or "(none)"]
626
+ 2. [next action if known, or "(none)"]
627
+
628
+ ## Relevant Files
629
+ - [file or directory path: why it matters, or "(none)"]
630
+ </template>
631
+
632
+ Rules:
633
+ - Keep every section, even when empty.
634
+ - Use terse bullets, not prose paragraphs.
635
+ - Preserve exact file paths, symbols, commands, error strings, URLs, and \
636
+ identifiers when known.
637
+ - Do not mention the summary process or that context was compacted.\
638
+ """
639
+
640
+
641
+ async def _await_user_response_any(
642
+ bus: object,
643
+ fut: asyncio.Future[Any],
644
+ pending_key: str,
645
+ *,
646
+ timeout_sec: float | None = None,
647
+ ) -> dict[str, Any]:
648
+ """Wait for POST/resolve on ``fut`` with optional bus timeout bookkeeping.
649
+
650
+ Used by the gateway :func:`_await_user_response` and the inspector confirm path.
651
+ """
652
+ t = timeout_sec if timeout_sec is not None else float(os.environ.get("PENDING_RESPONSE_TIMEOUT_SEC", "300"))
653
+ try:
654
+ return await asyncio.wait_for(asyncio.shield(fut), timeout=t)
655
+ except TimeoutError:
656
+ ap = getattr(bus, "abandon_pending_timeout", None)
657
+ if callable(ap):
658
+ ap(pending_key)
659
+ return {"_timeout": True}
660
+
661
+
662
+ def _flatten_tool_result_for_summary(resp: ToolResponse) -> str:
663
+ parts: list[str] = []
664
+ for b in resp.result:
665
+ if isinstance(b, Text):
666
+ parts.append(summarize_tool_result_text(b.text))
667
+ else:
668
+ parts.append(summarize_tool_result_text(json.dumps(b.to_dict(), sort_keys=True)))
669
+ return "".join(parts) or "(empty)"
670
+
671
+
672
+ def _summary_line_for_message(m: Message) -> str:
673
+ pieces: list[str] = []
674
+ for b in m.content:
675
+ if isinstance(b, Text):
676
+ pieces.append(b.text)
677
+ elif isinstance(b, ToolRequest):
678
+ pieces.append(f"[tool_call: {b.name}({json.dumps(b.args, sort_keys=True)})]")
679
+ elif isinstance(b, ToolResponse):
680
+ body = _flatten_tool_result_for_summary(b)
681
+ tag = "tool_error" if b.is_error else "tool_result"
682
+ pieces.append(f"[{tag} {b.tool_name}: {body}]")
683
+ else:
684
+ pieces.append(f"[{type(b).__name__}]")
685
+ joined = " ".join(pieces) if pieces else "(empty)"
686
+ return f"{m.role}: {joined}"
687
+
688
+
689
+ def _system_prompt_snapshot_text(
690
+ system: Message, mid_conversation_update: str = ""
691
+ ) -> str:
692
+ """Plain string for :class:`SystemPromptSnapshot` (composed prompt + mid-epoch update)."""
693
+ body = "".join(b.text for b in system.content if isinstance(b, Text))
694
+ update = mid_conversation_update.strip()
695
+ if not update:
696
+ return body
697
+ return f"{body}\n\n{update}"
698
+
699
+
700
+ def _is_resume_turn(resolved_messages: Sequence[Message]) -> bool:
701
+ """True when the model continues after tool results, not a new user question."""
702
+ if not resolved_messages:
703
+ return False
704
+ last = resolved_messages[-1]
705
+ if last.role != "user":
706
+ return False
707
+ has_tool_response = any(isinstance(b, ToolResponse) for b in last.content)
708
+ has_user_text = any(isinstance(b, Text) and b.text.strip() for b in last.content)
709
+ return has_tool_response and not has_user_text
710
+
711
+
712
+ def _is_routine_resume_turn(resolved_messages: Sequence[Message]) -> bool:
713
+ """Resume turn with only successful tool results (safe to reduce thinking budget)."""
714
+ if not _is_resume_turn(resolved_messages):
715
+ return False
716
+ last = resolved_messages[-1]
717
+ tool_responses = [b for b in last.content if isinstance(b, ToolResponse)]
718
+ return bool(tool_responses) and all(not b.is_error for b in tool_responses)
719
+
720
+
721
+ def _stream_thinking_budget(
722
+ provider: Provider,
723
+ resolved_messages: Sequence[Message],
724
+ ) -> int | None:
725
+ """Per-call thinking budget override; None keeps the provider default."""
726
+ if provider.name not in ("gemini", "claude", "ollama"):
727
+ return None
728
+ raw = os.environ.get("MONKEYBOT_RESUME_THINKING_BUDGET", "").strip()
729
+ if not raw:
730
+ return None
731
+ try:
732
+ resume_budget = int(raw)
733
+ except ValueError:
734
+ return None
735
+ if _is_routine_resume_turn(resolved_messages):
736
+ return resume_budget
737
+ return None
738
+
739
+
740
+ async def _provider_prompt_input_tokens(
741
+ provider: Provider,
742
+ messages: Sequence[Message],
743
+ tools: Sequence[ToolDef],
744
+ *,
745
+ model: str,
746
+ thinking_budget: int | None = None,
747
+ vertex_google_search: bool = False,
748
+ hints: ProviderCallHints | None = None,
749
+ ) -> int:
750
+ return await provider_count_input_tokens(
751
+ provider,
752
+ messages,
753
+ tools,
754
+ model=model,
755
+ thinking_budget=thinking_budget,
756
+ vertex_google_search=vertex_google_search,
757
+ hints=hints,
758
+ )
759
+
760
+
761
+ def _provider_call_hints(ctx: TurnContext) -> ProviderCallHints:
762
+ return ProviderCallHints(
763
+ session_id=ctx.thread_id,
764
+ cache_retention=cache_retention_from_env(),
765
+ )
766
+
767
+
768
+ async def _prompt_input_tokens_for_history(
769
+ *,
770
+ ctx: TurnContext,
771
+ chat_messages: Sequence[Message],
772
+ provider: Provider,
773
+ attachment_store: AttachmentStore | None,
774
+ attachment_catalog: SessionAttachmentCatalog | None,
775
+ memory_selection: MemoryPromptSelection | None = None,
776
+ extra_system_text: str | None = None,
777
+ vertex_google_search: bool = False,
778
+ epoch: ContextEpochTracker | None = None,
779
+ ) -> int:
780
+ """Provider-accurate prompt size for history rows already persisted (e.g. post-assistant).
781
+
782
+ When ``epoch`` is supplied, the count reflects the true wire shape for the
783
+ *current* epoch state — leading (cached) baseline plus any pending
784
+ mid-conversation update — via a non-mutating :meth:`ContextEpochTracker.peek`.
785
+ Falls back to a flat stable+volatile concatenation when no tracker is given
786
+ (e.g. summarizer/curator calls that have no epoch of their own). Either way,
787
+ the live tracker is never mutated by a budget recount.
788
+ """
789
+ catalog = (
790
+ attachment_catalog.list_records() if attachment_catalog is not None else None
791
+ )
792
+ stable = compose_stable_baseline(ctx, attachment_catalog=catalog)
793
+ volatile = compose_volatile_tail(
794
+ ctx, chat_messages=chat_messages, memory_selection=memory_selection
795
+ )
796
+ mid_conversation_update = ""
797
+ if epoch is not None:
798
+ admit = epoch.peek(
799
+ stable_baseline=stable,
800
+ volatile_text=volatile,
801
+ stable_fingerprint=fingerprint_text(stable),
802
+ volatile_fingerprint=fingerprint_text(volatile),
803
+ )
804
+ body = admit.leading_system_text
805
+ mid_conversation_update = admit.mid_conversation_update
806
+ else:
807
+ body = stable + volatile
808
+ system = _append_extra_system_text(_system_message_from_text(body), extra_system_text)
809
+ resolved_messages = convert_to_provider(
810
+ chat_messages,
811
+ attachment_store=attachment_store,
812
+ session_id=ctx.thread_id,
813
+ )
814
+ provider_messages = _messages_for_provider(
815
+ system, resolved_messages, mid_conversation_update=mid_conversation_update
816
+ )
817
+ return await _provider_prompt_input_tokens(
818
+ provider, provider_messages, ctx.tools, model=ctx.model,
819
+ vertex_google_search=vertex_google_search,
820
+ hints=_provider_call_hints(ctx),
821
+ )
822
+
823
+
824
+ def _cleanup_spill_files(workspace_root: Path, thread_id: str) -> None:
825
+ spill_path = Path(workspace_root).resolve() / _SPILL_REL / thread_id
826
+ if spill_path.exists():
827
+ shutil.rmtree(spill_path, ignore_errors=True)
828
+
829
+
830
+ def _summarization_viable(messages: Sequence[Message]) -> bool:
831
+ return len(messages) > _SUMMARY_KEEP_HEAD + _SUMMARY_KEEP_TAIL
832
+
833
+
834
+ def _summarization_model_id(ctx: TurnContext) -> str:
835
+ """Model id for history compression; env overrides ``ctx.summarization_model``, then ``ctx.model``."""
836
+ from_env = os.getenv("CONTEXT_SUMMARIZATION_MODEL", "").strip()
837
+ if from_env:
838
+ return from_env
839
+ ctx_sm = (ctx.summarization_model or "").strip()
840
+ if ctx_sm:
841
+ return ctx_sm
842
+ return ctx.model
843
+
844
+
845
+ async def _compact_history_if_needed(
846
+ *,
847
+ thread_id: str,
848
+ history: HistoryStore,
849
+ provider: Provider,
850
+ model: str,
851
+ ) -> int:
852
+ """Summarize middle history when tool results exhausted headroom."""
853
+ # Compaction persists via history.reset; use unrepaired rows so synthetic
854
+ # in-memory repairs are never written to the store.
855
+ chat_messages = await history.load(thread_id)
856
+ if not _summarization_viable(chat_messages):
857
+ return 0
858
+ return await _summarize_history(thread_id, chat_messages, history, provider, model)
859
+
860
+
861
+ async def _append_budgeted_tool_responses(
862
+ *,
863
+ chunk_responses: list[ContentBlock],
864
+ ctx: TurnContext,
865
+ history: HistoryStore,
866
+ usage: Usage,
867
+ budgeter: ContextBudgeter,
868
+ provider: Provider,
869
+ vertex_google_search: bool = False,
870
+ epoch: ContextEpochTracker | None = None,
871
+ ) -> AsyncIterator[AgentEvent]:
872
+ """Budget tool results against remaining context, append, compact if needed."""
873
+ trimmed, needs_compaction = budgeter.fit_content_blocks(chunk_responses)
874
+ await history.append(
875
+ ctx.thread_id,
876
+ Message(role="user", content=trimmed),
877
+ )
878
+ usage.estimated_prompt_tokens = max(usage.estimated_prompt_tokens, budgeter.used_tokens)
879
+ if not needs_compaction:
880
+ return
881
+ yield ContextSummarizing(
882
+ request_id=ctx.request_id,
883
+ estimated_tokens=usage.estimated_prompt_tokens,
884
+ context_window_tokens=ctx.context_window_tokens,
885
+ )
886
+ try:
887
+ turns_summarized = await _compact_history_if_needed(
888
+ thread_id=ctx.thread_id,
889
+ history=history,
890
+ provider=provider,
891
+ model=_summarization_model_id(ctx),
892
+ )
893
+ except Exception:
894
+ logger.warning(
895
+ "post-tool summarization failed %s; continuing",
896
+ kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
897
+ exc_info=True,
898
+ )
899
+ turns_summarized = 0
900
+ yield ContextSummarized(
901
+ request_id=ctx.request_id,
902
+ turns_summarized=turns_summarized,
903
+ )
904
+ if turns_summarized > 0:
905
+ if epoch is not None:
906
+ epoch.begin_new_epoch()
907
+ chat_messages = await _load_agent_chat_history(history, ctx.thread_id)
908
+ post = await _prompt_input_tokens_for_history(
909
+ ctx=ctx,
910
+ chat_messages=chat_messages,
911
+ provider=provider,
912
+ attachment_store=None,
913
+ attachment_catalog=None,
914
+ vertex_google_search=vertex_google_search,
915
+ epoch=epoch,
916
+ )
917
+ usage.estimated_prompt_tokens = max(usage.estimated_prompt_tokens, post)
918
+
919
+
920
+ async def _summarize_history(
921
+ thread_id: str,
922
+ messages: list[Message],
923
+ history: HistoryStore,
924
+ provider: Provider,
925
+ model: str,
926
+ ) -> int:
927
+ """Compress middle history into one assistant summary row. Returns middle row count."""
928
+ if not _summarization_viable(messages):
929
+ return 0
930
+ head = messages[:_SUMMARY_KEEP_HEAD]
931
+ tail = messages[-_SUMMARY_KEEP_TAIL :]
932
+ middle = messages[_SUMMARY_KEEP_HEAD : -_SUMMARY_KEEP_TAIL]
933
+ if not middle:
934
+ return 0
935
+ lines = [_summary_line_for_message(m) for m in middle]
936
+ blob = "\n\n---\n\n".join(lines)
937
+ summarize_messages = [
938
+ Message(
939
+ role="system",
940
+ content=[Text(text=_COMPACTION_SUMMARY_SYSTEM)],
941
+ ),
942
+ Message(
943
+ role="user",
944
+ content=[
945
+ Text(
946
+ text=(
947
+ "Create a structured summary from the conversation "
948
+ "segment below.\n\n" + blob
949
+ )
950
+ )
951
+ ],
952
+ ),
953
+ ]
954
+ summary_text = ""
955
+ async with aclosing(
956
+ cast(Any, provider.stream(summarize_messages, [], model=model))
957
+ ) as stream:
958
+ async for ev in stream:
959
+ if isinstance(ev, TextDelta):
960
+ summary_text += ev.text
961
+ elif isinstance(ev, Done):
962
+ break
963
+ summary_text = summary_text.strip() or "(empty summary)"
964
+ merged = [
965
+ *head,
966
+ Message(
967
+ role="assistant",
968
+ content=[Text(text=f"[Context Summary]:\n{summary_text}")],
969
+ ),
970
+ *tail,
971
+ ]
972
+ await history.reset(thread_id, merged)
973
+ return len(middle)
974
+
975
+
976
+ def _tool_outcome(
977
+ call: ToolCall,
978
+ request_id: str,
979
+ result: ToolExecutionResult,
980
+ ) -> tuple[ToolCallResult, ToolResponse]:
981
+ """Build the (event, history block) pair for a finished tool call.
982
+
983
+ Used by both the sequential and parallel ``task`` dispatch paths so that
984
+ result formatting stays in one place.
985
+ """
986
+ is_error = result.error is not None
987
+ body = "" if is_error else _blocks_to_sse_summary(result.blocks)
988
+ text = result.error if is_error else body
989
+ response_blocks: list[ContentBlock] = (
990
+ list(result.blocks) if not is_error else [Text(text=text or "")]
991
+ )
992
+ if not is_error:
993
+ budget = resolve_tool_budget(call.name)
994
+ if budget is not None:
995
+ flat = _blocks_to_sse_summary(response_blocks)
996
+ if exceeds_tool_output_budget(flat, tool_name=call.name, budget=budget):
997
+ shaped_blocks: list[ContentBlock] = []
998
+ for block in response_blocks:
999
+ if isinstance(block, Text):
1000
+ shaped = shape_tool_text(
1001
+ block.text,
1002
+ tool_name=call.name,
1003
+ budget=budget,
1004
+ pressure_tier=None,
1005
+ )
1006
+ shaped_blocks.append(
1007
+ Text(text=shaped) if shaped != block.text else block
1008
+ )
1009
+ else:
1010
+ shaped_blocks.append(block)
1011
+ response_blocks = shaped_blocks
1012
+ body = _blocks_to_sse_summary(response_blocks)
1013
+ event = ToolCallResult(
1014
+ request_id=request_id,
1015
+ tool=call.name,
1016
+ result=body,
1017
+ error=result.error,
1018
+ call_id=call.call_id,
1019
+ )
1020
+ response = ToolResponse(
1021
+ id=call.call_id,
1022
+ tool_name=call.name,
1023
+ result=response_blocks,
1024
+ is_error=is_error,
1025
+ )
1026
+ return event, response
1027
+
1028
+
1029
+ def _image_events(
1030
+ request_id: str,
1031
+ call_id: str,
1032
+ result: ToolExecutionResult,
1033
+ ) -> list[ImageBlock]:
1034
+ """SSE image payloads for tool results that include ``Image`` content blocks."""
1035
+ if result.error is not None:
1036
+ return []
1037
+ events: list[ImageBlock] = []
1038
+ for idx, b in enumerate(result.blocks):
1039
+ if not isinstance(b, Image) or not b.data:
1040
+ continue
1041
+ image_id = f"{call_id}:{idx}" if call_id else f"{request_id}:{idx}"
1042
+ events.append(
1043
+ ImageBlock(
1044
+ request_id=request_id,
1045
+ image_id=image_id,
1046
+ mime_type=b.mime_type,
1047
+ data=b.data,
1048
+ )
1049
+ )
1050
+ return events
1051
+
1052
+
1053
+ def _chunk_tool_calls(
1054
+ ordered: Sequence[ToolCall],
1055
+ *,
1056
+ parallel_safe: frozenset[str] | None = None,
1057
+ ) -> list[list[ToolCall]]:
1058
+ """Split into maximal runs of consecutive parallelizable tools vs serial singles.
1059
+
1060
+ * Consecutive ``task`` calls may run concurrently (bounded by
1061
+ :data:`_MAX_CONCURRENT_SUBAGENTS`).
1062
+ * Consecutive tools in ``parallel_safe`` (read-only) may run concurrently
1063
+ (bounded by :func:`_parallel_tool_concurrency`).
1064
+ * ``task`` never mixes with other parallel-safe tools in one chunk.
1065
+ * Mutating / unmarked tools stay sequential chunk-by-chunk.
1066
+ """
1067
+ safe = parallel_safe or frozenset()
1068
+ seq = list(ordered)
1069
+ chunks: list[list[ToolCall]] = []
1070
+ i = 0
1071
+ n = len(seq)
1072
+ while i < n:
1073
+ name = seq[i].name
1074
+ if name == "task":
1075
+ j = i + 1
1076
+ while j < n and seq[j].name == "task":
1077
+ j += 1
1078
+ chunks.append(seq[i:j])
1079
+ i = j
1080
+ elif name in safe:
1081
+ j = i + 1
1082
+ while j < n and seq[j].name in safe and seq[j].name != "task":
1083
+ j += 1
1084
+ chunks.append(seq[i:j])
1085
+ i = j
1086
+ else:
1087
+ chunks.append([seq[i]])
1088
+ i += 1
1089
+ return chunks
1090
+
1091
+
1092
+ @runtime_checkable
1093
+ class ToolExecutorPort(Protocol):
1094
+ """Fakeable tool execution boundary (Story 6 does not invoke real shell)."""
1095
+
1096
+ async def execute(self, *, call: ToolCall, ctx: TurnContext) -> ToolExecutionResult:
1097
+ """Return content blocks for history; ``error`` set on failure."""
1098
+
1099
+
1100
+ async def run(
1101
+ user_content: str | list[ContentBlock],
1102
+ ctx: TurnContext,
1103
+ *,
1104
+ provider: Provider,
1105
+ history: HistoryStore,
1106
+ inspectors: list[ToolInspector],
1107
+ tool_executor: ToolExecutorPort,
1108
+ cancelled: asyncio.Event | None = None,
1109
+ max_turns: int | None = None,
1110
+ hook_manager: HookManager | None = None,
1111
+ curator_provider: Provider | None = None,
1112
+ attachment_store: AttachmentStore | None = None,
1113
+ attachment_catalog: SessionAttachmentCatalog | None = None,
1114
+ transcript_writer: TranscriptWriter | None = None,
1115
+ vertex_google_search: bool = False,
1116
+ input_admission: InputAdmission | None = None,
1117
+ ) -> AsyncIterator[AgentEvent]:
1118
+ """Stream agent events for one user message; ends with ``TurnComplete`` (never raises).
1119
+
1120
+ Provider chunks are handled in Gemini-style batches: tool calls accumulate until ``Done``,
1121
+ then execute in lexicographic ``call_id`` order for deterministic replay.
1122
+
1123
+ Consecutive ``task`` tool calls and consecutive ``parallel_safe`` tools in one batch run
1124
+ concurrently (capped); mutating tools run one chunk at a time in order.
1125
+
1126
+ ``input_admission`` (optional) supplies mid-turn steer injections drained at safe
1127
+ boundaries (after tool batches / before the next provider call).
1128
+
1129
+ ``curator_provider`` is an optional dedicated provider for context curation (e.g. with
1130
+ ``thinking_budget=0``). Falls back to ``provider`` when not supplied.
1131
+
1132
+ ``vertex_google_search`` opts into Gemini native ``google_search`` grounding for agent
1133
+ turns in this run (main agent or subagent). Only passed through to ``GeminiProvider``;
1134
+ internal harness calls (history summarization, memory organizer) never set this flag.
1135
+ """
1136
+ usage = Usage()
1137
+ trace_id_capture: list[str | None] = [None]
1138
+ blocks = _normalize_user_content(user_content)
1139
+ effective_max = _effective_max_turns(max_turns)
1140
+ logger.debug(
1141
+ "harness run start %s",
1142
+ kv(
1143
+ request_id=ctx.request_id,
1144
+ thread_id=ctx.thread_id,
1145
+ model=ctx.model,
1146
+ max_turns=effective_max,
1147
+ ),
1148
+ )
1149
+ try:
1150
+ async for evt in _run_inner(
1151
+ blocks,
1152
+ ctx,
1153
+ provider=provider,
1154
+ history=history,
1155
+ inspectors=inspectors,
1156
+ tool_executor=tool_executor,
1157
+ cancelled=cancelled,
1158
+ max_turns=max_turns,
1159
+ usage=usage,
1160
+ hook_manager=hook_manager,
1161
+ curator_provider=curator_provider,
1162
+ trace_id_out=trace_id_capture,
1163
+ attachment_store=attachment_store,
1164
+ attachment_catalog=attachment_catalog,
1165
+ transcript_writer=transcript_writer,
1166
+ vertex_google_search=vertex_google_search,
1167
+ input_admission=input_admission,
1168
+ ):
1169
+ yield evt
1170
+ except asyncio.CancelledError:
1171
+ try:
1172
+ cur = asyncio.current_task()
1173
+ if cur is not None and getattr(cur, "uncancel", None):
1174
+ cur.uncancel()
1175
+ except Exception:
1176
+ logger.warning(
1177
+ "uncancel cleanup failed %s",
1178
+ kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
1179
+ exc_info=True,
1180
+ )
1181
+ yield Error(request_id=ctx.request_id, error="Request cancelled")
1182
+ except Exception as exc:
1183
+ logger.exception(
1184
+ "harness turn failed %s",
1185
+ kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
1186
+ )
1187
+ yield Error(request_id=ctx.request_id, error=str(exc))
1188
+ finally:
1189
+ await _drain_hook_settlement(hook_manager)
1190
+ logger.debug(
1191
+ "harness run end %s",
1192
+ kv(
1193
+ request_id=ctx.request_id,
1194
+ thread_id=ctx.thread_id,
1195
+ input_tokens=usage.input_tokens,
1196
+ output_tokens=usage.output_tokens,
1197
+ ),
1198
+ )
1199
+ yield TurnComplete(
1200
+ request_id=ctx.request_id,
1201
+ usage=_usage_to_totals(usage),
1202
+ trace_id=trace_id_capture[0],
1203
+ )
1204
+
1205
+
1206
+ async def _run_inner(
1207
+ user_content: list[ContentBlock],
1208
+ ctx: TurnContext,
1209
+ *,
1210
+ provider: Provider,
1211
+ history: HistoryStore,
1212
+ inspectors: list[ToolInspector],
1213
+ tool_executor: ToolExecutorPort,
1214
+ cancelled: asyncio.Event | None,
1215
+ max_turns: int | None,
1216
+ usage: Usage,
1217
+ hook_manager: HookManager | None = None,
1218
+ curator_provider: Provider | None = None,
1219
+ trace_id_out: list[str | None] | None = None,
1220
+ attachment_store: AttachmentStore | None = None,
1221
+ attachment_catalog: SessionAttachmentCatalog | None = None,
1222
+ transcript_writer: TranscriptWriter | None = None,
1223
+ vertex_google_search: bool = False,
1224
+ input_admission: InputAdmission | None = None,
1225
+ ) -> AsyncIterator[AgentEvent]:
1226
+ from monkeybot.observability.spans import set_run_output, span_run
1227
+
1228
+ last_assistant: list[str] = [""]
1229
+ user_text = _user_text_from_content(user_content)
1230
+ async with span_run(ctx, user_message=user_text):
1231
+ async for evt in _run_inner_core(
1232
+ user_content,
1233
+ ctx,
1234
+ provider=provider,
1235
+ history=history,
1236
+ inspectors=inspectors,
1237
+ tool_executor=tool_executor,
1238
+ cancelled=cancelled,
1239
+ max_turns=max_turns,
1240
+ usage=usage,
1241
+ hook_manager=hook_manager,
1242
+ curator_provider=curator_provider,
1243
+ last_assistant=last_assistant,
1244
+ attachment_store=attachment_store,
1245
+ attachment_catalog=attachment_catalog,
1246
+ transcript_writer=transcript_writer,
1247
+ vertex_google_search=vertex_google_search,
1248
+ input_admission=input_admission,
1249
+ ):
1250
+ yield evt
1251
+ set_run_output(last_assistant[0])
1252
+ if trace_id_out is not None:
1253
+ try:
1254
+ from monkeybot.observability import is_observability_enabled
1255
+ from monkeybot.observability.instrumentation import (
1256
+ get_current_trace_id_hex_optional,
1257
+ )
1258
+
1259
+ if is_observability_enabled():
1260
+ trace_id_out[0] = get_current_trace_id_hex_optional()
1261
+ except ImportError:
1262
+ pass
1263
+
1264
+
1265
+ async def _run_inner_core(
1266
+ user_content: list[ContentBlock],
1267
+ ctx: TurnContext,
1268
+ *,
1269
+ provider: Provider,
1270
+ history: HistoryStore,
1271
+ inspectors: list[ToolInspector],
1272
+ tool_executor: ToolExecutorPort,
1273
+ cancelled: asyncio.Event | None,
1274
+ max_turns: int | None,
1275
+ usage: Usage,
1276
+ hook_manager: HookManager | None = None,
1277
+ curator_provider: Provider | None = None,
1278
+ last_assistant: list[str],
1279
+ attachment_store: AttachmentStore | None = None,
1280
+ attachment_catalog: SessionAttachmentCatalog | None = None,
1281
+ transcript_writer: TranscriptWriter | None = None,
1282
+ vertex_google_search: bool = False,
1283
+ input_admission: InputAdmission | None = None,
1284
+ ) -> AsyncIterator[AgentEvent]:
1285
+ from monkeybot.observability.spans import (
1286
+ begin_turn_span,
1287
+ end_turn_span,
1288
+ record_tool_outcome,
1289
+ set_llm_io,
1290
+ set_llm_usage,
1291
+ set_summarize_turns,
1292
+ set_turn_io,
1293
+ set_turn_prompt_tokens,
1294
+ span_llm,
1295
+ span_summarize,
1296
+ span_tool,
1297
+ )
1298
+
1299
+ effective_max = _effective_max_turns(max_turns)
1300
+ user_text = _user_text_from_content(user_content)
1301
+ _ = await history.load(ctx.thread_id)
1302
+ if ctx.workspace_root is not None:
1303
+ _cleanup_spill_files(ctx.workspace_root, ctx.thread_id)
1304
+ await history.append(ctx.thread_id, Message(role="user", content=list(user_content)))
1305
+
1306
+ await _fire_hook(
1307
+ hook_manager,
1308
+ event=HookEvent.USER_MESSAGE,
1309
+ ctx=ctx,
1310
+ timeout_s=0,
1311
+ user_message=user_text,
1312
+ )
1313
+
1314
+ turn_index = 0
1315
+ needs_followup_after_tools = False
1316
+ provider_messages_written = 0
1317
+ tools_dirty = False
1318
+ # Final assistant write is backgrounded off the streaming path; awaited at the
1319
+ # turn tail before any history load/reset so the row is never lost.
1320
+ assistant_write_task: asyncio.Task[None] | None = None
1321
+ memory_selection: MemoryPromptSelection | None = None
1322
+ memory_selection_fingerprint: str | None = None
1323
+ pre_turn_extra: str | None = None
1324
+ pre_tool_extra_next: str | None = None
1325
+ doom_tracker = _DoomLoopTracker(
1326
+ threshold=_effective_doom_loop_threshold(),
1327
+ exempt_names=_doom_loop_exempt_names(ctx.tools),
1328
+ )
1329
+ epoch_tracker = ContextEpochTracker()
1330
+
1331
+ def _finish_tool(
1332
+ call: ToolCall,
1333
+ result: ToolExecutionResult,
1334
+ ) -> tuple[ToolCallResult, ToolResponse]:
1335
+ event, response = _tool_outcome(call, ctx.request_id, result)
1336
+ doom_tracker.record(call.name, dict(call.args))
1337
+ return event, response
1338
+
1339
+ async def _drain_steers() -> AsyncIterator[AgentEvent]:
1340
+ """Inject queued steer messages at a safe boundary (before next provider call)."""
1341
+ if input_admission is None:
1342
+ return
1343
+ while True:
1344
+ steered = input_admission.pop_steer()
1345
+ if steered is None:
1346
+ break
1347
+ await history.append(
1348
+ ctx.thread_id, Message(role="user", content=list(steered))
1349
+ )
1350
+ preview = preview_text(steered)
1351
+ logger.info(
1352
+ "steer injected %s",
1353
+ kv(
1354
+ request_id=ctx.request_id,
1355
+ thread_id=ctx.thread_id,
1356
+ preview=preview[:80],
1357
+ ),
1358
+ )
1359
+ yield UserSteered(request_id=ctx.request_id, text=preview)
1360
+
1361
+ while turn_index < effective_max:
1362
+ if cancelled is not None and cancelled.is_set():
1363
+ yield Error(request_id=ctx.request_id, error="Request cancelled")
1364
+ needs_followup_after_tools = False
1365
+ break
1366
+
1367
+ async for steer_evt in _drain_steers():
1368
+ yield steer_evt
1369
+
1370
+ # Settlement barrier: fire-and-forget hooks from the prior tool batch
1371
+ # must finish (or time out) before the next provider call.
1372
+ await _drain_hook_settlement(hook_manager)
1373
+
1374
+ turn_index += 1
1375
+ turn_input_text = user_text
1376
+ turn_output_text = ""
1377
+ logger.debug(
1378
+ "inner turn start %s",
1379
+ kv(
1380
+ request_id=ctx.request_id,
1381
+ thread_id=ctx.thread_id,
1382
+ turn=turn_index,
1383
+ estimated_prompt_tokens=usage.estimated_prompt_tokens,
1384
+ ),
1385
+ )
1386
+ turn_span = begin_turn_span(
1387
+ turn_index=turn_index,
1388
+ thread_id=ctx.thread_id,
1389
+ request_id=ctx.request_id,
1390
+ )
1391
+ try:
1392
+ yield Thinking(request_id=ctx.request_id)
1393
+
1394
+ if cancelled is not None and cancelled.is_set():
1395
+ yield Error(request_id=ctx.request_id, error="Request cancelled")
1396
+ needs_followup_after_tools = False
1397
+ break
1398
+
1399
+ chat_messages = await _load_agent_chat_history(history, ctx.thread_id)
1400
+ ctx = await refresh_memory_index(ctx)
1401
+ turn_input_text = latest_user_message_text(chat_messages) or user_text
1402
+ set_turn_io(input_value=turn_input_text)
1403
+
1404
+ if turn_index == 1:
1405
+ pre_turn_payload = await _fire_hook(
1406
+ hook_manager,
1407
+ event=HookEvent.PRE_TURN,
1408
+ ctx=ctx,
1409
+ timeout_s=_HOOK_READ_TIMEOUT_S,
1410
+ user_message=user_text,
1411
+ )
1412
+ if pre_turn_payload is not None:
1413
+ pre_turn_extra = pre_turn_payload.inject_text
1414
+ if pre_turn_payload.inject_memory_lines:
1415
+ ctx = dataclasses.replace(
1416
+ ctx,
1417
+ memory_index=[
1418
+ *ctx.memory_index,
1419
+ *pre_turn_payload.inject_memory_lines,
1420
+ ],
1421
+ )
1422
+
1423
+ index_fp = memory_index_fingerprint(ctx.memory_index)
1424
+ if memory_selection is None or memory_selection_fingerprint != index_fp:
1425
+ u = latest_user_message_text(chat_messages) or user_text
1426
+ memory_selection = await prepare_memory_for_prompt(
1427
+ ctx=ctx,
1428
+ user_message=u,
1429
+ provider=provider,
1430
+ curator_provider=curator_provider,
1431
+ )
1432
+ memory_selection_fingerprint = index_fp
1433
+ logger.debug(
1434
+ "memory prompt selection %s",
1435
+ kv(
1436
+ request_id=ctx.request_id,
1437
+ thread_id=ctx.thread_id,
1438
+ turn=turn_index,
1439
+ memory_lines=len(memory_selection.lines),
1440
+ total_lines=memory_selection.total_lines,
1441
+ coverage=memory_selection.coverage,
1442
+ confidence=memory_selection.confidence,
1443
+ nudge_search=memory_selection.nudge_search,
1444
+ ),
1445
+ )
1446
+
1447
+ admit = _admit_system_context(
1448
+ epoch_tracker,
1449
+ ctx,
1450
+ chat_messages,
1451
+ memory_selection=memory_selection,
1452
+ attachment_catalog=attachment_catalog,
1453
+ )
1454
+ for epoch_evt in _epoch_events(
1455
+ admit, request_id=ctx.request_id, thread_id=ctx.thread_id
1456
+ ):
1457
+ yield epoch_evt
1458
+ system = _system_message_from_text(admit.leading_system_text)
1459
+ combined_extra = _combine_extras(pre_turn_extra, pre_tool_extra_next)
1460
+ force_no_tools, doom_loop_note = doom_tracker.consume_recovery()
1461
+ combined_extra = _combine_extras(combined_extra, doom_loop_note)
1462
+ system = _append_extra_system_text(system, combined_extra)
1463
+ pre_tool_extra_next = None
1464
+ turn_tools: Sequence[ToolDef] = () if force_no_tools else ctx.tools
1465
+ tool_def_payload = await _fire_hook(
1466
+ hook_manager,
1467
+ event=HookEvent.TOOL_DEFINITION,
1468
+ ctx=ctx,
1469
+ timeout_s=_HOOK_READ_TIMEOUT_S,
1470
+ tools=list(turn_tools),
1471
+ inner_turn=turn_index,
1472
+ )
1473
+ if tool_def_payload is not None and tool_def_payload.tools is not None:
1474
+ turn_tools = list(tool_def_payload.tools)
1475
+ tools_dirty = True
1476
+ logger.debug(
1477
+ "tool.definition hook replaced tools %s",
1478
+ kv(
1479
+ request_id=ctx.request_id,
1480
+ thread_id=ctx.thread_id,
1481
+ turn=turn_index,
1482
+ tool_count=len(turn_tools),
1483
+ ),
1484
+ )
1485
+ # Preflight uses unshaped history; pressure shaping applied after token count.
1486
+ resolved_messages = convert_to_provider(
1487
+ chat_messages,
1488
+ attachment_store=attachment_store,
1489
+ session_id=ctx.thread_id,
1490
+ )
1491
+ provider_messages = _messages_for_provider(
1492
+ system,
1493
+ resolved_messages,
1494
+ mid_conversation_update=admit.mid_conversation_update,
1495
+ )
1496
+
1497
+ stream_thinking = _stream_thinking_budget(provider, resolved_messages)
1498
+ preflight = await _provider_prompt_input_tokens(
1499
+ provider,
1500
+ provider_messages,
1501
+ turn_tools,
1502
+ model=ctx.model,
1503
+ thinking_budget=stream_thinking,
1504
+ vertex_google_search=vertex_google_search,
1505
+ hints=_provider_call_hints(ctx),
1506
+ )
1507
+ usage.estimated_prompt_tokens = max(usage.estimated_prompt_tokens, preflight)
1508
+ cap = max(1, int(ctx.context_window_tokens * _SUMMARY_TRIGGER_RATIO))
1509
+ if preflight >= cap and _summarization_viable(chat_messages):
1510
+ logger.debug(
1511
+ "summarization triggered %s",
1512
+ kv(
1513
+ request_id=ctx.request_id,
1514
+ thread_id=ctx.thread_id,
1515
+ turn=turn_index,
1516
+ preflight=preflight,
1517
+ cap=cap,
1518
+ ),
1519
+ )
1520
+ yield ContextSummarizing(
1521
+ request_id=ctx.request_id,
1522
+ estimated_tokens=preflight,
1523
+ context_window_tokens=ctx.context_window_tokens,
1524
+ )
1525
+ async with span_summarize(
1526
+ thread_id=ctx.thread_id,
1527
+ request_id=ctx.request_id,
1528
+ ):
1529
+ try:
1530
+ turns_summarized = await _summarize_history(
1531
+ ctx.thread_id,
1532
+ chat_messages,
1533
+ history,
1534
+ provider,
1535
+ _summarization_model_id(ctx),
1536
+ )
1537
+ except Exception:
1538
+ logger.warning(
1539
+ "summarization failed %s; continuing",
1540
+ kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
1541
+ exc_info=True,
1542
+ )
1543
+ turns_summarized = 0
1544
+ set_summarize_turns(turns_summarized)
1545
+ yield ContextSummarized(
1546
+ request_id=ctx.request_id,
1547
+ turns_summarized=turns_summarized,
1548
+ )
1549
+ logger.debug(
1550
+ "summarization done %s",
1551
+ kv(
1552
+ request_id=ctx.request_id,
1553
+ thread_id=ctx.thread_id,
1554
+ turn=turn_index,
1555
+ turns_summarized=turns_summarized,
1556
+ ),
1557
+ )
1558
+ if turns_summarized > 0:
1559
+ epoch_tracker.begin_new_epoch()
1560
+ chat_messages = await _load_agent_chat_history(history, ctx.thread_id)
1561
+ ctx = await refresh_memory_index(ctx)
1562
+ admit = _admit_system_context(
1563
+ epoch_tracker,
1564
+ ctx,
1565
+ chat_messages,
1566
+ memory_selection=memory_selection,
1567
+ attachment_catalog=attachment_catalog,
1568
+ )
1569
+ for epoch_evt in _epoch_events(
1570
+ admit, request_id=ctx.request_id, thread_id=ctx.thread_id
1571
+ ):
1572
+ yield epoch_evt
1573
+ system = _append_extra_system_text(
1574
+ _system_message_from_text(admit.leading_system_text),
1575
+ pre_turn_extra,
1576
+ )
1577
+ resolved_messages = convert_to_provider(
1578
+ chat_messages,
1579
+ attachment_store=attachment_store,
1580
+ session_id=ctx.thread_id,
1581
+ )
1582
+ provider_messages = _messages_for_provider(
1583
+ system,
1584
+ resolved_messages,
1585
+ mid_conversation_update=admit.mid_conversation_update,
1586
+ )
1587
+ post = await _provider_prompt_input_tokens(
1588
+ provider,
1589
+ provider_messages,
1590
+ turn_tools,
1591
+ model=ctx.model,
1592
+ thinking_budget=_stream_thinking_budget(provider, resolved_messages),
1593
+ vertex_google_search=vertex_google_search,
1594
+ hints=_provider_call_hints(ctx),
1595
+ )
1596
+ usage.estimated_prompt_tokens = max(usage.estimated_prompt_tokens, post)
1597
+
1598
+ pressure_tier = compute_context_pressure_tier(
1599
+ usage.estimated_prompt_tokens,
1600
+ ctx.context_window_tokens,
1601
+ )
1602
+ if pressure_tier in ("moderate", "aggressive"):
1603
+ resolved_messages = convert_to_provider(
1604
+ chat_messages,
1605
+ attachment_store=attachment_store,
1606
+ session_id=ctx.thread_id,
1607
+ pressure_tier=pressure_tier,
1608
+ protect_recent=_SUMMARY_KEEP_TAIL,
1609
+ )
1610
+ provider_messages = _messages_for_provider(
1611
+ system,
1612
+ resolved_messages,
1613
+ mid_conversation_update=admit.mid_conversation_update,
1614
+ )
1615
+
1616
+ before_payload = await _fire_hook(
1617
+ hook_manager,
1618
+ event=HookEvent.BEFORE_PROVIDER_REQUEST,
1619
+ ctx=ctx,
1620
+ timeout_s=_HOOK_READ_TIMEOUT_S,
1621
+ tools=list(turn_tools),
1622
+ provider_messages=list(provider_messages),
1623
+ inner_turn=turn_index,
1624
+ )
1625
+ prev_msg_count = len(provider_messages)
1626
+ provider_messages, turn_tools, tools_replaced = _apply_before_provider_hook(
1627
+ before_payload, provider_messages, turn_tools
1628
+ )
1629
+ if tools_replaced:
1630
+ tools_dirty = True
1631
+ if before_payload is not None and (
1632
+ tools_replaced
1633
+ or before_payload.provider_messages is not None
1634
+ ):
1635
+ logger.debug(
1636
+ "before_provider_request hook rewrote request %s",
1637
+ kv(
1638
+ request_id=ctx.request_id,
1639
+ thread_id=ctx.thread_id,
1640
+ turn=turn_index,
1641
+ tools_replaced=tools_replaced,
1642
+ message_count=len(provider_messages),
1643
+ prev_message_count=prev_msg_count,
1644
+ ),
1645
+ )
1646
+
1647
+ yield SystemPromptSnapshot(
1648
+ request_id=ctx.request_id,
1649
+ inner_turn=turn_index,
1650
+ text=_system_prompt_snapshot_text(system, admit.mid_conversation_update),
1651
+ )
1652
+
1653
+ llm_input = 0
1654
+ llm_output = 0
1655
+ llm_cached = 0
1656
+ llm_cache_read = 0
1657
+ llm_cache_creation = 0
1658
+ if transcript_writer is not None:
1659
+ if provider_messages_written >= len(provider_messages):
1660
+ delta_messages = provider_messages
1661
+ message_offset = 0
1662
+ messages_reset = provider_messages_written > 0
1663
+ else:
1664
+ delta_messages = provider_messages[provider_messages_written:]
1665
+ message_offset = provider_messages_written
1666
+ messages_reset = False
1667
+ include_tools = turn_index == 1 or messages_reset or tools_dirty
1668
+ await transcript_writer.write_provider_request(
1669
+ request_id=ctx.request_id,
1670
+ inner_turn=turn_index,
1671
+ model=ctx.model,
1672
+ messages=[m.to_dict() for m in delta_messages],
1673
+ message_offset=message_offset,
1674
+ messages_reset=messages_reset,
1675
+ tools=[dataclasses.asdict(t) for t in turn_tools] if include_tools else None,
1676
+ thinking_budget=stream_thinking,
1677
+ )
1678
+ provider_messages_written = len(provider_messages)
1679
+ tools_dirty = False
1680
+ stream_mapper = ProviderStreamMapper(ctx.request_id)
1681
+ try:
1682
+ async with span_llm(ctx=ctx, vertex_google_search=vertex_google_search):
1683
+ async with aclosing(
1684
+ cast(
1685
+ Any,
1686
+ provider_stream(
1687
+ provider,
1688
+ provider_messages,
1689
+ turn_tools,
1690
+ model=ctx.model,
1691
+ thinking_budget=stream_thinking,
1692
+ vertex_google_search=vertex_google_search,
1693
+ hints=_provider_call_hints(ctx),
1694
+ ),
1695
+ )
1696
+ ) as stream:
1697
+ async for ev in stream:
1698
+ if isinstance(ev, UsageEvent):
1699
+ _merge_usage_event(usage, ev)
1700
+ usage.cost_usd += estimate_cost(
1701
+ ctx.model,
1702
+ ev.input_tokens,
1703
+ ev.output_tokens,
1704
+ cache_read_tokens=ev.cache_read_tokens,
1705
+ cache_creation_tokens=ev.cache_creation_tokens,
1706
+ )
1707
+ llm_input += ev.input_tokens
1708
+ llm_output += ev.output_tokens
1709
+ llm_cached += ev.cached_tokens
1710
+ llm_cache_read += ev.cache_read_tokens
1711
+ llm_cache_creation += ev.cache_creation_tokens
1712
+ continue
1713
+ if isinstance(ev, Done):
1714
+ for aev in stream_mapper.map(ev):
1715
+ yield aev
1716
+ break
1717
+ for aev in stream_mapper.map(ev):
1718
+ yield aev
1719
+ for aev in stream_mapper.finish():
1720
+ yield aev
1721
+ pending = stream_mapper.pending
1722
+ assistant_text = stream_mapper.assistant_text
1723
+ thinking_text = stream_mapper.thinking_text
1724
+ thinking_signature = stream_mapper.thinking_signature
1725
+ stream_truncated = stream_mapper.stream_truncated
1726
+ set_llm_usage(
1727
+ input_tokens=llm_input,
1728
+ output_tokens=llm_output,
1729
+ cached_tokens=llm_cached,
1730
+ cache_read_tokens=llm_cache_read,
1731
+ cache_creation_tokens=llm_cache_creation,
1732
+ )
1733
+ set_llm_io(
1734
+ prompt=_provider_messages_prompt_summary(provider_messages),
1735
+ completion=assistant_text or "",
1736
+ )
1737
+ if transcript_writer is not None:
1738
+ await transcript_writer.write_provider_response(
1739
+ request_id=ctx.request_id,
1740
+ inner_turn=turn_index,
1741
+ model=ctx.model,
1742
+ text=assistant_text,
1743
+ thinking=thinking_text,
1744
+ tool_requests=[
1745
+ {"call_id": tc.call_id, "name": tc.name, "args": tc.args}
1746
+ for tc in pending.values()
1747
+ ],
1748
+ usage={
1749
+ "input_tokens": llm_input,
1750
+ "output_tokens": llm_output,
1751
+ "cached_tokens": llm_cached,
1752
+ "cache_read_tokens": llm_cache_read,
1753
+ "cache_creation_tokens": llm_cache_creation,
1754
+ },
1755
+ )
1756
+ logger.debug(
1757
+ "provider stream done %s",
1758
+ kv(
1759
+ request_id=ctx.request_id,
1760
+ thread_id=ctx.thread_id,
1761
+ turn=turn_index,
1762
+ llm_input=llm_input,
1763
+ llm_output=llm_output,
1764
+ llm_cached=llm_cached,
1765
+ tool_calls=len(pending),
1766
+ ),
1767
+ )
1768
+ await _fire_after_provider_response(
1769
+ hook_manager,
1770
+ ctx=ctx,
1771
+ inner_turn=turn_index,
1772
+ assistant_text=assistant_text,
1773
+ thinking_text=thinking_text,
1774
+ tool_requests=[
1775
+ {"call_id": tc.call_id, "name": tc.name, "args": dict(tc.args)}
1776
+ for tc in pending.values()
1777
+ ],
1778
+ usage={
1779
+ "input_tokens": llm_input,
1780
+ "output_tokens": llm_output,
1781
+ "cached_tokens": llm_cached,
1782
+ "cache_read_tokens": llm_cache_read,
1783
+ "cache_creation_tokens": llm_cache_creation,
1784
+ },
1785
+ )
1786
+ except asyncio.CancelledError:
1787
+ for aev in stream_mapper.finish():
1788
+ yield aev
1789
+ await _fire_after_provider_response(
1790
+ hook_manager,
1791
+ ctx=ctx,
1792
+ inner_turn=turn_index,
1793
+ provider_error="Request cancelled",
1794
+ )
1795
+ yield Error(request_id=ctx.request_id, error="Request cancelled")
1796
+ needs_followup_after_tools = False
1797
+ return
1798
+ except Exception as exc:
1799
+ for aev in stream_mapper.finish():
1800
+ yield aev
1801
+ logger.exception(
1802
+ "provider stream failed %s",
1803
+ kv(request_id=ctx.request_id, thread_id=ctx.thread_id, model=ctx.model),
1804
+ )
1805
+ await _fire_after_provider_response(
1806
+ hook_manager,
1807
+ ctx=ctx,
1808
+ inner_turn=turn_index,
1809
+ provider_error=str(exc),
1810
+ )
1811
+ yield Error(request_id=ctx.request_id, error=str(exc))
1812
+ needs_followup_after_tools = False
1813
+ return
1814
+
1815
+ if cancelled is not None and cancelled.is_set():
1816
+ yield Error(request_id=ctx.request_id, error="Request cancelled")
1817
+ needs_followup_after_tools = False
1818
+ break
1819
+
1820
+ if not pending:
1821
+ cleaned_text = (assistant_text or "").strip()
1822
+ if cleaned_text:
1823
+ # Fire-and-forget: this is the final assistant turn (we break
1824
+ # right after), so nothing reads it again in-loop. Keeping it
1825
+ # off the await path means the Firestore write does not delay
1826
+ # TurnComplete / the user-visible reply. Awaited at the tail.
1827
+ assistant_write_task = asyncio.create_task(
1828
+ history.append(
1829
+ ctx.thread_id,
1830
+ Message(role="assistant", content=[Text(text=cleaned_text)]),
1831
+ )
1832
+ )
1833
+ last_assistant[0] = cleaned_text
1834
+ turn_output_text = cleaned_text
1835
+ needs_followup_after_tools = False
1836
+ break
1837
+ # Model returned no text after tool results (or only whitespace). Without another
1838
+ # provider round the user sees tools then silence — retry until turn budget.
1839
+ rows = await _load_agent_chat_history(history, ctx.thread_id)
1840
+ owes_tool_followup = needs_followup_after_tools or (
1841
+ bool(rows)
1842
+ and rows[-1].role == "user"
1843
+ and any(isinstance(b, ToolResponse) for b in rows[-1].content)
1844
+ )
1845
+ if owes_tool_followup and turn_index < effective_max:
1846
+ continue
1847
+ needs_followup_after_tools = False
1848
+ break
1849
+
1850
+ ordered = sorted(pending.values(), key=lambda c: c.call_id)
1851
+ assist_blocks: list[ContentBlock] = []
1852
+ if thinking_text.strip():
1853
+ assist_blocks.append(
1854
+ ThinkingBlock(thinking=thinking_text, signature=thinking_signature or "")
1855
+ )
1856
+ prose = (assistant_text or "").strip()
1857
+ if prose:
1858
+ assist_blocks.append(Text(text=prose))
1859
+ for c in ordered:
1860
+ assist_blocks.append(
1861
+ ToolRequest(
1862
+ id=c.call_id,
1863
+ name=c.name,
1864
+ args=dict(c.args),
1865
+ parse_error=c.parse_error,
1866
+ metadata=dict(c.metadata) if c.metadata else None,
1867
+ )
1868
+ )
1869
+ await history.append(
1870
+ ctx.thread_id,
1871
+ Message(role="assistant", content=assist_blocks),
1872
+ )
1873
+
1874
+ needs_followup_after_tools = True
1875
+
1876
+ # One user Message for the whole model tool-call turn (all chunks).
1877
+ # Gemini 400s when functionResponse count != functionCall count on the
1878
+ # preceding model turn; serial non-task tools are separate chunks but
1879
+ # must still share a single user row.
1880
+ all_tool_responses: list[ContentBlock] = []
1881
+ mcp_registry_mutated = False
1882
+ # Computed once per batch and reused for both chunking and dispatch
1883
+ # so the two stay consistent even if ctx.tools were ever mutated
1884
+ # mid-batch (e.g. by an MCP registry reload).
1885
+ safe_names = _parallel_safe_names(ctx.tools)
1886
+ reject_batch = _should_reject_tool_batch(ordered, truncated=stream_truncated)
1887
+ if reject_batch:
1888
+ logger.warning(
1889
+ "rejecting truncated/incomplete tool batch %s",
1890
+ kv(
1891
+ request_id=ctx.request_id,
1892
+ thread_id=ctx.thread_id,
1893
+ truncated=stream_truncated,
1894
+ n_calls=len(ordered),
1895
+ ),
1896
+ )
1897
+ for call in ordered:
1898
+ err = _rejected_tool_batch_error(call, truncated=stream_truncated)
1899
+ yield ToolCallStarted(
1900
+ request_id=ctx.request_id,
1901
+ tool=call.name,
1902
+ label=call.name,
1903
+ args=dict(call.args),
1904
+ parse_error=call.parse_error,
1905
+ )
1906
+ result_evt, tool_resp = _finish_tool(
1907
+ call, ToolExecutionResult.err(err)
1908
+ )
1909
+ yield result_evt
1910
+ all_tool_responses.append(tool_resp)
1911
+
1912
+ for chunk in (() if reject_batch else _chunk_tool_calls(
1913
+ ordered, parallel_safe=safe_names
1914
+ )):
1915
+ if cancelled is not None and cancelled.is_set():
1916
+ yield Error(request_id=ctx.request_id, error="Request cancelled")
1917
+ needs_followup_after_tools = False
1918
+ return
1919
+
1920
+ allowed_exec: list[ToolCall] = []
1921
+ # Collect ToolResponse blocks for this chunk; merged into
1922
+ # ``all_tool_responses`` after every chunk completes.
1923
+ chunk_responses: list[ContentBlock] = []
1924
+
1925
+ for call in chunk:
1926
+ if cancelled is not None and cancelled.is_set():
1927
+ yield Error(request_id=ctx.request_id, error="Request cancelled")
1928
+ needs_followup_after_tools = False
1929
+ return
1930
+
1931
+ # A provider couldn't parse the streamed tool JSON: args are
1932
+ # empty and ``parse_error`` holds the reason. Surface it as a
1933
+ # tool error result so the model can self-correct instead of
1934
+ # executing the tool with empty arguments.
1935
+ if call.parse_error:
1936
+ yield ToolCallStarted(
1937
+ request_id=ctx.request_id,
1938
+ tool=call.name,
1939
+ label=call.name,
1940
+ args=dict(call.args),
1941
+ parse_error=call.parse_error,
1942
+ call_id=call.call_id,
1943
+ )
1944
+ result_evt, tool_resp = _finish_tool(
1945
+ call, ToolExecutionResult.err(call.parse_error)
1946
+ )
1947
+ yield result_evt
1948
+ chunk_responses.append(tool_resp)
1949
+ continue
1950
+
1951
+ inspector_call = InspectorToolCall(
1952
+ call_id=call.call_id, name=call.name, args=dict(call.args)
1953
+ )
1954
+ allowed = True
1955
+ denial_message: str | None = None
1956
+ for insp in inspectors:
1957
+ decision = await insp.check(inspector_call, ctx)
1958
+ match decision.kind:
1959
+ case "allow":
1960
+ pass
1961
+ case "deny":
1962
+ allowed = False
1963
+ denial_message = decision.message
1964
+ logger.debug(
1965
+ "tool inspector deny %s",
1966
+ kv(
1967
+ request_id=ctx.request_id,
1968
+ thread_id=ctx.thread_id,
1969
+ tool=call.name,
1970
+ call_id=call.call_id,
1971
+ decision="deny",
1972
+ ),
1973
+ )
1974
+ break
1975
+ case "confirm":
1976
+ if ctx.sse_bus is None:
1977
+ allowed = False
1978
+ denial_message = (
1979
+ decision.message
1980
+ or "Confirmation required but no SSE session is available"
1981
+ )
1982
+ break
1983
+ bus = ctx.sse_bus
1984
+ fut = bus.register_pending(call.call_id)
1985
+ yield ToolConfirmationRequestEvent(
1986
+ request_id=ctx.request_id,
1987
+ tool_call_id=call.call_id,
1988
+ tool_name=call.name,
1989
+ arguments=dict(call.args),
1990
+ prompt=decision.message,
1991
+ )
1992
+ try:
1993
+ payload = await _await_user_response_any(
1994
+ bus, fut, call.call_id, timeout_sec=None
1995
+ )
1996
+ except asyncio.CancelledError:
1997
+ # Re-raise so turn cancellation (client disconnect /
1998
+ # abort) propagates; do not continue the tool loop
1999
+ # on a dead session.
2000
+ raise
2001
+ if payload.get("_timeout"):
2002
+ allowed = False
2003
+ to = int(
2004
+ float(os.environ.get("PENDING_RESPONSE_TIMEOUT_SEC", "300"))
2005
+ )
2006
+ denial_message = f"user did not respond within {to}s"
2007
+ break
2008
+ if payload.get("approved"):
2009
+ allowed = True
2010
+ if payload.get("always"):
2011
+ remember_always_approval(
2012
+ bus, call.name, resource_for_call(inspector_call)
2013
+ )
2014
+ logger.debug(
2015
+ "tool inspector confirm %s",
2016
+ kv(
2017
+ request_id=ctx.request_id,
2018
+ thread_id=ctx.thread_id,
2019
+ tool=call.name,
2020
+ call_id=call.call_id,
2021
+ decision="confirm_always",
2022
+ ),
2023
+ )
2024
+ logger.debug(
2025
+ "tool inspector confirm %s",
2026
+ kv(
2027
+ request_id=ctx.request_id,
2028
+ thread_id=ctx.thread_id,
2029
+ tool=call.name,
2030
+ call_id=call.call_id,
2031
+ decision="confirm_approved",
2032
+ ),
2033
+ )
2034
+ else:
2035
+ allowed = False
2036
+ reason_raw = payload.get("reason")
2037
+ denial_message = (
2038
+ (reason_raw if isinstance(reason_raw, str) else None)
2039
+ or "denied by user"
2040
+ )
2041
+ logger.debug(
2042
+ "tool inspector confirm %s",
2043
+ kv(
2044
+ request_id=ctx.request_id,
2045
+ thread_id=ctx.thread_id,
2046
+ tool=call.name,
2047
+ call_id=call.call_id,
2048
+ decision="confirm_denied",
2049
+ ),
2050
+ )
2051
+ break
2052
+
2053
+ if not allowed:
2054
+ msg = denial_message or "tool call denied"
2055
+ yield ToolCallStarted(
2056
+ request_id=ctx.request_id,
2057
+ tool=call.name,
2058
+ label=call.name,
2059
+ args=dict(call.args),
2060
+ call_id=call.call_id,
2061
+ )
2062
+ result_evt, tool_resp = _finish_tool(
2063
+ call, ToolExecutionResult.err(msg)
2064
+ )
2065
+ yield result_evt
2066
+ chunk_responses.append(tool_resp)
2067
+ continue
2068
+
2069
+ yield ToolCallStarted(
2070
+ request_id=ctx.request_id,
2071
+ tool=call.name,
2072
+ label=call.name,
2073
+ args=dict(call.args),
2074
+ call_id=call.call_id,
2075
+ )
2076
+ allowed_exec.append(call)
2077
+
2078
+ if not allowed_exec:
2079
+ all_tool_responses.extend(chunk_responses)
2080
+ continue
2081
+
2082
+ # ToolCallStarted already published via async-gen consumer;
2083
+ # PRE_TOOL is awaited inside _execute_one_tool_call.
2084
+ parallel_chunk = (
2085
+ all(c.name == "task" for c in allowed_exec)
2086
+ or (
2087
+ len(allowed_exec) > 1
2088
+ and all(c.name in safe_names for c in allowed_exec)
2089
+ )
2090
+ )
2091
+ dispatch_mode = "parallel" if parallel_chunk else "serial"
2092
+ logger.debug(
2093
+ "tool dispatch %s",
2094
+ kv(
2095
+ request_id=ctx.request_id,
2096
+ thread_id=ctx.thread_id,
2097
+ turn=turn_index,
2098
+ mode=dispatch_mode,
2099
+ chunk_size=len(allowed_exec),
2100
+ ),
2101
+ )
2102
+
2103
+ async def _execute_one_tool_call(
2104
+ call: ToolCall,
2105
+ *,
2106
+ _ctx: TurnContext = ctx,
2107
+ ) -> ToolExecutionResult:
2108
+ nonlocal pre_tool_extra_next
2109
+ logger.debug(
2110
+ "tool execute %s",
2111
+ kv(
2112
+ request_id=_ctx.request_id,
2113
+ thread_id=_ctx.thread_id,
2114
+ tool=call.name,
2115
+ call_id=call.call_id,
2116
+ ),
2117
+ )
2118
+ async with span_tool(
2119
+ tool_name=call.name,
2120
+ tool_call_id=call.call_id,
2121
+ thread_id=_ctx.thread_id,
2122
+ request_id=_ctx.request_id,
2123
+ args=dict(call.args),
2124
+ ):
2125
+ pre_tool_payload = await _fire_hook(
2126
+ hook_manager,
2127
+ event=HookEvent.PRE_TOOL,
2128
+ ctx=_ctx,
2129
+ timeout_s=_HOOK_PRE_TOOL_TIMEOUT_S,
2130
+ tool_name=call.name,
2131
+ tool_args=dict(call.args),
2132
+ )
2133
+ if pre_tool_payload is not None and pre_tool_payload.inject_text:
2134
+ pre_tool_extra_next = _combine_extras(
2135
+ pre_tool_extra_next, pre_tool_payload.inject_text
2136
+ )
2137
+ _record_tool_hook_span_event("pre_tool", call.name)
2138
+ try:
2139
+ tool_result = await tool_executor.execute(call=call, ctx=_ctx)
2140
+ except asyncio.CancelledError:
2141
+ raise
2142
+ except Exception as exc:
2143
+ logger.warning(
2144
+ "tool execution failed %s",
2145
+ kv(
2146
+ request_id=_ctx.request_id,
2147
+ thread_id=_ctx.thread_id,
2148
+ tool=call.name,
2149
+ call_id=call.call_id,
2150
+ ),
2151
+ exc_info=True,
2152
+ )
2153
+ tool_result = ToolExecutionResult.err(str(exc))
2154
+ result_summary = (
2155
+ _blocks_to_sse_summary(tool_result.blocks)
2156
+ if tool_result.error is None
2157
+ else None
2158
+ )
2159
+ record_tool_outcome(result_summary, tool_result.error)
2160
+ await _fire_hook(
2161
+ hook_manager,
2162
+ event=HookEvent.POST_TOOL,
2163
+ ctx=_ctx,
2164
+ timeout_s=0,
2165
+ tool_name=call.name,
2166
+ tool_args=dict(call.args),
2167
+ tool_result=result_summary,
2168
+ tool_error=tool_result.error,
2169
+ )
2170
+ _record_tool_hook_span_event("post_tool", call.name)
2171
+ return tool_result
2172
+
2173
+ if not parallel_chunk:
2174
+ call = allowed_exec[0]
2175
+ try:
2176
+ tool_result = await _execute_one_tool_call(call)
2177
+ except asyncio.CancelledError:
2178
+ yield Error(request_id=ctx.request_id, error="Request cancelled")
2179
+ needs_followup_after_tools = False
2180
+ return
2181
+
2182
+ event, response = _finish_tool(call, tool_result)
2183
+ yield event
2184
+ for img_evt in _image_events(ctx.request_id, call.call_id, tool_result):
2185
+ yield img_evt
2186
+ chunk_responses.append(response)
2187
+ if (
2188
+ tool_result.error is None
2189
+ and call.name in MCP_REGISTRY_MUTATING_TOOLS
2190
+ ):
2191
+ mcp_registry_mutated = True
2192
+ else:
2193
+ if cancelled is not None and cancelled.is_set():
2194
+ yield Error(request_id=ctx.request_id, error="Request cancelled")
2195
+ needs_followup_after_tools = False
2196
+ return
2197
+
2198
+ conc = (
2199
+ _MAX_CONCURRENT_SUBAGENTS
2200
+ if all(c.name == "task" for c in allowed_exec)
2201
+ else _parallel_tool_concurrency()
2202
+ )
2203
+ sem = asyncio.Semaphore(conc)
2204
+
2205
+ async def _run_parallel(
2206
+ call: ToolCall,
2207
+ *,
2208
+ _sem: asyncio.Semaphore = sem,
2209
+ _ctx: TurnContext = ctx,
2210
+ ) -> tuple[ToolCall, ToolExecutionResult]:
2211
+ async with _sem:
2212
+ return (call, await _execute_one_tool_call(call, _ctx=_ctx))
2213
+
2214
+ try:
2215
+ outcomes = await asyncio.gather(
2216
+ *[_run_parallel(c) for c in allowed_exec],
2217
+ return_exceptions=True,
2218
+ )
2219
+ except asyncio.CancelledError:
2220
+ yield Error(request_id=ctx.request_id, error="Request cancelled")
2221
+ needs_followup_after_tools = False
2222
+ return
2223
+
2224
+ for idx, outcome in enumerate(outcomes):
2225
+ call = allowed_exec[idx]
2226
+ if isinstance(outcome, asyncio.CancelledError):
2227
+ yield Error(request_id=ctx.request_id, error="Request cancelled")
2228
+ needs_followup_after_tools = False
2229
+ return
2230
+ if isinstance(outcome, Exception):
2231
+ logger.warning(
2232
+ "tool execution failed %s",
2233
+ kv(
2234
+ request_id=ctx.request_id,
2235
+ thread_id=ctx.thread_id,
2236
+ tool=call.name,
2237
+ call_id=call.call_id,
2238
+ ),
2239
+ exc_info=(type(outcome), outcome, outcome.__traceback__),
2240
+ )
2241
+ tool_result = ToolExecutionResult.err(str(outcome))
2242
+ else:
2243
+ _, tool_result = cast(tuple[ToolCall, ToolExecutionResult], outcome)
2244
+
2245
+ event, response = _finish_tool(call, tool_result)
2246
+ yield event
2247
+ for img_evt in _image_events(ctx.request_id, call.call_id, tool_result):
2248
+ yield img_evt
2249
+ chunk_responses.append(response)
2250
+ if (
2251
+ tool_result.error is None
2252
+ and call.name in MCP_REGISTRY_MUTATING_TOOLS
2253
+ ):
2254
+ mcp_registry_mutated = True
2255
+
2256
+ all_tool_responses.extend(chunk_responses)
2257
+ doom_msg = doom_tracker.take_error()
2258
+ if doom_msg is not None:
2259
+ logger.warning(
2260
+ "doom loop detected %s",
2261
+ kv(
2262
+ request_id=ctx.request_id,
2263
+ thread_id=ctx.thread_id,
2264
+ tool=doom_tracker.triggered_tool,
2265
+ threshold=doom_tracker.threshold,
2266
+ ),
2267
+ )
2268
+ yield Error(request_id=ctx.request_id, error=doom_msg)
2269
+
2270
+ if all_tool_responses:
2271
+ chat_for_budget = await _load_agent_chat_history(history, ctx.thread_id)
2272
+ budget_used = usage.estimated_prompt_tokens
2273
+ try:
2274
+ budget_used = await _prompt_input_tokens_for_history(
2275
+ ctx=ctx,
2276
+ chat_messages=chat_for_budget,
2277
+ provider=provider,
2278
+ attachment_store=attachment_store,
2279
+ attachment_catalog=attachment_catalog,
2280
+ memory_selection=memory_selection,
2281
+ extra_system_text=pre_turn_extra,
2282
+ vertex_google_search=vertex_google_search,
2283
+ epoch=epoch_tracker,
2284
+ )
2285
+ except Exception:
2286
+ logger.warning(
2287
+ "tool budget token recount failed %s; using estimated_prompt_tokens",
2288
+ kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
2289
+ exc_info=True,
2290
+ )
2291
+ usage.estimated_prompt_tokens = max(
2292
+ usage.estimated_prompt_tokens, budget_used
2293
+ )
2294
+ budgeter = ContextBudgeter.from_env(
2295
+ window_tokens=ctx.context_window_tokens,
2296
+ used_tokens=budget_used,
2297
+ )
2298
+ async for budget_evt in _append_budgeted_tool_responses(
2299
+ chunk_responses=all_tool_responses,
2300
+ ctx=ctx,
2301
+ history=history,
2302
+ usage=usage,
2303
+ budgeter=budgeter,
2304
+ provider=provider,
2305
+ vertex_google_search=vertex_google_search,
2306
+ epoch=epoch_tracker,
2307
+ ):
2308
+ yield budget_evt
2309
+
2310
+ if mcp_registry_mutated:
2311
+ mcp_client = getattr(tool_executor, "mcp", None)
2312
+ if mcp_client is None:
2313
+ logger.error(
2314
+ "MCP registry mutated but tool executor has no mcp client %s",
2315
+ kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
2316
+ )
2317
+ raise RuntimeError(
2318
+ "tool executor missing mcp client after MCP registry mutation"
2319
+ )
2320
+ ctx = refresh_tools_after_mcp_change(ctx, mcp_client)
2321
+ tools_dirty = True
2322
+ doom_tracker.exempt_names = _doom_loop_exempt_names(ctx.tools)
2323
+ logger.info(
2324
+ "refreshed ctx.tools after MCP registry change %s",
2325
+ kv(
2326
+ request_id=ctx.request_id,
2327
+ thread_id=ctx.thread_id,
2328
+ tool_count=len(ctx.tools),
2329
+ ),
2330
+ )
2331
+
2332
+ finally:
2333
+ set_turn_prompt_tokens(usage.estimated_prompt_tokens)
2334
+ if turn_output_text:
2335
+ set_turn_io(output_value=turn_output_text)
2336
+ end_turn_span(turn_span)
2337
+ if needs_followup_after_tools:
2338
+ logger.warning(
2339
+ "max turns exceeded %s",
2340
+ kv(
2341
+ request_id=ctx.request_id,
2342
+ thread_id=ctx.thread_id,
2343
+ max_turns=effective_max,
2344
+ ),
2345
+ )
2346
+ yield Error(request_id=ctx.request_id, error="Max turns exceeded")
2347
+
2348
+ # Ensure the backgrounded assistant write has landed before any load/reset
2349
+ # below (freeze) so the assistant row is durable and not overwritten.
2350
+ await _await_history_write(assistant_write_task)
2351
+
2352
+ descriptor_events = await freeze_attachments_in_history(
2353
+ thread_id=ctx.thread_id,
2354
+ history=history,
2355
+ catalog=attachment_catalog,
2356
+ last_assistant_text=last_assistant[0],
2357
+ )
2358
+ for desc_evt in descriptor_events:
2359
+ yield AttachmentDescriptorEvent(
2360
+ request_id=ctx.request_id,
2361
+ attachment_id=desc_evt.attachment_id,
2362
+ mime_type=desc_evt.mime_type,
2363
+ filename=desc_evt.filename,
2364
+ description=desc_evt.description,
2365
+ )
2366
+
2367
+ await _fire_hook(
2368
+ hook_manager,
2369
+ event=HookEvent.POST_TURN,
2370
+ ctx=ctx,
2371
+ timeout_s=0,
2372
+ )
2373
+ # Settlement for POST_TURN / lingering POST_TOOL runs in run() finally
2374
+ # before TurnComplete — do not drain twice here.