monkeybot-cli 0.2.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_cli/__init__.py +3 -0
- monkeybot_cli/chat_renderer.py +87 -0
- monkeybot_cli/chat_session.py +911 -0
- monkeybot_cli/chat_status_bar.py +205 -0
- monkeybot_cli/chat_theme.py +91 -0
- monkeybot_cli/chat_tool_display.py +334 -0
- monkeybot_cli/chat_tui.py +1491 -0
- monkeybot_cli/chat_tui_widgets.py +996 -0
- monkeybot_cli/commands/__init__.py +1 -0
- monkeybot_cli/commands/chat.py +817 -0
- monkeybot_cli/commands/doctor.py +293 -0
- monkeybot_cli/commands/loop.py +207 -0
- monkeybot_cli/commands/new.py +207 -0
- monkeybot_cli/commands/run_cmd.py +41 -0
- monkeybot_cli/commands/talk.py +102 -0
- monkeybot_cli/commands/validate.py +385 -0
- monkeybot_cli/compat.py +7 -0
- monkeybot_cli/config_resolve.py +55 -0
- monkeybot_cli/exit_commands.py +13 -0
- monkeybot_cli/extras_catalog.py +95 -0
- monkeybot_cli/gateway_health.py +34 -0
- monkeybot_cli/main.py +38 -0
- monkeybot_cli/opensandbox_lifecycle.py +314 -0
- monkeybot_cli/output.py +110 -0
- monkeybot_cli/providers.py +112 -0
- monkeybot_cli/realtime/__init__.py +13 -0
- monkeybot_cli/realtime/audio_io.py +147 -0
- monkeybot_cli/realtime/client.py +17 -0
- monkeybot_cli/realtime/gateway_manager.py +142 -0
- monkeybot_cli/realtime/push_to_talk.py +128 -0
- monkeybot_cli/realtime/session.py +256 -0
- monkeybot_cli/realtime/session_controller.py +501 -0
- monkeybot_cli/realtime/talk_ui.py +243 -0
- monkeybot_cli/realtime/wire_encode.py +39 -0
- monkeybot_cli/runtime_python.py +91 -0
- monkeybot_cli/scaffold.py +287 -0
- monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
- monkeybot_cli/scaffold_defaults/__init__.py +1 -0
- monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
- monkeybot_cli/scaffold_defaults/env.example +35 -0
- monkeybot_cli/scaffold_defaults/mcp.json +49 -0
- monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
- monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
- monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
- monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
- monkeybot_cli/session_controller.py +7 -0
- monkeybot_cli/terminal_markdown.py +48 -0
- monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
- monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
- monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
- monkeybot_cli-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""WebSocket session controller that emits ChatUiEvent for the shared TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
import websockets
|
|
14
|
+
from monkeybot.gateway.realtime.wire import (
|
|
15
|
+
ClientAudioStreamEndFrame,
|
|
16
|
+
ClientCloseFrame,
|
|
17
|
+
ClientElicitationResponseFrame,
|
|
18
|
+
ClientInterruptFrame,
|
|
19
|
+
ClientTextFrame,
|
|
20
|
+
ClientToolConfirmationResponseFrame,
|
|
21
|
+
ProtocolError,
|
|
22
|
+
ServerConnectedFrame,
|
|
23
|
+
ServerElicitationFrame,
|
|
24
|
+
ServerErrorFrame,
|
|
25
|
+
ServerInterruptedFrame,
|
|
26
|
+
ServerSessionEndedFrame,
|
|
27
|
+
ServerTextDeltaFrame,
|
|
28
|
+
ServerToolCallFrame,
|
|
29
|
+
ServerToolConfirmationFrame,
|
|
30
|
+
ServerToolResultFrame,
|
|
31
|
+
ServerTurnBoundaryFrame,
|
|
32
|
+
ServerUsageFrame,
|
|
33
|
+
ServerUserTranscriptFrame,
|
|
34
|
+
parse_server_frame,
|
|
35
|
+
)
|
|
36
|
+
from websockets.typing import Data
|
|
37
|
+
|
|
38
|
+
from monkeybot_cli.chat_session import ChatUiEvent, HitlAnswer
|
|
39
|
+
from monkeybot_cli.chat_status_bar import UsageStore, parse_usage_response
|
|
40
|
+
from monkeybot_cli.realtime.wire_encode import encode_client_frame
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
EmitFn = Callable[[ChatUiEvent], None]
|
|
45
|
+
|
|
46
|
+
_MIC_ENERGY_THRESHOLD_DB = -40.0
|
|
47
|
+
_POST_SPEAK_MUTE_SEC = 0.4
|
|
48
|
+
_AUDIO_CHUNK_EMIT_EVERY = 5
|
|
49
|
+
|
|
50
|
+
VoiceState = Literal["listening", "muted", "ptt_held", "speaking"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RealtimeSessionController:
|
|
54
|
+
"""Owns the realtime WebSocket loop; emits UI events; never writes stdout."""
|
|
55
|
+
|
|
56
|
+
turn_based: bool = False
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
gateway_url: str,
|
|
62
|
+
session_id: str,
|
|
63
|
+
emit: EmitFn | None = None,
|
|
64
|
+
audio_enabled: bool = False,
|
|
65
|
+
audio_recorder: Any | None = None,
|
|
66
|
+
audio_player: Any | None = None,
|
|
67
|
+
push_to_talk: Any | None = None,
|
|
68
|
+
verbose: bool = False,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.gateway_url = gateway_url.rstrip("/")
|
|
71
|
+
self.session_id = session_id
|
|
72
|
+
self._emit_fn = emit or (lambda _e: None)
|
|
73
|
+
self.audio_enabled = audio_enabled and audio_recorder is not None
|
|
74
|
+
self.audio_recorder = audio_recorder
|
|
75
|
+
self.audio_player = audio_player
|
|
76
|
+
self.push_to_talk = push_to_talk
|
|
77
|
+
self.verbose = verbose
|
|
78
|
+
self.show_usage = False
|
|
79
|
+
self.usage = UsageStore()
|
|
80
|
+
self.stream_error = False
|
|
81
|
+
self._stream_alive = False
|
|
82
|
+
self._reconnecting = False
|
|
83
|
+
self._closed = False
|
|
84
|
+
self._stop = asyncio.Event()
|
|
85
|
+
self._ws: Any | None = None
|
|
86
|
+
self._recv_task: asyncio.Task[None] | None = None
|
|
87
|
+
self._audio_task: asyncio.Task[None] | None = None
|
|
88
|
+
self._assistant_open = False
|
|
89
|
+
self._model_speaking = False
|
|
90
|
+
self._last_model_audio_at = 0.0
|
|
91
|
+
self._tui_ptt_held = False
|
|
92
|
+
self._voice_state: VoiceState | None = None
|
|
93
|
+
self._pending_hitl: dict[str, Any] | None = None
|
|
94
|
+
self._background_tasks: set[asyncio.Task[None]] = set()
|
|
95
|
+
self._out_chunk_n = 0
|
|
96
|
+
self._in_chunk_n = 0
|
|
97
|
+
self._last_usage_payload: dict[str, Any] | None = None
|
|
98
|
+
|
|
99
|
+
def _emit(self, kind: str, **payload: Any) -> None:
|
|
100
|
+
self._emit_fn(ChatUiEvent(kind=kind, payload=payload))
|
|
101
|
+
|
|
102
|
+
def set_emit(self, emit: EmitFn) -> None:
|
|
103
|
+
"""Replace the UI event sink (used when the TUI takes ownership)."""
|
|
104
|
+
self._emit_fn = emit
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def stream_alive(self) -> bool:
|
|
108
|
+
return self._stream_alive and not self._closed
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def reconnecting(self) -> bool:
|
|
112
|
+
return self._reconnecting
|
|
113
|
+
|
|
114
|
+
def set_ptt_held(self, held: bool) -> None:
|
|
115
|
+
"""In-TUI Space PTT (SSH-safe alternative to global pynput)."""
|
|
116
|
+
self._tui_ptt_held = bool(held)
|
|
117
|
+
self._emit_voice_state()
|
|
118
|
+
|
|
119
|
+
def _ptt_held(self) -> bool:
|
|
120
|
+
if self.push_to_talk is not None and self.push_to_talk.is_held():
|
|
121
|
+
return True
|
|
122
|
+
return self._tui_ptt_held
|
|
123
|
+
|
|
124
|
+
def _mic_open(self) -> bool:
|
|
125
|
+
if self._stop.is_set():
|
|
126
|
+
return False
|
|
127
|
+
if self._model_speaking:
|
|
128
|
+
return False
|
|
129
|
+
if (time.monotonic() - self._last_model_audio_at) < _POST_SPEAK_MUTE_SEC:
|
|
130
|
+
return False
|
|
131
|
+
if self.push_to_talk is not None or self._tui_ptt_held:
|
|
132
|
+
return self._ptt_held()
|
|
133
|
+
# No PTT configured: open mic when audio enabled (open-mic mode).
|
|
134
|
+
return self.audio_enabled
|
|
135
|
+
|
|
136
|
+
def _compute_voice_state(self, *, level_db: float | None = None) -> VoiceState:
|
|
137
|
+
if self._model_speaking:
|
|
138
|
+
return "speaking"
|
|
139
|
+
if self._ptt_held() and self._mic_open():
|
|
140
|
+
return "ptt_held"
|
|
141
|
+
if self._mic_open():
|
|
142
|
+
return "listening"
|
|
143
|
+
return "muted"
|
|
144
|
+
|
|
145
|
+
def _emit_voice_state(self, *, level_db: float | None = None) -> None:
|
|
146
|
+
state = self._compute_voice_state(level_db=level_db)
|
|
147
|
+
if state == self._voice_state and level_db is None:
|
|
148
|
+
return
|
|
149
|
+
self._voice_state = state
|
|
150
|
+
payload: dict[str, Any] = {"state": state}
|
|
151
|
+
if level_db is not None:
|
|
152
|
+
payload["level_db"] = level_db
|
|
153
|
+
self._emit("voice_state", **payload)
|
|
154
|
+
|
|
155
|
+
async def connect(self, resume_session_id: str | None = None) -> None:
|
|
156
|
+
if resume_session_id and resume_session_id.strip():
|
|
157
|
+
self.session_id = resume_session_id.strip()
|
|
158
|
+
url = f"{self.gateway_url}/sessions/{self.session_id}/realtime"
|
|
159
|
+
self._stop.clear()
|
|
160
|
+
self._closed = False
|
|
161
|
+
if self.push_to_talk is not None:
|
|
162
|
+
try:
|
|
163
|
+
self.push_to_talk.start()
|
|
164
|
+
except Exception:
|
|
165
|
+
logger.exception("push-to-talk start failed")
|
|
166
|
+
self._emit(
|
|
167
|
+
"device_error",
|
|
168
|
+
message="Push-to-talk failed to start",
|
|
169
|
+
hint="Check Accessibility permissions, or use in-TUI Space PTT / --text.",
|
|
170
|
+
)
|
|
171
|
+
self.push_to_talk = None
|
|
172
|
+
try:
|
|
173
|
+
self._ws = await websockets.connect(url)
|
|
174
|
+
await self._ws.send(
|
|
175
|
+
json.dumps({"kind": "connect", "session_id": self.session_id})
|
|
176
|
+
)
|
|
177
|
+
except websockets.exceptions.InvalidStatus as exc:
|
|
178
|
+
status = getattr(exc, "status_code", getattr(exc, "status", "unknown"))
|
|
179
|
+
raise RuntimeError(f"Failed to connect to {url}: HTTP {status}") from exc
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
raise RuntimeError(f"Realtime connect failed: {exc}") from exc
|
|
182
|
+
|
|
183
|
+
self._stream_alive = True
|
|
184
|
+
self.stream_error = False
|
|
185
|
+
self._recv_task = asyncio.create_task(self._receive_loop(), name="rt-recv")
|
|
186
|
+
if self.audio_enabled and self.audio_recorder is not None:
|
|
187
|
+
self._audio_task = asyncio.create_task(self._send_audio(), name="rt-audio")
|
|
188
|
+
self._emit("connection_state", state="connected")
|
|
189
|
+
self._emit_voice_state()
|
|
190
|
+
|
|
191
|
+
async def _receive_loop(self) -> None:
|
|
192
|
+
assert self._ws is not None
|
|
193
|
+
try:
|
|
194
|
+
async for raw in self._ws:
|
|
195
|
+
if self._stop.is_set():
|
|
196
|
+
return
|
|
197
|
+
await self._handle_server_frame(raw)
|
|
198
|
+
except websockets.exceptions.ConnectionClosed:
|
|
199
|
+
logger.debug("realtime websocket closed")
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
logger.exception("realtime receive loop failed")
|
|
202
|
+
self.stream_error = True
|
|
203
|
+
self._emit("stream_failed", message=str(exc), fatal=True)
|
|
204
|
+
finally:
|
|
205
|
+
self._stream_alive = False
|
|
206
|
+
if not self._stop.is_set():
|
|
207
|
+
self._emit("stream_ended")
|
|
208
|
+
|
|
209
|
+
async def _send_audio(self) -> None:
|
|
210
|
+
if self.audio_recorder is None or self._ws is None:
|
|
211
|
+
return
|
|
212
|
+
was_open = False
|
|
213
|
+
while not self._stop.is_set():
|
|
214
|
+
try:
|
|
215
|
+
chunk = await asyncio.to_thread(self.audio_recorder.read_chunk)
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
self._emit(
|
|
218
|
+
"device_error",
|
|
219
|
+
message=f"Microphone read failed: {exc}",
|
|
220
|
+
hint="Check PortAudio / mic permissions, or use --text.",
|
|
221
|
+
)
|
|
222
|
+
return
|
|
223
|
+
if self._stop.is_set() or self._ws is None:
|
|
224
|
+
return
|
|
225
|
+
mic_open = self._mic_open()
|
|
226
|
+
if was_open and not mic_open:
|
|
227
|
+
try:
|
|
228
|
+
await self._ws.send(encode_client_frame(ClientAudioStreamEndFrame()))
|
|
229
|
+
except Exception:
|
|
230
|
+
logger.warning("failed to send audio_stream_end", exc_info=True)
|
|
231
|
+
self._emit_voice_state()
|
|
232
|
+
was_open = mic_open
|
|
233
|
+
if not mic_open:
|
|
234
|
+
self._emit_voice_state()
|
|
235
|
+
await asyncio.sleep(0.02)
|
|
236
|
+
continue
|
|
237
|
+
level: float | None = None
|
|
238
|
+
if hasattr(self.audio_recorder, "chunk_peak_db"):
|
|
239
|
+
level = float(self.audio_recorder.chunk_peak_db(chunk))
|
|
240
|
+
if level < _MIC_ENERGY_THRESHOLD_DB:
|
|
241
|
+
await asyncio.sleep(0)
|
|
242
|
+
continue
|
|
243
|
+
try:
|
|
244
|
+
await self._ws.send(chunk)
|
|
245
|
+
except Exception as exc:
|
|
246
|
+
logger.exception("realtime outbound audio send failed")
|
|
247
|
+
self.stream_error = True
|
|
248
|
+
self._emit("stream_failed", message=str(exc), fatal=True)
|
|
249
|
+
return
|
|
250
|
+
self._in_chunk_n += 1
|
|
251
|
+
if self._in_chunk_n % _AUDIO_CHUNK_EMIT_EVERY == 0:
|
|
252
|
+
self._emit("audio_chunk", direction="in", level_db=level)
|
|
253
|
+
self._emit_voice_state(level_db=level)
|
|
254
|
+
else:
|
|
255
|
+
self._emit_voice_state(level_db=level)
|
|
256
|
+
await asyncio.sleep(0)
|
|
257
|
+
|
|
258
|
+
async def _handle_server_frame(self, raw: Data) -> None:
|
|
259
|
+
if isinstance(raw, bytes):
|
|
260
|
+
await self._on_audio_bytes(raw)
|
|
261
|
+
return
|
|
262
|
+
try:
|
|
263
|
+
frame = parse_server_frame(raw)
|
|
264
|
+
except ProtocolError as exc:
|
|
265
|
+
logger.warning("ignored malformed server frame: %s", exc)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if isinstance(frame, ServerConnectedFrame):
|
|
269
|
+
self._on_connected(frame)
|
|
270
|
+
elif isinstance(frame, ServerUserTranscriptFrame):
|
|
271
|
+
self._emit(
|
|
272
|
+
"user_transcript",
|
|
273
|
+
text=frame.text,
|
|
274
|
+
is_final=bool(frame.is_final),
|
|
275
|
+
)
|
|
276
|
+
elif isinstance(frame, ServerTextDeltaFrame):
|
|
277
|
+
self._on_text_delta(frame)
|
|
278
|
+
elif isinstance(frame, ServerTurnBoundaryFrame):
|
|
279
|
+
self._on_turn_boundary(frame)
|
|
280
|
+
elif isinstance(frame, ServerToolCallFrame):
|
|
281
|
+
self._emit(
|
|
282
|
+
"tool_started",
|
|
283
|
+
tool=frame.name,
|
|
284
|
+
label=frame.name,
|
|
285
|
+
args=dict(frame.args or {}),
|
|
286
|
+
call_id=frame.call_id or "",
|
|
287
|
+
)
|
|
288
|
+
elif isinstance(frame, ServerToolResultFrame):
|
|
289
|
+
self._emit(
|
|
290
|
+
"tool_finished",
|
|
291
|
+
tool=frame.name,
|
|
292
|
+
error=frame.error,
|
|
293
|
+
result=frame.result or "",
|
|
294
|
+
verbose=self.verbose,
|
|
295
|
+
call_id=frame.call_id or "",
|
|
296
|
+
)
|
|
297
|
+
elif isinstance(frame, ServerUsageFrame):
|
|
298
|
+
payload = dict(frame.usage or {})
|
|
299
|
+
self._last_usage_payload = payload
|
|
300
|
+
view = parse_usage_response(payload)
|
|
301
|
+
self.usage.update(view)
|
|
302
|
+
self._emit("usage_updated", usage=view)
|
|
303
|
+
elif isinstance(frame, ServerToolConfirmationFrame):
|
|
304
|
+
self._pending_hitl = {
|
|
305
|
+
"hitl_kind": "confirm",
|
|
306
|
+
"tool_call_id": frame.tool_call_id,
|
|
307
|
+
"tool_name": frame.tool_name,
|
|
308
|
+
"prompt": frame.prompt,
|
|
309
|
+
"arguments": dict(frame.arguments or {}),
|
|
310
|
+
"timeout_sec": frame.timeout_sec,
|
|
311
|
+
}
|
|
312
|
+
self._emit("hitl_required", **self._pending_hitl)
|
|
313
|
+
elif isinstance(frame, ServerElicitationFrame):
|
|
314
|
+
self._pending_hitl = {
|
|
315
|
+
"hitl_kind": "elicit",
|
|
316
|
+
"elicitation_id": frame.elicitation_id,
|
|
317
|
+
"prompt": frame.prompt,
|
|
318
|
+
"schema": frame.schema,
|
|
319
|
+
"timeout_sec": frame.timeout_sec,
|
|
320
|
+
}
|
|
321
|
+
self._emit("hitl_required", **self._pending_hitl)
|
|
322
|
+
elif isinstance(frame, ServerInterruptedFrame):
|
|
323
|
+
self._model_speaking = False
|
|
324
|
+
self._assistant_open = False
|
|
325
|
+
self._emit_voice_state()
|
|
326
|
+
self._emit("turn_aborted", cancel_ok=True)
|
|
327
|
+
elif isinstance(frame, ServerErrorFrame):
|
|
328
|
+
self._emit("turn_error", error=frame.error)
|
|
329
|
+
elif isinstance(frame, ServerSessionEndedFrame):
|
|
330
|
+
self._emit("stream_ended")
|
|
331
|
+
self._stop.set()
|
|
332
|
+
self._stream_alive = False
|
|
333
|
+
|
|
334
|
+
async def _on_audio_bytes(self, raw: bytes) -> None:
|
|
335
|
+
self._model_speaking = True
|
|
336
|
+
self._last_model_audio_at = time.monotonic()
|
|
337
|
+
self._out_chunk_n += 1
|
|
338
|
+
if self._out_chunk_n % _AUDIO_CHUNK_EMIT_EVERY == 0:
|
|
339
|
+
self._emit("audio_chunk", direction="out")
|
|
340
|
+
self._emit_voice_state()
|
|
341
|
+
if self.audio_player is not None:
|
|
342
|
+
await asyncio.to_thread(self.audio_player.write, raw)
|
|
343
|
+
|
|
344
|
+
def _on_connected(self, frame: ServerConnectedFrame) -> None:
|
|
345
|
+
self.session_id = frame.session_id or self.session_id
|
|
346
|
+
self._emit(
|
|
347
|
+
"session_ready",
|
|
348
|
+
session_id=self.session_id,
|
|
349
|
+
input_format=frame.input_format,
|
|
350
|
+
output_format=frame.output_format,
|
|
351
|
+
chunk_ms=frame.chunk_ms,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _on_text_delta(self, frame: ServerTextDeltaFrame) -> None:
|
|
355
|
+
if not self._assistant_open:
|
|
356
|
+
self._emit("assistant_start")
|
|
357
|
+
self._assistant_open = True
|
|
358
|
+
if frame.delta:
|
|
359
|
+
self._emit("assistant_delta", delta=frame.delta)
|
|
360
|
+
if frame.is_final:
|
|
361
|
+
self._assistant_open = False
|
|
362
|
+
self._emit("turn_complete", usage=None)
|
|
363
|
+
|
|
364
|
+
def _on_turn_boundary(self, frame: ServerTurnBoundaryFrame) -> None:
|
|
365
|
+
if frame.role == "assistant":
|
|
366
|
+
self._model_speaking = False
|
|
367
|
+
self._last_model_audio_at = time.monotonic()
|
|
368
|
+
self._emit_voice_state()
|
|
369
|
+
if self._assistant_open:
|
|
370
|
+
self._assistant_open = False
|
|
371
|
+
self._emit("turn_complete", usage=None)
|
|
372
|
+
elif frame.role == "user":
|
|
373
|
+
self._emit("thinking", text="listening…")
|
|
374
|
+
|
|
375
|
+
async def submit(self, message: str) -> None:
|
|
376
|
+
if self._ws is None or not self._stream_alive:
|
|
377
|
+
self._emit("error", message="Not connected")
|
|
378
|
+
return
|
|
379
|
+
text = message.strip()
|
|
380
|
+
if not text:
|
|
381
|
+
return
|
|
382
|
+
self._emit("turn_started", request_id="")
|
|
383
|
+
self._emit("thinking", text="thinking…")
|
|
384
|
+
try:
|
|
385
|
+
await self._ws.send(encode_client_frame(ClientTextFrame(text=text)))
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
self._emit("error", message=f"Send failed: {exc}")
|
|
388
|
+
|
|
389
|
+
def _spawn_background(self, coro: Any, *, name: str) -> None:
|
|
390
|
+
"""Schedule ``coro`` and retain the task so it is not GC'd mid-flight."""
|
|
391
|
+
try:
|
|
392
|
+
loop = asyncio.get_running_loop()
|
|
393
|
+
except RuntimeError:
|
|
394
|
+
logger.debug("%s called with no running event loop", name)
|
|
395
|
+
return
|
|
396
|
+
task = loop.create_task(coro, name=name)
|
|
397
|
+
self._background_tasks.add(task)
|
|
398
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
399
|
+
|
|
400
|
+
def abort_turn(self) -> None:
|
|
401
|
+
if self._ws is None:
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
async def _interrupt() -> None:
|
|
405
|
+
try:
|
|
406
|
+
assert self._ws is not None
|
|
407
|
+
await self._ws.send(encode_client_frame(ClientInterruptFrame()))
|
|
408
|
+
except Exception:
|
|
409
|
+
logger.warning("failed to send interrupt frame", exc_info=True)
|
|
410
|
+
|
|
411
|
+
self._spawn_background(_interrupt(), name="rt-abort")
|
|
412
|
+
|
|
413
|
+
def provide_hitl_answer(self, answer: HitlAnswer) -> None:
|
|
414
|
+
if self._ws is None or self._pending_hitl is None:
|
|
415
|
+
return
|
|
416
|
+
pending = self._pending_hitl
|
|
417
|
+
self._pending_hitl = None
|
|
418
|
+
|
|
419
|
+
async def _send() -> None:
|
|
420
|
+
assert self._ws is not None
|
|
421
|
+
if answer.cancelled:
|
|
422
|
+
if pending.get("hitl_kind") == "confirm":
|
|
423
|
+
frame = ClientToolConfirmationResponseFrame(
|
|
424
|
+
tool_call_id=str(pending.get("tool_call_id") or ""),
|
|
425
|
+
approved=False,
|
|
426
|
+
reason="cancelled",
|
|
427
|
+
)
|
|
428
|
+
else:
|
|
429
|
+
frame = ClientElicitationResponseFrame(
|
|
430
|
+
elicitation_id=str(pending.get("elicitation_id") or ""),
|
|
431
|
+
user_data=None,
|
|
432
|
+
cancelled=True,
|
|
433
|
+
)
|
|
434
|
+
elif pending.get("hitl_kind") == "confirm":
|
|
435
|
+
frame = ClientToolConfirmationResponseFrame(
|
|
436
|
+
tool_call_id=str(pending.get("tool_call_id") or ""),
|
|
437
|
+
approved=bool(answer.approved),
|
|
438
|
+
reason=answer.text or "",
|
|
439
|
+
)
|
|
440
|
+
else:
|
|
441
|
+
frame = ClientElicitationResponseFrame(
|
|
442
|
+
elicitation_id=str(pending.get("elicitation_id") or ""),
|
|
443
|
+
user_data=answer.user_data if answer.user_data is not None else answer.text,
|
|
444
|
+
cancelled=False,
|
|
445
|
+
)
|
|
446
|
+
try:
|
|
447
|
+
await self._ws.send(encode_client_frame(frame))
|
|
448
|
+
except Exception:
|
|
449
|
+
logger.warning("failed to send HITL response frame", exc_info=True)
|
|
450
|
+
self._emit("error", message="Failed to send confirmation response")
|
|
451
|
+
|
|
452
|
+
self._spawn_background(_send(), name="rt-hitl")
|
|
453
|
+
|
|
454
|
+
async def restart_session(self) -> None:
|
|
455
|
+
self._emit("error", message="Session restart is not supported in realtime mode")
|
|
456
|
+
|
|
457
|
+
async def resume_session(self, session_id: str) -> None:
|
|
458
|
+
await self.close()
|
|
459
|
+
self.session_id = session_id
|
|
460
|
+
await self.connect(session_id)
|
|
461
|
+
|
|
462
|
+
async def refresh_usage(self) -> None:
|
|
463
|
+
if self._last_usage_payload is not None:
|
|
464
|
+
view = parse_usage_response(self._last_usage_payload)
|
|
465
|
+
self.usage.update(view)
|
|
466
|
+
self._emit("usage_updated", usage=view)
|
|
467
|
+
|
|
468
|
+
async def close(self) -> None:
|
|
469
|
+
if self._closed:
|
|
470
|
+
return
|
|
471
|
+
self._closed = True
|
|
472
|
+
self._stop.set()
|
|
473
|
+
if self.push_to_talk is not None:
|
|
474
|
+
try:
|
|
475
|
+
self.push_to_talk.stop()
|
|
476
|
+
except Exception:
|
|
477
|
+
logger.warning("push-to-talk stop failed", exc_info=True)
|
|
478
|
+
ws = self._ws
|
|
479
|
+
self._ws = None
|
|
480
|
+
if ws is not None:
|
|
481
|
+
try:
|
|
482
|
+
await ws.send(encode_client_frame(ClientCloseFrame(reason="client_close")))
|
|
483
|
+
except Exception:
|
|
484
|
+
logger.debug("close frame send failed during shutdown", exc_info=True)
|
|
485
|
+
try:
|
|
486
|
+
await ws.close()
|
|
487
|
+
except Exception:
|
|
488
|
+
logger.debug("websocket close failed during shutdown", exc_info=True)
|
|
489
|
+
for task in (self._audio_task, self._recv_task):
|
|
490
|
+
if task is not None:
|
|
491
|
+
task.cancel()
|
|
492
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
493
|
+
await task
|
|
494
|
+
for task in list(self._background_tasks):
|
|
495
|
+
task.cancel()
|
|
496
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
497
|
+
await task
|
|
498
|
+
self._background_tasks.clear()
|
|
499
|
+
self._audio_task = None
|
|
500
|
+
self._recv_task = None
|
|
501
|
+
self._stream_alive = False
|