python-codex 0.1.14__py3-none-any.whl → 0.2.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/agent.py +6 -11
- pycodex/cli.py +3 -0
- pycodex/context.py +12 -0
- pycodex/model.py +10 -3
- pycodex/runtime.py +10 -0
- pycodex/tools/code_mode_manager.py +1 -1
- pycodex/tools/unified_exec_manager.py +7 -92
- pycodex/utils/session_persist.py +42 -1
- pycodex/utils/truncation.py +206 -0
- {python_codex-0.1.14.dist-info → python_codex-0.2.0.dist-info}/METADATA +1 -1
- {python_codex-0.1.14.dist-info → python_codex-0.2.0.dist-info}/RECORD +17 -16
- workspace_server/__init__.py +2 -0
- workspace_server/app.py +473 -109
- workspace_server/workspace.html +144 -68
- {python_codex-0.1.14.dist-info → python_codex-0.2.0.dist-info}/WHEEL +0 -0
- {python_codex-0.1.14.dist-info → python_codex-0.2.0.dist-info}/entry_points.txt +0 -0
- {python_codex-0.1.14.dist-info → python_codex-0.2.0.dist-info}/licenses/LICENSE +0 -0
workspace_server/app.py
CHANGED
|
@@ -3,6 +3,7 @@ import asyncio
|
|
|
3
3
|
import html
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import threading
|
|
6
7
|
import tempfile
|
|
7
8
|
from uuid import uuid4
|
|
8
9
|
from dataclasses import asdict, is_dataclass
|
|
@@ -19,6 +20,10 @@ from pycodex.cli import build_agent, build_cli_queue, build_model, configure_log
|
|
|
19
20
|
from pycodex.interactive_session import run_interactive_session
|
|
20
21
|
from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
|
|
21
22
|
from pycodex.protocol import AgentEvent, ToolCall
|
|
23
|
+
from pycodex.utils.session_persist import (
|
|
24
|
+
SessionRolloutRecorder,
|
|
25
|
+
load_resumed_session_path,
|
|
26
|
+
)
|
|
22
27
|
from pycodex.utils import uuid7_string
|
|
23
28
|
from pycodex.utils.visualize import IDLE_LISTENING_STATUS, shorten_title, tool_summary
|
|
24
29
|
import typing
|
|
@@ -35,6 +40,55 @@ JSONValue = typing.Union[
|
|
|
35
40
|
]
|
|
36
41
|
|
|
37
42
|
|
|
43
|
+
class WorkspaceStateStore:
|
|
44
|
+
def __init__(self, board_path: "typing.Union[Path, None]") -> None:
|
|
45
|
+
self.path = None if board_path is None else board_path.with_suffix(".pycodex-ws.json")
|
|
46
|
+
|
|
47
|
+
def load_tabs(self) -> "typing.List[typing.Dict[str, str]]":
|
|
48
|
+
if self.path is None or not self.path.is_file():
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
payload = json.loads(
|
|
53
|
+
self.path.read_text(encoding="utf-8", errors="replace") or "{}"
|
|
54
|
+
)
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
tabs = payload.get("tabs") if isinstance(payload, dict) else None
|
|
59
|
+
if not isinstance(tabs, list):
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
result = []
|
|
63
|
+
for tab in tabs:
|
|
64
|
+
if not isinstance(tab, dict):
|
|
65
|
+
continue
|
|
66
|
+
title = str(tab.get("title") or "").strip()
|
|
67
|
+
rollout_path = str(tab.get("rollout_path") or "").strip()
|
|
68
|
+
if title or rollout_path:
|
|
69
|
+
result.append({"title": title, "rollout_path": rollout_path})
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
def save_tabs(self, tabs: "typing.Iterable[typing.Dict[str, str]]") -> None:
|
|
73
|
+
if self.path is None:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
state_tabs = [
|
|
77
|
+
{
|
|
78
|
+
"title": str(tab.get("title") or ""),
|
|
79
|
+
"rollout_path": str(tab.get("rollout_path") or ""),
|
|
80
|
+
}
|
|
81
|
+
for tab in tabs
|
|
82
|
+
]
|
|
83
|
+
payload = json.dumps(
|
|
84
|
+
{"version": 1, "tabs": state_tabs},
|
|
85
|
+
ensure_ascii=False,
|
|
86
|
+
indent=2,
|
|
87
|
+
) + "\n"
|
|
88
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
self.path.write_text(payload, encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
|
|
38
92
|
def build_parser() -> "argparse.ArgumentParser":
|
|
39
93
|
parser = argparse.ArgumentParser(
|
|
40
94
|
prog="pycodex-ws",
|
|
@@ -109,8 +163,10 @@ def parse_target(
|
|
|
109
163
|
return host, port, board_path
|
|
110
164
|
|
|
111
165
|
|
|
112
|
-
SessionFactory = typing.Callable[[],
|
|
166
|
+
SessionFactory = typing.Callable[[], object]
|
|
167
|
+
ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
|
|
113
168
|
SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
|
|
169
|
+
SPINNER_STATUS_PREVIEW_LIMIT = 180
|
|
114
170
|
|
|
115
171
|
|
|
116
172
|
class WebSessionView:
|
|
@@ -125,12 +181,21 @@ class WebSessionView:
|
|
|
125
181
|
self._spinner_status = ""
|
|
126
182
|
self._stream_buffer = ""
|
|
127
183
|
self._closed = False
|
|
184
|
+
self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
185
|
+
self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
186
|
+
self._lock = threading.RLock()
|
|
187
|
+
|
|
188
|
+
def attach_server_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
|
|
189
|
+
self._server_loop = loop
|
|
190
|
+
|
|
191
|
+
def attach_worker_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
|
|
192
|
+
self._worker_loop = loop
|
|
128
193
|
|
|
129
194
|
async def submit(self, prompt: str) -> "typing.Dict[str, object]":
|
|
130
195
|
prompt = str(prompt or "").strip()
|
|
131
196
|
if not prompt:
|
|
132
197
|
return {"ok": False, "error": "prompt is empty"}
|
|
133
|
-
await self.
|
|
198
|
+
await self._put_input(prompt)
|
|
134
199
|
await self._publish(
|
|
135
200
|
{
|
|
136
201
|
"type": "input",
|
|
@@ -140,6 +205,18 @@ class WebSessionView:
|
|
|
140
205
|
)
|
|
141
206
|
return {"ok": True, "type": "submitted", "snapshot": self.snapshot()}
|
|
142
207
|
|
|
208
|
+
async def _put_input(self, item: object) -> None:
|
|
209
|
+
worker_loop = self._worker_loop
|
|
210
|
+
try:
|
|
211
|
+
running_loop = asyncio.get_running_loop()
|
|
212
|
+
except RuntimeError:
|
|
213
|
+
running_loop = None
|
|
214
|
+
if worker_loop is None or worker_loop is running_loop:
|
|
215
|
+
await self._input_queue.put(item)
|
|
216
|
+
return
|
|
217
|
+
future = asyncio.run_coroutine_threadsafe(self._input_queue.put(item), worker_loop)
|
|
218
|
+
await asyncio.wrap_future(future)
|
|
219
|
+
|
|
143
220
|
async def poll_prompt(self, prompt: "typing.Union[str, None]" = None) -> "typing.Union[str, None]":
|
|
144
221
|
del prompt
|
|
145
222
|
if self._closed and self._input_queue.empty():
|
|
@@ -161,45 +238,51 @@ class WebSessionView:
|
|
|
161
238
|
return str(item)
|
|
162
239
|
|
|
163
240
|
def handle_event(self, event: "AgentEvent") -> None:
|
|
164
|
-
self.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
payload["
|
|
241
|
+
with self._lock:
|
|
242
|
+
self._apply_runtime_event(event)
|
|
243
|
+
payload = {
|
|
244
|
+
"type": "event",
|
|
245
|
+
"kind": str(getattr(event, "kind", "")),
|
|
246
|
+
"turn_id": str(getattr(event, "turn_id", "")),
|
|
247
|
+
"payload": _json_safe(getattr(event, "payload", {})),
|
|
248
|
+
"snapshot": self.snapshot(),
|
|
249
|
+
}
|
|
250
|
+
if payload["kind"] == "tool_completed":
|
|
251
|
+
payload["summary"] = tool_summary(getattr(event, "payload", {}))
|
|
174
252
|
self._publish_nowait(payload)
|
|
175
253
|
|
|
176
254
|
def finish_stream(self) -> None:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
active_turn
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
255
|
+
with self._lock:
|
|
256
|
+
if not self._stream_buffer:
|
|
257
|
+
return
|
|
258
|
+
active_turn = self._last_active_turn()
|
|
259
|
+
if active_turn is not None and not active_turn.get("response"):
|
|
260
|
+
active_turn["response"] = self._stream_buffer
|
|
261
|
+
active_turn["thinking"] = ""
|
|
262
|
+
active_turn["_thinking_active"] = False
|
|
263
|
+
self._stream_buffer = ""
|
|
264
|
+
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
265
|
+
self._publish_nowait(event)
|
|
186
266
|
|
|
187
267
|
def write_line(self, text: str) -> None:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
268
|
+
with self._lock:
|
|
269
|
+
text = str(text or "")
|
|
270
|
+
turn = self._new_control_turn(text)
|
|
271
|
+
turn["response"] = text
|
|
272
|
+
turn["status"] = "completed"
|
|
273
|
+
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
274
|
+
self._publish_nowait(event)
|
|
193
275
|
|
|
194
276
|
def show_error(self, text: str) -> None:
|
|
195
277
|
self.finish_stream()
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
278
|
+
with self._lock:
|
|
279
|
+
turn = self._new_control_turn("")
|
|
280
|
+
turn["error"] = str(text or "")
|
|
281
|
+
turn["status"] = "error"
|
|
282
|
+
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
283
|
+
self._publish_nowait(event)
|
|
200
284
|
|
|
201
285
|
def show_history(self) -> None:
|
|
202
|
-
self.finish_stream()
|
|
203
286
|
assistant_turns = [turn for turn in self._turns if turn.get("kind") != "control"]
|
|
204
287
|
if not assistant_turns:
|
|
205
288
|
self.write_line("No history yet.")
|
|
@@ -214,17 +297,23 @@ class WebSessionView:
|
|
|
214
297
|
self.write_line("\n".join(lines))
|
|
215
298
|
|
|
216
299
|
def show_title(self) -> None:
|
|
217
|
-
self.finish_stream()
|
|
218
300
|
self.write_line("Session: {0}".format(self._title or "untitled"))
|
|
219
301
|
|
|
220
302
|
def set_session_title(self, title: str) -> None:
|
|
221
|
-
self.
|
|
222
|
-
|
|
223
|
-
|
|
303
|
+
with self._lock:
|
|
304
|
+
self._set_title(title)
|
|
305
|
+
event = {
|
|
306
|
+
"type": "title_changed",
|
|
307
|
+
"title": self._title,
|
|
308
|
+
"snapshot": self.snapshot(),
|
|
309
|
+
}
|
|
310
|
+
self._publish_nowait(event)
|
|
224
311
|
|
|
225
312
|
def show_resumed_session(self, title: str) -> None:
|
|
226
|
-
self.
|
|
227
|
-
|
|
313
|
+
with self._lock:
|
|
314
|
+
self._set_title(title)
|
|
315
|
+
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
316
|
+
self._publish_nowait(event)
|
|
228
317
|
|
|
229
318
|
def load_session_history(
|
|
230
319
|
self,
|
|
@@ -232,26 +321,27 @@ class WebSessionView:
|
|
|
232
321
|
history: "typing.Iterable[typing.Tuple[str, str]]",
|
|
233
322
|
) -> None:
|
|
234
323
|
self.finish_stream()
|
|
235
|
-
self.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
324
|
+
with self._lock:
|
|
325
|
+
self._set_title(title)
|
|
326
|
+
self._turns = []
|
|
327
|
+
self._turns_by_submission_id = {}
|
|
328
|
+
self._turns_by_turn_id = {}
|
|
329
|
+
self._events = []
|
|
330
|
+
for prompt, response in history:
|
|
331
|
+
submission_id = uuid7_string()
|
|
332
|
+
turn = self._ensure_turn(submission_id, submission_id, str(prompt or ""))
|
|
333
|
+
turn["response"] = str(response or "")
|
|
334
|
+
turn["status"] = "completed"
|
|
335
|
+
turn["queue"] = "history"
|
|
336
|
+
turn["sender"] = "resume"
|
|
337
|
+
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
338
|
+
self._publish_nowait(event)
|
|
248
339
|
|
|
249
340
|
def show_steer_queued(self, turn_id: str, prompt: str) -> None:
|
|
250
|
-
|
|
341
|
+
del turn_id, prompt
|
|
251
342
|
|
|
252
343
|
def schedule_steer_inserted(self, turn_id: str, prompt: str) -> None:
|
|
253
|
-
del turn_id
|
|
254
|
-
self.write_line("[steer] inserted: {0}".format(shorten_title(prompt, limit=72)))
|
|
344
|
+
del turn_id, prompt
|
|
255
345
|
|
|
256
346
|
def set_context_window_tokens(
|
|
257
347
|
self,
|
|
@@ -261,36 +351,52 @@ class WebSessionView:
|
|
|
261
351
|
|
|
262
352
|
def subscribe(self) -> "asyncio.Queue":
|
|
263
353
|
queue: "asyncio.Queue" = asyncio.Queue()
|
|
264
|
-
self.
|
|
265
|
-
|
|
266
|
-
{
|
|
354
|
+
with self._lock:
|
|
355
|
+
self._subscribers.add(queue)
|
|
356
|
+
event = {
|
|
267
357
|
"type": "hello",
|
|
268
358
|
"events": list(self._events[-200:]),
|
|
269
359
|
"snapshot": self.snapshot(),
|
|
270
360
|
}
|
|
271
|
-
)
|
|
361
|
+
queue.put_nowait(event)
|
|
272
362
|
return queue
|
|
273
363
|
|
|
274
364
|
def unsubscribe(self, queue: "asyncio.Queue") -> None:
|
|
275
|
-
self.
|
|
365
|
+
with self._lock:
|
|
366
|
+
self._subscribers.discard(queue)
|
|
276
367
|
|
|
277
368
|
def close(self) -> None:
|
|
278
|
-
self.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
self.
|
|
369
|
+
with self._lock:
|
|
370
|
+
self._closed = True
|
|
371
|
+
subscribers = tuple(self._subscribers)
|
|
372
|
+
self._subscribers.clear()
|
|
373
|
+
worker_loop = self._worker_loop
|
|
374
|
+
if worker_loop is None:
|
|
375
|
+
self._input_queue.put_nowait(None)
|
|
376
|
+
else:
|
|
377
|
+
asyncio.run_coroutine_threadsafe(self._input_queue.put(None), worker_loop)
|
|
378
|
+
self._publish_to_queues(subscribers, None)
|
|
283
379
|
|
|
284
380
|
def snapshot(self) -> "typing.Dict[str, object]":
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
381
|
+
with self._lock:
|
|
382
|
+
return {
|
|
383
|
+
"running": bool(self._spinner_status),
|
|
384
|
+
"status": self._spinner_status,
|
|
385
|
+
"status_kind": "spinner" if self._spinner_status else "idle",
|
|
386
|
+
"spinner": self._spinner_status,
|
|
387
|
+
"model": "pycodex",
|
|
388
|
+
"title": self._title,
|
|
389
|
+
"turns": [_public_turn(turn) for turn in self._turns[-80:]],
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
def summary(self) -> "typing.Dict[str, object]":
|
|
393
|
+
with self._lock:
|
|
394
|
+
return {
|
|
395
|
+
"running": bool(self._spinner_status),
|
|
396
|
+
"spinner": self._spinner_status,
|
|
397
|
+
"title": self._title,
|
|
398
|
+
"turn_count": len(self._turns),
|
|
399
|
+
}
|
|
294
400
|
|
|
295
401
|
def _apply_runtime_event(self, event: "AgentEvent") -> None:
|
|
296
402
|
kind = str(getattr(event, "kind", "") or "")
|
|
@@ -300,7 +406,7 @@ class WebSessionView:
|
|
|
300
406
|
turn_id = str(payload.get("turn_id") or getattr(event, "turn_id", "") or "")
|
|
301
407
|
submission_id = str(payload.get("submission_id") or turn_id or "")
|
|
302
408
|
turn = self._turns_by_submission_id.get(submission_id)
|
|
303
|
-
if turn is None and turn_id:
|
|
409
|
+
if turn is None and turn_id and not submission_id:
|
|
304
410
|
turn = self._turns_by_turn_id.get(turn_id)
|
|
305
411
|
|
|
306
412
|
if kind == "turn_started":
|
|
@@ -309,7 +415,7 @@ class WebSessionView:
|
|
|
309
415
|
str(item) for item in payload.get("user_texts", []) or []
|
|
310
416
|
)
|
|
311
417
|
if not self._title and str(prompt or "").strip():
|
|
312
|
-
self.
|
|
418
|
+
self._set_title(shorten_title(str(prompt or "")))
|
|
313
419
|
turn = self._ensure_turn(submission_id, turn_id, str(prompt or ""))
|
|
314
420
|
turn["status"] = "running"
|
|
315
421
|
turn["thinking"] = ""
|
|
@@ -340,11 +446,7 @@ class WebSessionView:
|
|
|
340
446
|
|
|
341
447
|
if kind == "tool_completed":
|
|
342
448
|
turn["_thinking_active"] = False
|
|
343
|
-
|
|
344
|
-
turn["status"] = "error"
|
|
345
|
-
turn["error"] = str(payload.get("summary") or payload.get("tool_name") or "tool failed")
|
|
346
|
-
else:
|
|
347
|
-
turn["status"] = "running"
|
|
449
|
+
turn["status"] = "running"
|
|
348
450
|
return
|
|
349
451
|
|
|
350
452
|
if kind == "turn_completed":
|
|
@@ -397,7 +499,7 @@ class WebSessionView:
|
|
|
397
499
|
self._set_spinner_status(
|
|
398
500
|
shorten_title(
|
|
399
501
|
"calling {0}({1})".format(tool_name, call.arguments),
|
|
400
|
-
limit=
|
|
502
|
+
limit=SPINNER_STATUS_PREVIEW_LIMIT,
|
|
401
503
|
)
|
|
402
504
|
)
|
|
403
505
|
elif tool_name:
|
|
@@ -438,7 +540,7 @@ class WebSessionView:
|
|
|
438
540
|
submission_id = str(submission_id or "").strip()
|
|
439
541
|
turn_id = str(turn_id or submission_id).strip()
|
|
440
542
|
turn = self._turns_by_submission_id.get(submission_id)
|
|
441
|
-
if turn is None and turn_id:
|
|
543
|
+
if turn is None and turn_id and not submission_id:
|
|
442
544
|
turn = self._turns_by_turn_id.get(turn_id)
|
|
443
545
|
if turn is None:
|
|
444
546
|
turn = {
|
|
@@ -478,6 +580,9 @@ class WebSessionView:
|
|
|
478
580
|
turn["prompt"] = ""
|
|
479
581
|
return turn
|
|
480
582
|
|
|
583
|
+
def _set_title(self, title: "typing.Union[str, None]") -> None:
|
|
584
|
+
self._title = str(title or "").strip()
|
|
585
|
+
|
|
481
586
|
def _last_active_turn(self) -> "typing.Union[typing.Dict[str, object], None]":
|
|
482
587
|
for turn in reversed(self._turns):
|
|
483
588
|
if turn.get("kind") != "control" and turn.get("status") not in {
|
|
@@ -489,11 +594,29 @@ class WebSessionView:
|
|
|
489
594
|
return None
|
|
490
595
|
|
|
491
596
|
def _publish_nowait(self, event: "typing.Dict[str, object]") -> None:
|
|
492
|
-
self.
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
597
|
+
with self._lock:
|
|
598
|
+
self._events.append(event)
|
|
599
|
+
if len(self._events) > 500:
|
|
600
|
+
del self._events[:-500]
|
|
601
|
+
subscribers = tuple(self._subscribers)
|
|
602
|
+
self._publish_to_queues(subscribers, event)
|
|
603
|
+
|
|
604
|
+
def _publish_to_queues(
|
|
605
|
+
self,
|
|
606
|
+
queues: "typing.Iterable[asyncio.Queue]",
|
|
607
|
+
event: "typing.Union[typing.Dict[str, object], None]",
|
|
608
|
+
) -> None:
|
|
609
|
+
loop = self._server_loop
|
|
610
|
+
if loop is None:
|
|
611
|
+
for queue in queues:
|
|
612
|
+
queue.put_nowait(event)
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
def publish() -> None:
|
|
616
|
+
for queue in queues:
|
|
617
|
+
queue.put_nowait(event)
|
|
618
|
+
|
|
619
|
+
loop.call_soon_threadsafe(publish)
|
|
497
620
|
|
|
498
621
|
async def _publish(self, event: "typing.Dict[str, object]") -> None:
|
|
499
622
|
self._publish_nowait(event)
|
|
@@ -504,14 +627,11 @@ class WorkspaceInteractiveSession:
|
|
|
504
627
|
self,
|
|
505
628
|
queue,
|
|
506
629
|
config_path: "typing.Union[str, None]" = None,
|
|
507
|
-
initial_prompt: "typing.Union[str, None]" = None,
|
|
508
630
|
) -> None:
|
|
509
631
|
self.queue = queue
|
|
510
632
|
self.config_path = config_path
|
|
511
|
-
self.initial_prompt = str(initial_prompt or "").strip()
|
|
512
633
|
self.view = WebSessionView()
|
|
513
634
|
self._task: "typing.Union[asyncio.Task[int], None]" = None
|
|
514
|
-
self._initial_prompt_submitted = False
|
|
515
635
|
|
|
516
636
|
async def start(self) -> "WorkspaceInteractiveSession":
|
|
517
637
|
if self._task is None:
|
|
@@ -524,9 +644,6 @@ class WorkspaceInteractiveSession:
|
|
|
524
644
|
show_banner=False,
|
|
525
645
|
)
|
|
526
646
|
)
|
|
527
|
-
if self.initial_prompt and not self._initial_prompt_submitted:
|
|
528
|
-
self._initial_prompt_submitted = True
|
|
529
|
-
await self.view.submit(self.initial_prompt)
|
|
530
647
|
return self
|
|
531
648
|
|
|
532
649
|
async def close(self) -> None:
|
|
@@ -566,31 +683,220 @@ class WorkspaceInteractiveSession:
|
|
|
566
683
|
snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
567
684
|
return snapshot
|
|
568
685
|
|
|
686
|
+
def summary(self) -> "typing.Dict[str, object]":
|
|
687
|
+
summary = self.view.summary()
|
|
688
|
+
agent = getattr(self.queue, "_agent", None)
|
|
689
|
+
summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
690
|
+
return summary
|
|
691
|
+
|
|
692
|
+
def rollout_path(self) -> str:
|
|
693
|
+
recorder = getattr(getattr(self.queue, "_agent", None), "_rollout_recorder", None)
|
|
694
|
+
path = getattr(recorder, "rollout_path", None)
|
|
695
|
+
return "" if path is None else str(path)
|
|
696
|
+
|
|
697
|
+
async def restore_from_rollout(self, rollout_path: str, title: str = "") -> None:
|
|
698
|
+
resumed = load_resumed_session_path(rollout_path, thread_name=title or None)
|
|
699
|
+
agent = self.queue._agent
|
|
700
|
+
agent.replace_history(resumed["history"])
|
|
701
|
+
model_client = getattr(agent, "_model_client", None)
|
|
702
|
+
if hasattr(model_client, "_session_id"):
|
|
703
|
+
model_client._session_id = str(resumed["session_id"])
|
|
704
|
+
agent.set_rollout_recorder(SessionRolloutRecorder.resume(resumed["rollout_path"]))
|
|
705
|
+
self.view.load_session_history(
|
|
706
|
+
str(title or resumed["title"]),
|
|
707
|
+
tuple(resumed["turns"]),
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class ThreadedWorkspaceInteractiveSession:
|
|
712
|
+
def __init__(
|
|
713
|
+
self,
|
|
714
|
+
session_factory: "ThreadedSessionFactory",
|
|
715
|
+
server_loop: "asyncio.AbstractEventLoop",
|
|
716
|
+
) -> None:
|
|
717
|
+
self._session_factory = session_factory
|
|
718
|
+
self._server_loop = server_loop
|
|
719
|
+
self._view = WebSessionView()
|
|
720
|
+
self._view.attach_server_loop(server_loop)
|
|
721
|
+
self._thread: "typing.Union[threading.Thread, None]" = None
|
|
722
|
+
self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
723
|
+
self._ready = threading.Event()
|
|
724
|
+
self._closed = threading.Event()
|
|
725
|
+
self._startup_error: "typing.Union[BaseException, None]" = None
|
|
726
|
+
self._session: "typing.Union[WorkspaceInteractiveSession, None]" = None
|
|
727
|
+
|
|
728
|
+
async def start(self) -> "ThreadedWorkspaceInteractiveSession":
|
|
729
|
+
if self._thread is not None:
|
|
730
|
+
return self
|
|
731
|
+
self._thread = threading.Thread(
|
|
732
|
+
target=self._thread_main,
|
|
733
|
+
name="pycodex-workspace-session",
|
|
734
|
+
daemon=True,
|
|
735
|
+
)
|
|
736
|
+
self._thread.start()
|
|
737
|
+
await asyncio.to_thread(self._ready.wait)
|
|
738
|
+
if self._startup_error is not None:
|
|
739
|
+
raise RuntimeError("workspace session thread failed to start") from self._startup_error
|
|
740
|
+
return self
|
|
741
|
+
|
|
742
|
+
def _thread_main(self) -> None:
|
|
743
|
+
loop = asyncio.new_event_loop()
|
|
744
|
+
self._worker_loop = loop
|
|
745
|
+
self._view.attach_worker_loop(loop)
|
|
746
|
+
asyncio.set_event_loop(loop)
|
|
747
|
+
try:
|
|
748
|
+
session = self._session_factory()
|
|
749
|
+
session.view = self._view
|
|
750
|
+
self._session = session
|
|
751
|
+
loop.run_until_complete(session.start())
|
|
752
|
+
self._ready.set()
|
|
753
|
+
loop.run_forever()
|
|
754
|
+
except BaseException as exc:
|
|
755
|
+
self._startup_error = exc
|
|
756
|
+
self._ready.set()
|
|
757
|
+
finally:
|
|
758
|
+
session = self._session
|
|
759
|
+
if session is not None:
|
|
760
|
+
try:
|
|
761
|
+
loop.run_until_complete(session.close())
|
|
762
|
+
except BaseException:
|
|
763
|
+
pass
|
|
764
|
+
pending = asyncio.all_tasks(loop)
|
|
765
|
+
for task in pending:
|
|
766
|
+
task.cancel()
|
|
767
|
+
if pending:
|
|
768
|
+
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
|
769
|
+
asyncio.set_event_loop(None)
|
|
770
|
+
loop.close()
|
|
771
|
+
self._closed.set()
|
|
772
|
+
|
|
773
|
+
async def close(self) -> None:
|
|
774
|
+
session = self._session
|
|
775
|
+
loop = self._worker_loop
|
|
776
|
+
if session is not None and loop is not None and loop.is_running():
|
|
777
|
+
future = asyncio.run_coroutine_threadsafe(session.close(), loop)
|
|
778
|
+
try:
|
|
779
|
+
await asyncio.wait_for(
|
|
780
|
+
asyncio.wrap_future(future),
|
|
781
|
+
timeout=SESSION_CLOSE_TIMEOUT_SECONDS + 1.0,
|
|
782
|
+
)
|
|
783
|
+
except (asyncio.TimeoutError, RuntimeError):
|
|
784
|
+
cancel_current = getattr(getattr(session, "queue", None), "cancel_current", None)
|
|
785
|
+
if callable(cancel_current):
|
|
786
|
+
cancel_current()
|
|
787
|
+
if loop is not None and loop.is_running():
|
|
788
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
789
|
+
thread = self._thread
|
|
790
|
+
if thread is not None:
|
|
791
|
+
await asyncio.to_thread(thread.join, SESSION_CLOSE_TIMEOUT_SECONDS + 1.0)
|
|
792
|
+
self._thread = None
|
|
793
|
+
|
|
794
|
+
async def submit(self, prompt: str, sender: str = "web") -> "typing.Dict[str, object]":
|
|
795
|
+
del sender
|
|
796
|
+
result = await self._view.submit(prompt)
|
|
797
|
+
result["snapshot"] = self.snapshot()
|
|
798
|
+
return result
|
|
799
|
+
|
|
800
|
+
def subscribe(self) -> "asyncio.Queue":
|
|
801
|
+
return self._view.subscribe()
|
|
802
|
+
|
|
803
|
+
def unsubscribe(self, queue: "asyncio.Queue") -> None:
|
|
804
|
+
self._view.unsubscribe(queue)
|
|
805
|
+
|
|
806
|
+
def snapshot(self) -> "typing.Dict[str, object]":
|
|
807
|
+
snapshot = self._view.snapshot()
|
|
808
|
+
session = self._session
|
|
809
|
+
queue = getattr(session, "queue", None)
|
|
810
|
+
agent = getattr(queue, "_agent", None)
|
|
811
|
+
snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
812
|
+
return snapshot
|
|
813
|
+
|
|
814
|
+
def summary(self) -> "typing.Dict[str, object]":
|
|
815
|
+
summary = self._view.summary()
|
|
816
|
+
session = self._session
|
|
817
|
+
queue = getattr(session, "queue", None)
|
|
818
|
+
agent = getattr(queue, "_agent", None)
|
|
819
|
+
summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
820
|
+
return summary
|
|
821
|
+
|
|
822
|
+
def rollout_path(self) -> str:
|
|
823
|
+
if self._session is None:
|
|
824
|
+
return ""
|
|
825
|
+
return self._session.rollout_path()
|
|
826
|
+
|
|
827
|
+
async def restore_from_rollout(self, rollout_path: str, title: str = "") -> None:
|
|
828
|
+
session = self._session
|
|
829
|
+
loop = self._worker_loop
|
|
830
|
+
if session is None or loop is None:
|
|
831
|
+
return
|
|
832
|
+
|
|
833
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
834
|
+
session.restore_from_rollout(rollout_path, title=title),
|
|
835
|
+
loop,
|
|
836
|
+
)
|
|
837
|
+
await asyncio.wrap_future(future)
|
|
838
|
+
|
|
569
839
|
|
|
570
840
|
class WorkspaceSessionManager:
|
|
571
|
-
def __init__(
|
|
841
|
+
def __init__(
|
|
842
|
+
self,
|
|
843
|
+
session_factory: "SessionFactory",
|
|
844
|
+
board_path: "typing.Union[Path, None]" = None,
|
|
845
|
+
) -> None:
|
|
572
846
|
self._session_factory = session_factory
|
|
573
847
|
self._sessions: "typing.Dict[str, WorkspaceInteractiveSession]" = {}
|
|
574
848
|
self._session_order: "typing.List[str]" = []
|
|
849
|
+
self._state_watchers: "typing.Dict[str, asyncio.Task]" = {}
|
|
850
|
+
self._persisted_titles: "typing.Dict[str, str]" = {}
|
|
575
851
|
self._lock = asyncio.Lock()
|
|
852
|
+
self._state_store = WorkspaceStateStore(board_path)
|
|
576
853
|
|
|
577
854
|
async def start(self) -> None:
|
|
578
|
-
|
|
855
|
+
state_tabs = self._state_store.load_tabs()
|
|
856
|
+
if not state_tabs:
|
|
857
|
+
await self.create_session()
|
|
858
|
+
return
|
|
859
|
+
for tab in state_tabs:
|
|
860
|
+
await self.create_session(
|
|
861
|
+
title=str(tab.get("title") or ""),
|
|
862
|
+
rollout_path=str(tab.get("rollout_path") or ""),
|
|
863
|
+
)
|
|
579
864
|
|
|
580
865
|
async def close(self) -> None:
|
|
581
866
|
sessions = list(self._sessions.values())
|
|
867
|
+
watchers = list(self._state_watchers.values())
|
|
582
868
|
self._sessions.clear()
|
|
583
869
|
self._session_order = []
|
|
870
|
+
self._state_watchers.clear()
|
|
871
|
+
self._persisted_titles.clear()
|
|
872
|
+
for watcher in watchers:
|
|
873
|
+
watcher.cancel()
|
|
874
|
+
if watchers:
|
|
875
|
+
await asyncio.gather(*watchers, return_exceptions=True)
|
|
584
876
|
for session in sessions:
|
|
585
877
|
await session.close()
|
|
586
878
|
|
|
587
|
-
async def create_session(
|
|
879
|
+
async def create_session(
|
|
880
|
+
self,
|
|
881
|
+
title: str = "",
|
|
882
|
+
rollout_path: str = "",
|
|
883
|
+
) -> str:
|
|
588
884
|
async with self._lock:
|
|
589
885
|
session_id = uuid7_string()
|
|
590
886
|
session = self._session_factory()
|
|
591
887
|
await session.start()
|
|
888
|
+
|
|
889
|
+
if rollout_path:
|
|
890
|
+
await session.restore_from_rollout(rollout_path, title=title)
|
|
891
|
+
|
|
592
892
|
self._sessions[session_id] = session
|
|
593
893
|
self._session_order.append(session_id)
|
|
894
|
+
self._persisted_titles[session_id] = str(
|
|
895
|
+
_session_summary(session).get("title") or ""
|
|
896
|
+
)
|
|
897
|
+
self._state_watchers[session_id] = asyncio.create_task(
|
|
898
|
+
self._watch_session_title(session_id, session)
|
|
899
|
+
)
|
|
594
900
|
return session_id
|
|
595
901
|
|
|
596
902
|
async def close_session(self, session_id: str) -> None:
|
|
@@ -600,10 +906,47 @@ class WorkspaceSessionManager:
|
|
|
600
906
|
session = self._sessions.pop(session_id, None)
|
|
601
907
|
if session is None:
|
|
602
908
|
raise KeyError(session_id)
|
|
909
|
+
watcher = self._state_watchers.pop(session_id, None)
|
|
910
|
+
self._persisted_titles.pop(session_id, None)
|
|
603
911
|
self._session_order = [
|
|
604
912
|
item for item in self._session_order if item != session_id
|
|
605
913
|
]
|
|
914
|
+
if watcher is not None:
|
|
915
|
+
watcher.cancel()
|
|
916
|
+
await asyncio.gather(watcher, return_exceptions=True)
|
|
606
917
|
await session.close()
|
|
918
|
+
self.persist_workspace_state()
|
|
919
|
+
|
|
920
|
+
async def _watch_session_title(self, session_id: str, session) -> None:
|
|
921
|
+
subscriber = session.subscribe()
|
|
922
|
+
try:
|
|
923
|
+
while True:
|
|
924
|
+
event = await subscriber.get()
|
|
925
|
+
if event is None:
|
|
926
|
+
return
|
|
927
|
+
if not isinstance(event, dict) or event.get("type") != "title_changed":
|
|
928
|
+
continue
|
|
929
|
+
title = str(event.get("title") or "")
|
|
930
|
+
if title == self._persisted_titles.get(session_id, ""):
|
|
931
|
+
continue
|
|
932
|
+
self._persisted_titles[session_id] = title
|
|
933
|
+
self.persist_workspace_state()
|
|
934
|
+
finally:
|
|
935
|
+
session.unsubscribe(subscriber)
|
|
936
|
+
|
|
937
|
+
def persist_workspace_state(self) -> None:
|
|
938
|
+
tabs = []
|
|
939
|
+
for session_id in self._session_order:
|
|
940
|
+
session = self._sessions.get(session_id)
|
|
941
|
+
if session is None:
|
|
942
|
+
continue
|
|
943
|
+
summary = _session_summary(session)
|
|
944
|
+
title = str(summary.get("title") or "").strip()
|
|
945
|
+
rollout_path = str(session.rollout_path() or "")
|
|
946
|
+
if not title and not rollout_path:
|
|
947
|
+
continue
|
|
948
|
+
tabs.append({"title": title, "rollout_path": rollout_path})
|
|
949
|
+
self._state_store.save_tabs(tabs)
|
|
607
950
|
|
|
608
951
|
def get(self, session_id: "typing.Union[str, None]" = None) -> "WorkspaceInteractiveSession":
|
|
609
952
|
resolved_id = self.resolve_session_id(session_id)
|
|
@@ -623,14 +966,14 @@ class WorkspaceSessionManager:
|
|
|
623
966
|
result = []
|
|
624
967
|
for session_id in self._session_order:
|
|
625
968
|
session = self._sessions[session_id]
|
|
626
|
-
|
|
969
|
+
summary = _session_summary(session)
|
|
627
970
|
result.append(
|
|
628
971
|
{
|
|
629
972
|
"id": session_id,
|
|
630
|
-
"title":
|
|
631
|
-
"running": bool(
|
|
632
|
-
"spinner":
|
|
633
|
-
"turn_count":
|
|
973
|
+
"title": summary.get("title") or "pycodex",
|
|
974
|
+
"running": bool(summary.get("running")),
|
|
975
|
+
"spinner": summary.get("spinner") or "",
|
|
976
|
+
"turn_count": summary.get("turn_count") or 0,
|
|
634
977
|
}
|
|
635
978
|
)
|
|
636
979
|
return result
|
|
@@ -643,7 +986,7 @@ def create_app(
|
|
|
643
986
|
manager = (
|
|
644
987
|
session_source
|
|
645
988
|
if isinstance(session_source, WorkspaceSessionManager)
|
|
646
|
-
else WorkspaceSessionManager(session_source)
|
|
989
|
+
else WorkspaceSessionManager(session_source, board_path)
|
|
647
990
|
)
|
|
648
991
|
|
|
649
992
|
if asynccontextmanager is not None:
|
|
@@ -742,7 +1085,9 @@ def create_app(
|
|
|
742
1085
|
return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
|
|
743
1086
|
|
|
744
1087
|
@app.get("/api/session")
|
|
745
|
-
async def session(
|
|
1088
|
+
async def session(
|
|
1089
|
+
session_id: "typing.Union[str, None]" = None,
|
|
1090
|
+
) -> JSONResponse:
|
|
746
1091
|
try:
|
|
747
1092
|
resolved_id = manager.resolve_session_id(session_id)
|
|
748
1093
|
link = manager.get(resolved_id)
|
|
@@ -757,7 +1102,9 @@ def create_app(
|
|
|
757
1102
|
)
|
|
758
1103
|
|
|
759
1104
|
@app.post("/api/session/message")
|
|
760
|
-
async def message(
|
|
1105
|
+
async def message(
|
|
1106
|
+
payload: "typing.Dict[str, object]",
|
|
1107
|
+
) -> JSONResponse:
|
|
761
1108
|
session_id = str(payload.get("session_id") or "")
|
|
762
1109
|
try:
|
|
763
1110
|
link = manager.get(session_id or None)
|
|
@@ -821,6 +1168,19 @@ def _session_snapshot(session) -> "typing.Dict[str, object]":
|
|
|
821
1168
|
return typing.cast("typing.Dict[str, object]", session.snapshot())
|
|
822
1169
|
|
|
823
1170
|
|
|
1171
|
+
def _session_summary(session) -> "typing.Dict[str, object]":
|
|
1172
|
+
summary = getattr(session, "summary", None)
|
|
1173
|
+
if callable(summary):
|
|
1174
|
+
return typing.cast("typing.Dict[str, object]", summary())
|
|
1175
|
+
snapshot = _session_snapshot(session)
|
|
1176
|
+
return {
|
|
1177
|
+
"title": snapshot.get("title") or "",
|
|
1178
|
+
"running": bool(snapshot.get("running")),
|
|
1179
|
+
"spinner": snapshot.get("spinner") or "",
|
|
1180
|
+
"turn_count": len(snapshot.get("turns") or []),
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
|
|
824
1184
|
def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]":
|
|
825
1185
|
return typing.cast(
|
|
826
1186
|
"typing.Dict[str, object]",
|
|
@@ -884,7 +1244,7 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
884
1244
|
raise ValueError("board parent directory does not exist: {0}".format(board_path.parent))
|
|
885
1245
|
|
|
886
1246
|
configure_loguru()
|
|
887
|
-
def
|
|
1247
|
+
def build_session() -> "WorkspaceInteractiveSession":
|
|
888
1248
|
model = build_model(
|
|
889
1249
|
config_path=args.config,
|
|
890
1250
|
profile=args.profile,
|
|
@@ -899,15 +1259,19 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
899
1259
|
profile=args.profile,
|
|
900
1260
|
system_prompt=args.system_prompt,
|
|
901
1261
|
session_mode="tui",
|
|
1262
|
+
extra_contextual_user_messages=(
|
|
1263
|
+
[_board_context_text(board_path)] if board_path is not None else []
|
|
1264
|
+
),
|
|
902
1265
|
)
|
|
903
|
-
initial_prompt = _board_prompt_text(board_path) if board_path is not None else None
|
|
904
1266
|
return WorkspaceInteractiveSession(
|
|
905
1267
|
build_cli_queue(agent),
|
|
906
1268
|
config_path=args.config,
|
|
907
|
-
initial_prompt=initial_prompt,
|
|
908
1269
|
)
|
|
909
1270
|
|
|
910
|
-
|
|
1271
|
+
def session_factory() -> "ThreadedWorkspaceInteractiveSession":
|
|
1272
|
+
return ThreadedWorkspaceInteractiveSession(build_session, asyncio.get_running_loop())
|
|
1273
|
+
|
|
1274
|
+
app = create_app(WorkspaceSessionManager(session_factory, board_path), board_path)
|
|
911
1275
|
print(
|
|
912
1276
|
"pycodex workspace listening on http://{0}:{1}".format(host, port),
|
|
913
1277
|
flush=True,
|
|
@@ -918,11 +1282,11 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
918
1282
|
return 0
|
|
919
1283
|
|
|
920
1284
|
|
|
921
|
-
def
|
|
1285
|
+
def _board_context_text(board_path: Path) -> str:
|
|
922
1286
|
return (
|
|
923
1287
|
"Current workspace board file: {0}. "
|
|
924
1288
|
"Changes you make to this file are shown to the user in real time. "
|
|
925
|
-
"You can create or modify this file anytime.
|
|
1289
|
+
"You can create or modify this file anytime."
|
|
926
1290
|
).format(_format_board_path_for_prompt(board_path))
|
|
927
1291
|
|
|
928
1292
|
|