python-codex 0.2.7__py3-none-any.whl → 0.3.0__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.
- pycodex/__init__.py +14 -14
- pycodex/agent.py +465 -499
- pycodex/bootstrap.py +417 -0
- pycodex/cli.py +236 -510
- pycodex/compat.py +19 -5
- pycodex/context.py +222 -212
- pycodex/doctor.py +52 -48
- pycodex/events.py +857 -0
- pycodex/feishu_card.py +217 -163
- pycodex/feishu_link.py +43 -83
- pycodex/model.py +324 -253
- pycodex/model_metadata.py +19 -7
- pycodex/portable.py +76 -45
- pycodex/portable_server.py +32 -24
- pycodex/prompts/models.json +245 -983
- pycodex/protocol.py +177 -137
- pycodex/runtime.py +579 -176
- pycodex/runtime_services.py +204 -157
- pycodex/tools/__init__.py +1 -1
- pycodex/tools/apply_patch_tool.py +69 -48
- pycodex/tools/base_tool.py +89 -42
- pycodex/tools/clock_tool.py +58 -25
- pycodex/tools/close_agent_tool.py +2 -2
- pycodex/tools/code_mode_manager.py +77 -64
- pycodex/tools/exec_command_tool.py +26 -11
- pycodex/tools/exec_tool.py +4 -4
- pycodex/tools/grep_files_tool.py +12 -10
- pycodex/tools/ipython_tool.py +10 -13
- pycodex/tools/list_dir_tool.py +13 -9
- pycodex/tools/read_file_tool.py +29 -17
- pycodex/tools/request_permissions_tool.py +15 -5
- pycodex/tools/request_user_input_tool.py +13 -104
- pycodex/tools/resume_agent_tool.py +2 -2
- pycodex/tools/send_input_tool.py +11 -8
- pycodex/tools/shell_command_tool.py +7 -5
- pycodex/tools/shell_tool.py +7 -5
- pycodex/tools/spawn_agent_tool.py +7 -4
- pycodex/tools/unified_exec_manager.py +102 -69
- pycodex/tools/update_plan_tool.py +8 -5
- pycodex/tools/view_image_tool.py +7 -5
- pycodex/tools/wait_agent_tool.py +27 -4
- pycodex/tools/wait_tool.py +5 -4
- pycodex/tools/web_search_tool.py +4 -2
- pycodex/tools/write_stdin_tool.py +12 -11
- pycodex/utils/__init__.py +2 -17
- pycodex/utils/compactor.py +41 -72
- pycodex/utils/debug.py +2 -2
- pycodex/utils/dotenv.py +6 -7
- pycodex/utils/event_helpers.py +190 -0
- pycodex/utils/get_env.py +27 -70
- pycodex/{image_utils.py → utils/image_utils.py} +8 -11
- pycodex/utils/random_ids.py +1 -2
- pycodex/utils/session_persist.py +217 -163
- pycodex/utils/truncation.py +21 -45
- python_codex-0.3.0.dist-info/METADATA +704 -0
- python_codex-0.3.0.dist-info/RECORD +90 -0
- responses_server/__init__.py +1 -5
- responses_server/__main__.py +0 -1
- responses_server/app.py +36 -31
- responses_server/config.py +23 -23
- responses_server/messages_api.py +51 -53
- responses_server/payload_processors.py +25 -20
- responses_server/server.py +11 -11
- responses_server/session_store.py +14 -11
- responses_server/stream_router.py +101 -98
- responses_server/tools/custom_adapter.py +17 -16
- responses_server/tools/web_search.py +39 -36
- responses_server/trajectory_dump.py +36 -14
- workspace_server/__main__.py +0 -1
- workspace_server/app.py +461 -375
- workspace_server/workspace.html +852 -228
- workspace_server/workspaces.html +94 -95
- workspace_server/workspaces.py +137 -79
- pycodex/collaboration.py +0 -20
- pycodex/interactive_session.py +0 -415
- pycodex/prompts/collaboration_default.md +0 -11
- pycodex/prompts/collaboration_plan.md +0 -128
- pycodex/utils/toolcall_visualize.py +0 -713
- pycodex/utils/visualize.py +0 -560
- python_codex-0.2.7.dist-info/METADATA +0 -455
- python_codex-0.2.7.dist-info/RECORD +0 -93
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
workspace_server/app.py
CHANGED
|
@@ -6,12 +6,15 @@ import mimetypes
|
|
|
6
6
|
import os
|
|
7
7
|
import secrets
|
|
8
8
|
import threading
|
|
9
|
-
from dataclasses import asdict, is_dataclass
|
|
9
|
+
from dataclasses import asdict, fields, is_dataclass
|
|
10
|
+
|
|
10
11
|
try:
|
|
11
12
|
from contextlib import asynccontextmanager
|
|
12
13
|
except ImportError: # pragma: no cover - Python 3.6 compatibility
|
|
13
14
|
asynccontextmanager = None
|
|
15
|
+
import typing
|
|
14
16
|
from pathlib import Path
|
|
17
|
+
from urllib.parse import urlencode
|
|
15
18
|
|
|
16
19
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
17
20
|
from fastapi.responses import (
|
|
@@ -22,22 +25,43 @@ from fastapi.responses import (
|
|
|
22
25
|
Response,
|
|
23
26
|
)
|
|
24
27
|
|
|
25
|
-
from pycodex.
|
|
26
|
-
from pycodex.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
from pycodex.bootstrap import build_agent, build_model, build_runtime, configure_loguru
|
|
29
|
+
from pycodex.events import (
|
|
30
|
+
IDLE_SLEEPING_STATUS,
|
|
31
|
+
AssistantDeltaEvent,
|
|
32
|
+
AutoCompactCompletedEvent,
|
|
33
|
+
AutoCompactFailedEvent,
|
|
34
|
+
AutoCompactStartedEvent,
|
|
35
|
+
CommandCompletedEvent,
|
|
36
|
+
CommandFailedEvent,
|
|
37
|
+
CompactCompletedEvent,
|
|
38
|
+
CompactFailedEvent,
|
|
39
|
+
CompactStartedEvent,
|
|
40
|
+
Event,
|
|
41
|
+
InputQueuedEvent,
|
|
42
|
+
InputRequestedEvent,
|
|
43
|
+
InputResolvedEvent,
|
|
44
|
+
SessionClosedEvent,
|
|
45
|
+
SessionStateEvent,
|
|
46
|
+
StreamErrorEvent,
|
|
47
|
+
TerminalEvent,
|
|
48
|
+
TokenCountEvent,
|
|
49
|
+
ToolCalledEvent,
|
|
50
|
+
ToolCompletedEvent,
|
|
51
|
+
ToolStartedEvent,
|
|
52
|
+
TurnCompletedEvent,
|
|
53
|
+
TurnEvent,
|
|
54
|
+
TurnFailedEvent,
|
|
55
|
+
TurnInterruptedEvent,
|
|
56
|
+
TurnStartedEvent,
|
|
32
57
|
)
|
|
58
|
+
from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
|
|
33
59
|
from pycodex.utils import uuid7_string
|
|
34
|
-
from pycodex.utils.
|
|
35
|
-
|
|
36
|
-
background_work_count,
|
|
37
|
-
percent_of_context_window_remaining,
|
|
60
|
+
from pycodex.utils.event_helpers import (
|
|
61
|
+
completed_history,
|
|
38
62
|
shorten_title,
|
|
39
|
-
tool_summary,
|
|
40
63
|
)
|
|
64
|
+
|
|
41
65
|
from .workspaces import (
|
|
42
66
|
WorkspaceDefinition,
|
|
43
67
|
WorkspaceEntry,
|
|
@@ -46,8 +70,6 @@ from .workspaces import (
|
|
|
46
70
|
load_workspace_definitions,
|
|
47
71
|
session_snapshot,
|
|
48
72
|
)
|
|
49
|
-
import typing
|
|
50
|
-
|
|
51
73
|
|
|
52
74
|
JSONValue = typing.Union[
|
|
53
75
|
None,
|
|
@@ -141,93 +163,41 @@ def parse_listen(target: str) -> "typing.Tuple[str, int]":
|
|
|
141
163
|
|
|
142
164
|
SessionFactory = typing.Callable[[], object]
|
|
143
165
|
ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
|
|
144
|
-
SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
|
|
145
166
|
SPINNER_STATUS_PREVIEW_LIMIT = 180
|
|
146
167
|
AUTH_COOKIE_NAME = "pycodex_ws_auth"
|
|
147
168
|
|
|
148
169
|
|
|
149
170
|
class WebSessionView:
|
|
150
171
|
def __init__(self) -> None:
|
|
151
|
-
self._input_queue: "asyncio.Queue" = asyncio.Queue()
|
|
152
172
|
self._subscribers: "typing.Set[asyncio.Queue]" = set()
|
|
153
173
|
self._events: "typing.List[typing.Dict[str, object]]" = []
|
|
154
174
|
self._turns: "typing.List[typing.Dict[str, object]]" = []
|
|
155
175
|
self._turns_by_submission_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
|
|
156
176
|
self._turns_by_turn_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
|
|
157
177
|
self._title = ""
|
|
178
|
+
self._model = "pycodex"
|
|
179
|
+
self._rollout_path = ""
|
|
180
|
+
self._recorded_rollout_path = ""
|
|
181
|
+
self._input_request = None
|
|
182
|
+
self._accepts_input = True
|
|
158
183
|
self._spinner_status = ""
|
|
159
184
|
self._stream_buffer = ""
|
|
160
|
-
self.
|
|
161
|
-
self.
|
|
162
|
-
self.
|
|
185
|
+
self._max_context_window: "typing.Union[int, None]" = None
|
|
186
|
+
self._auto_compact_token_limit: "typing.Union[int, None]" = None
|
|
187
|
+
self._usage_tokens: "typing.Union[int, None]" = None
|
|
163
188
|
self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
164
|
-
self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
165
189
|
self._lock = threading.RLock()
|
|
166
190
|
|
|
167
191
|
def attach_server_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
|
|
168
192
|
self._server_loop = loop
|
|
169
193
|
|
|
170
|
-
def
|
|
171
|
-
self._worker_loop = loop
|
|
172
|
-
|
|
173
|
-
async def submit(self, prompt: str) -> "typing.Dict[str, object]":
|
|
174
|
-
prompt = str(prompt or "").strip()
|
|
175
|
-
if not prompt:
|
|
176
|
-
return {"ok": False, "error": "prompt is empty"}
|
|
177
|
-
await self._put_input(prompt)
|
|
178
|
-
await self._publish(
|
|
179
|
-
{
|
|
180
|
-
"type": "input",
|
|
181
|
-
"prompt": prompt,
|
|
182
|
-
"snapshot": self.snapshot(),
|
|
183
|
-
}
|
|
184
|
-
)
|
|
185
|
-
return {"ok": True, "type": "submitted", "snapshot": self.snapshot()}
|
|
186
|
-
|
|
187
|
-
async def _put_input(self, item: object) -> None:
|
|
188
|
-
worker_loop = self._worker_loop
|
|
189
|
-
try:
|
|
190
|
-
running_loop = asyncio.get_running_loop()
|
|
191
|
-
except RuntimeError:
|
|
192
|
-
running_loop = None
|
|
193
|
-
if worker_loop is None or worker_loop is running_loop:
|
|
194
|
-
await self._input_queue.put(item)
|
|
195
|
-
return
|
|
196
|
-
future = asyncio.run_coroutine_threadsafe(self._input_queue.put(item), worker_loop)
|
|
197
|
-
await asyncio.wrap_future(future)
|
|
198
|
-
|
|
199
|
-
async def poll_prompt(self, prompt: "typing.Union[str, None]" = None) -> "typing.Union[str, None]":
|
|
200
|
-
del prompt
|
|
201
|
-
if self._closed and self._input_queue.empty():
|
|
202
|
-
raise EOFError()
|
|
203
|
-
try:
|
|
204
|
-
item = self._input_queue.get_nowait()
|
|
205
|
-
except asyncio.QueueEmpty:
|
|
206
|
-
return None
|
|
207
|
-
if item is None:
|
|
208
|
-
raise EOFError()
|
|
209
|
-
return str(item)
|
|
210
|
-
|
|
211
|
-
async def get_prompt(self, prompt: "typing.Union[str, None]" = None) -> "str":
|
|
212
|
-
if prompt:
|
|
213
|
-
self.write_line(prompt)
|
|
214
|
-
item = await self._input_queue.get()
|
|
215
|
-
if item is None:
|
|
216
|
-
raise EOFError()
|
|
217
|
-
return str(item)
|
|
218
|
-
|
|
219
|
-
def handle_event(self, event: "AgentEvent") -> None:
|
|
194
|
+
def handle_event(self, event: "Event") -> None:
|
|
220
195
|
with self._lock:
|
|
221
196
|
self._apply_runtime_event(event)
|
|
222
|
-
payload =
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
"
|
|
226
|
-
"payload": _json_safe(getattr(event, "payload", {})),
|
|
227
|
-
"snapshot": self.snapshot(),
|
|
228
|
-
}
|
|
229
|
-
if payload["kind"] == "tool_completed":
|
|
230
|
-
payload["summary"] = tool_summary(getattr(event, "payload", {}))
|
|
197
|
+
payload = _event_data(event)
|
|
198
|
+
payload.update({"type": "event", "snapshot": self.snapshot()})
|
|
199
|
+
if isinstance(event, ToolCompletedEvent):
|
|
200
|
+
payload["summary"] = event.visualize()
|
|
231
201
|
self._publish_nowait(payload)
|
|
232
202
|
|
|
233
203
|
def finish_stream(self) -> None:
|
|
@@ -261,23 +231,6 @@ class WebSessionView:
|
|
|
261
231
|
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
262
232
|
self._publish_nowait(event)
|
|
263
233
|
|
|
264
|
-
def show_history(self) -> None:
|
|
265
|
-
assistant_turns = [turn for turn in self._turns if turn.get("kind") != "control"]
|
|
266
|
-
if not assistant_turns:
|
|
267
|
-
self.write_line("No history yet.")
|
|
268
|
-
return
|
|
269
|
-
lines = ["Session: {0}".format(self._title or "untitled")]
|
|
270
|
-
for index, turn in enumerate(assistant_turns, start=1):
|
|
271
|
-
prompt = str(turn.get("prompt") or "")
|
|
272
|
-
response = str(turn.get("response") or turn.get("thinking") or "")
|
|
273
|
-
lines.append("[{0}]U> {1}".format(index, prompt))
|
|
274
|
-
if response:
|
|
275
|
-
lines.append("[{0}]A> {1}".format(index, response))
|
|
276
|
-
self.write_line("\n".join(lines))
|
|
277
|
-
|
|
278
|
-
def show_title(self) -> None:
|
|
279
|
-
self.write_line("Session: {0}".format(self._title or "untitled"))
|
|
280
|
-
|
|
281
234
|
def set_session_title(self, title: str) -> None:
|
|
282
235
|
with self._lock:
|
|
283
236
|
self._set_title(title)
|
|
@@ -288,12 +241,6 @@ class WebSessionView:
|
|
|
288
241
|
}
|
|
289
242
|
self._publish_nowait(event)
|
|
290
243
|
|
|
291
|
-
def show_resumed_session(self, title: str) -> None:
|
|
292
|
-
with self._lock:
|
|
293
|
-
self._set_title(title)
|
|
294
|
-
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
295
|
-
self._publish_nowait(event)
|
|
296
|
-
|
|
297
244
|
def load_session_history(
|
|
298
245
|
self,
|
|
299
246
|
title: "typing.Union[str, None]",
|
|
@@ -308,7 +255,9 @@ class WebSessionView:
|
|
|
308
255
|
self._events = []
|
|
309
256
|
for prompt, response in history:
|
|
310
257
|
submission_id = uuid7_string()
|
|
311
|
-
turn = self._ensure_turn(
|
|
258
|
+
turn = self._ensure_turn(
|
|
259
|
+
submission_id, submission_id, str(prompt or "")
|
|
260
|
+
)
|
|
312
261
|
turn["response"] = str(response or "")
|
|
313
262
|
turn["status"] = "completed"
|
|
314
263
|
turn["queue"] = "history"
|
|
@@ -316,22 +265,6 @@ class WebSessionView:
|
|
|
316
265
|
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
317
266
|
self._publish_nowait(event)
|
|
318
267
|
|
|
319
|
-
def show_steer_queued(self, turn_id: str, prompt: str) -> None:
|
|
320
|
-
del turn_id, prompt
|
|
321
|
-
|
|
322
|
-
def schedule_steer_inserted(self, turn_id: str, prompt: str) -> None:
|
|
323
|
-
del turn_id, prompt
|
|
324
|
-
|
|
325
|
-
def set_context_window_tokens(
|
|
326
|
-
self,
|
|
327
|
-
context_window_tokens: "typing.Union[int, None]",
|
|
328
|
-
) -> None:
|
|
329
|
-
with self._lock:
|
|
330
|
-
self._context_window_tokens = context_window_tokens
|
|
331
|
-
self._context_remaining_percent = (
|
|
332
|
-
100 if context_window_tokens is not None else None
|
|
333
|
-
)
|
|
334
|
-
|
|
335
268
|
def subscribe(self) -> "asyncio.Queue":
|
|
336
269
|
queue: "asyncio.Queue" = asyncio.Queue()
|
|
337
270
|
with self._lock:
|
|
@@ -350,14 +283,8 @@ class WebSessionView:
|
|
|
350
283
|
|
|
351
284
|
def close(self) -> None:
|
|
352
285
|
with self._lock:
|
|
353
|
-
self._closed = True
|
|
354
286
|
subscribers = tuple(self._subscribers)
|
|
355
287
|
self._subscribers.clear()
|
|
356
|
-
worker_loop = self._worker_loop
|
|
357
|
-
if worker_loop is None:
|
|
358
|
-
self._input_queue.put_nowait(None)
|
|
359
|
-
else:
|
|
360
|
-
asyncio.run_coroutine_threadsafe(self._input_queue.put(None), worker_loop)
|
|
361
288
|
self._publish_to_queues(subscribers, None)
|
|
362
289
|
|
|
363
290
|
def snapshot(self) -> "typing.Dict[str, object]":
|
|
@@ -367,58 +294,120 @@ class WebSessionView:
|
|
|
367
294
|
"status": self._spinner_status,
|
|
368
295
|
"status_kind": "spinner" if self._spinner_status else "idle",
|
|
369
296
|
"spinner": self._spinner_status,
|
|
370
|
-
"model":
|
|
297
|
+
"model": self._model,
|
|
298
|
+
"rollout_path": self._rollout_path,
|
|
299
|
+
"recorded_rollout_path": self._recorded_rollout_path,
|
|
300
|
+
"input_request": _json_safe(self._input_request),
|
|
301
|
+
"queued_inputs": [
|
|
302
|
+
{"queue": turn["queue"], "prompt": turn["prompt"]}
|
|
303
|
+
for turn in self._turns_by_submission_id.values()
|
|
304
|
+
if not turn["turn_id"]
|
|
305
|
+
],
|
|
306
|
+
"accepts_input": self._accepts_input,
|
|
371
307
|
"title": self._title,
|
|
372
|
-
|
|
308
|
+
**self._context_usage(),
|
|
373
309
|
"turns": [_public_turn(turn) for turn in self._turns[-80:]],
|
|
374
310
|
}
|
|
375
311
|
|
|
376
312
|
def summary(self) -> "typing.Dict[str, object]":
|
|
377
313
|
with self._lock:
|
|
378
314
|
return {
|
|
315
|
+
"model": self._model,
|
|
379
316
|
"running": bool(self._spinner_status),
|
|
380
317
|
"spinner": self._spinner_status,
|
|
381
318
|
"title": self._title,
|
|
382
319
|
"turn_count": len(self._turns),
|
|
383
320
|
"last_assistant": _last_assistant_text(self._turns),
|
|
384
|
-
|
|
321
|
+
**self._context_usage(),
|
|
385
322
|
}
|
|
386
323
|
|
|
387
|
-
def _apply_runtime_event(self, event: "
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
self.
|
|
324
|
+
def _apply_runtime_event(self, event: "Event") -> None:
|
|
325
|
+
if isinstance(event, SessionStateEvent):
|
|
326
|
+
state = event.state
|
|
327
|
+
self._model = state["model"]
|
|
328
|
+
self._rollout_path = state["rollout_path"]
|
|
329
|
+
self._recorded_rollout_path = state["recorded_rollout_path"]
|
|
330
|
+
self._input_request = state["input_request"]
|
|
331
|
+
self._accepts_input = state["accepts_input"]
|
|
332
|
+
self._max_context_window = state["max_context_window"]
|
|
333
|
+
self._auto_compact_token_limit = state["auto_compact_token_limit"]
|
|
334
|
+
self._usage_tokens = state["usage_tokens"]
|
|
335
|
+
if event.reason in {"attach", "history"}:
|
|
336
|
+
self.load_session_history(state["title"], completed_history(state))
|
|
337
|
+
active = state["active_turn"]
|
|
338
|
+
if active is not None:
|
|
339
|
+
self._apply_runtime_event(
|
|
340
|
+
TurnStartedEvent(
|
|
341
|
+
active["turn_id"],
|
|
342
|
+
tuple(active["user_texts"]),
|
|
343
|
+
active["submission_id"],
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
self._apply_runtime_event(
|
|
347
|
+
AssistantDeltaEvent(
|
|
348
|
+
active["assistant_text"],
|
|
349
|
+
active["turn_id"],
|
|
350
|
+
active["submission_id"],
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
elif event.reason == "title":
|
|
354
|
+
self.set_session_title(state["title"])
|
|
355
|
+
else:
|
|
356
|
+
self._set_title(state["title"])
|
|
357
|
+
return
|
|
358
|
+
if isinstance(event, CommandCompletedEvent):
|
|
359
|
+
if event.result["kind"] in {"title_changed", "resumed"}:
|
|
360
|
+
return
|
|
361
|
+
message = event.visualize()
|
|
362
|
+
if message:
|
|
363
|
+
self.write_line(message)
|
|
364
|
+
return
|
|
365
|
+
if isinstance(event, CommandFailedEvent):
|
|
366
|
+
self.show_error(event.visualize())
|
|
367
|
+
return
|
|
368
|
+
if isinstance(event, InputRequestedEvent):
|
|
369
|
+
self._input_request = event
|
|
370
|
+
return
|
|
371
|
+
if isinstance(event, InputResolvedEvent):
|
|
372
|
+
self._input_request = None
|
|
373
|
+
return
|
|
374
|
+
if isinstance(event, SessionClosedEvent):
|
|
375
|
+
self._accepts_input = False
|
|
376
|
+
self._spinner_status = ""
|
|
394
377
|
return
|
|
395
|
-
|
|
396
|
-
|
|
378
|
+
if isinstance(event, InputQueuedEvent):
|
|
379
|
+
turn = self._ensure_turn(event.submission_id, "", "")
|
|
380
|
+
turn["prompt"] += ("\n" if turn["prompt"] else "") + event.prompt
|
|
381
|
+
turn["queue"] = event.queue
|
|
382
|
+
turn["sender"] = event.sender
|
|
383
|
+
return
|
|
384
|
+
if isinstance(event, TokenCountEvent):
|
|
385
|
+
self._usage_tokens = int(event.usage["total_tokens"])
|
|
386
|
+
return
|
|
387
|
+
if isinstance(event, (AutoCompactCompletedEvent, CompactCompletedEvent)):
|
|
388
|
+
self._usage_tokens = None
|
|
389
|
+
if not isinstance(event, TurnEvent):
|
|
390
|
+
return
|
|
391
|
+
turn_id = event.turn_id
|
|
392
|
+
submission_id = event.submission_id or turn_id
|
|
397
393
|
turn = self._turns_by_submission_id.get(submission_id)
|
|
398
|
-
if turn is None and turn_id and not submission_id:
|
|
399
|
-
turn = self._turns_by_turn_id.get(turn_id)
|
|
400
394
|
|
|
401
|
-
if
|
|
402
|
-
self._set_spinner_status(kind)
|
|
403
|
-
|
|
404
|
-
str(item) for item in payload.get("user_texts", []) or []
|
|
405
|
-
)
|
|
406
|
-
if not self._title and str(prompt or "").strip():
|
|
407
|
-
self._set_title(shorten_title(str(prompt or "")))
|
|
408
|
-
turn = self._ensure_turn(submission_id, turn_id, str(prompt or ""))
|
|
395
|
+
if isinstance(event, TurnStartedEvent):
|
|
396
|
+
self._set_spinner_status(event.kind)
|
|
397
|
+
turn = self._ensure_turn(submission_id, turn_id, event.visualize())
|
|
409
398
|
turn["status"] = "running"
|
|
410
399
|
turn["thinking"] = ""
|
|
411
400
|
turn["_thinking_active"] = False
|
|
412
401
|
turn["error"] = ""
|
|
413
402
|
return
|
|
414
403
|
|
|
415
|
-
self._apply_spinner_event(
|
|
404
|
+
self._apply_spinner_event(event)
|
|
416
405
|
if turn is None:
|
|
417
406
|
return
|
|
418
407
|
|
|
419
|
-
if
|
|
408
|
+
if isinstance(event, AssistantDeltaEvent):
|
|
420
409
|
turn["status"] = "responding"
|
|
421
|
-
delta =
|
|
410
|
+
delta = event.visualize()
|
|
422
411
|
self._stream_buffer += delta
|
|
423
412
|
if turn.get("_thinking_active"):
|
|
424
413
|
turn["thinking"] = str(turn.get("thinking") or "") + delta
|
|
@@ -427,19 +416,19 @@ class WebSessionView:
|
|
|
427
416
|
turn["_thinking_active"] = True
|
|
428
417
|
return
|
|
429
418
|
|
|
430
|
-
if
|
|
419
|
+
if isinstance(event, ToolStartedEvent):
|
|
431
420
|
turn["status"] = "tool"
|
|
432
|
-
turn["tool_name"] =
|
|
421
|
+
turn["tool_name"] = event.call.name
|
|
433
422
|
turn["_thinking_active"] = False
|
|
434
423
|
return
|
|
435
424
|
|
|
436
|
-
if
|
|
425
|
+
if isinstance(event, ToolCompletedEvent):
|
|
437
426
|
turn["_thinking_active"] = False
|
|
438
427
|
turn["status"] = "running"
|
|
439
428
|
return
|
|
440
429
|
|
|
441
|
-
if
|
|
442
|
-
response =
|
|
430
|
+
if isinstance(event, TurnCompletedEvent):
|
|
431
|
+
response = event.visualize()
|
|
443
432
|
if response:
|
|
444
433
|
turn["response"] = response
|
|
445
434
|
elif turn.get("thinking"):
|
|
@@ -450,84 +439,76 @@ class WebSessionView:
|
|
|
450
439
|
self._stream_buffer = ""
|
|
451
440
|
return
|
|
452
441
|
|
|
453
|
-
if
|
|
442
|
+
if isinstance(event, TurnFailedEvent):
|
|
454
443
|
turn["status"] = "error"
|
|
455
|
-
turn["error"] =
|
|
444
|
+
turn["error"] = event.visualize()
|
|
456
445
|
self._stream_buffer = ""
|
|
457
446
|
return
|
|
458
447
|
|
|
459
|
-
if
|
|
460
|
-
if
|
|
448
|
+
if isinstance(event, TurnInterruptedEvent):
|
|
449
|
+
if event.output_text:
|
|
450
|
+
turn["response"] = event.output_text
|
|
451
|
+
elif turn.get("thinking") and not turn.get("response"):
|
|
461
452
|
turn["response"] = str(turn.get("thinking") or "")
|
|
462
453
|
turn["thinking"] = ""
|
|
463
454
|
turn["_thinking_active"] = False
|
|
464
455
|
turn["status"] = "interrupted"
|
|
465
456
|
self._stream_buffer = ""
|
|
466
457
|
|
|
467
|
-
def
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
458
|
+
def _context_usage(self) -> "typing.Dict[str, object]":
|
|
459
|
+
limit = self._auto_compact_token_limit
|
|
460
|
+
if limit is None:
|
|
461
|
+
limit = self._max_context_window
|
|
462
|
+
if limit is None:
|
|
463
|
+
remaining_percent = None
|
|
464
|
+
elif self._usage_tokens is None:
|
|
465
|
+
remaining_percent = 100
|
|
466
|
+
elif self._usage_tokens >= limit:
|
|
467
|
+
remaining_percent = 0
|
|
468
|
+
else:
|
|
469
|
+
# Round up so zero means the actual threshold has been reached.
|
|
470
|
+
remaining_percent = min(
|
|
471
|
+
100, ((limit - self._usage_tokens) * 100 + limit - 1) // limit
|
|
472
|
+
)
|
|
473
|
+
return {
|
|
474
|
+
"usage_tokens": self._usage_tokens,
|
|
475
|
+
"auto_compact_token_limit": self._auto_compact_token_limit,
|
|
476
|
+
"max_context_window": self._max_context_window,
|
|
477
|
+
"context_remaining_percent": remaining_percent,
|
|
478
|
+
}
|
|
482
479
|
|
|
483
|
-
def _apply_spinner_event(
|
|
484
|
-
|
|
485
|
-
kind: str,
|
|
486
|
-
payload: "typing.Dict[str, object]",
|
|
487
|
-
) -> None:
|
|
488
|
-
if kind == "assistant_delta":
|
|
480
|
+
def _apply_spinner_event(self, event: "TurnEvent") -> None:
|
|
481
|
+
if isinstance(event, AssistantDeltaEvent):
|
|
489
482
|
self._set_spinner_status("talking")
|
|
490
483
|
return
|
|
491
|
-
if
|
|
484
|
+
if isinstance(event, StreamErrorEvent):
|
|
492
485
|
self._set_spinner_status("reconnecting")
|
|
493
486
|
return
|
|
494
|
-
if
|
|
487
|
+
if isinstance(event, (AutoCompactStartedEvent, CompactStartedEvent)):
|
|
495
488
|
self._set_spinner_status("compacting")
|
|
496
489
|
return
|
|
497
|
-
if
|
|
490
|
+
if isinstance(event, AutoCompactCompletedEvent):
|
|
498
491
|
self._set_spinner_status("compacted")
|
|
499
492
|
return
|
|
500
|
-
if
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
shorten_title(
|
|
506
|
-
"calling {0}({1})".format(tool_name, call.arguments),
|
|
507
|
-
limit=SPINNER_STATUS_PREVIEW_LIMIT,
|
|
508
|
-
)
|
|
493
|
+
if isinstance(event, ToolStartedEvent):
|
|
494
|
+
self._set_spinner_status(
|
|
495
|
+
shorten_title(
|
|
496
|
+
event.visualize(),
|
|
497
|
+
limit=SPINNER_STATUS_PREVIEW_LIMIT,
|
|
509
498
|
)
|
|
510
|
-
|
|
511
|
-
self._set_spinner_status("calling {0}".format(tool_name))
|
|
512
|
-
else:
|
|
513
|
-
self._set_spinner_status("calling provider tools")
|
|
514
|
-
return
|
|
515
|
-
if kind == "tool_completed":
|
|
516
|
-
tool_name = str(payload.get("tool_name") or "").strip()
|
|
517
|
-
if tool_name:
|
|
518
|
-
self._set_spinner_status("called {0}".format(tool_name))
|
|
499
|
+
)
|
|
519
500
|
return
|
|
520
|
-
if
|
|
521
|
-
self.
|
|
501
|
+
if isinstance(event, ToolCompletedEvent):
|
|
502
|
+
self._set_spinner_status("called {0}".format(event.call.name))
|
|
522
503
|
return
|
|
523
|
-
if
|
|
524
|
-
self.
|
|
504
|
+
if isinstance(event, TerminalEvent):
|
|
505
|
+
self._set_idle_spinner_status(event)
|
|
525
506
|
|
|
526
507
|
def _set_spinner_status(self, text: "typing.Union[str, None]") -> None:
|
|
527
508
|
self._spinner_status = str(text or "").strip()
|
|
528
509
|
|
|
529
|
-
def _set_idle_spinner_status(self,
|
|
530
|
-
if background_work_count
|
|
510
|
+
def _set_idle_spinner_status(self, event: "TerminalEvent") -> None:
|
|
511
|
+
if (event.background_work_count or 0) > 0:
|
|
531
512
|
self._set_spinner_status(IDLE_SLEEPING_STATUS)
|
|
532
513
|
else:
|
|
533
514
|
self._set_spinner_status("")
|
|
@@ -539,14 +520,14 @@ class WebSessionView:
|
|
|
539
520
|
prompt: str,
|
|
540
521
|
) -> "typing.Dict[str, object]":
|
|
541
522
|
submission_id = str(submission_id or "").strip()
|
|
542
|
-
turn_id = str(turn_id or
|
|
523
|
+
turn_id = str(turn_id or "").strip()
|
|
543
524
|
turn = self._turns_by_submission_id.get(submission_id)
|
|
544
525
|
if turn is None and turn_id and not submission_id:
|
|
545
526
|
turn = self._turns_by_turn_id.get(turn_id)
|
|
546
527
|
if turn is None:
|
|
547
528
|
turn = {
|
|
548
529
|
"submission_id": submission_id,
|
|
549
|
-
"turn_id":
|
|
530
|
+
"turn_id": "",
|
|
550
531
|
"prompt": prompt,
|
|
551
532
|
"response": "",
|
|
552
533
|
"thinking": "",
|
|
@@ -557,11 +538,13 @@ class WebSessionView:
|
|
|
557
538
|
"queue": "steer",
|
|
558
539
|
"sender": "web",
|
|
559
540
|
}
|
|
560
|
-
self._turns.append(turn)
|
|
561
541
|
if submission_id:
|
|
562
542
|
turn["submission_id"] = submission_id
|
|
563
543
|
self._turns_by_submission_id[submission_id] = turn
|
|
564
544
|
if turn_id:
|
|
545
|
+
if not turn["turn_id"]:
|
|
546
|
+
# Queue admission stays hidden until the turn actually starts.
|
|
547
|
+
self._turns.append(turn)
|
|
565
548
|
turn["turn_id"] = turn_id
|
|
566
549
|
self._turns_by_turn_id[turn_id] = turn
|
|
567
550
|
if prompt:
|
|
@@ -619,58 +602,56 @@ class WebSessionView:
|
|
|
619
602
|
|
|
620
603
|
loop.call_soon_threadsafe(publish)
|
|
621
604
|
|
|
622
|
-
async def _publish(self, event: "typing.Dict[str, object]") -> None:
|
|
623
|
-
self._publish_nowait(event)
|
|
624
|
-
|
|
625
605
|
|
|
626
606
|
class WorkspaceInteractiveSession:
|
|
627
607
|
def __init__(
|
|
628
608
|
self,
|
|
629
|
-
|
|
609
|
+
runtime,
|
|
630
610
|
config_path: "typing.Union[str, None]" = None,
|
|
631
611
|
) -> None:
|
|
632
|
-
self.
|
|
612
|
+
self.runtime = runtime
|
|
633
613
|
self.config_path = config_path
|
|
634
614
|
self.view = WebSessionView()
|
|
635
|
-
self.
|
|
615
|
+
self._frontend_id = None
|
|
636
616
|
|
|
637
617
|
async def start(self) -> "WorkspaceInteractiveSession":
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
self.queue,
|
|
642
|
-
False,
|
|
643
|
-
self.config_path,
|
|
644
|
-
view=self.view,
|
|
645
|
-
show_banner=False,
|
|
646
|
-
)
|
|
647
|
-
)
|
|
618
|
+
await self.runtime.start(self.config_path)
|
|
619
|
+
if self._frontend_id is None:
|
|
620
|
+
self._frontend_id = self.runtime.attach(self.view.handle_event)
|
|
648
621
|
return self
|
|
649
622
|
|
|
650
623
|
async def close(self) -> None:
|
|
651
|
-
self.view.close()
|
|
652
|
-
task = self._task
|
|
653
|
-
if task is None:
|
|
654
|
-
return
|
|
655
624
|
try:
|
|
656
|
-
await
|
|
657
|
-
asyncio.shield(task),
|
|
658
|
-
timeout=SESSION_CLOSE_TIMEOUT_SECONDS,
|
|
659
|
-
)
|
|
660
|
-
except asyncio.TimeoutError:
|
|
661
|
-
cancel_current = getattr(self.queue, "cancel_current", None)
|
|
662
|
-
if callable(cancel_current):
|
|
663
|
-
cancel_current()
|
|
664
|
-
task.cancel()
|
|
665
|
-
await asyncio.gather(task, return_exceptions=True)
|
|
625
|
+
await self.runtime.close()
|
|
666
626
|
finally:
|
|
667
|
-
self.
|
|
627
|
+
self.detach()
|
|
628
|
+
|
|
629
|
+
def detach(self):
|
|
630
|
+
if self._frontend_id is not None:
|
|
631
|
+
self.runtime.detach(self._frontend_id)
|
|
632
|
+
self._frontend_id = None
|
|
633
|
+
self.view.close()
|
|
634
|
+
|
|
635
|
+
async def submit(
|
|
636
|
+
self, prompt: str, sender: str = "web"
|
|
637
|
+
) -> "typing.Dict[str, object]":
|
|
638
|
+
try:
|
|
639
|
+
receipt = await self.runtime.submit_input(prompt, sender)
|
|
640
|
+
except (ValueError, RuntimeError) as exc:
|
|
641
|
+
return {"ok": False, "error": str(exc), "snapshot": self.snapshot()}
|
|
642
|
+
return {
|
|
643
|
+
"ok": True,
|
|
644
|
+
"type": "submitted",
|
|
645
|
+
"submission_id": receipt.submission_id,
|
|
646
|
+
"snapshot": self.snapshot(),
|
|
647
|
+
}
|
|
668
648
|
|
|
669
|
-
async def
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
649
|
+
async def answer_input(self, request_id, answer):
|
|
650
|
+
try:
|
|
651
|
+
self.runtime.answer_input(request_id, answer)
|
|
652
|
+
except ValueError as exc:
|
|
653
|
+
return {"ok": False, "error": str(exc), "snapshot": self.snapshot()}
|
|
654
|
+
return {"ok": True, "type": "answered", "snapshot": self.snapshot()}
|
|
674
655
|
|
|
675
656
|
def subscribe(self) -> "asyncio.Queue":
|
|
676
657
|
return self.view.subscribe()
|
|
@@ -679,34 +660,23 @@ class WorkspaceInteractiveSession:
|
|
|
679
660
|
self.view.unsubscribe(queue)
|
|
680
661
|
|
|
681
662
|
def snapshot(self) -> "typing.Dict[str, object]":
|
|
682
|
-
|
|
683
|
-
agent = getattr(self.queue, "_agent", None)
|
|
684
|
-
snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
685
|
-
return snapshot
|
|
663
|
+
return self.view.snapshot()
|
|
686
664
|
|
|
687
665
|
def summary(self) -> "typing.Dict[str, object]":
|
|
688
|
-
|
|
689
|
-
agent = getattr(self.queue, "_agent", None)
|
|
690
|
-
summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
691
|
-
return summary
|
|
666
|
+
return self.view.summary()
|
|
692
667
|
|
|
693
668
|
def rollout_path(self) -> str:
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
agent.set_rollout_recorder(SessionRolloutRecorder.resume(resumed["rollout_path"]))
|
|
706
|
-
self.view.load_session_history(
|
|
707
|
-
str(title or resumed["title"]),
|
|
708
|
-
tuple(resumed["turns"]),
|
|
709
|
-
)
|
|
669
|
+
return self.view.snapshot()["rollout_path"]
|
|
670
|
+
|
|
671
|
+
async def restore_from_rollout(
|
|
672
|
+
self, rollout_path: str, title: str = "", fork: bool = False
|
|
673
|
+
) -> None:
|
|
674
|
+
if rollout_path:
|
|
675
|
+
self.runtime.resume(rollout_path, title)
|
|
676
|
+
if fork:
|
|
677
|
+
self.runtime.fork()
|
|
678
|
+
else:
|
|
679
|
+
self.runtime.set_title(title)
|
|
710
680
|
|
|
711
681
|
|
|
712
682
|
class ThreadedWorkspaceInteractiveSession:
|
|
@@ -722,7 +692,6 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
722
692
|
self._thread: "typing.Union[threading.Thread, None]" = None
|
|
723
693
|
self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
724
694
|
self._ready = threading.Event()
|
|
725
|
-
self._closed = threading.Event()
|
|
726
695
|
self._startup_error: "typing.Union[BaseException, None]" = None
|
|
727
696
|
self._session: "typing.Union[WorkspaceInteractiveSession, None]" = None
|
|
728
697
|
|
|
@@ -737,66 +706,76 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
737
706
|
self._thread.start()
|
|
738
707
|
await asyncio.to_thread(self._ready.wait)
|
|
739
708
|
if self._startup_error is not None:
|
|
740
|
-
raise RuntimeError(
|
|
709
|
+
raise RuntimeError(
|
|
710
|
+
"workspace session thread failed to start"
|
|
711
|
+
) from self._startup_error
|
|
741
712
|
return self
|
|
742
713
|
|
|
743
714
|
def _thread_main(self) -> None:
|
|
744
715
|
loop = asyncio.new_event_loop()
|
|
745
716
|
self._worker_loop = loop
|
|
746
|
-
self._view.attach_worker_loop(loop)
|
|
747
717
|
asyncio.set_event_loop(loop)
|
|
748
718
|
try:
|
|
749
719
|
session = self._session_factory()
|
|
750
720
|
session.view = self._view
|
|
751
721
|
self._session = session
|
|
752
|
-
|
|
722
|
+
try:
|
|
723
|
+
loop.run_until_complete(session.start())
|
|
724
|
+
except BaseException:
|
|
725
|
+
loop.run_until_complete(session.close())
|
|
726
|
+
raise
|
|
753
727
|
self._ready.set()
|
|
754
728
|
loop.run_forever()
|
|
755
729
|
except BaseException as exc:
|
|
756
730
|
self._startup_error = exc
|
|
757
731
|
self._ready.set()
|
|
758
732
|
finally:
|
|
759
|
-
session = self._session
|
|
760
|
-
if session is not None:
|
|
761
|
-
try:
|
|
762
|
-
loop.run_until_complete(session.close())
|
|
763
|
-
except BaseException:
|
|
764
|
-
pass
|
|
765
733
|
pending = asyncio.all_tasks(loop)
|
|
766
734
|
for task in pending:
|
|
767
735
|
task.cancel()
|
|
768
736
|
if pending:
|
|
769
|
-
loop.run_until_complete(
|
|
737
|
+
loop.run_until_complete(
|
|
738
|
+
asyncio.gather(*pending, return_exceptions=True)
|
|
739
|
+
)
|
|
770
740
|
asyncio.set_event_loop(None)
|
|
771
741
|
loop.close()
|
|
772
|
-
self._closed.set()
|
|
773
742
|
|
|
774
743
|
async def close(self) -> None:
|
|
775
744
|
session = self._session
|
|
776
745
|
loop = self._worker_loop
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
asyncio.wrap_future(future),
|
|
782
|
-
timeout=SESSION_CLOSE_TIMEOUT_SECONDS + 1.0,
|
|
746
|
+
try:
|
|
747
|
+
if session is not None and loop is not None and loop.is_running():
|
|
748
|
+
future = asyncio.wrap_future(
|
|
749
|
+
asyncio.run_coroutine_threadsafe(session.close(), loop)
|
|
783
750
|
)
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
751
|
+
try:
|
|
752
|
+
await asyncio.shield(future)
|
|
753
|
+
except asyncio.CancelledError:
|
|
754
|
+
await future
|
|
755
|
+
raise
|
|
756
|
+
finally:
|
|
757
|
+
if loop is not None and loop.is_running():
|
|
758
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
759
|
+
thread = self._thread
|
|
760
|
+
if thread is not None:
|
|
761
|
+
await asyncio.to_thread(thread.join)
|
|
762
|
+
self._thread = None
|
|
763
|
+
|
|
764
|
+
async def submit(
|
|
765
|
+
self, prompt: str, sender: str = "web"
|
|
766
|
+
) -> "typing.Dict[str, object]":
|
|
767
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
768
|
+
self._session.submit(prompt, sender),
|
|
769
|
+
self._worker_loop,
|
|
770
|
+
)
|
|
771
|
+
return await asyncio.wrap_future(future)
|
|
772
|
+
|
|
773
|
+
async def answer_input(self, request_id, answer):
|
|
774
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
775
|
+
self._session.answer_input(request_id, answer),
|
|
776
|
+
self._worker_loop,
|
|
777
|
+
)
|
|
778
|
+
return await asyncio.wrap_future(future)
|
|
800
779
|
|
|
801
780
|
def subscribe(self) -> "asyncio.Queue":
|
|
802
781
|
return self._view.subscribe()
|
|
@@ -805,34 +784,26 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
805
784
|
self._view.unsubscribe(queue)
|
|
806
785
|
|
|
807
786
|
def snapshot(self) -> "typing.Dict[str, object]":
|
|
808
|
-
|
|
809
|
-
session = self._session
|
|
810
|
-
queue = getattr(session, "queue", None)
|
|
811
|
-
agent = getattr(queue, "_agent", None)
|
|
812
|
-
snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
813
|
-
return snapshot
|
|
787
|
+
return self._view.snapshot()
|
|
814
788
|
|
|
815
789
|
def summary(self) -> "typing.Dict[str, object]":
|
|
816
|
-
|
|
817
|
-
session = self._session
|
|
818
|
-
queue = getattr(session, "queue", None)
|
|
819
|
-
agent = getattr(queue, "_agent", None)
|
|
820
|
-
summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
821
|
-
return summary
|
|
790
|
+
return self._view.summary()
|
|
822
791
|
|
|
823
792
|
def rollout_path(self) -> str:
|
|
824
793
|
if self._session is None:
|
|
825
794
|
return ""
|
|
826
795
|
return self._session.rollout_path()
|
|
827
796
|
|
|
828
|
-
async def restore_from_rollout(
|
|
797
|
+
async def restore_from_rollout(
|
|
798
|
+
self, rollout_path: str, title: str = "", fork: bool = False
|
|
799
|
+
) -> None:
|
|
829
800
|
session = self._session
|
|
830
801
|
loop = self._worker_loop
|
|
831
802
|
if session is None or loop is None:
|
|
832
803
|
return
|
|
833
804
|
|
|
834
805
|
future = asyncio.run_coroutine_threadsafe(
|
|
835
|
-
session.restore_from_rollout(rollout_path, title=title),
|
|
806
|
+
session.restore_from_rollout(rollout_path, title=title, fork=fork),
|
|
836
807
|
loop,
|
|
837
808
|
)
|
|
838
809
|
await asyncio.wrap_future(future)
|
|
@@ -856,7 +827,7 @@ def create_app(
|
|
|
856
827
|
|
|
857
828
|
|
|
858
829
|
def create_multi_workspace_app(
|
|
859
|
-
registry:
|
|
830
|
+
registry: "WorkspaceRegistry",
|
|
860
831
|
password: "typing.Union[str, None]" = None,
|
|
861
832
|
) -> FastAPI:
|
|
862
833
|
app = _create_lifespan_app(registry.start, registry.close)
|
|
@@ -942,7 +913,9 @@ def create_multi_workspace_app(
|
|
|
942
913
|
return await _new_session_response(entry.manager)
|
|
943
914
|
|
|
944
915
|
@app.delete("/w/{workspace_id}/api/sessions/{session_id}")
|
|
945
|
-
async def workspace_delete_session(
|
|
916
|
+
async def workspace_delete_session(
|
|
917
|
+
workspace_id: str, session_id: str
|
|
918
|
+
) -> JSONResponse:
|
|
946
919
|
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
947
920
|
return await _delete_session_response(entry.manager, session_id)
|
|
948
921
|
|
|
@@ -963,8 +936,12 @@ def create_multi_workspace_app(
|
|
|
963
936
|
return await _message_response(entry.manager, payload)
|
|
964
937
|
|
|
965
938
|
@app.websocket("/w/{workspace_id}/ws/session")
|
|
966
|
-
async def workspace_websocket_session(
|
|
967
|
-
|
|
939
|
+
async def workspace_websocket_session(
|
|
940
|
+
workspace_id: str, websocket: WebSocket
|
|
941
|
+
) -> None:
|
|
942
|
+
if not _auth_cookie_matches(
|
|
943
|
+
auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)
|
|
944
|
+
):
|
|
968
945
|
await websocket.close(code=1008)
|
|
969
946
|
return
|
|
970
947
|
try:
|
|
@@ -1004,20 +981,35 @@ def _install_auth(app: FastAPI, password: "typing.Union[str, None]") -> str:
|
|
|
1004
981
|
{"ok": False, "error": "authentication required"},
|
|
1005
982
|
status_code=401,
|
|
1006
983
|
)
|
|
1007
|
-
|
|
984
|
+
target = path + ("?" + request.url.query if request.url.query else "")
|
|
985
|
+
return RedirectResponse(
|
|
986
|
+
url="/login?" + urlencode({"next": target}), status_code=303
|
|
987
|
+
)
|
|
1008
988
|
|
|
1009
989
|
@app.get("/login")
|
|
1010
990
|
async def login_page() -> HTMLResponse:
|
|
1011
991
|
return _html_response(_render_login_shell())
|
|
1012
992
|
|
|
1013
993
|
@app.post("/login")
|
|
1014
|
-
async def login(
|
|
1015
|
-
|
|
994
|
+
async def login(
|
|
995
|
+
request: Request, payload: "typing.Dict[str, object]"
|
|
996
|
+
) -> JSONResponse:
|
|
997
|
+
if not secrets.compare_digest(
|
|
998
|
+
str(payload.get("password") or ""), password_text
|
|
999
|
+
):
|
|
1016
1000
|
return JSONResponse(
|
|
1017
1001
|
{"ok": False, "error": "invalid password"},
|
|
1018
1002
|
status_code=401,
|
|
1019
1003
|
)
|
|
1020
|
-
|
|
1004
|
+
target = request.query_params.get("next", "/")
|
|
1005
|
+
if (
|
|
1006
|
+
not target.startswith("/")
|
|
1007
|
+
or target.startswith("//")
|
|
1008
|
+
or "\\" in target
|
|
1009
|
+
or any(ord(char) < 32 for char in target)
|
|
1010
|
+
):
|
|
1011
|
+
target = "/"
|
|
1012
|
+
response = JSONResponse({"ok": True, "redirect": target})
|
|
1021
1013
|
response.set_cookie(
|
|
1022
1014
|
AUTH_COOKIE_NAME,
|
|
1023
1015
|
token,
|
|
@@ -1095,13 +1087,14 @@ def _render_login_shell() -> str:
|
|
|
1095
1087
|
form.addEventListener("submit", async function(event) {
|
|
1096
1088
|
event.preventDefault();
|
|
1097
1089
|
statusEl.textContent = "";
|
|
1098
|
-
const response = await fetch(
|
|
1090
|
+
const response = await fetch(window.location.pathname + window.location.search, {
|
|
1099
1091
|
method: "POST",
|
|
1100
1092
|
headers: {"Content-Type": "application/json"},
|
|
1101
1093
|
body: JSON.stringify({password: passwordInput.value}),
|
|
1102
1094
|
});
|
|
1103
1095
|
if (response.ok) {
|
|
1104
|
-
|
|
1096
|
+
const result = await response.json();
|
|
1097
|
+
window.location.href = result.redirect;
|
|
1105
1098
|
return;
|
|
1106
1099
|
}
|
|
1107
1100
|
statusEl.textContent = "Invalid password";
|
|
@@ -1116,6 +1109,7 @@ def _create_lifespan_app(
|
|
|
1116
1109
|
close: "typing.Callable[[], typing.Awaitable[None]]",
|
|
1117
1110
|
) -> FastAPI:
|
|
1118
1111
|
if asynccontextmanager is not None:
|
|
1112
|
+
|
|
1119
1113
|
@asynccontextmanager
|
|
1120
1114
|
async def lifespan(_app):
|
|
1121
1115
|
await start()
|
|
@@ -1135,6 +1129,7 @@ def _create_lifespan_app(
|
|
|
1135
1129
|
@app.on_event("shutdown")
|
|
1136
1130
|
async def shutdown() -> None:
|
|
1137
1131
|
await close()
|
|
1132
|
+
|
|
1138
1133
|
return app
|
|
1139
1134
|
|
|
1140
1135
|
|
|
@@ -1190,7 +1185,9 @@ def _install_workspace_routes(
|
|
|
1190
1185
|
@app.websocket("/ws/session")
|
|
1191
1186
|
async def websocket_session(websocket: WebSocket) -> None:
|
|
1192
1187
|
auth_token = typing.cast(str, app.state.workspace_auth_token)
|
|
1193
|
-
if not _auth_cookie_matches(
|
|
1188
|
+
if not _auth_cookie_matches(
|
|
1189
|
+
auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)
|
|
1190
|
+
):
|
|
1194
1191
|
await websocket.close(code=1008)
|
|
1195
1192
|
return
|
|
1196
1193
|
await _websocket_session_handler(manager, websocket)
|
|
@@ -1223,11 +1220,11 @@ def _websocket_backend_hint_response() -> JSONResponse:
|
|
|
1223
1220
|
)
|
|
1224
1221
|
|
|
1225
1222
|
|
|
1226
|
-
def _sessions_response(manager:
|
|
1223
|
+
def _sessions_response(manager: "WorkspaceSessionManager") -> JSONResponse:
|
|
1227
1224
|
return JSONResponse({"sessions": manager.list_sessions()})
|
|
1228
1225
|
|
|
1229
1226
|
|
|
1230
|
-
async def _new_session_response(manager:
|
|
1227
|
+
async def _new_session_response(manager: "WorkspaceSessionManager") -> JSONResponse:
|
|
1231
1228
|
session_id = await manager.create_session()
|
|
1232
1229
|
return JSONResponse(
|
|
1233
1230
|
{
|
|
@@ -1240,7 +1237,7 @@ async def _new_session_response(manager: 'WorkspaceSessionManager') -> JSONRespo
|
|
|
1240
1237
|
|
|
1241
1238
|
|
|
1242
1239
|
async def _delete_session_response(
|
|
1243
|
-
manager:
|
|
1240
|
+
manager: "WorkspaceSessionManager",
|
|
1244
1241
|
session_id: str,
|
|
1245
1242
|
) -> JSONResponse:
|
|
1246
1243
|
try:
|
|
@@ -1253,7 +1250,7 @@ async def _delete_session_response(
|
|
|
1253
1250
|
|
|
1254
1251
|
|
|
1255
1252
|
def _session_response(
|
|
1256
|
-
manager:
|
|
1253
|
+
manager: "WorkspaceSessionManager",
|
|
1257
1254
|
session_id: "typing.Union[str, None]" = None,
|
|
1258
1255
|
) -> JSONResponse:
|
|
1259
1256
|
try:
|
|
@@ -1271,7 +1268,7 @@ def _session_response(
|
|
|
1271
1268
|
|
|
1272
1269
|
|
|
1273
1270
|
async def _message_response(
|
|
1274
|
-
manager:
|
|
1271
|
+
manager: "WorkspaceSessionManager",
|
|
1275
1272
|
payload: "typing.Dict[str, object]",
|
|
1276
1273
|
) -> JSONResponse:
|
|
1277
1274
|
session_id = str(payload.get("session_id") or "")
|
|
@@ -1279,7 +1276,13 @@ async def _message_response(
|
|
|
1279
1276
|
link = manager.get(session_id or None)
|
|
1280
1277
|
except KeyError:
|
|
1281
1278
|
raise HTTPException(status_code=404, detail="session not found")
|
|
1282
|
-
|
|
1279
|
+
if "request_id" in payload:
|
|
1280
|
+
result = await link.answer_input(payload["request_id"], payload.get("answer"))
|
|
1281
|
+
else:
|
|
1282
|
+
result = await link.submit(
|
|
1283
|
+
str(payload.get("prompt") or ""),
|
|
1284
|
+
sender=str(payload.get("sender") or "web"),
|
|
1285
|
+
)
|
|
1283
1286
|
if isinstance(result, dict):
|
|
1284
1287
|
result.setdefault("sessions", manager.list_sessions())
|
|
1285
1288
|
status = 200 if result.get("ok") else 400
|
|
@@ -1287,7 +1290,7 @@ async def _message_response(
|
|
|
1287
1290
|
|
|
1288
1291
|
|
|
1289
1292
|
async def _websocket_session_handler(
|
|
1290
|
-
manager:
|
|
1293
|
+
manager: "WorkspaceSessionManager",
|
|
1291
1294
|
websocket: WebSocket,
|
|
1292
1295
|
) -> None:
|
|
1293
1296
|
await websocket.accept()
|
|
@@ -1308,7 +1311,7 @@ async def _websocket_session_handler(
|
|
|
1308
1311
|
await websocket.send_json({"type": "error", "error": "invalid json"})
|
|
1309
1312
|
continue
|
|
1310
1313
|
action = str(payload.get("type") or payload.get("action") or "")
|
|
1311
|
-
if action
|
|
1314
|
+
if action in {"send", "answer"}:
|
|
1312
1315
|
target_session_id = str(payload.get("session_id") or session_id or "")
|
|
1313
1316
|
try:
|
|
1314
1317
|
target_link = manager.get(target_session_id or None)
|
|
@@ -1317,10 +1320,16 @@ async def _websocket_session_handler(
|
|
|
1317
1320
|
{"type": "error", "error": "session not found"}
|
|
1318
1321
|
)
|
|
1319
1322
|
continue
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1323
|
+
if action == "answer":
|
|
1324
|
+
result = await target_link.answer_input(
|
|
1325
|
+
payload.get("request_id"),
|
|
1326
|
+
payload.get("answer"),
|
|
1327
|
+
)
|
|
1328
|
+
else:
|
|
1329
|
+
result = await target_link.submit(
|
|
1330
|
+
str(payload.get("prompt") or ""),
|
|
1331
|
+
sender=str(payload.get("sender") or "web"),
|
|
1332
|
+
)
|
|
1324
1333
|
await websocket.send_json({"type": "send_result", "result": result})
|
|
1325
1334
|
elif action == "ping":
|
|
1326
1335
|
await websocket.send_json({"type": "pong"})
|
|
@@ -1364,6 +1373,58 @@ def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]"
|
|
|
1364
1373
|
)
|
|
1365
1374
|
|
|
1366
1375
|
|
|
1376
|
+
def _event_data(event: "Event") -> "typing.Dict[str, object]":
|
|
1377
|
+
payload = {
|
|
1378
|
+
item.name: getattr(event, item.name)
|
|
1379
|
+
for item in fields(event)
|
|
1380
|
+
if item.name not in {"turn_id", "submission_id"}
|
|
1381
|
+
}
|
|
1382
|
+
turn_id = ""
|
|
1383
|
+
if isinstance(event, TurnEvent):
|
|
1384
|
+
turn_id = event.turn_id
|
|
1385
|
+
if event.submission_id is not None:
|
|
1386
|
+
payload.update(submission_id=event.submission_id, turn_id=event.turn_id)
|
|
1387
|
+
elif isinstance(
|
|
1388
|
+
event, (CommandCompletedEvent, CommandFailedEvent, InputQueuedEvent)
|
|
1389
|
+
):
|
|
1390
|
+
turn_id = event.submission_id
|
|
1391
|
+
if isinstance(event, InputQueuedEvent):
|
|
1392
|
+
payload["submission_id"] = event.submission_id
|
|
1393
|
+
if isinstance(event, TurnStartedEvent):
|
|
1394
|
+
payload["user_text"] = "\n".join(event.user_texts)
|
|
1395
|
+
if isinstance(event, (ToolStartedEvent, ToolCompletedEvent)):
|
|
1396
|
+
payload.update(tool_name=event.call.name, call_id=event.call.call_id)
|
|
1397
|
+
if isinstance(event, ToolCompletedEvent):
|
|
1398
|
+
payload["is_error"] = event.result.is_error
|
|
1399
|
+
if isinstance(event, ToolCalledEvent):
|
|
1400
|
+
payload = {name: value for name, value in payload.items() if value is not None}
|
|
1401
|
+
if isinstance(
|
|
1402
|
+
event,
|
|
1403
|
+
(
|
|
1404
|
+
CompactStartedEvent,
|
|
1405
|
+
CompactCompletedEvent,
|
|
1406
|
+
CompactFailedEvent,
|
|
1407
|
+
AutoCompactStartedEvent,
|
|
1408
|
+
AutoCompactCompletedEvent,
|
|
1409
|
+
AutoCompactFailedEvent,
|
|
1410
|
+
),
|
|
1411
|
+
):
|
|
1412
|
+
for name in ("total_tokens", "token_limit"):
|
|
1413
|
+
if payload[name] is None:
|
|
1414
|
+
del payload[name]
|
|
1415
|
+
if payload.get("pruned_tool_results") == 0:
|
|
1416
|
+
del payload["pruned_tool_results"]
|
|
1417
|
+
if isinstance(event, (CompactCompletedEvent, AutoCompactCompletedEvent)):
|
|
1418
|
+
payload["summary"] = event.summary
|
|
1419
|
+
if isinstance(event, TerminalEvent) and event.background_work_count is None:
|
|
1420
|
+
del payload["background_work_count"]
|
|
1421
|
+
if isinstance(event, InputRequestedEvent):
|
|
1422
|
+
payload["kind"] = payload.pop("request_kind")
|
|
1423
|
+
payload["text"] = event.visualize()
|
|
1424
|
+
payload = {name: value for name, value in payload.items() if value is not None}
|
|
1425
|
+
return {"kind": event.kind, "turn_id": turn_id, "payload": _json_safe(payload)}
|
|
1426
|
+
|
|
1427
|
+
|
|
1367
1428
|
def _json_safe(value: object) -> "JSONValue":
|
|
1368
1429
|
if value is None or isinstance(value, (bool, int, float, str)):
|
|
1369
1430
|
return value
|
|
@@ -1371,6 +1432,8 @@ def _json_safe(value: object) -> "JSONValue":
|
|
|
1371
1432
|
return [_json_safe(item) for item in value]
|
|
1372
1433
|
if isinstance(value, dict):
|
|
1373
1434
|
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
1435
|
+
if isinstance(value, InputRequestedEvent):
|
|
1436
|
+
return _event_data(value)["payload"]
|
|
1374
1437
|
if is_dataclass(value):
|
|
1375
1438
|
return _json_safe(asdict(value))
|
|
1376
1439
|
try:
|
|
@@ -1419,10 +1482,9 @@ def _board_asset_response(
|
|
|
1419
1482
|
try:
|
|
1420
1483
|
board_directory = board_path.parent.resolve()
|
|
1421
1484
|
resolved_asset = (board_directory / asset_path).resolve()
|
|
1422
|
-
within_board_directory = (
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
)
|
|
1485
|
+
within_board_directory = os.path.commonpath(
|
|
1486
|
+
[str(board_directory), str(resolved_asset)]
|
|
1487
|
+
) == str(board_directory)
|
|
1426
1488
|
except (OSError, RuntimeError, ValueError):
|
|
1427
1489
|
within_board_directory = False
|
|
1428
1490
|
|
|
@@ -1444,9 +1506,9 @@ def _board_asset_response(
|
|
|
1444
1506
|
|
|
1445
1507
|
|
|
1446
1508
|
def _workspace_entry_or_404(
|
|
1447
|
-
registry:
|
|
1509
|
+
registry: "WorkspaceRegistry",
|
|
1448
1510
|
workspace_id: str,
|
|
1449
|
-
) ->
|
|
1511
|
+
) -> "WorkspaceEntry":
|
|
1450
1512
|
try:
|
|
1451
1513
|
return registry.get(workspace_id)
|
|
1452
1514
|
except (KeyError, ValueError):
|
|
@@ -1460,10 +1522,7 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
1460
1522
|
configure_loguru()
|
|
1461
1523
|
|
|
1462
1524
|
definitions = load_workspace_definitions(args.workspace_config)
|
|
1463
|
-
entries = [
|
|
1464
|
-
_build_workspace_entry(definition, args)
|
|
1465
|
-
for definition in definitions
|
|
1466
|
-
]
|
|
1525
|
+
entries = [_build_workspace_entry(definition, args) for definition in definitions]
|
|
1467
1526
|
registry = WorkspaceRegistry(
|
|
1468
1527
|
entries,
|
|
1469
1528
|
config_path=args.workspace_config,
|
|
@@ -1495,10 +1554,10 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
1495
1554
|
|
|
1496
1555
|
|
|
1497
1556
|
def _build_workspace_entry(
|
|
1498
|
-
definition:
|
|
1557
|
+
definition: "WorkspaceDefinition",
|
|
1499
1558
|
args: "argparse.Namespace",
|
|
1500
1559
|
persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
|
|
1501
|
-
) ->
|
|
1560
|
+
) -> "WorkspaceEntry":
|
|
1502
1561
|
def build_session() -> "WorkspaceInteractiveSession":
|
|
1503
1562
|
model = build_model(
|
|
1504
1563
|
config_path=args.config,
|
|
@@ -1513,7 +1572,6 @@ def _build_workspace_entry(
|
|
|
1513
1572
|
config_path=args.config,
|
|
1514
1573
|
profile=args.profile,
|
|
1515
1574
|
system_prompt=args.system_prompt,
|
|
1516
|
-
session_mode="tui",
|
|
1517
1575
|
extra_contextual_user_messages=(
|
|
1518
1576
|
[_board_context_text(definition.board_path, definition.work_dir)]
|
|
1519
1577
|
if definition.board_path is not None
|
|
@@ -1523,12 +1581,14 @@ def _build_workspace_entry(
|
|
|
1523
1581
|
toolset=args.toolset,
|
|
1524
1582
|
)
|
|
1525
1583
|
return WorkspaceInteractiveSession(
|
|
1526
|
-
|
|
1584
|
+
build_runtime(agent),
|
|
1527
1585
|
config_path=args.config,
|
|
1528
1586
|
)
|
|
1529
1587
|
|
|
1530
1588
|
def session_factory() -> "ThreadedWorkspaceInteractiveSession":
|
|
1531
|
-
return ThreadedWorkspaceInteractiveSession(
|
|
1589
|
+
return ThreadedWorkspaceInteractiveSession(
|
|
1590
|
+
build_session, asyncio.get_running_loop()
|
|
1591
|
+
)
|
|
1532
1592
|
|
|
1533
1593
|
return WorkspaceEntry(
|
|
1534
1594
|
definition=definition,
|
|
@@ -1578,12 +1638,9 @@ def _render_workspace_shell(
|
|
|
1578
1638
|
board_label = str(board_path) if board_path is not None else "No board"
|
|
1579
1639
|
cwd_label = str(work_dir or Path.cwd())
|
|
1580
1640
|
page_title = str(title or "pycodex workspace")
|
|
1581
|
-
template = (Path(__file__).with_name("workspace.html")).read_text(
|
|
1582
|
-
encoding="utf-8"
|
|
1583
|
-
)
|
|
1641
|
+
template = (Path(__file__).with_name("workspace.html")).read_text(encoding="utf-8")
|
|
1584
1642
|
return (
|
|
1585
|
-
template
|
|
1586
|
-
.replace("__WORKSPACE_TITLE__", html.escape(page_title))
|
|
1643
|
+
template.replace("__WORKSPACE_TITLE__", html.escape(page_title))
|
|
1587
1644
|
.replace("__BOARD_LABEL__", html.escape(board_label))
|
|
1588
1645
|
.replace("__CWD_LABEL__", html.escape(cwd_label))
|
|
1589
1646
|
)
|
|
@@ -1594,23 +1651,52 @@ def _render_workspaces_manager_shell() -> str:
|
|
|
1594
1651
|
|
|
1595
1652
|
|
|
1596
1653
|
def _render_empty_board() -> str:
|
|
1597
|
-
return
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
</body></html>"""
|
|
1654
|
+
return _render_board_placeholder(
|
|
1655
|
+
"No board",
|
|
1656
|
+
"No board connected",
|
|
1657
|
+
"Add a board from the workspaces page to see your work here.",
|
|
1658
|
+
)
|
|
1603
1659
|
|
|
1604
1660
|
|
|
1605
1661
|
def _render_missing_board(board_path: Path) -> str:
|
|
1606
|
-
|
|
1662
|
+
return _render_board_placeholder(
|
|
1663
|
+
"Board pending",
|
|
1664
|
+
"Your work will appear here",
|
|
1665
|
+
"Ask pycodex to create a page, a report, or a visual. "
|
|
1666
|
+
"This canvas updates as you work.",
|
|
1667
|
+
str(board_path),
|
|
1668
|
+
)
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _render_board_placeholder(
|
|
1672
|
+
title: str, heading: str, description: str, path_label: str = ""
|
|
1673
|
+
) -> str:
|
|
1607
1674
|
return """<!doctype html>
|
|
1608
|
-
<html><head><meta charset="utf-8"
|
|
1609
|
-
<
|
|
1610
|
-
<
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1675
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
1676
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1677
|
+
<title>{0}</title><style>
|
|
1678
|
+
* {{ box-sizing: border-box; }}
|
|
1679
|
+
body {{ margin: 0; min-height: 100dvh; display: grid; place-items: center;
|
|
1680
|
+
padding: 32px; background: #f8fbfd; color: #172630;
|
|
1681
|
+
font: 14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }}
|
|
1682
|
+
main {{ max-width: 360px; text-align: center; }}
|
|
1683
|
+
svg {{ width: 40px; height: 40px; color: #aebfc9; margin-bottom: 20px; }}
|
|
1684
|
+
h1 {{ margin: 0 0 12px; font-size: 23px; font-weight: 500; line-height: 1.35;
|
|
1685
|
+
letter-spacing: -0.5px; }}
|
|
1686
|
+
p {{ margin: 0; color: #60727d; }}
|
|
1687
|
+
code {{ display: block; margin-top: 28px; font-size: 11px; color: #60727d;
|
|
1688
|
+
overflow-wrap: anywhere; }}
|
|
1689
|
+
</style></head><body><main>
|
|
1690
|
+
<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.25"
|
|
1691
|
+
aria-hidden="true"><rect x="3" y="3" width="26" height="26" rx="5"/>
|
|
1692
|
+
<path d="M3 11h26M11 11v18"/></svg>
|
|
1693
|
+
<h1>{1}</h1><p>{2}</p><code>{3}</code>
|
|
1694
|
+
</main></body></html>""".format(
|
|
1695
|
+
html.escape(title),
|
|
1696
|
+
html.escape(heading),
|
|
1697
|
+
html.escape(description),
|
|
1698
|
+
html.escape(path_label),
|
|
1699
|
+
)
|
|
1614
1700
|
|
|
1615
1701
|
|
|
1616
1702
|
def main(argv: "typing.Union[typing.Sequence[str], None]" = None) -> int:
|