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,654 @@
1
+ """Post-utterance processing for the realtime harness.
2
+
3
+ This module mirrors :func:`~monkeybot.core.runtime.loop.run` for realtime turns: it
4
+ commits finalized user/assistant utterances to ``HistoryStore``, runs hooks, and
5
+ dispatches tools. It does **not** call a model provider; the realtime vendor session
6
+ is managed by the gateway.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import uuid
15
+ from collections.abc import AsyncIterator, Sequence
16
+ from typing import Any
17
+
18
+ from monkeybot.core.attachments.catalog import SessionAttachmentCatalog
19
+ from monkeybot.core.attachments.freeze import freeze_attachments_in_history
20
+ from monkeybot.core.attachments.store import AttachmentStore
21
+ from monkeybot.core.context import (
22
+ PendingResponseBusPort,
23
+ TurnContext,
24
+ MCP_REGISTRY_MUTATING_TOOLS,
25
+ refresh_memory_index,
26
+ refresh_tools_after_mcp_change,
27
+ )
28
+ from monkeybot.core.hooks import HookEvent, HookManager, HookPayload
29
+ from monkeybot.core.llm.provider import Message, ToolCall
30
+ from monkeybot.core.llm.realtime_provider import RealtimeToolCall
31
+ from monkeybot.core.logging_utils import kv
32
+ from monkeybot.core.persistence.backends import HistoryStore
33
+ from monkeybot.core.persistence.transcript import TranscriptWriter
34
+ from monkeybot.core.runtime.events import (
35
+ ActionRequiredEvent,
36
+ AgentEvent,
37
+ AssistantDelta,
38
+ AttachmentDescriptorEvent,
39
+ Error,
40
+ Thinking,
41
+ ToolCallResult,
42
+ ToolCallStarted,
43
+ ToolConfirmationRequestEvent,
44
+ TurnComplete,
45
+ UsageTotals,
46
+ )
47
+ from monkeybot.core.tools.inspector import InspectorToolCall
48
+ from monkeybot.core.tools.permission import remember_always_approval, resource_for_call
49
+ from monkeybot.core.tools.types import ToolExecutionResult
50
+ from monkeybot.core.types.content_blocks import (
51
+ ActionRequired,
52
+ ContentBlock,
53
+ ElicitationAction,
54
+ Text,
55
+ ToolRequest,
56
+ ToolResponse,
57
+ )
58
+
59
+ from .loop import (
60
+ ToolExecutorPort,
61
+ _await_user_response_any,
62
+ _rejected_tool_batch_error,
63
+ _should_reject_tool_batch,
64
+ )
65
+
66
+ logger = logging.getLogger("monkeybot.core.runtime.realtime_loop")
67
+
68
+
69
+ _HOOK_PRE_TOOL_TIMEOUT_S = 1.5
70
+
71
+ _REALTIME_MCP_NEW_SESSION_NOTE = (
72
+ "MCP registry updated on the harness. Newly enabled MCP tool schemas are not "
73
+ "pushed into the live voice session; start a new realtime session to advertise "
74
+ "them to the model (v1 has no reconnect/resume)."
75
+ )
76
+
77
+
78
+ def _user_text_from_content(blocks: Sequence[ContentBlock]) -> str:
79
+ return " ".join(
80
+ b.text.strip() for b in blocks if isinstance(b, Text) and b.text.strip()
81
+ )
82
+
83
+
84
+ def _elicitation_user_data_text(user_data: object) -> str:
85
+ if user_data is None:
86
+ return ""
87
+ if isinstance(user_data, str):
88
+ return user_data
89
+ try:
90
+ return json.dumps(user_data, sort_keys=True)
91
+ except (TypeError, ValueError):
92
+ return str(user_data)
93
+
94
+
95
+ async def _resolve_elicitation_blocks(
96
+ blocks: Sequence[ContentBlock],
97
+ *,
98
+ ctx: TurnContext,
99
+ pending_bus: PendingResponseBusPort | None,
100
+ ) -> AsyncIterator[AgentEvent | list[ContentBlock]]:
101
+ """Yield ``ActionRequiredEvent``s for elicitation blocks, then the resolved block list.
102
+
103
+ Tools may return :class:`ActionRequired` / :class:`ElicitationAction` blocks to pause
104
+ the realtime turn until the client answers via ``elicitation_response``.
105
+ """
106
+ out: list[ContentBlock] = []
107
+ for block in blocks:
108
+ if not (
109
+ isinstance(block, ActionRequired) and isinstance(block.data, ElicitationAction)
110
+ ):
111
+ out.append(block)
112
+ continue
113
+ action = block.data
114
+ elicitation_id = action.id.strip() or str(uuid.uuid4())
115
+ if pending_bus is None:
116
+ logger.warning(
117
+ "realtime elicitation unavailable %s",
118
+ kv(
119
+ request_id=ctx.request_id,
120
+ thread_id=ctx.thread_id,
121
+ elicitation_id=elicitation_id,
122
+ ),
123
+ )
124
+ out.append(
125
+ Text(text=f"{action.message} (elicitation unavailable)")
126
+ )
127
+ continue
128
+ fut = pending_bus.register_pending(elicitation_id)
129
+ yield ActionRequiredEvent(
130
+ request_id=ctx.request_id,
131
+ action_type="elicitation",
132
+ id=elicitation_id,
133
+ payload={
134
+ "message": action.message,
135
+ "requestedSchema": dict(action.requested_schema or {}),
136
+ },
137
+ )
138
+ try:
139
+ payload = await _await_user_response_any(
140
+ pending_bus, fut, elicitation_id, timeout_sec=None
141
+ )
142
+ except asyncio.CancelledError:
143
+ raise
144
+ if payload.get("_timeout"):
145
+ out.append(Text(text="User did not respond to elicitation in time"))
146
+ continue
147
+ if payload.get("cancelled"):
148
+ out.append(Text(text="User cancelled elicitation"))
149
+ continue
150
+ out.append(Text(text=_elicitation_user_data_text(payload.get("user_data"))))
151
+ yield out
152
+
153
+
154
+ async def _fire_hook(
155
+ hook_manager: HookManager | None,
156
+ *,
157
+ event: HookEvent,
158
+ ctx: TurnContext,
159
+ timeout_s: float,
160
+ user_message: str | None = None,
161
+ tool_name: str | None = None,
162
+ tool_args: dict[str, Any] | None = None,
163
+ tool_result: str | None = None,
164
+ tool_error: str | None = None,
165
+ ) -> HookPayload | None:
166
+ if hook_manager is None:
167
+ return None
168
+ payload = HookPayload(
169
+ event=event,
170
+ thread_id=ctx.thread_id,
171
+ request_id=ctx.request_id,
172
+ ctx=ctx,
173
+ user_message=user_message,
174
+ tool_name=tool_name,
175
+ tool_args=tool_args,
176
+ tool_result=tool_result,
177
+ tool_error=tool_error,
178
+ )
179
+ return await hook_manager.fire(payload, timeout_s=timeout_s)
180
+
181
+
182
+ def _blocks_to_sse_summary(blocks: Sequence[ContentBlock]) -> str:
183
+ parts: list[str] = []
184
+ for block in blocks:
185
+ if isinstance(block, Text):
186
+ parts.append(block.text)
187
+ else:
188
+ parts.append(f"[{type(block).__name__}]")
189
+ return "\n".join(parts)
190
+
191
+
192
+ def _tool_outcome(
193
+ call: ToolCall,
194
+ request_id: str,
195
+ result: ToolExecutionResult,
196
+ ) -> tuple[ToolCallResult, ToolResponse]:
197
+ is_error = result.error is not None
198
+ body = "" if is_error else _blocks_to_sse_summary(result.blocks)
199
+ text = result.error if is_error else body
200
+ response_blocks: list[ContentBlock] = (
201
+ list(result.blocks) if not is_error else [Text(text=text or "")]
202
+ )
203
+ event = ToolCallResult(
204
+ request_id=request_id,
205
+ tool=call.name,
206
+ result=body,
207
+ error=result.error,
208
+ call_id=call.call_id,
209
+ )
210
+ response = ToolResponse(
211
+ id=call.call_id,
212
+ tool_name=call.name,
213
+ result=response_blocks,
214
+ is_error=is_error,
215
+ )
216
+ return event, response
217
+
218
+
219
+ async def _execute_tool(
220
+ call: ToolCall,
221
+ ctx: TurnContext,
222
+ *,
223
+ tool_executor: ToolExecutorPort,
224
+ hook_manager: HookManager | None,
225
+ request_id: str,
226
+ ) -> tuple[ToolExecutionResult, str | None]:
227
+ """Execute one tool; return (result, optional inject_text from pre-tool hooks)."""
228
+ logger.debug(
229
+ "realtime tool execute %s",
230
+ kv(
231
+ request_id=request_id,
232
+ thread_id=ctx.thread_id,
233
+ tool=call.name,
234
+ call_id=call.call_id,
235
+ ),
236
+ )
237
+ pre_tool_payload = await _fire_hook(
238
+ hook_manager,
239
+ event=HookEvent.PRE_TOOL,
240
+ ctx=ctx,
241
+ timeout_s=_HOOK_PRE_TOOL_TIMEOUT_S,
242
+ tool_name=call.name,
243
+ tool_args=dict(call.args),
244
+ )
245
+ inject_text: str | None = None
246
+ if pre_tool_payload is not None and pre_tool_payload.inject_text:
247
+ inject_text = pre_tool_payload.inject_text
248
+ try:
249
+ result = await tool_executor.execute(call=call, ctx=ctx)
250
+ except asyncio.CancelledError:
251
+ raise
252
+ except Exception as exc:
253
+ logger.warning(
254
+ "realtime tool execution failed %s",
255
+ kv(
256
+ request_id=request_id,
257
+ thread_id=ctx.thread_id,
258
+ tool=call.name,
259
+ call_id=call.call_id,
260
+ ),
261
+ exc_info=True,
262
+ )
263
+ result = ToolExecutionResult.err(str(exc))
264
+ result_summary = (
265
+ _blocks_to_sse_summary(result.blocks) if result.error is None else None
266
+ )
267
+ await _fire_hook(
268
+ hook_manager,
269
+ event=HookEvent.POST_TOOL,
270
+ ctx=ctx,
271
+ timeout_s=0,
272
+ tool_name=call.name,
273
+ tool_args=dict(call.args),
274
+ tool_result=result_summary,
275
+ tool_error=result.error,
276
+ )
277
+ return result, inject_text
278
+
279
+
280
+ def _realtime_tool_call_to_tool_call(call: RealtimeToolCall) -> ToolCall:
281
+ return ToolCall(
282
+ call_id=call.call_id,
283
+ name=call.name,
284
+ args=dict(call.args),
285
+ parse_error=call.parse_error,
286
+ )
287
+
288
+
289
+ async def run_realtime_turn(
290
+ user_content: str | list[ContentBlock],
291
+ assistant_text: str,
292
+ assistant_tool_calls: Sequence[RealtimeToolCall],
293
+ ctx: TurnContext,
294
+ *,
295
+ history: HistoryStore,
296
+ tool_executor: ToolExecutorPort,
297
+ inspectors: Sequence[Any] | None = None,
298
+ hook_manager: HookManager | None = None,
299
+ attachment_store: AttachmentStore | None = None,
300
+ attachment_catalog: SessionAttachmentCatalog | None = None,
301
+ transcript_writer: TranscriptWriter | None = None,
302
+ tool_results_out: list[ContentBlock] | None = None,
303
+ inject_texts_out: list[str] | None = None,
304
+ pending_bus: PendingResponseBusPort | None = None,
305
+ ) -> AsyncIterator[AgentEvent]:
306
+ """Process a finalized realtime user utterance + assistant response.
307
+
308
+ Commits the user message, runs memory hooks, commits the assistant message (with
309
+ any tool requests), and dispatches the tools sequentially. Tool results are
310
+ yielded as ``ToolCallResult`` events and also appended to ``tool_results_out``
311
+ when the generator completes. Pre-tool hook ``inject_text`` values are appended to
312
+ ``inject_texts_out`` for the gateway to inject into the live session.
313
+
314
+ The caller is responsible for injecting the collected tool results back into the
315
+ live realtime session via ``RealtimeSession.send_tool_results()`` /
316
+ ``send_context()`` when the session is idle.
317
+
318
+ Truncation (``Done.truncated``) is text-loop only — Gemini Live does not expose an
319
+ output length-limit signal. Realtime still rejects all-``parse_error`` batches.
320
+ """
321
+ usage = UsageTotals()
322
+ # attachment_store is accepted for symmetry with loop.run and future resolve paths;
323
+ # freeze_attachments_in_history uses the catalog rebuilt from history.
324
+ _ = attachment_store
325
+ blocks = [Text(text=user_content)] if isinstance(user_content, str) else list(user_content)
326
+ user_text = _user_text_from_content(blocks)
327
+
328
+ logger.debug(
329
+ "realtime turn start %s",
330
+ kv(
331
+ request_id=ctx.request_id,
332
+ thread_id=ctx.thread_id,
333
+ model=ctx.model,
334
+ user_text_len=len(user_text),
335
+ n_tool_calls=len(assistant_tool_calls),
336
+ ),
337
+ )
338
+
339
+ try:
340
+ # 1. Commit user message to history (skip empty audio-only placeholders).
341
+ if user_text or any(not isinstance(b, Text) for b in blocks):
342
+ await history.append(ctx.thread_id, Message(role="user", content=list(blocks)))
343
+ await _fire_hook(
344
+ hook_manager,
345
+ event=HookEvent.USER_MESSAGE,
346
+ ctx=ctx,
347
+ timeout_s=0,
348
+ user_message=user_text,
349
+ )
350
+
351
+ if transcript_writer is not None:
352
+ await transcript_writer.write_user_message(
353
+ request_id=ctx.request_id,
354
+ content=user_text,
355
+ )
356
+
357
+ # 2. Refresh memory index for the next system prompt.
358
+ ctx = await refresh_memory_index(ctx)
359
+
360
+ yield Thinking(request_id=ctx.request_id)
361
+
362
+ # 3. Build assistant message with text + tool requests.
363
+ assistant_blocks: list[ContentBlock] = []
364
+ if assistant_text.strip():
365
+ assistant_blocks.append(Text(text=assistant_text.strip()))
366
+ yield AssistantDelta(request_id=ctx.request_id, delta=assistant_text.strip())
367
+
368
+ tool_results: list[ContentBlock] = []
369
+ for rtc in assistant_tool_calls:
370
+ assistant_blocks.append(
371
+ ToolRequest(
372
+ id=rtc.call_id,
373
+ name=rtc.name,
374
+ args=dict(rtc.args),
375
+ parse_error=rtc.parse_error,
376
+ )
377
+ )
378
+
379
+ if assistant_blocks:
380
+ # Await the assistant write so tool-response rows are always ordered after it.
381
+ await history.append(
382
+ ctx.thread_id,
383
+ Message(role="assistant", content=assistant_blocks),
384
+ )
385
+
386
+ # 4. Dispatch tools sequentially (v1: no parallel subagent dispatch here).
387
+ mcp_registry_mutated = False
388
+ pending_calls = [_realtime_tool_call_to_tool_call(rtc) for rtc in assistant_tool_calls]
389
+ # Realtime has no vendor length-limit signal; reject only all-parse_error batches.
390
+ reject_batch = _should_reject_tool_batch(pending_calls, truncated=False)
391
+ if reject_batch:
392
+ logger.warning(
393
+ "rejecting incomplete realtime tool batch %s",
394
+ kv(
395
+ request_id=ctx.request_id,
396
+ thread_id=ctx.thread_id,
397
+ n_calls=len(pending_calls),
398
+ ),
399
+ )
400
+ for call in pending_calls:
401
+ if reject_batch:
402
+ err = _rejected_tool_batch_error(call, truncated=False)
403
+ yield ToolCallStarted(
404
+ request_id=ctx.request_id,
405
+ tool=call.name,
406
+ label=call.name,
407
+ args=dict(call.args),
408
+ parse_error=call.parse_error,
409
+ )
410
+ event, response = _tool_outcome(
411
+ call, ctx.request_id, ToolExecutionResult.err(err)
412
+ )
413
+ yield event
414
+ tool_results.append(response)
415
+ continue
416
+
417
+ # Provider couldn't parse streamed tool JSON: surface as a tool error
418
+ # so the model can self-correct instead of executing with empty args.
419
+ if call.parse_error:
420
+ yield ToolCallStarted(
421
+ request_id=ctx.request_id,
422
+ tool=call.name,
423
+ label=call.name,
424
+ args=dict(call.args),
425
+ parse_error=call.parse_error,
426
+ call_id=call.call_id,
427
+ )
428
+ event, response = _tool_outcome(
429
+ call, ctx.request_id, ToolExecutionResult.err(call.parse_error)
430
+ )
431
+ yield event
432
+ tool_results.append(response)
433
+ continue
434
+
435
+ # Inspectors (tool confirmation / deny) are applied if provided.
436
+ allowed = True
437
+ denial_message: str | None = None
438
+ inspector_call = InspectorToolCall(
439
+ call_id=call.call_id, name=call.name, args=dict(call.args)
440
+ )
441
+ for insp in inspectors or []:
442
+ decision = await insp.check(inspector_call, ctx)
443
+ if decision.kind == "deny":
444
+ allowed = False
445
+ denial_message = decision.message
446
+ break
447
+ if decision.kind == "confirm":
448
+ if pending_bus is None:
449
+ allowed = False
450
+ denial_message = (
451
+ decision.message
452
+ or "Confirmation required but realtime HITL is unavailable"
453
+ )
454
+ logger.warning(
455
+ "realtime HITL unavailable %s",
456
+ kv(
457
+ request_id=ctx.request_id,
458
+ thread_id=ctx.thread_id,
459
+ call_id=call.call_id,
460
+ tool=call.name,
461
+ ),
462
+ )
463
+ break
464
+ fut = pending_bus.register_pending(call.call_id)
465
+ yield ToolConfirmationRequestEvent(
466
+ request_id=ctx.request_id,
467
+ tool_call_id=call.call_id,
468
+ tool_name=call.name,
469
+ arguments=dict(call.args),
470
+ prompt=decision.message,
471
+ )
472
+ try:
473
+ payload = await _await_user_response_any(
474
+ pending_bus, fut, call.call_id, timeout_sec=None
475
+ )
476
+ except asyncio.CancelledError:
477
+ # Do not swallow cancellation into a normal deny path —
478
+ # re-raise so the outer handler tears down the turn and
479
+ # the generator stops (client disconnect / abort).
480
+ raise
481
+ if payload.get("_timeout"):
482
+ allowed = False
483
+ denial_message = "user did not respond in time"
484
+ logger.info(
485
+ "realtime HITL timeout %s",
486
+ kv(
487
+ request_id=ctx.request_id,
488
+ thread_id=ctx.thread_id,
489
+ call_id=call.call_id,
490
+ approved=False,
491
+ reason="timeout",
492
+ ),
493
+ )
494
+ break
495
+ if payload.get("approved"):
496
+ allowed = True
497
+ if payload.get("always"):
498
+ remember_always_approval(
499
+ pending_bus, call.name, resource_for_call(inspector_call)
500
+ )
501
+ logger.debug(
502
+ "realtime HITL always %s",
503
+ kv(
504
+ request_id=ctx.request_id,
505
+ thread_id=ctx.thread_id,
506
+ call_id=call.call_id,
507
+ tool=call.name,
508
+ decision="confirm_always",
509
+ ),
510
+ )
511
+ else:
512
+ allowed = False
513
+ reason_raw = payload.get("reason")
514
+ denial_message = (
515
+ (reason_raw if isinstance(reason_raw, str) else None)
516
+ or decision.message
517
+ or "denied by user"
518
+ )
519
+ logger.info(
520
+ "realtime HITL denied %s",
521
+ kv(
522
+ request_id=ctx.request_id,
523
+ thread_id=ctx.thread_id,
524
+ call_id=call.call_id,
525
+ approved=False,
526
+ reason=denial_message,
527
+ ),
528
+ )
529
+ break
530
+
531
+ if not allowed:
532
+ result = ToolExecutionResult.err(denial_message or "tool call denied")
533
+ inject_text = None
534
+ else:
535
+ yield ToolCallStarted(
536
+ request_id=ctx.request_id,
537
+ tool=call.name,
538
+ label=call.name,
539
+ args=dict(call.args),
540
+ call_id=call.call_id,
541
+ )
542
+ result, inject_text = await _execute_tool(
543
+ call,
544
+ ctx,
545
+ tool_executor=tool_executor,
546
+ hook_manager=hook_manager,
547
+ request_id=ctx.request_id,
548
+ )
549
+ if result.error is None and result.blocks:
550
+ resolved_blocks: list[ContentBlock] | None = None
551
+ async for item in _resolve_elicitation_blocks(
552
+ result.blocks,
553
+ ctx=ctx,
554
+ pending_bus=pending_bus,
555
+ ):
556
+ if isinstance(item, ActionRequiredEvent):
557
+ yield item
558
+ elif isinstance(item, list):
559
+ resolved_blocks = item
560
+ if resolved_blocks is not None:
561
+ result = ToolExecutionResult.ok_blocks(resolved_blocks)
562
+ if (
563
+ result.error is None
564
+ and call.name in MCP_REGISTRY_MUTATING_TOOLS
565
+ ):
566
+ mcp_registry_mutated = True
567
+
568
+ if inject_text and inject_texts_out is not None:
569
+ inject_texts_out.append(inject_text)
570
+
571
+ event, response = _tool_outcome(call, ctx.request_id, result)
572
+ yield event
573
+ tool_results.append(response)
574
+
575
+ if mcp_registry_mutated:
576
+ mcp_client = getattr(tool_executor, "mcp", None)
577
+ if mcp_client is None:
578
+ logger.error(
579
+ "MCP registry mutated but tool executor has no mcp client %s",
580
+ kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
581
+ )
582
+ raise RuntimeError(
583
+ "tool executor missing mcp client after MCP registry mutation"
584
+ )
585
+ ctx = refresh_tools_after_mcp_change(ctx, mcp_client)
586
+ logger.info(
587
+ "refreshed ctx.tools after MCP registry change (realtime) %s",
588
+ kv(
589
+ request_id=ctx.request_id,
590
+ thread_id=ctx.thread_id,
591
+ tool_count=len(ctx.tools),
592
+ ),
593
+ )
594
+ # Live vendor sessions fix tools at connect time; schemas are not
595
+ # updated mid-session. Tell the model to start a new session if needed.
596
+ if inject_texts_out is not None:
597
+ inject_texts_out.append(_REALTIME_MCP_NEW_SESSION_NOTE)
598
+
599
+ # 5. Append all tool responses as one user message (reuses loop.py semantics).
600
+ if tool_results:
601
+ await history.append(
602
+ ctx.thread_id,
603
+ Message(role="user", content=tool_results),
604
+ )
605
+
606
+ # 6. Freeze attachments and emit descriptors.
607
+ descriptor_events = await freeze_attachments_in_history(
608
+ thread_id=ctx.thread_id,
609
+ history=history,
610
+ catalog=attachment_catalog,
611
+ last_assistant_text=assistant_text.strip(),
612
+ )
613
+ for desc in descriptor_events:
614
+ yield AttachmentDescriptorEvent(
615
+ request_id=ctx.request_id,
616
+ attachment_id=desc.attachment_id,
617
+ mime_type=desc.mime_type,
618
+ filename=desc.filename,
619
+ description=desc.description,
620
+ )
621
+
622
+ await _fire_hook(
623
+ hook_manager,
624
+ event=HookEvent.POST_TURN,
625
+ ctx=ctx,
626
+ timeout_s=0,
627
+ )
628
+
629
+ logger.debug(
630
+ "realtime turn end %s",
631
+ kv(
632
+ request_id=ctx.request_id,
633
+ thread_id=ctx.thread_id,
634
+ input_tokens=usage.input_tokens,
635
+ output_tokens=usage.output_tokens,
636
+ tool_results=len(tool_results),
637
+ ),
638
+ )
639
+
640
+ # Make tool results available to the caller for later injection into the live session.
641
+ if tool_results_out is not None:
642
+ tool_results_out.extend(tool_results)
643
+ except asyncio.CancelledError:
644
+ yield Error(request_id=ctx.request_id, error="Realtime turn cancelled")
645
+ raise
646
+ except Exception as exc:
647
+ logger.exception(
648
+ "realtime turn failed %s",
649
+ kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
650
+ )
651
+ yield Error(request_id=ctx.request_id, error=str(exc))
652
+ raise
653
+ finally:
654
+ yield TurnComplete(request_id=ctx.request_id, usage=usage)