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,911 @@
|
|
|
1
|
+
"""Gateway session controller for ``monkeybot chat`` (no terminal I/O)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import uuid
|
|
11
|
+
from collections import deque
|
|
12
|
+
from collections.abc import AsyncIterator, Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from monkeybot.core.runtime.events import (
|
|
18
|
+
ActionRequiredEvent,
|
|
19
|
+
AssistantDelta,
|
|
20
|
+
ContextSummarized,
|
|
21
|
+
ContextSummarizing,
|
|
22
|
+
Error,
|
|
23
|
+
FrontendToolRequestEvent,
|
|
24
|
+
GroundingEvent,
|
|
25
|
+
ThinkingBlockComplete,
|
|
26
|
+
ThinkingBlockDelta,
|
|
27
|
+
ToolCallResult,
|
|
28
|
+
ToolCallStarted,
|
|
29
|
+
ToolConfirmationRequestEvent,
|
|
30
|
+
TurnComplete,
|
|
31
|
+
event_from_json,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from monkeybot_cli.chat_status_bar import UsageStore, parse_usage_response
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
EmitFn = Callable[["ChatUiEvent"], None]
|
|
39
|
+
HitlReaderFn = Callable[["HitlRequest"], Any] # async (HitlRequest) -> HitlAnswer
|
|
40
|
+
|
|
41
|
+
HitlKind = Literal["confirm", "elicit", "frontend_unsupported"]
|
|
42
|
+
|
|
43
|
+
_HITL_TYPES = (ToolConfirmationRequestEvent, ActionRequiredEvent, FrontendToolRequestEvent)
|
|
44
|
+
|
|
45
|
+
_RECONNECT_INITIAL_DELAY = 0.5
|
|
46
|
+
_RECONNECT_MAX_DELAY = 15.0
|
|
47
|
+
_RECONNECT_MAX_ATTEMPTS = 20
|
|
48
|
+
_ARGS_PREVIEW_LIMIT = 200
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pending_response_timeout_sec() -> float:
|
|
52
|
+
"""Mirror gateway ``PENDING_RESPONSE_TIMEOUT_SEC`` (default 300)."""
|
|
53
|
+
try:
|
|
54
|
+
return float(os.environ.get("PENDING_RESPONSE_TIMEOUT_SEC", "300"))
|
|
55
|
+
except ValueError:
|
|
56
|
+
return 300.0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_schema_field_lines(schema: dict[str, Any]) -> list[str]:
|
|
60
|
+
"""One labeled line per JSON-schema property for HITL display."""
|
|
61
|
+
props = schema.get("properties")
|
|
62
|
+
if not isinstance(props, dict) or not props:
|
|
63
|
+
return []
|
|
64
|
+
lines: list[str] = []
|
|
65
|
+
for name, raw in props.items():
|
|
66
|
+
if not isinstance(name, str):
|
|
67
|
+
continue
|
|
68
|
+
spec = raw if isinstance(raw, dict) else {}
|
|
69
|
+
typ = spec.get("type")
|
|
70
|
+
type_s = str(typ) if typ is not None else "any"
|
|
71
|
+
desc = spec.get("description")
|
|
72
|
+
label = f" {name} ({type_s})"
|
|
73
|
+
if isinstance(desc, str) and desc.strip():
|
|
74
|
+
label = f"{label} — {desc.strip()}"
|
|
75
|
+
lines.append(label)
|
|
76
|
+
return lines
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def format_args_preview(arguments: dict[str, Any], *, limit: int = _ARGS_PREVIEW_LIMIT) -> str:
|
|
80
|
+
try:
|
|
81
|
+
raw = json.dumps(arguments, default=str, ensure_ascii=False)
|
|
82
|
+
except TypeError:
|
|
83
|
+
raw = str(arguments)
|
|
84
|
+
if len(raw) > limit:
|
|
85
|
+
return raw[: limit - 1] + "…"
|
|
86
|
+
return raw
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def extract_elicitation_fields(payload: dict[str, Any]) -> tuple[str | None, dict[str, Any] | None]:
|
|
90
|
+
"""Return (message, schema) from an ActionRequiredEvent payload."""
|
|
91
|
+
message = payload.get("message")
|
|
92
|
+
if not isinstance(message, str) or not message.strip():
|
|
93
|
+
message = None
|
|
94
|
+
else:
|
|
95
|
+
message = message.strip()
|
|
96
|
+
schema_raw = (
|
|
97
|
+
payload.get("requestedSchema")
|
|
98
|
+
or payload.get("requested_schema")
|
|
99
|
+
or payload.get("schema")
|
|
100
|
+
)
|
|
101
|
+
schema = dict(schema_raw) if isinstance(schema_raw, dict) else None
|
|
102
|
+
return message, schema
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def format_hitl_plain_prompt(req: "HitlRequest") -> str:
|
|
106
|
+
"""Multi-line prompt for the plain (non-TTY) HITL reader."""
|
|
107
|
+
parts: list[str] = [req.prompt]
|
|
108
|
+
if req.kind == "confirm" and req.tool_name:
|
|
109
|
+
parts.append(f" tool: {req.tool_name}")
|
|
110
|
+
if req.arguments:
|
|
111
|
+
parts.append(f" args: {format_args_preview(req.arguments)}")
|
|
112
|
+
if req.schema:
|
|
113
|
+
field_lines = format_schema_field_lines(req.schema)
|
|
114
|
+
if field_lines:
|
|
115
|
+
parts.append(" fields:")
|
|
116
|
+
parts.extend(field_lines)
|
|
117
|
+
if req.timeout_sec is not None and req.timeout_sec > 0:
|
|
118
|
+
parts.append(f" (timeout {int(req.timeout_sec)}s)")
|
|
119
|
+
if req.kind == "confirm":
|
|
120
|
+
parts.append(" [y/n]")
|
|
121
|
+
return "\n".join(parts)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class HitlRequest:
|
|
126
|
+
kind: HitlKind
|
|
127
|
+
prompt: str
|
|
128
|
+
tool_call_id: str = ""
|
|
129
|
+
elicitation_id: str = ""
|
|
130
|
+
tool_name: str = ""
|
|
131
|
+
schema: dict[str, Any] | None = None
|
|
132
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
133
|
+
timeout_sec: float | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class HitlAnswer:
|
|
138
|
+
cancelled: bool = False
|
|
139
|
+
approved: bool | None = None
|
|
140
|
+
text: str = ""
|
|
141
|
+
user_data: Any = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class ChatUiEvent:
|
|
146
|
+
kind: str
|
|
147
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class _TurnState:
|
|
152
|
+
assistant_started: bool = False
|
|
153
|
+
done: bool = False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class SseFrame:
|
|
158
|
+
"""One SSE data payload plus optional event id from an ``id:`` line."""
|
|
159
|
+
|
|
160
|
+
data: str
|
|
161
|
+
event_id: int | None = None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def format_http_error(operation: str, exc: BaseException) -> str:
|
|
165
|
+
return f"{operation} failed: {exc}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def iter_sse_frames(resp: httpx.Response) -> AsyncIterator[SseFrame]:
|
|
169
|
+
"""Yield SSE data frames, tracking ``id:`` lines for Last-Event-ID reconnect."""
|
|
170
|
+
data_lines: list[str] = []
|
|
171
|
+
event_id: int | None = None
|
|
172
|
+
async for line in resp.aiter_lines():
|
|
173
|
+
if line.startswith(":"):
|
|
174
|
+
continue
|
|
175
|
+
if line.startswith("id:"):
|
|
176
|
+
raw = line[3:].strip()
|
|
177
|
+
with contextlib.suppress(ValueError):
|
|
178
|
+
event_id = int(raw)
|
|
179
|
+
continue
|
|
180
|
+
if line.startswith("data:"):
|
|
181
|
+
data_lines.append(line[5:].lstrip())
|
|
182
|
+
continue
|
|
183
|
+
if line == "" and data_lines:
|
|
184
|
+
yield SseFrame(data="\n".join(data_lines), event_id=event_id)
|
|
185
|
+
data_lines = []
|
|
186
|
+
event_id = None
|
|
187
|
+
if data_lines:
|
|
188
|
+
yield SseFrame(data="\n".join(data_lines), event_id=event_id)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def iter_sse_lines(resp: httpx.Response) -> AsyncIterator[str]:
|
|
192
|
+
"""Yield SSE data payloads (compat wrapper around :func:`iter_sse_frames`)."""
|
|
193
|
+
async for frame in iter_sse_frames(resp):
|
|
194
|
+
yield frame.data
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ChatSessionController:
|
|
198
|
+
"""Owns HTTP session + SSE turn loop. Emits UI events; never writes stdout."""
|
|
199
|
+
|
|
200
|
+
turn_based: bool = True
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
*,
|
|
205
|
+
base: str,
|
|
206
|
+
model_provider: str | None = None,
|
|
207
|
+
model_name: str | None = None,
|
|
208
|
+
show_thinking: bool = False,
|
|
209
|
+
verbose: bool = False,
|
|
210
|
+
show_usage: bool = False,
|
|
211
|
+
emit: EmitFn | None = None,
|
|
212
|
+
hitl_reader: HitlReaderFn | None = None,
|
|
213
|
+
resume_session_id: str | None = None,
|
|
214
|
+
) -> None:
|
|
215
|
+
self.base = base.rstrip("/")
|
|
216
|
+
self.model_provider = model_provider
|
|
217
|
+
self.model_name = model_name
|
|
218
|
+
self.show_thinking = show_thinking
|
|
219
|
+
self.verbose = verbose
|
|
220
|
+
self.show_usage = show_usage
|
|
221
|
+
self._emit_fn = emit or (lambda _e: None)
|
|
222
|
+
self._hitl_reader = hitl_reader
|
|
223
|
+
self._resume_session_id = (
|
|
224
|
+
resume_session_id.strip()
|
|
225
|
+
if isinstance(resume_session_id, str) and resume_session_id.strip()
|
|
226
|
+
else None
|
|
227
|
+
)
|
|
228
|
+
self.usage = UsageStore()
|
|
229
|
+
self.session_id: str | None = None
|
|
230
|
+
self.stream_error = False
|
|
231
|
+
self._client: httpx.AsyncClient | None = None
|
|
232
|
+
self._event_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
|
233
|
+
self._stream_task: asyncio.Task[None] | None = None
|
|
234
|
+
self._stream_alive = True
|
|
235
|
+
self._reconnecting = False
|
|
236
|
+
self._last_event_id: int | None = None
|
|
237
|
+
self._abandoned: deque[str] = deque(maxlen=64)
|
|
238
|
+
self._turn_abort = asyncio.Event()
|
|
239
|
+
self._active_request_id: str | None = None
|
|
240
|
+
self._hitl_future: asyncio.Future[HitlAnswer] | None = None
|
|
241
|
+
self._closed = False
|
|
242
|
+
self._last_cancel_ok: bool | None = None
|
|
243
|
+
self._cancel_tasks: set[asyncio.Task[None]] = set()
|
|
244
|
+
|
|
245
|
+
def _emit(self, kind: str, **payload: Any) -> None:
|
|
246
|
+
self._emit_fn(ChatUiEvent(kind=kind, payload=payload))
|
|
247
|
+
|
|
248
|
+
def set_emit(self, emit: EmitFn) -> None:
|
|
249
|
+
"""Replace the UI event sink (used when the TUI takes ownership)."""
|
|
250
|
+
self._emit_fn = emit
|
|
251
|
+
|
|
252
|
+
def _warn(self, message: str) -> None:
|
|
253
|
+
logger.warning("%s", message)
|
|
254
|
+
|
|
255
|
+
def _session_body(self, *, session_id: str | None = None) -> dict[str, Any]:
|
|
256
|
+
body: dict[str, Any] = {}
|
|
257
|
+
if session_id:
|
|
258
|
+
body["session_id"] = session_id
|
|
259
|
+
if self.model_provider:
|
|
260
|
+
body["model_provider"] = self.model_provider
|
|
261
|
+
if self.model_name:
|
|
262
|
+
body["model_name"] = self.model_name
|
|
263
|
+
return body
|
|
264
|
+
|
|
265
|
+
async def connect(self, resume_session_id: str | None = None) -> None:
|
|
266
|
+
"""Open HTTP client (if needed), create/attach session, start SSE + backfill."""
|
|
267
|
+
timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0)
|
|
268
|
+
if self._client is None:
|
|
269
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
270
|
+
assert self._client is not None
|
|
271
|
+
self._closed = False
|
|
272
|
+
try:
|
|
273
|
+
health = await self._client.get(f"{self.base}/health")
|
|
274
|
+
health.raise_for_status()
|
|
275
|
+
except httpx.HTTPError as exc:
|
|
276
|
+
raise RuntimeError(f"Gateway not reachable at {self.base}: {exc}") from exc
|
|
277
|
+
|
|
278
|
+
sid = resume_session_id or self._resume_session_id
|
|
279
|
+
if isinstance(sid, str) and sid.strip():
|
|
280
|
+
await self._attach_or_create_session(sid.strip())
|
|
281
|
+
else:
|
|
282
|
+
await self._create_session()
|
|
283
|
+
|
|
284
|
+
self._stream_alive = True
|
|
285
|
+
self.stream_error = False
|
|
286
|
+
self._reconnecting = False
|
|
287
|
+
self._last_event_id = None
|
|
288
|
+
self._stream_task = asyncio.create_task(self._consume_stream())
|
|
289
|
+
self._emit("session_ready", session_id=self.session_id)
|
|
290
|
+
self._emit("connection_state", state="connected")
|
|
291
|
+
await self._emit_transcript_backfill()
|
|
292
|
+
|
|
293
|
+
async def _create_session(self) -> None:
|
|
294
|
+
assert self._client is not None
|
|
295
|
+
try:
|
|
296
|
+
create = await self._client.post(f"{self.base}/sessions", json=self._session_body())
|
|
297
|
+
create.raise_for_status()
|
|
298
|
+
except httpx.HTTPError as exc:
|
|
299
|
+
raise RuntimeError(format_http_error("Session creation", exc)) from exc
|
|
300
|
+
self.session_id = create.json()["session_id"]
|
|
301
|
+
|
|
302
|
+
async def _attach_or_create_session(self, session_id: str) -> None:
|
|
303
|
+
assert self._client is not None
|
|
304
|
+
live = False
|
|
305
|
+
try:
|
|
306
|
+
probe = await self._client.get(f"{self.base}/sessions/{session_id}/usage")
|
|
307
|
+
if probe.status_code == 200:
|
|
308
|
+
live = True
|
|
309
|
+
elif probe.status_code != 404:
|
|
310
|
+
probe.raise_for_status()
|
|
311
|
+
except httpx.HTTPError as exc:
|
|
312
|
+
if not isinstance(exc, httpx.HTTPStatusError) or exc.response.status_code != 404:
|
|
313
|
+
raise RuntimeError(format_http_error("Session probe", exc)) from exc
|
|
314
|
+
|
|
315
|
+
if live:
|
|
316
|
+
self.session_id = session_id
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
create = await self._client.post(
|
|
321
|
+
f"{self.base}/sessions",
|
|
322
|
+
json=self._session_body(session_id=session_id),
|
|
323
|
+
)
|
|
324
|
+
if create.status_code == 409:
|
|
325
|
+
self.session_id = session_id
|
|
326
|
+
return
|
|
327
|
+
create.raise_for_status()
|
|
328
|
+
except httpx.HTTPError as exc:
|
|
329
|
+
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 409:
|
|
330
|
+
self.session_id = session_id
|
|
331
|
+
return
|
|
332
|
+
raise RuntimeError(format_http_error("Session creation", exc)) from exc
|
|
333
|
+
self.session_id = create.json().get("session_id") or session_id
|
|
334
|
+
|
|
335
|
+
async def _emit_transcript_backfill(self) -> None:
|
|
336
|
+
assert self._client is not None and self.session_id is not None
|
|
337
|
+
try:
|
|
338
|
+
resp = await self._client.get(f"{self.base}/api/chat-history/{self.session_id}")
|
|
339
|
+
if resp.status_code in (404, 503):
|
|
340
|
+
return
|
|
341
|
+
resp.raise_for_status()
|
|
342
|
+
data = resp.json()
|
|
343
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
344
|
+
self._warn(f"chat history backfill skipped: {exc}")
|
|
345
|
+
return
|
|
346
|
+
messages = data.get("messages") or []
|
|
347
|
+
if not isinstance(messages, list) or not messages:
|
|
348
|
+
return
|
|
349
|
+
wire: list[dict[str, str]] = []
|
|
350
|
+
for item in messages:
|
|
351
|
+
if not isinstance(item, dict):
|
|
352
|
+
continue
|
|
353
|
+
role = str(item.get("role") or "")
|
|
354
|
+
text = str(item.get("text") or "")
|
|
355
|
+
if role in ("user", "assistant") and text:
|
|
356
|
+
wire.append({"role": role, "text": text})
|
|
357
|
+
if wire:
|
|
358
|
+
self._emit("transcript_backfill", messages=wire)
|
|
359
|
+
|
|
360
|
+
async def _consume_stream(self) -> None:
|
|
361
|
+
"""Read SSE forever, reconnecting with backoff until closed or session gone."""
|
|
362
|
+
assert self._client is not None and self.session_id is not None
|
|
363
|
+
delay = _RECONNECT_INITIAL_DELAY
|
|
364
|
+
attempts = 0
|
|
365
|
+
first_connect = True
|
|
366
|
+
|
|
367
|
+
while not self._closed:
|
|
368
|
+
outcome = await self._pump_sse_connection(first_connect=first_connect)
|
|
369
|
+
if outcome == "fatal":
|
|
370
|
+
return
|
|
371
|
+
if outcome == "open":
|
|
372
|
+
first_connect = False
|
|
373
|
+
attempts = 0
|
|
374
|
+
delay = _RECONNECT_INITIAL_DELAY
|
|
375
|
+
if self._closed:
|
|
376
|
+
return
|
|
377
|
+
attempts += 1
|
|
378
|
+
if attempts > _RECONNECT_MAX_ATTEMPTS:
|
|
379
|
+
self._stream_alive = False
|
|
380
|
+
self.stream_error = True
|
|
381
|
+
self._reconnecting = False
|
|
382
|
+
self._emit(
|
|
383
|
+
"stream_failed",
|
|
384
|
+
message="Event stream lost after repeated reconnect attempts",
|
|
385
|
+
fatal=True,
|
|
386
|
+
)
|
|
387
|
+
await self._event_queue.put(None)
|
|
388
|
+
return
|
|
389
|
+
delay = await self._reconnect_backoff(attempts, delay)
|
|
390
|
+
|
|
391
|
+
async def _pump_sse_connection(
|
|
392
|
+
self, *, first_connect: bool
|
|
393
|
+
) -> Literal["open", "retry", "fatal"]:
|
|
394
|
+
"""Open one SSE connection and pump frames until EOF, retryable error, or fatal."""
|
|
395
|
+
assert self._client is not None and self.session_id is not None
|
|
396
|
+
headers: dict[str, str] = {}
|
|
397
|
+
if self._last_event_id is not None:
|
|
398
|
+
headers["Last-Event-ID"] = str(self._last_event_id)
|
|
399
|
+
opened = False
|
|
400
|
+
try:
|
|
401
|
+
async with self._client.stream(
|
|
402
|
+
"GET",
|
|
403
|
+
f"{self.base}/sessions/{self.session_id}/events",
|
|
404
|
+
headers=headers,
|
|
405
|
+
) as resp:
|
|
406
|
+
if resp.status_code == 404:
|
|
407
|
+
self._stream_alive = False
|
|
408
|
+
self.stream_error = True
|
|
409
|
+
self._reconnecting = False
|
|
410
|
+
self._emit(
|
|
411
|
+
"stream_failed",
|
|
412
|
+
message="Session not found on gateway",
|
|
413
|
+
fatal=True,
|
|
414
|
+
)
|
|
415
|
+
await self._event_queue.put(None)
|
|
416
|
+
return "fatal"
|
|
417
|
+
resp.raise_for_status()
|
|
418
|
+
opened = True
|
|
419
|
+
if not first_connect:
|
|
420
|
+
self.stream_error = False
|
|
421
|
+
self._reconnecting = False
|
|
422
|
+
self._emit("connection_state", state="connected")
|
|
423
|
+
self._emit("session_ready", session_id=self.session_id)
|
|
424
|
+
async for frame in iter_sse_frames(resp):
|
|
425
|
+
if frame.event_id is not None:
|
|
426
|
+
self._last_event_id = frame.event_id
|
|
427
|
+
await self._event_queue.put(frame.data)
|
|
428
|
+
return "open" if opened else "fatal"
|
|
429
|
+
except asyncio.CancelledError:
|
|
430
|
+
raise
|
|
431
|
+
except Exception as exc:
|
|
432
|
+
if self._closed:
|
|
433
|
+
return "fatal"
|
|
434
|
+
status = None
|
|
435
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
436
|
+
status = exc.response.status_code
|
|
437
|
+
if status == 404:
|
|
438
|
+
self._stream_alive = False
|
|
439
|
+
self.stream_error = True
|
|
440
|
+
self._reconnecting = False
|
|
441
|
+
self._emit(
|
|
442
|
+
"stream_failed",
|
|
443
|
+
message=format_http_error("Event stream", exc),
|
|
444
|
+
fatal=True,
|
|
445
|
+
)
|
|
446
|
+
await self._event_queue.put(None)
|
|
447
|
+
return "fatal"
|
|
448
|
+
logger.warning("SSE stream error; will reconnect: %s", exc)
|
|
449
|
+
return "open" if opened else "retry"
|
|
450
|
+
|
|
451
|
+
async def _reconnect_backoff(self, attempts: int, delay: float) -> float:
|
|
452
|
+
self._reconnecting = True
|
|
453
|
+
# Keep stream_alive True so the REPL/TUI stay up during backoff.
|
|
454
|
+
self._emit(
|
|
455
|
+
"connection_state",
|
|
456
|
+
state="reconnecting",
|
|
457
|
+
attempt=attempts,
|
|
458
|
+
delay=delay,
|
|
459
|
+
)
|
|
460
|
+
try:
|
|
461
|
+
await asyncio.sleep(delay)
|
|
462
|
+
except asyncio.CancelledError:
|
|
463
|
+
raise
|
|
464
|
+
return min(_RECONNECT_MAX_DELAY, delay * 2)
|
|
465
|
+
|
|
466
|
+
def abort_turn(self) -> None:
|
|
467
|
+
self._turn_abort.set()
|
|
468
|
+
if self._hitl_future is not None and not self._hitl_future.done():
|
|
469
|
+
self._hitl_future.set_result(HitlAnswer(cancelled=True))
|
|
470
|
+
request_id = self._active_request_id
|
|
471
|
+
if request_id and self._client is not None and self.session_id is not None:
|
|
472
|
+
task = asyncio.create_task(self._post_cancel(request_id))
|
|
473
|
+
self._cancel_tasks.add(task)
|
|
474
|
+
task.add_done_callback(self._cancel_tasks.discard)
|
|
475
|
+
|
|
476
|
+
async def _post_cancel(self, request_id: str) -> None:
|
|
477
|
+
assert self._client is not None and self.session_id is not None
|
|
478
|
+
try:
|
|
479
|
+
resp = await self._client.post(
|
|
480
|
+
f"{self.base}/sessions/{self.session_id}/cancel",
|
|
481
|
+
json={"request_id": request_id},
|
|
482
|
+
)
|
|
483
|
+
resp.raise_for_status()
|
|
484
|
+
self._last_cancel_ok = True
|
|
485
|
+
except httpx.HTTPError as exc:
|
|
486
|
+
self._last_cancel_ok = False
|
|
487
|
+
logger.warning("cancel POST failed request_id=%s: %s", request_id, exc)
|
|
488
|
+
|
|
489
|
+
def provide_hitl_answer(self, answer: HitlAnswer) -> None:
|
|
490
|
+
if self._hitl_future is not None and not self._hitl_future.done():
|
|
491
|
+
self._hitl_future.set_result(answer)
|
|
492
|
+
|
|
493
|
+
async def submit(self, message: str) -> None:
|
|
494
|
+
if self._client is None or self.session_id is None:
|
|
495
|
+
raise RuntimeError("Session not connected")
|
|
496
|
+
if not self._stream_alive or self._reconnecting:
|
|
497
|
+
self._emit("error", message="Not connected — wait for reconnect")
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
request_id = str(uuid.uuid4())
|
|
501
|
+
self._active_request_id = request_id
|
|
502
|
+
self._last_cancel_ok = None
|
|
503
|
+
self._turn_abort.clear()
|
|
504
|
+
try:
|
|
505
|
+
reply = await self._client.post(
|
|
506
|
+
f"{self.base}/sessions/{self.session_id}/reply",
|
|
507
|
+
json={"request_id": request_id, "message": message},
|
|
508
|
+
)
|
|
509
|
+
if reply.status_code == 409:
|
|
510
|
+
self._emit("session_busy")
|
|
511
|
+
return
|
|
512
|
+
reply.raise_for_status()
|
|
513
|
+
except httpx.HTTPError as exc:
|
|
514
|
+
self._emit("error", message=format_http_error("Reply", exc))
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
self._emit("turn_started", request_id=request_id)
|
|
518
|
+
await self._run_turn(request_id)
|
|
519
|
+
|
|
520
|
+
async def _dequeue_payload(self) -> str | None | Literal[False]:
|
|
521
|
+
"""Return payload, None for stream end, False on poll timeout."""
|
|
522
|
+
try:
|
|
523
|
+
return await asyncio.wait_for(self._event_queue.get(), timeout=0.15)
|
|
524
|
+
except TimeoutError:
|
|
525
|
+
return False
|
|
526
|
+
|
|
527
|
+
def _decode_event(self, payload: str) -> Any | None:
|
|
528
|
+
try:
|
|
529
|
+
data = json.loads(payload)
|
|
530
|
+
except json.JSONDecodeError as exc:
|
|
531
|
+
self._warn(f"skipping malformed SSE JSON: {exc}")
|
|
532
|
+
return None
|
|
533
|
+
if data.get("type") == "ActiveRequests":
|
|
534
|
+
return None
|
|
535
|
+
try:
|
|
536
|
+
return event_from_json(payload)
|
|
537
|
+
except Exception as exc:
|
|
538
|
+
self._warn(f"skipping unparseable event: {exc}")
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
async def _run_turn(self, request_id: str) -> None:
|
|
542
|
+
assert self._client is not None and self.session_id is not None
|
|
543
|
+
state = _TurnState()
|
|
544
|
+
while not state.done and not self._turn_abort.is_set():
|
|
545
|
+
payload = await self._dequeue_payload()
|
|
546
|
+
if payload is False:
|
|
547
|
+
continue
|
|
548
|
+
if payload is None:
|
|
549
|
+
self._emit("thinking_clear")
|
|
550
|
+
break
|
|
551
|
+
evt = self._decode_event(payload)
|
|
552
|
+
if evt is None:
|
|
553
|
+
continue
|
|
554
|
+
evt_rid = getattr(evt, "request_id", "") or ""
|
|
555
|
+
if evt_rid and evt_rid in self._abandoned:
|
|
556
|
+
continue
|
|
557
|
+
await self._dispatch_turn_event(evt, request_id, state)
|
|
558
|
+
if self._turn_abort.is_set() and isinstance(evt, _HITL_TYPES):
|
|
559
|
+
break
|
|
560
|
+
|
|
561
|
+
if self._turn_abort.is_set() and not state.done:
|
|
562
|
+
self._abandoned.append(request_id)
|
|
563
|
+
self._emit("thinking_clear")
|
|
564
|
+
# Wait briefly for cancel POST to settle so the UI message is accurate.
|
|
565
|
+
if self._cancel_tasks:
|
|
566
|
+
with contextlib.suppress(TimeoutError, asyncio.TimeoutError):
|
|
567
|
+
await asyncio.wait(self._cancel_tasks, timeout=0.4)
|
|
568
|
+
cancel_ok = self._last_cancel_ok
|
|
569
|
+
self._emit("turn_aborted", cancel_ok=cancel_ok)
|
|
570
|
+
self._active_request_id = None
|
|
571
|
+
if not self._stream_alive and not self._reconnecting:
|
|
572
|
+
self._emit("stream_ended")
|
|
573
|
+
|
|
574
|
+
async def _dispatch_turn_event(self, evt: Any, request_id: str, state: _TurnState) -> None:
|
|
575
|
+
evt_rid = getattr(evt, "request_id", "") or ""
|
|
576
|
+
|
|
577
|
+
if isinstance(evt, _HITL_TYPES):
|
|
578
|
+
if evt_rid and evt_rid != request_id:
|
|
579
|
+
return
|
|
580
|
+
self._emit("thinking_clear")
|
|
581
|
+
await self._handle_hitl(evt)
|
|
582
|
+
if not self._turn_abort.is_set():
|
|
583
|
+
self._emit("thinking", text="thinking…")
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
if evt_rid != request_id:
|
|
587
|
+
self._maybe_thinking_trace(evt, request_id)
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
if isinstance(evt, GroundingEvent):
|
|
591
|
+
self._emit(
|
|
592
|
+
"grounding",
|
|
593
|
+
sources=list(evt.sources),
|
|
594
|
+
search_queries=list(evt.search_queries),
|
|
595
|
+
)
|
|
596
|
+
return
|
|
597
|
+
if isinstance(evt, ToolCallStarted):
|
|
598
|
+
state.assistant_started = False
|
|
599
|
+
self._emit("thinking_clear")
|
|
600
|
+
self._emit(
|
|
601
|
+
"tool_started",
|
|
602
|
+
tool=evt.tool,
|
|
603
|
+
label=evt.label,
|
|
604
|
+
args=dict(evt.args),
|
|
605
|
+
call_id=evt.call_id,
|
|
606
|
+
)
|
|
607
|
+
return
|
|
608
|
+
if isinstance(evt, ToolCallResult):
|
|
609
|
+
state.assistant_started = False
|
|
610
|
+
self._emit(
|
|
611
|
+
"tool_finished",
|
|
612
|
+
tool=evt.tool,
|
|
613
|
+
error=evt.error,
|
|
614
|
+
result=evt.result or "",
|
|
615
|
+
verbose=self.verbose,
|
|
616
|
+
call_id=evt.call_id,
|
|
617
|
+
)
|
|
618
|
+
return
|
|
619
|
+
if isinstance(evt, ContextSummarizing):
|
|
620
|
+
self._on_context_summarizing(evt)
|
|
621
|
+
return
|
|
622
|
+
if isinstance(evt, ContextSummarized):
|
|
623
|
+
self._emit("summarized", turns=evt.turns_summarized)
|
|
624
|
+
await self._fetch_usage()
|
|
625
|
+
return
|
|
626
|
+
if isinstance(evt, AssistantDelta):
|
|
627
|
+
self._on_assistant_delta(evt, state)
|
|
628
|
+
return
|
|
629
|
+
if isinstance(evt, ThinkingBlockDelta):
|
|
630
|
+
self._on_thinking_block_delta(evt)
|
|
631
|
+
return
|
|
632
|
+
if isinstance(evt, ThinkingBlockComplete):
|
|
633
|
+
self._emit("thinking_block_complete")
|
|
634
|
+
return
|
|
635
|
+
if isinstance(evt, Error):
|
|
636
|
+
self._emit("thinking_clear")
|
|
637
|
+
self._emit("turn_error", error=evt.error)
|
|
638
|
+
state.done = True
|
|
639
|
+
return
|
|
640
|
+
if isinstance(evt, TurnComplete):
|
|
641
|
+
await self._emit_turn_complete(evt)
|
|
642
|
+
state.done = True
|
|
643
|
+
return
|
|
644
|
+
|
|
645
|
+
self._maybe_thinking_trace(evt, request_id)
|
|
646
|
+
|
|
647
|
+
def _on_context_summarizing(self, evt: ContextSummarizing) -> None:
|
|
648
|
+
self.usage.update_context_hint(
|
|
649
|
+
estimated_prompt_tokens=evt.estimated_tokens,
|
|
650
|
+
context_window_tokens=evt.context_window_tokens,
|
|
651
|
+
)
|
|
652
|
+
self._emit("usage_updated", usage=self.usage.usage)
|
|
653
|
+
self._emit("summarizing", tokens=evt.estimated_tokens)
|
|
654
|
+
|
|
655
|
+
def _on_assistant_delta(self, evt: AssistantDelta, state: _TurnState) -> None:
|
|
656
|
+
if not state.assistant_started:
|
|
657
|
+
self._emit("thinking_clear")
|
|
658
|
+
self._emit("assistant_start")
|
|
659
|
+
state.assistant_started = True
|
|
660
|
+
self._emit("assistant_delta", delta=evt.delta)
|
|
661
|
+
|
|
662
|
+
def _on_thinking_block_delta(self, evt: ThinkingBlockDelta) -> None:
|
|
663
|
+
text = evt.text or ""
|
|
664
|
+
if not text:
|
|
665
|
+
return
|
|
666
|
+
# Clear the transient spinner once real thinking text arrives.
|
|
667
|
+
self._emit("thinking_clear")
|
|
668
|
+
self._emit("thinking_block_delta", text=text)
|
|
669
|
+
if self.show_thinking:
|
|
670
|
+
self._emit("thinking_trace", text=text)
|
|
671
|
+
|
|
672
|
+
def _maybe_thinking_trace(self, evt: Any, request_id: str) -> None:
|
|
673
|
+
if not self.show_thinking:
|
|
674
|
+
return
|
|
675
|
+
evt_rid = getattr(evt, "request_id", "") or ""
|
|
676
|
+
if evt_rid != request_id:
|
|
677
|
+
return
|
|
678
|
+
kind = getattr(evt, "kind", "")
|
|
679
|
+
if kind not in ("Thinking", "ThinkingBlockDelta"):
|
|
680
|
+
return
|
|
681
|
+
text = getattr(evt, "text", "") or getattr(evt, "delta", "")
|
|
682
|
+
if text:
|
|
683
|
+
self._emit("thinking_trace", text=text)
|
|
684
|
+
|
|
685
|
+
async def _emit_turn_complete(self, evt: TurnComplete) -> None:
|
|
686
|
+
self._emit("thinking_clear")
|
|
687
|
+
usage_payload = None
|
|
688
|
+
if self.show_usage and evt.usage is not None:
|
|
689
|
+
u = evt.usage
|
|
690
|
+
usage_payload = {
|
|
691
|
+
"input_tokens": u.input_tokens,
|
|
692
|
+
"output_tokens": u.output_tokens,
|
|
693
|
+
"cost_usd": u.cost_usd,
|
|
694
|
+
"duration_ms": u.duration_ms,
|
|
695
|
+
}
|
|
696
|
+
self._emit("turn_complete", usage=usage_payload)
|
|
697
|
+
await self._fetch_usage()
|
|
698
|
+
|
|
699
|
+
async def _handle_hitl(self, evt: Any) -> None:
|
|
700
|
+
assert self._client is not None and self.session_id is not None
|
|
701
|
+
if isinstance(evt, FrontendToolRequestEvent):
|
|
702
|
+
self._emit("hitl_frontend_unsupported", name=evt.name)
|
|
703
|
+
return
|
|
704
|
+
if isinstance(evt, ToolConfirmationRequestEvent):
|
|
705
|
+
await self._handle_tool_confirm(evt)
|
|
706
|
+
return
|
|
707
|
+
if isinstance(evt, ActionRequiredEvent):
|
|
708
|
+
await self._handle_elicit(evt)
|
|
709
|
+
|
|
710
|
+
async def _handle_tool_confirm(self, evt: ToolConfirmationRequestEvent) -> None:
|
|
711
|
+
assert self.session_id is not None
|
|
712
|
+
prompt = evt.prompt or f"Approve tool {evt.tool_name}?"
|
|
713
|
+
args = dict(evt.arguments) if evt.arguments else {}
|
|
714
|
+
answer = await self._await_hitl(
|
|
715
|
+
HitlRequest(
|
|
716
|
+
kind="confirm",
|
|
717
|
+
prompt=prompt,
|
|
718
|
+
tool_call_id=evt.tool_call_id,
|
|
719
|
+
tool_name=evt.tool_name,
|
|
720
|
+
arguments=args,
|
|
721
|
+
timeout_sec=pending_response_timeout_sec(),
|
|
722
|
+
)
|
|
723
|
+
)
|
|
724
|
+
approved = (not answer.cancelled) and (
|
|
725
|
+
answer.approved is True
|
|
726
|
+
or (answer.text.strip().lower() in ("y", "yes") if answer.approved is None else False)
|
|
727
|
+
)
|
|
728
|
+
if answer.cancelled:
|
|
729
|
+
approved = False
|
|
730
|
+
reason = (
|
|
731
|
+
"cancelled by user"
|
|
732
|
+
if answer.cancelled
|
|
733
|
+
else (None if approved else "denied by user")
|
|
734
|
+
)
|
|
735
|
+
ok = await self._post_hitl(
|
|
736
|
+
f"{self.base}/sessions/{self.session_id}/tool-confirmations/{evt.tool_call_id}",
|
|
737
|
+
{"approved": approved, "reason": reason},
|
|
738
|
+
label="Tool confirmation",
|
|
739
|
+
)
|
|
740
|
+
if answer.cancelled or not ok:
|
|
741
|
+
self._turn_abort.set()
|
|
742
|
+
|
|
743
|
+
async def _handle_elicit(self, evt: ActionRequiredEvent) -> None:
|
|
744
|
+
assert self.session_id is not None
|
|
745
|
+
payload = dict(evt.payload) if evt.payload else {}
|
|
746
|
+
message, schema = extract_elicitation_fields(payload)
|
|
747
|
+
prompt = message or f"Agent requests input (id={evt.id or '?'})"
|
|
748
|
+
answer = await self._await_hitl(
|
|
749
|
+
HitlRequest(
|
|
750
|
+
kind="elicit",
|
|
751
|
+
prompt=prompt,
|
|
752
|
+
elicitation_id=evt.id,
|
|
753
|
+
schema=schema,
|
|
754
|
+
timeout_sec=pending_response_timeout_sec(),
|
|
755
|
+
)
|
|
756
|
+
)
|
|
757
|
+
if answer.cancelled:
|
|
758
|
+
user_data: Any = {"cancelled": True}
|
|
759
|
+
elif answer.user_data is not None:
|
|
760
|
+
user_data = answer.user_data
|
|
761
|
+
else:
|
|
762
|
+
raw = answer.text.strip()
|
|
763
|
+
user_data = raw
|
|
764
|
+
if raw.startswith("{"):
|
|
765
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
766
|
+
user_data = json.loads(raw)
|
|
767
|
+
ok = await self._post_hitl(
|
|
768
|
+
f"{self.base}/sessions/{self.session_id}/elicitations/{evt.id}",
|
|
769
|
+
{"user_data": user_data},
|
|
770
|
+
label="Elicitation",
|
|
771
|
+
)
|
|
772
|
+
if answer.cancelled or not ok:
|
|
773
|
+
self._turn_abort.set()
|
|
774
|
+
|
|
775
|
+
async def _await_hitl(self, req: HitlRequest) -> HitlAnswer:
|
|
776
|
+
timeout = req.timeout_sec if req.timeout_sec is not None else pending_response_timeout_sec()
|
|
777
|
+
self._emit(
|
|
778
|
+
"hitl_required",
|
|
779
|
+
hitl_kind=req.kind,
|
|
780
|
+
prompt=req.prompt,
|
|
781
|
+
tool_call_id=req.tool_call_id,
|
|
782
|
+
elicitation_id=req.elicitation_id,
|
|
783
|
+
tool_name=req.tool_name,
|
|
784
|
+
schema=req.schema,
|
|
785
|
+
arguments=req.arguments,
|
|
786
|
+
timeout_sec=timeout,
|
|
787
|
+
)
|
|
788
|
+
if self._hitl_reader is not None:
|
|
789
|
+
result = self._hitl_reader(req)
|
|
790
|
+
if asyncio.iscoroutine(result):
|
|
791
|
+
return await result
|
|
792
|
+
return result # type: ignore[return-value]
|
|
793
|
+
loop = asyncio.get_running_loop()
|
|
794
|
+
self._hitl_future = loop.create_future()
|
|
795
|
+
try:
|
|
796
|
+
return await self._hitl_future
|
|
797
|
+
finally:
|
|
798
|
+
self._hitl_future = None
|
|
799
|
+
|
|
800
|
+
async def _post_hitl(self, url: str, payload: dict[str, Any], *, label: str) -> bool:
|
|
801
|
+
assert self._client is not None
|
|
802
|
+
try:
|
|
803
|
+
resp = await self._client.post(url, json=payload)
|
|
804
|
+
resp.raise_for_status()
|
|
805
|
+
return True
|
|
806
|
+
except httpx.HTTPError as exc:
|
|
807
|
+
status = ""
|
|
808
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
809
|
+
status = f" (HTTP {exc.response.status_code})"
|
|
810
|
+
self._emit(
|
|
811
|
+
"hitl_failed",
|
|
812
|
+
message=(
|
|
813
|
+
f"{label} failed{status}: {exc}. "
|
|
814
|
+
"The agent may still be waiting server-side — the CLI did not "
|
|
815
|
+
"acknowledge this action."
|
|
816
|
+
),
|
|
817
|
+
)
|
|
818
|
+
return False
|
|
819
|
+
|
|
820
|
+
async def _fetch_usage(self) -> None:
|
|
821
|
+
assert self._client is not None and self.session_id is not None
|
|
822
|
+
try:
|
|
823
|
+
resp = await self._client.get(f"{self.base}/sessions/{self.session_id}/usage")
|
|
824
|
+
resp.raise_for_status()
|
|
825
|
+
except httpx.HTTPError as exc:
|
|
826
|
+
if self.verbose or self.show_usage:
|
|
827
|
+
self._warn(format_http_error("Usage", exc))
|
|
828
|
+
return
|
|
829
|
+
view = parse_usage_response(resp.json())
|
|
830
|
+
self.usage.update(view)
|
|
831
|
+
self._emit("usage_updated", usage=view)
|
|
832
|
+
|
|
833
|
+
async def refresh_usage(self) -> None:
|
|
834
|
+
if self._client is None or self.session_id is None:
|
|
835
|
+
return
|
|
836
|
+
await self._fetch_usage()
|
|
837
|
+
|
|
838
|
+
async def _teardown_stream(self) -> None:
|
|
839
|
+
if self._hitl_future is not None and not self._hitl_future.done():
|
|
840
|
+
self._hitl_future.set_result(HitlAnswer(cancelled=True))
|
|
841
|
+
self._hitl_future = None
|
|
842
|
+
self._turn_abort.set()
|
|
843
|
+
if self._active_request_id is not None:
|
|
844
|
+
self._abandoned.append(self._active_request_id)
|
|
845
|
+
self._active_request_id = None
|
|
846
|
+
|
|
847
|
+
if self._stream_task is not None:
|
|
848
|
+
self._stream_task.cancel()
|
|
849
|
+
with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError, TimeoutError):
|
|
850
|
+
await asyncio.wait_for(self._stream_task, timeout=0.5)
|
|
851
|
+
self._stream_task = None
|
|
852
|
+
|
|
853
|
+
while True:
|
|
854
|
+
try:
|
|
855
|
+
self._event_queue.get_nowait()
|
|
856
|
+
except asyncio.QueueEmpty:
|
|
857
|
+
break
|
|
858
|
+
self._event_queue = asyncio.Queue()
|
|
859
|
+
self._abandoned.clear()
|
|
860
|
+
self._last_event_id = None
|
|
861
|
+
self._reconnecting = False
|
|
862
|
+
self._stream_alive = True
|
|
863
|
+
self.stream_error = False
|
|
864
|
+
self.usage = UsageStore()
|
|
865
|
+
self._turn_abort.clear()
|
|
866
|
+
|
|
867
|
+
async def restart_session(self) -> None:
|
|
868
|
+
"""Close the current gateway session and open a fresh one (same HTTP client)."""
|
|
869
|
+
if self._client is None or self._closed:
|
|
870
|
+
raise RuntimeError("Session not connected")
|
|
871
|
+
await self._teardown_stream()
|
|
872
|
+
await self._create_session()
|
|
873
|
+
self._stream_task = asyncio.create_task(self._consume_stream())
|
|
874
|
+
self._emit("session_ready", session_id=self.session_id)
|
|
875
|
+
self._emit("connection_state", state="connected")
|
|
876
|
+
|
|
877
|
+
async def resume_session(self, session_id: str) -> None:
|
|
878
|
+
"""Switch to an existing (or recreated) session id and backfill transcript."""
|
|
879
|
+
if self._client is None or self._closed:
|
|
880
|
+
raise RuntimeError("Session not connected")
|
|
881
|
+
sid = session_id.strip()
|
|
882
|
+
if not sid:
|
|
883
|
+
raise RuntimeError("Session id required")
|
|
884
|
+
await self._teardown_stream()
|
|
885
|
+
await self._attach_or_create_session(sid)
|
|
886
|
+
self._stream_task = asyncio.create_task(self._consume_stream())
|
|
887
|
+
self._emit("session_ready", session_id=self.session_id)
|
|
888
|
+
self._emit("connection_state", state="connected")
|
|
889
|
+
await self._emit_transcript_backfill()
|
|
890
|
+
|
|
891
|
+
@property
|
|
892
|
+
def stream_alive(self) -> bool:
|
|
893
|
+
return self._stream_alive
|
|
894
|
+
|
|
895
|
+
@property
|
|
896
|
+
def reconnecting(self) -> bool:
|
|
897
|
+
return self._reconnecting
|
|
898
|
+
|
|
899
|
+
async def close(self) -> None:
|
|
900
|
+
if self._closed:
|
|
901
|
+
return
|
|
902
|
+
self._closed = True
|
|
903
|
+
if self._stream_task is not None:
|
|
904
|
+
self._stream_task.cancel()
|
|
905
|
+
with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError, TimeoutError):
|
|
906
|
+
await asyncio.wait_for(self._stream_task, timeout=0.5)
|
|
907
|
+
for task in list(self._cancel_tasks):
|
|
908
|
+
task.cancel()
|
|
909
|
+
if self._client is not None:
|
|
910
|
+
await self._client.aclose()
|
|
911
|
+
self._client = None
|