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,864 @@
1
+ """FastAPI WebSocket route for MonkeyBot realtime sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import os
9
+ import time
10
+ from collections.abc import AsyncIterator
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
15
+ from fastapi.responses import JSONResponse
16
+
17
+ from monkeybot.core.attachments.catalog import SessionAttachmentCatalog
18
+ from monkeybot.core.config.realtime_config import RealtimeConfig
19
+ from monkeybot.core.context import TurnContext, build_context
20
+ from monkeybot.core.llm.realtime_provider import (
21
+ AudioFormat,
22
+ RealtimeAudioDelta,
23
+ RealtimeEvent,
24
+ RealtimeInterrupted,
25
+ RealtimePartialTranscript,
26
+ RealtimeSessionConfig,
27
+ RealtimeTextDelta,
28
+ RealtimeToolCall,
29
+ RealtimeTurnBoundary,
30
+ RealtimeUsage,
31
+ )
32
+ from monkeybot.core.llm.realtime_provider import RealtimeError as ProviderError
33
+ from monkeybot.core.logging_utils import kv
34
+ from monkeybot.core.persistence.backends import HistoryStore
35
+ from monkeybot.core.runtime.events import ActionRequiredEvent
36
+ from monkeybot.core.runtime.events import Error as AgentError
37
+ from monkeybot.core.runtime.events import ToolCallResult, ToolConfirmationRequestEvent
38
+ from monkeybot.core.runtime.realtime_loop import run_realtime_turn
39
+ from monkeybot.core.runtime.utterance_buffer import UtteranceBuffer
40
+ from monkeybot.core.tools.core_tool_executor import CoreToolExecutor
41
+ from monkeybot.core.types.content_blocks import Text
42
+ from monkeybot.gateway.sse.workspace_layout import resolve_agent_workspace_root
43
+
44
+ from .deps import RealtimeDependencies
45
+ from .errors import (
46
+ AudioFormatError,
47
+ ClientProtocolError,
48
+ ConcurrencyLimitError,
49
+ GatewayInternalError,
50
+ GuardrailError,
51
+ ProviderConnectionError,
52
+ ProviderStreamError,
53
+ RealtimeError,
54
+ SessionConflictError,
55
+ SubagentDispatchError,
56
+ )
57
+ from .guardrails import run_guardrails
58
+ from .manager import RealtimeSessionManager
59
+ from .session import RealtimeConnectionState
60
+ from .wire import (
61
+ ClientAudioStreamEndFrame,
62
+ ClientCloseFrame,
63
+ ClientConnectFrame,
64
+ ClientElicitationResponseFrame,
65
+ ClientInterruptFrame,
66
+ ClientTextFrame,
67
+ ClientToolConfirmationResponseFrame,
68
+ ProtocolError,
69
+ ServerAudioFrame,
70
+ ServerConnectedFrame,
71
+ ServerElicitationFrame,
72
+ ServerErrorFrame,
73
+ ServerInterruptedFrame,
74
+ ServerSessionEndedFrame,
75
+ ServerTextDeltaFrame,
76
+ ServerToolCallFrame,
77
+ ServerToolConfirmationFrame,
78
+ ServerToolResultFrame,
79
+ ServerTurnBoundaryFrame,
80
+ ServerUsageFrame,
81
+ ServerUserTranscriptFrame,
82
+ audio_duration_sec,
83
+ encode_server_frame,
84
+ parse_client_frame,
85
+ )
86
+
87
+ logger = logging.getLogger("monkeybot.gateway.realtime.routes")
88
+
89
+
90
+ def _env_context_window_tokens() -> int:
91
+ cap_raw = os.environ.get("MODEL_CONTEXT_WINDOW", "200000").strip()
92
+ try:
93
+ return max(1, int(cap_raw))
94
+ except ValueError:
95
+ return 200_000
96
+
97
+
98
+ def _pending_response_timeout_sec() -> float:
99
+ try:
100
+ return max(1.0, float(os.environ.get("PENDING_RESPONSE_TIMEOUT_SEC", "300")))
101
+ except ValueError:
102
+ return 300.0
103
+
104
+
105
+ def _resolved_workspace_paths() -> tuple[Path, Path]:
106
+ root = resolve_agent_workspace_root()
107
+ cwd = Path.cwd().resolve()
108
+ skills = Path(os.environ.get("SKILLS_PATH", "skills"))
109
+ skills_p = skills.resolve() if skills.is_absolute() else (cwd / skills).resolve()
110
+ return root, skills_p
111
+
112
+
113
+ async def _build_realtime_context(
114
+ session_id: str,
115
+ request_id: str,
116
+ deps: RealtimeDependencies,
117
+ ) -> TurnContext:
118
+ if deps.mcp is None:
119
+ raise RuntimeError("MCP client is not initialized")
120
+ workspace_root, skills_path = _resolved_workspace_paths()
121
+ agent_path = Path(os.environ.get("AGENT_MD", "monkeybot_config/AGENT.md"))
122
+ if not agent_path.is_absolute():
123
+ agent_path = Path.cwd().resolve() / agent_path
124
+ model = os.environ.get("MODEL_NAME", "gemini-2.5-flash")
125
+ return await build_context(
126
+ thread_id=session_id,
127
+ request_id=request_id,
128
+ agent_md_path=agent_path,
129
+ memory=deps.memory,
130
+ skills_path=skills_path,
131
+ mcp_client=deps.mcp,
132
+ model=model,
133
+ context_window_tokens=_env_context_window_tokens(),
134
+ workspace_root=workspace_root,
135
+ enable_context_curation=True,
136
+ extra_tools=[deps.web_search_tool] if deps.web_search_tool is not None else [],
137
+ subagent_registry=deps.subagent_registry,
138
+ )
139
+
140
+
141
+ def _create_tool_executor(
142
+ deps: RealtimeDependencies,
143
+ attachment_store: Any | None = None,
144
+ attachment_catalog: Any | None = None,
145
+ ) -> CoreToolExecutor:
146
+ if deps.mcp is None:
147
+ raise RuntimeError("MCP client is not initialized")
148
+ workspace_root, skills_path = _resolved_workspace_paths()
149
+ storage = deps.storage
150
+ return CoreToolExecutor(
151
+ workspace_root=workspace_root,
152
+ memory=deps.memory,
153
+ skills_path=skills_path,
154
+ mcp=deps.mcp,
155
+ extra_tools=[deps.web_search_tool] if deps.web_search_tool is not None else [],
156
+ run_command_allowed_commands=deps.run_command_allowed_commands,
157
+ run_command_allowed_path_prefixes=deps.run_command_allowed_path_prefixes,
158
+ attachment_store=attachment_store,
159
+ attachment_catalog=attachment_catalog,
160
+ run_store=storage.runs() if storage is not None else None,
161
+ scheduled_loop_store=storage.scheduled_loops() if storage is not None else None,
162
+ subagent_registry=deps.subagent_registry,
163
+ )
164
+
165
+
166
+ def _make_realtime_session_config(
167
+ ctx: TurnContext,
168
+ realtime_config: RealtimeConfig,
169
+ ) -> RealtimeSessionConfig:
170
+ input_fmt_str = os.environ.get("MONKEYBOT_REALTIME_AUDIO_INPUT_FORMAT", "pcm_s16le_24khz_mono")
171
+ output_fmt_str = os.environ.get("MONKEYBOT_REALTIME_AUDIO_OUTPUT_FORMAT", "pcm_s16le_24khz_mono")
172
+
173
+ def _parse(fmt: str) -> AudioFormat:
174
+ parts = fmt.lower().split("_")
175
+ rate = 24000
176
+ for p in parts:
177
+ if p.endswith("khz"):
178
+ with contextlib.suppress(ValueError):
179
+ rate = int(p.replace("khz", "")) * 1000
180
+ return AudioFormat(
181
+ encoding="pcm_s16le" if "pcm_s16le" in fmt else "pcm_s16be",
182
+ sample_rate_hz=rate,
183
+ channels=1,
184
+ frame_ms=200,
185
+ )
186
+
187
+ # Live-only models (e.g. gemini-3.1-flash-live-preview) cannot be used by the
188
+ # turn-based providers, so realtime gets its own model override.
189
+ model = realtime_config.model.name or ctx.model
190
+ return RealtimeSessionConfig(
191
+ model=model,
192
+ system_prompt=ctx.agent_md,
193
+ tools=ctx.tools,
194
+ preferred_input_format=_parse(input_fmt_str),
195
+ preferred_output_format=_parse(output_fmt_str),
196
+ )
197
+
198
+
199
+ async def _send_frame(ws: WebSocket, frame: Any) -> None:
200
+ encoded = encode_server_frame(frame)
201
+ if isinstance(encoded, bytes):
202
+ await ws.send_bytes(encoded)
203
+ else:
204
+ await ws.send_text(encoded)
205
+
206
+
207
+ async def _inject_tool_results(state: RealtimeConnectionState, results: list[Any]) -> None:
208
+ """Serialize tool results and send them to the live provider session."""
209
+ payloads: list[tuple[str, str, dict[str, object], bool]] = []
210
+ for resp in results:
211
+ if not hasattr(resp, "tool_name"):
212
+ continue
213
+ call_id = str(getattr(resp, "id", "") or "")
214
+ name = str(getattr(resp, "tool_name", "") or "")
215
+ is_error = bool(getattr(resp, "is_error", False))
216
+ parts = getattr(resp, "result", []) or []
217
+ texts = [b.text for b in parts if hasattr(b, "text") and b.text]
218
+ if is_error:
219
+ payload: dict[str, object] = {
220
+ "error": "\n".join(texts) if texts else "tool execution failed",
221
+ }
222
+ else:
223
+ payload = {"result": "\n".join(texts) if texts else ""}
224
+ payloads.append((call_id, name, payload, is_error))
225
+ if not payloads:
226
+ return
227
+ send_tool_results = getattr(state.realtime_session, "send_tool_results", None)
228
+ if callable(send_tool_results):
229
+ await send_tool_results(payloads)
230
+ return
231
+ # Fallback for providers without a dedicated tool-response RPC.
232
+ summaries = [
233
+ f"[{name}]\n{payload.get('error') or payload.get('result') or ''}"
234
+ for _call_id, name, payload, _is_error in payloads
235
+ ]
236
+ await state.realtime_session.send_context("\n\n".join(summaries))
237
+
238
+
239
+ async def _deliver_idle_payload(state: RealtimeConnectionState, payload: Any) -> None:
240
+ """Inject one queued idle payload into the live provider session."""
241
+ if isinstance(payload, str):
242
+ await state.realtime_session.send_context(payload)
243
+ return
244
+ if isinstance(payload, list):
245
+ await _inject_tool_results(state, payload)
246
+ return
247
+ logger.warning(
248
+ "ignored unknown idle delivery payload %s",
249
+ kv(session_id=state.session_id, payload_type=type(payload).__name__),
250
+ )
251
+
252
+
253
+ async def _flush_idle_deliveries(state: RealtimeConnectionState) -> None:
254
+ """Drain the idle queue while the session is listening."""
255
+ while state.is_idle() and not state.idle_delivery_queue.empty():
256
+ payload = state.idle_delivery_queue.get_nowait()
257
+ try:
258
+ await _deliver_idle_payload(state, payload)
259
+ except Exception as exc:
260
+ logger.exception(
261
+ "idle delivery failed %s",
262
+ kv(session_id=state.session_id, request_id=state.request_id),
263
+ )
264
+ raise SubagentDispatchError(
265
+ f"Failed to deliver idle payload: {exc}",
266
+ details="idle_delivery_failed",
267
+ ) from exc
268
+
269
+
270
+ async def _handle_assistant_boundary(
271
+ ws: WebSocket,
272
+ state: RealtimeConnectionState,
273
+ ctx: TurnContext,
274
+ history: HistoryStore,
275
+ deps: RealtimeDependencies,
276
+ attachment_store: Any | None,
277
+ attachment_catalog: Any | None,
278
+ ) -> None:
279
+ """Called when the assistant turn ends. Commits the turn and dispatches tools."""
280
+ turn = state.buffer.current_assistant_turn()
281
+ if turn.is_empty:
282
+ return
283
+
284
+ tool_executor = _create_tool_executor(deps, attachment_store, attachment_catalog)
285
+ tool_results: list[Any] = []
286
+ inject_texts: list[str] = []
287
+ # Consume once per utterance so tool-call then prose boundaries do not
288
+ # duplicate the same user row in HistoryStore.
289
+ user_text = state.buffer.consume_user_text_for_commit().strip()
290
+ turn_error: str | None = None
291
+ try:
292
+ async for event in run_realtime_turn(
293
+ user_content=[Text(text=user_text)] if user_text else "",
294
+ assistant_text=turn.text,
295
+ assistant_tool_calls=turn.tool_calls,
296
+ ctx=ctx,
297
+ history=history,
298
+ tool_executor=tool_executor,
299
+ inspectors=deps.inspectors,
300
+ hook_manager=deps.hook_manager,
301
+ attachment_store=attachment_store,
302
+ attachment_catalog=attachment_catalog,
303
+ tool_results_out=tool_results,
304
+ inject_texts_out=inject_texts,
305
+ pending_bus=state,
306
+ ):
307
+ if isinstance(event, AgentError):
308
+ turn_error = event.error
309
+ elif isinstance(event, ToolCallResult):
310
+ await _send_frame(
311
+ ws,
312
+ ServerToolResultFrame(
313
+ call_id=event.call_id,
314
+ name=event.tool,
315
+ result=event.result or "",
316
+ error=event.error,
317
+ ),
318
+ )
319
+ elif isinstance(event, ToolConfirmationRequestEvent):
320
+ await _send_frame(
321
+ ws,
322
+ ServerToolConfirmationFrame(
323
+ tool_call_id=event.tool_call_id,
324
+ tool_name=event.tool_name,
325
+ prompt=event.prompt or f"Allow tool `{event.tool_name}`?",
326
+ arguments=dict(event.arguments or {}),
327
+ timeout_sec=_pending_response_timeout_sec(),
328
+ ),
329
+ )
330
+ elif isinstance(event, ActionRequiredEvent) and event.action_type == "elicitation":
331
+ payload = dict(event.payload or {})
332
+ prompt_raw = payload.get("message") or payload.get("prompt")
333
+ prompt = (
334
+ prompt_raw.strip()
335
+ if isinstance(prompt_raw, str) and prompt_raw.strip()
336
+ else "Agent requests input"
337
+ )
338
+ schema_raw = (
339
+ payload.get("requestedSchema")
340
+ or payload.get("requested_schema")
341
+ or payload.get("schema")
342
+ )
343
+ schema = dict(schema_raw) if isinstance(schema_raw, dict) else None
344
+ await _send_frame(
345
+ ws,
346
+ ServerElicitationFrame(
347
+ elicitation_id=event.id,
348
+ prompt=prompt,
349
+ schema=schema,
350
+ timeout_sec=_pending_response_timeout_sec(),
351
+ ),
352
+ )
353
+ except Exception:
354
+ logger.exception(
355
+ "run_realtime_turn failed %s",
356
+ kv(request_id=state.request_id, session_id=state.session_id),
357
+ )
358
+ await _send_frame(
359
+ ws,
360
+ ServerErrorFrame(error="Failed to process realtime turn"),
361
+ )
362
+ if state.state == "tool_running":
363
+ state.transition("listening")
364
+ return
365
+
366
+ if turn_error:
367
+ await _send_frame(ws, ServerErrorFrame(error=turn_error))
368
+
369
+ try:
370
+ if tool_results:
371
+ # Outstanding tool calls must be answered immediately or Gemini Live hangs.
372
+ # Also enqueue for idle flush so any follow-up context is ordered correctly.
373
+ await _inject_tool_results(state, tool_results)
374
+
375
+ for text in inject_texts:
376
+ if state.is_idle():
377
+ await state.realtime_session.send_context(text)
378
+ else:
379
+ state.enqueue_idle_delivery(text)
380
+
381
+ await _flush_idle_deliveries(state)
382
+ finally:
383
+ if state.state == "tool_running":
384
+ state.transition("listening")
385
+
386
+
387
+ async def _process_provider_events(
388
+ ws: WebSocket,
389
+ state: RealtimeConnectionState,
390
+ ctx: TurnContext,
391
+ history: HistoryStore,
392
+ deps: RealtimeDependencies,
393
+ attachment_store: Any | None,
394
+ attachment_catalog: Any | None,
395
+ ) -> None:
396
+ """Consume events from the realtime provider and forward to the client."""
397
+ async for event in state.realtime_session.events():
398
+ state.mark_activity(time.monotonic())
399
+ try:
400
+ await _handle_provider_event(
401
+ ws,
402
+ state,
403
+ event,
404
+ ctx,
405
+ history,
406
+ deps,
407
+ attachment_store,
408
+ attachment_catalog,
409
+ )
410
+ if state.is_idle():
411
+ await _flush_idle_deliveries(state)
412
+ except SubagentDispatchError as exc:
413
+ logger.warning(
414
+ "idle delivery failed (non-fatal) %s",
415
+ kv(
416
+ request_id=state.request_id,
417
+ session_id=state.session_id,
418
+ error=exc.message,
419
+ ),
420
+ )
421
+ await _send_frame(ws, ServerErrorFrame(error=exc.message))
422
+ except RealtimeError:
423
+ # Session-fatal error: propagate so the session loop can close cleanly.
424
+ raise
425
+ except Exception:
426
+ logger.exception(
427
+ "provider event handling failed %s",
428
+ kv(request_id=state.request_id, session_id=state.session_id),
429
+ )
430
+ await _send_frame(
431
+ ws,
432
+ ServerErrorFrame(error="Failed to process provider event"),
433
+ )
434
+
435
+
436
+ async def _handle_provider_event(
437
+ ws: WebSocket,
438
+ state: RealtimeConnectionState,
439
+ event: RealtimeEvent,
440
+ ctx: TurnContext,
441
+ history: HistoryStore,
442
+ deps: RealtimeDependencies,
443
+ attachment_store: Any | None,
444
+ attachment_catalog: Any | None,
445
+ ) -> None:
446
+ # Update the state machine and buffer first, then mirror the event to the client.
447
+ state.apply_provider_event(event)
448
+ if isinstance(event, RealtimePartialTranscript):
449
+ await _send_frame(
450
+ ws,
451
+ ServerUserTranscriptFrame(text=event.text, is_final=event.is_final),
452
+ )
453
+ elif isinstance(event, RealtimeTextDelta):
454
+ await _send_frame(
455
+ ws,
456
+ ServerTextDeltaFrame(delta=event.text, is_final=False),
457
+ )
458
+ elif isinstance(event, RealtimeAudioDelta):
459
+ await _send_frame(ws, ServerAudioFrame(chunk=event.chunk))
460
+ elif isinstance(event, RealtimeTurnBoundary):
461
+ await _send_frame(ws, ServerTurnBoundaryFrame(role=event.role))
462
+ if event.role == "assistant":
463
+ await _handle_assistant_boundary(
464
+ ws, state, ctx, history, deps, attachment_store, attachment_catalog
465
+ )
466
+ await _send_frame(
467
+ ws,
468
+ ServerUsageFrame(
469
+ usage=state.metrics.to_usage_payload(
470
+ context_window_tokens=_env_context_window_tokens()
471
+ )
472
+ ),
473
+ )
474
+ elif isinstance(event, RealtimeToolCall):
475
+ await _send_frame(
476
+ ws,
477
+ ServerToolCallFrame(
478
+ call_id=event.call_id, name=event.name, args=event.args
479
+ ),
480
+ )
481
+ elif isinstance(event, RealtimeUsage):
482
+ await _send_frame(
483
+ ws,
484
+ ServerUsageFrame(
485
+ usage=state.metrics.to_usage_payload(
486
+ context_window_tokens=_env_context_window_tokens()
487
+ )
488
+ ),
489
+ )
490
+ elif isinstance(event, RealtimeInterrupted):
491
+ await _send_frame(ws, ServerInterruptedFrame())
492
+ elif isinstance(event, ProviderError):
493
+ raise ProviderStreamError(event.error)
494
+
495
+
496
+ def _validate_client_audio_format(
497
+ chunk: bytes,
498
+ input_format: AudioFormat,
499
+ ) -> None:
500
+ """Reject empty or misaligned PCM frames from the client."""
501
+ bytes_per_sample = 2 if input_format.encoding.startswith("pcm_s") else 1
502
+ frame_size = bytes_per_sample * input_format.channels
503
+ if frame_size <= 0 or len(chunk) % frame_size != 0:
504
+ raise AudioFormatError(
505
+ f"Audio chunk length {len(chunk)} is not aligned to frame size {frame_size}",
506
+ details="audio_format_mismatch",
507
+ )
508
+
509
+
510
+ async def _handle_client_frames(
511
+ ws: WebSocket,
512
+ state: RealtimeConnectionState,
513
+ input_format: AudioFormat,
514
+ ) -> None:
515
+ """Read frames from the client and forward audio/text to the provider."""
516
+ async for raw in _receive_frames(ws):
517
+ now = time.monotonic()
518
+ state.mark_activity(now)
519
+ if isinstance(raw, bytes):
520
+ _validate_client_audio_format(raw, input_format)
521
+ state.buffer.add_user_audio(raw, fmt=input_format)
522
+ await state.realtime_session.send_audio(raw)
523
+ duration = audio_duration_sec(raw, input_format)
524
+ state.mark_user_audio(duration)
525
+ continue
526
+ try:
527
+ frame = parse_client_frame(raw)
528
+ except ProtocolError as exc:
529
+ raise ClientProtocolError(str(exc), details="malformed_client_frame") from exc
530
+
531
+ if isinstance(frame, ClientTextFrame):
532
+ # Typed turns have no provider user-boundary event (Gemini only emits
533
+ # assistant boundaries). Finalize here so history commit is once-per
534
+ # utterance and idle delivery can flush after tool/hook work.
535
+ state.buffer.add_user_text(frame.text)
536
+ await state.realtime_session.send_text(frame.text)
537
+ state.buffer.mark_user_turn_boundary()
538
+ if state.is_idle():
539
+ await _flush_idle_deliveries(state)
540
+ elif isinstance(frame, ClientAudioStreamEndFrame):
541
+ # Push-to-talk release: tell the provider speech ended so it responds
542
+ # immediately instead of waiting on VAD silence timeout.
543
+ end_audio = getattr(state.realtime_session, "end_audio_turn", None)
544
+ if callable(end_audio):
545
+ await end_audio()
546
+ state.buffer.mark_user_turn_boundary()
547
+ if state.is_idle():
548
+ await _flush_idle_deliveries(state)
549
+ elif isinstance(frame, ClientInterruptFrame):
550
+ state.handle_user_interrupt()
551
+ await state.realtime_session.interrupt()
552
+ await _send_frame(ws, ServerInterruptedFrame())
553
+ elif isinstance(frame, ClientToolConfirmationResponseFrame):
554
+ state.resolve_pending(
555
+ frame.tool_call_id,
556
+ {"approved": frame.approved, "reason": frame.reason},
557
+ )
558
+ elif isinstance(frame, ClientElicitationResponseFrame):
559
+ state.resolve_pending(
560
+ frame.elicitation_id,
561
+ {
562
+ "user_data": frame.user_data,
563
+ "cancelled": frame.cancelled,
564
+ "approved": not frame.cancelled,
565
+ },
566
+ )
567
+ elif isinstance(frame, ClientCloseFrame):
568
+ # Client requested a clean exit (/bye). End the reader so the session
569
+ # loop tears down provider + guardrail tasks.
570
+ await _send_frame(ws, ServerSessionEndedFrame(reason=frame.reason or "client_close"))
571
+ return
572
+ elif isinstance(frame, ClientConnectFrame):
573
+ # Already handled before entering the frame loop.
574
+ pass
575
+
576
+
577
+ async def _receive_frames(ws: WebSocket) -> AsyncIterator[str | bytes]:
578
+ while True:
579
+ try:
580
+ data = await ws.receive()
581
+ except WebSocketDisconnect:
582
+ break
583
+ if data.get("type") == "websocket.receive":
584
+ text = data.get("text")
585
+ if text is not None:
586
+ yield text
587
+ else:
588
+ bytes_data = data.get("bytes")
589
+ if bytes_data is not None:
590
+ yield bytes_data
591
+ elif data.get("type") == "websocket.disconnect":
592
+ break
593
+
594
+
595
+ async def _run_session(
596
+ ws: WebSocket,
597
+ state: RealtimeConnectionState,
598
+ ctx: TurnContext,
599
+ history: HistoryStore,
600
+ deps: RealtimeDependencies,
601
+ attachment_store: Any | None,
602
+ attachment_catalog: Any | None,
603
+ config: RealtimeConfig,
604
+ ) -> None:
605
+ """Main session loop: client reader, provider event handler, and guardrails."""
606
+ provider_task = asyncio.create_task(
607
+ _process_provider_events(
608
+ ws, state, ctx, history, deps, attachment_store, attachment_catalog
609
+ )
610
+ )
611
+ client_task = asyncio.create_task(
612
+ _handle_client_frames(ws, state, state.realtime_session.input_format)
613
+ )
614
+ guardrail_task = asyncio.create_task(run_guardrails(state, config))
615
+
616
+ all_tasks = {provider_task, client_task, guardrail_task}
617
+ try:
618
+ done, pending = await asyncio.wait(
619
+ all_tasks, return_when=asyncio.FIRST_COMPLETED
620
+ )
621
+ for task in pending:
622
+ task.cancel()
623
+ with contextlib.suppress(asyncio.CancelledError):
624
+ for task in pending:
625
+ await task
626
+ for task in done:
627
+ exc = task.exception()
628
+ if exc is not None:
629
+ raise exc
630
+ except asyncio.CancelledError:
631
+ for task in all_tasks:
632
+ task.cancel()
633
+ with contextlib.suppress(asyncio.CancelledError):
634
+ await provider_task
635
+ with contextlib.suppress(asyncio.CancelledError):
636
+ await client_task
637
+ with contextlib.suppress(asyncio.CancelledError):
638
+ await guardrail_task
639
+ raise
640
+
641
+
642
+ async def _close_session(
643
+ state: RealtimeConnectionState,
644
+ manager: RealtimeSessionManager,
645
+ reason: str = "session_end",
646
+ ) -> None:
647
+ state.abandon_pending_cancel_all()
648
+ state.close()
649
+ try:
650
+ await state.realtime_session.close(reason=reason)
651
+ except Exception:
652
+ logger.exception(
653
+ "realtime session close failed %s",
654
+ kv(session_id=state.session_id),
655
+ )
656
+ state.metrics.close(reason=reason)
657
+ state.metrics.emit_summary()
658
+ manager.release_slot(state.session_id)
659
+ manager.remove(state.session_id, state)
660
+
661
+
662
+ async def _safe_client_notify(ws: WebSocket, coro: Any) -> None:
663
+ """Best-effort client notify; log failures instead of swallowing silently."""
664
+ try:
665
+ await coro
666
+ except Exception:
667
+ logger.debug("client notify failed after session error", exc_info=True)
668
+
669
+
670
+ def create_realtime_router(
671
+ deps: RealtimeDependencies,
672
+ manager: RealtimeSessionManager,
673
+ ) -> APIRouter:
674
+ """Build an APIRouter with the realtime WebSocket endpoint and health snapshot."""
675
+ router = APIRouter()
676
+
677
+ @router.get("/realtime/health")
678
+ async def realtime_health() -> JSONResponse:
679
+ """Liveness plus in-process session concurrency snapshot."""
680
+ return JSONResponse(
681
+ {
682
+ "status": "ok",
683
+ "realtime": manager.snapshot_metrics(),
684
+ }
685
+ )
686
+
687
+ @router.get("/realtime/sessions/{session_id}")
688
+ async def realtime_session_lookup(session_id: str) -> JSONResponse:
689
+ """Return whether a session is registered in this process."""
690
+ state = manager.get(session_id)
691
+ if state is None:
692
+ return JSONResponse(
693
+ {"session_id": session_id, "active": False},
694
+ status_code=404,
695
+ )
696
+ return JSONResponse(
697
+ {
698
+ "session_id": session_id,
699
+ "active": True,
700
+ "state": state.state,
701
+ "request_id": state.request_id,
702
+ }
703
+ )
704
+
705
+ @router.websocket("/sessions/{session_id}/realtime")
706
+ async def realtime_endpoint(ws: WebSocket, session_id: str) -> None:
707
+ request_id = os.environ.get("REQUEST_ID", "") or f"rt-{id(ws):x}"
708
+ # Concurrency guardrail: reject the HTTP upgrade before accepting the WebSocket.
709
+ if not await manager.acquire_slot(session_id):
710
+ err = ConcurrencyLimitError(
711
+ "Realtime concurrency limit reached",
712
+ details="concurrency_limit",
713
+ )
714
+ logger.warning(
715
+ "realtime concurrency limit reached %s",
716
+ kv(session_id=session_id, request_id=request_id, error=err.message),
717
+ )
718
+ raise HTTPException(
719
+ status_code=503,
720
+ detail=err.message,
721
+ headers={"Retry-After": "10"},
722
+ )
723
+
724
+ await ws.accept()
725
+ logger.info(
726
+ "realtime websocket accepted %s",
727
+ kv(session_id=session_id, request_id=request_id),
728
+ )
729
+
730
+ state: RealtimeConnectionState | None = None
731
+ close_reason = "connection_closed"
732
+ try:
733
+ provider = deps.realtime_provider
734
+ if provider is None:
735
+ raise ProviderConnectionError("Realtime provider not configured")
736
+
737
+ storage = deps.storage
738
+ if storage is None:
739
+ raise GatewayInternalError("Storage backend not initialized")
740
+
741
+ history = storage.history()
742
+ ctx = await _build_realtime_context(session_id, request_id, deps)
743
+ session_config = _make_realtime_session_config(ctx, manager.config)
744
+ try:
745
+ realtime_session = await provider.connect(config=session_config)
746
+ except Exception as exc:
747
+ raise ProviderConnectionError(str(exc)) from exc
748
+
749
+ state = RealtimeConnectionState(
750
+ session_id=session_id,
751
+ request_id=request_id,
752
+ provider=provider,
753
+ realtime_session=realtime_session,
754
+ buffer=UtteranceBuffer(),
755
+ opened_at=time.monotonic(),
756
+ last_activity_at=time.monotonic(),
757
+ )
758
+ try:
759
+ manager.register(session_id, state)
760
+ except ValueError as exc:
761
+ raise SessionConflictError(
762
+ str(exc),
763
+ details="session_already_active",
764
+ ) from exc
765
+
766
+ await _send_frame(
767
+ ws,
768
+ ServerConnectedFrame(
769
+ session_id=session_id,
770
+ input_format=session_config.preferred_input_format.encoding,
771
+ output_format=session_config.preferred_output_format.encoding,
772
+ chunk_ms=200,
773
+ ),
774
+ )
775
+
776
+ attachment_store = deps.attachment_store
777
+ attachment_catalog = SessionAttachmentCatalog(session_id=session_id)
778
+ if attachment_store is not None:
779
+ try:
780
+ rows = await history.load(session_id)
781
+ attachment_catalog.rebuild_from_history(rows)
782
+ except Exception:
783
+ logger.warning(
784
+ "attachment catalog rebuild failed %s",
785
+ kv(session_id=session_id),
786
+ exc_info=True,
787
+ )
788
+
789
+ await _run_session(
790
+ ws,
791
+ state,
792
+ ctx,
793
+ history,
794
+ deps,
795
+ attachment_store,
796
+ attachment_catalog,
797
+ manager.config,
798
+ )
799
+ except WebSocketDisconnect:
800
+ close_reason = "client_disconnect"
801
+ except RealtimeError as exc:
802
+ close_reason = exc.details or exc.__class__.__name__
803
+ level_name_to_int: dict[str, int] = {
804
+ "debug": logging.DEBUG,
805
+ "info": logging.INFO,
806
+ "warning": logging.WARNING,
807
+ "error": logging.ERROR,
808
+ "critical": logging.CRITICAL,
809
+ }
810
+ logger.log(
811
+ level_name_to_int.get(exc.log_level.lower(), logging.ERROR),
812
+ "realtime endpoint error %s",
813
+ kv(
814
+ session_id=session_id,
815
+ request_id=request_id,
816
+ error=exc.message,
817
+ close_reason=close_reason,
818
+ ),
819
+ )
820
+ if ws.client_state.name == "CONNECTED":
821
+ if isinstance(exc, GuardrailError):
822
+ await _safe_client_notify(
823
+ ws, _send_frame(ws, ServerSessionEndedFrame(reason=close_reason))
824
+ )
825
+ elif isinstance(exc, ConcurrencyLimitError):
826
+ await _safe_client_notify(
827
+ ws, _send_frame(ws, ServerErrorFrame(error=exc.message))
828
+ )
829
+ else:
830
+ await _safe_client_notify(
831
+ ws, _send_frame(ws, ServerErrorFrame(error=exc.message))
832
+ )
833
+ if exc.close_code:
834
+ await _safe_client_notify(
835
+ ws, ws.close(code=exc.close_code, reason=exc.message[:123])
836
+ )
837
+ except Exception as exc:
838
+ close_reason = "error"
839
+ logger.exception(
840
+ "realtime endpoint error %s",
841
+ kv(session_id=session_id, request_id=request_id),
842
+ )
843
+ if ws.client_state.name == "CONNECTED":
844
+ await _safe_client_notify(
845
+ ws, _send_frame(ws, ServerErrorFrame(error=str(exc)))
846
+ )
847
+ await _safe_client_notify(
848
+ ws, ws.close(code=1011, reason="Internal error")
849
+ )
850
+ finally:
851
+ if state is not None:
852
+ await _close_session(state, manager, reason=close_reason)
853
+ else:
854
+ manager.release_slot(session_id)
855
+ logger.info(
856
+ "realtime websocket closed %s",
857
+ kv(
858
+ session_id=session_id,
859
+ request_id=request_id,
860
+ close_reason=close_reason,
861
+ ),
862
+ )
863
+
864
+ return router