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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- 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
|