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.
Files changed (84) hide show
  1. pycodex/__init__.py +14 -14
  2. pycodex/agent.py +465 -499
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -510
  5. pycodex/compat.py +19 -5
  6. pycodex/context.py +222 -212
  7. pycodex/doctor.py +52 -48
  8. pycodex/events.py +857 -0
  9. pycodex/feishu_card.py +217 -163
  10. pycodex/feishu_link.py +43 -83
  11. pycodex/model.py +324 -253
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +76 -45
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +245 -983
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -176
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +1 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +58 -25
  23. pycodex/tools/close_agent_tool.py +2 -2
  24. pycodex/tools/code_mode_manager.py +77 -64
  25. pycodex/tools/exec_command_tool.py +26 -11
  26. pycodex/tools/exec_tool.py +4 -4
  27. pycodex/tools/grep_files_tool.py +12 -10
  28. pycodex/tools/ipython_tool.py +10 -13
  29. pycodex/tools/list_dir_tool.py +13 -9
  30. pycodex/tools/read_file_tool.py +29 -17
  31. pycodex/tools/request_permissions_tool.py +15 -5
  32. pycodex/tools/request_user_input_tool.py +13 -104
  33. pycodex/tools/resume_agent_tool.py +2 -2
  34. pycodex/tools/send_input_tool.py +11 -8
  35. pycodex/tools/shell_command_tool.py +7 -5
  36. pycodex/tools/shell_tool.py +7 -5
  37. pycodex/tools/spawn_agent_tool.py +7 -4
  38. pycodex/tools/unified_exec_manager.py +102 -69
  39. pycodex/tools/update_plan_tool.py +8 -5
  40. pycodex/tools/view_image_tool.py +7 -5
  41. pycodex/tools/wait_agent_tool.py +27 -4
  42. pycodex/tools/wait_tool.py +5 -4
  43. pycodex/tools/web_search_tool.py +4 -2
  44. pycodex/tools/write_stdin_tool.py +12 -11
  45. pycodex/utils/__init__.py +2 -17
  46. pycodex/utils/compactor.py +41 -72
  47. pycodex/utils/debug.py +2 -2
  48. pycodex/utils/dotenv.py +6 -7
  49. pycodex/utils/event_helpers.py +190 -0
  50. pycodex/utils/get_env.py +27 -70
  51. pycodex/{image_utils.py → utils/image_utils.py} +8 -11
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +217 -163
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.0.dist-info/METADATA +704 -0
  56. python_codex-0.3.0.dist-info/RECORD +90 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +36 -31
  60. responses_server/config.py +23 -23
  61. responses_server/messages_api.py +51 -53
  62. responses_server/payload_processors.py +25 -20
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +101 -98
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +36 -14
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +461 -375
  71. workspace_server/workspace.html +852 -228
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +137 -79
  74. pycodex/collaboration.py +0 -20
  75. pycodex/interactive_session.py +0 -415
  76. pycodex/prompts/collaboration_default.md +0 -11
  77. pycodex/prompts/collaboration_plan.md +0 -128
  78. pycodex/utils/toolcall_visualize.py +0 -713
  79. pycodex/utils/visualize.py +0 -560
  80. python_codex-0.2.7.dist-info/METADATA +0 -455
  81. python_codex-0.2.7.dist-info/RECORD +0 -93
  82. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
pycodex/runtime.py CHANGED
@@ -1,225 +1,628 @@
1
-
2
1
  import asyncio
2
+ import typing
3
3
  from collections import deque
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, field, replace
5
5
 
6
- from .agent import Agent, EventHandler, BASE_EVENT_HANDLER, TurnInterrupted
6
+ from .agent import BASE_EVENT_HANDLER, Agent, TurnInterrupted
7
7
  from .compat import Literal
8
- from .protocol import AgentEvent, Operation, ShutdownOp, Submission, TurnResult, UserTurnOp
8
+ from .events import (
9
+ AssistantDeltaEvent,
10
+ AutoCompactCompletedEvent,
11
+ CommandCompletedEvent,
12
+ CommandFailedEvent,
13
+ Event,
14
+ InputQueuedEvent,
15
+ InputRequestedEvent,
16
+ InputResolvedEvent,
17
+ SessionClosedEvent,
18
+ SessionStateEvent,
19
+ StreamErrorEvent,
20
+ TerminalEvent,
21
+ ToolCompletedEvent,
22
+ TurnCompletedEvent,
23
+ TurnEvent,
24
+ TurnFailedEvent,
25
+ TurnInterruptedEvent,
26
+ TurnStartedEvent,
27
+ )
28
+ from .protocol import TurnResult
9
29
  from .utils import uuid7_string
10
- import typing
30
+ from .utils.event_helpers import shorten_title
31
+ from .utils.session_persist import (
32
+ conversation_history_to_turns,
33
+ list_resumable_sessions,
34
+ resolve_codex_home,
35
+ select_resumable_session,
36
+ )
37
+
38
+
39
+ class SubmissionInterrupted(RuntimeError):
40
+ def __init__(self) -> "None":
41
+ super().__init__("submission interrupted")
11
42
 
12
43
 
13
44
  @dataclass
14
45
  class _QueuedSubmission:
15
- submission: 'Submission'
16
- turn_id: 'str'
17
- futures: 'typing.List[asyncio.Future[typing.Union[TurnResult, None]]]'
46
+ submission_id: "str"
47
+ turn_id: "str"
48
+ texts: "typing.List[str]"
49
+ futures: "typing.List[asyncio.Future[TurnResult]]"
18
50
 
19
51
 
20
- class CliSubmissionQueue:
21
- """Thin outer queue that mirrors the Rust `submission_loop` shape."""
52
+ @dataclass(frozen=True)
53
+ class SubmittedInput:
54
+ submission_id: "str"
55
+ kind: "str"
56
+ future: "asyncio.Future"
22
57
 
23
- def __init__(self, agent: 'Agent') -> 'None':
24
- self._agent = agent
25
- self._enqueue_queue: 'deque[_QueuedSubmission]' = deque()
26
- self._steer_queue: 'deque[_QueuedSubmission]' = deque()
27
- self._queue_lock = asyncio.Lock()
28
- self._queue_event = asyncio.Event()
29
- self._current_submission: 'typing.Union[_QueuedSubmission, None]' = None
30
- self._current_task: 'typing.Union[asyncio.Task[TurnResult], None]' = None
31
- self._event_handler = BASE_EVENT_HANDLER
32
- self._agent.set_event_handler(self._handle_agent_event)
33
58
 
34
- def set_event_handler(self, event_handler: 'EventHandler' = BASE_EVENT_HANDLER) -> 'None':
35
- self._event_handler = event_handler
59
+ @dataclass
60
+ class _InputRequest:
61
+ request_id: "str"
62
+ kind: "str"
63
+ payload: "dict"
64
+ future: "asyncio.Future"
65
+ question_index: "int" = 0
66
+ answers: "dict" = field(default_factory=dict)
67
+ other: "bool" = False
68
+ timer: "typing.Union[asyncio.TimerHandle, None]" = None
69
+
70
+
71
+ class AgentRuntime:
72
+ """Frontend-independent session input, scheduling, state and events."""
73
+
74
+ def __init__(self, agent: "Agent") -> "None":
75
+ self.agent = agent
76
+ self._enqueue_queue: "deque[_QueuedSubmission]" = deque()
77
+ self._steer_queue: "deque[_QueuedSubmission]" = deque()
78
+ self._queue_event = None
79
+ self._current_submission: "typing.Union[_QueuedSubmission, None]" = None
80
+ self.event_handler = BASE_EVENT_HANDLER
81
+ self.agent.event_handler = self._handle_agent_event
82
+ self.codex_home = (
83
+ agent.context_manager._config.codex_home or resolve_codex_home()
84
+ )
85
+ self.title = ""
86
+ self.command_handlers = {}
87
+ self._frontends = {}
88
+ self._close_handlers = []
89
+ self._worker = None
90
+ self._command_lock = None
91
+ self._input_request = None
92
+ self._background_work_count = 0
93
+ self._active_turn = None
94
+ self._recorded_rollout_path = agent.recorded_session_file_path
95
+ environment = agent.tool_registry.runtime_environment
96
+ environment.request_user_input_manager.set_handler(self._request_user_input)
97
+ environment.request_permissions_manager.set_handler(self._request_permissions)
98
+
99
+ def attach(self, event_handler):
100
+ frontend_id = uuid7_string()
101
+ self._frontends[frontend_id] = event_handler
102
+ self._notify_handler(
103
+ event_handler, SessionStateEvent("attach", self.snapshot())
104
+ )
105
+ return frontend_id
106
+
107
+ def detach(self, frontend_id):
108
+ del self._frontends[frontend_id]
109
+ if not self._frontends and self._input_request is not None:
110
+ self._finish_input_request(None)
111
+
112
+ def register_command(self, name, handler):
113
+ if name in self.commands():
114
+ raise ValueError("command already registered: /{0}".format(name))
115
+ self.command_handlers[name] = handler
116
+
117
+ def add_close_handler(self, handler):
118
+ self._close_handlers.append(handler)
119
+
120
+ def commands(self):
121
+ return (
122
+ (
123
+ "help",
124
+ "history",
125
+ "title",
126
+ "model",
127
+ "resume",
128
+ "compact",
129
+ "fork",
130
+ )
131
+ + tuple(self.command_handlers)
132
+ + ("queue", "exit", "quit")
133
+ )
134
+
135
+ def snapshot(self):
136
+ rollout_path = self.agent.session_file_path
137
+ recorded_path = self.agent.recorded_session_file_path
138
+ return {
139
+ "session_id": self.agent.session_id,
140
+ "rollout_path": str(rollout_path) if rollout_path is not None else None,
141
+ "recorded_rollout_path": (
142
+ str(recorded_path) if recorded_path is not None else None
143
+ ),
144
+ "model": self.agent.model_name,
145
+ "title": self.title,
146
+ "history": conversation_history_to_turns(self.agent.history),
147
+ "busy": self.is_busy,
148
+ "closed": self.agent.is_shutdown,
149
+ "accepts_input": self.agent.accepts_input,
150
+ "context_window": self.agent.context_manager.resolve_model_context_window(),
151
+ "max_context_window": (
152
+ self.agent.context_manager.resolve_model_max_context_window()
153
+ ),
154
+ "auto_compact_token_limit": (
155
+ self.agent.context_manager.resolve_auto_compact_token_limit()
156
+ ),
157
+ "usage_tokens": self.agent._last_total_usage_tokens,
158
+ "background_work_count": self._background_work_count,
159
+ "input_request": self._input_request_event(),
160
+ "active_turn": (
161
+ dict(self._active_turn) if self._active_turn is not None else None
162
+ ),
163
+ "plan": self.agent.tool_registry.runtime_environment.plan_store.snapshot(),
164
+ }
165
+
166
+ def publish_state(self, reason):
167
+ self._recorded_rollout_path = self.agent.recorded_session_file_path
168
+ self._publish(SessionStateEvent(reason, self.snapshot()))
169
+
170
+ def require_idle(self, operation):
171
+ if self.is_busy:
172
+ raise RuntimeError(
173
+ "Cannot {0} while work is running or queued.".format(operation)
174
+ )
175
+
176
+ def set_title(self, title):
177
+ self.title = title
178
+ self.publish_state("title")
179
+
180
+ def set_model(self, model):
181
+ self.require_idle("change model")
182
+ self.agent.set_model(model)
183
+ self.publish_state("model")
184
+
185
+ def resume(self, path=None, title=""):
186
+ self.require_idle("resume")
187
+ resumed = self.agent.resume(path)
188
+ if resumed is not None:
189
+ self.title = title or str(resumed["title"])
190
+ self.publish_state("history" if path is not None else "admission")
191
+ return resumed
192
+
193
+ def fork(self):
194
+ self.require_idle("fork")
195
+ self.agent.fork()
196
+ self.publish_state("identity")
197
+
198
+ async def start(self, config_path=None):
199
+ if config_path is not None:
200
+ self.codex_home = resolve_codex_home(config_path)
201
+ if self._worker is None or self._worker.done():
202
+ if self.agent.is_shutdown:
203
+ raise RuntimeError("resume the agent before restarting its runtime")
204
+ self.agent.accepts_input = True
205
+ self._worker = asyncio.create_task(self._run_forever())
206
+ return self
207
+
208
+ def _get_command_lock(self):
209
+ if self._command_lock is None:
210
+ self._command_lock = asyncio.Lock()
211
+ return self._command_lock
212
+
213
+ def _wake_worker(self):
214
+ if self._queue_event is not None:
215
+ self._queue_event.set()
216
+
217
+ async def close(self):
218
+ if self._worker is None:
219
+ self._worker = asyncio.create_task(self._run_forever())
220
+ if self.accepts_input:
221
+ self.agent.accepts_input = False
222
+ self.publish_state("admission")
223
+ if self._input_request is not None:
224
+ self._finish_input_request(None)
225
+ self._wake_worker()
226
+ await asyncio.shield(self._worker)
227
+
228
+ async def submit_input(self, text, sender="user"):
229
+ if not self.agent.accepts_input:
230
+ raise RuntimeError("agent is shutting down")
231
+ text = text.strip()
232
+ if self._input_request is not None and not text.startswith("/"):
233
+ self._answer_input(text)
234
+ future = asyncio.get_running_loop().create_future()
235
+ future.set_result(None)
236
+ return SubmittedInput(uuid7_string(), "answer", future)
237
+ if not text:
238
+ future = asyncio.get_running_loop().create_future()
239
+ future.set_result(None)
240
+ return SubmittedInput(uuid7_string(), "empty", future)
241
+ parts = text.split(None, 1)
242
+ command = parts[0]
243
+ argument = parts[1] if len(parts) == 2 else ""
244
+ if command == "/queue":
245
+ if not argument.strip():
246
+ raise ValueError("Usage: /queue <message>")
247
+ return await self._submit_message(argument.strip(), "enqueue", sender, True)
248
+ if not command.startswith("/"):
249
+ return await self._submit_message(text, "steer", sender, False)
250
+ submission_id = uuid7_string()
251
+ future = asyncio.get_running_loop().create_future()
252
+ try:
253
+ if command in {"/exit", "/quit"}:
254
+ result = await self._execute_command(command[1:], argument.strip())
255
+ else:
256
+ async with self._get_command_lock():
257
+ if not self.agent.accepts_input:
258
+ raise RuntimeError("agent is shutting down")
259
+ result = await self._execute_command(command[1:], argument.strip())
260
+ except Exception as exc:
261
+ future.set_exception(exc)
262
+ self._publish(CommandFailedEvent(submission_id, command, str(exc), sender))
263
+ else:
264
+ future.set_result(result)
265
+ self._publish(CommandCompletedEvent(submission_id, command, result, sender))
266
+ future.add_done_callback(self._observe_completion)
267
+ return SubmittedInput(submission_id, "command", future)
268
+
269
+ async def _execute_command(self, command, argument):
270
+ if (
271
+ command in {"help", "history", "compact", "fork", "exit", "quit"}
272
+ and argument
273
+ ):
274
+ raise ValueError("Usage: /{0}".format(command))
275
+ if command == "help":
276
+ return {"kind": "help", "commands": self.commands()}
277
+ if command == "history":
278
+ return {"kind": "history", "state": self.snapshot()}
279
+ if command == "title":
280
+ if argument:
281
+ self.set_title(argument)
282
+ return {
283
+ "kind": "title_changed" if argument else "title",
284
+ "title": self.title,
285
+ }
286
+ if command == "model":
287
+ if argument:
288
+ self.set_model(argument)
289
+ return {"kind": "model_changed", "model": self.agent.model_name}
290
+ return {
291
+ "kind": "models",
292
+ "model": self.agent.model_name,
293
+ "models": await self.agent.model_client.list_models(),
294
+ }
295
+ if command == "resume":
296
+ if not argument:
297
+ return {
298
+ "kind": "sessions",
299
+ "sessions": list_resumable_sessions(self.codex_home),
300
+ }
301
+ self.require_idle("resume")
302
+ selected = select_resumable_session(self.codex_home, argument)
303
+ self.resume(selected["rollout_path"], selected["title"])
304
+ return {"kind": "resumed", "state": self.snapshot()}
305
+ if command == "compact":
306
+ self.require_idle("compact")
307
+ if not self.agent.history:
308
+ return {"kind": "compact_empty"}
309
+ result = await self.agent.compact()
310
+ self.publish_state("history")
311
+ return {
312
+ "kind": "compacted",
313
+ "original_item_count": result.original_item_count,
314
+ "retained_item_count": result.retained_item_count,
315
+ "pruned_tool_results": result.pruned_tool_results,
316
+ }
317
+ if command == "fork":
318
+ self.fork()
319
+ return {"kind": "forked", "session_id": self.agent.session_id}
320
+ if command in {"exit", "quit"}:
321
+ await self.close()
322
+ return {"kind": "closed"}
323
+ handler = self.command_handlers.get(command)
324
+ if handler is None:
325
+ raise ValueError("Unknown command: /{0}".format(command))
326
+ return await handler(argument)
327
+
328
+ async def _submit_message(self, text, queue, sender, explicit_queue):
329
+ busy = self.is_busy
330
+ submission_id, future = await self.enqueue_user_turn(text, queue)
331
+ future.add_done_callback(self._observe_completion)
332
+ self._publish(
333
+ InputQueuedEvent(submission_id, text, queue, sender, explicit_queue, busy)
334
+ )
335
+ return SubmittedInput(submission_id, "turn", future)
336
+
337
+ @staticmethod
338
+ def _observe_completion(future):
339
+ if not future.cancelled():
340
+ future.exception()
341
+
342
+ async def _request_user_input(self, payload):
343
+ return await self._request_input("questions", payload)
344
+
345
+ async def _request_permissions(self, payload):
346
+ return await self._request_input("permissions", payload)
347
+
348
+ async def _request_input(self, kind, payload):
349
+ if not self._frontends or not self.agent.accepts_input:
350
+ return None
351
+ if self._input_request is not None:
352
+ raise RuntimeError("a user input request is already pending")
353
+ future = asyncio.get_running_loop().create_future()
354
+ request = _InputRequest(uuid7_string(), kind, payload, future)
355
+ self._input_request = request
356
+ if kind == "questions" and not payload["questions"]:
357
+ self._finish_input_request({"answers": {}})
358
+ else:
359
+ self._publish_input_request()
360
+ timeout = payload.get("autoResolutionMs")
361
+ request.timer = (
362
+ None
363
+ if timeout is None
364
+ else asyncio.get_running_loop().call_later(
365
+ timeout / 1000.0,
366
+ self._finish_input_request,
367
+ None,
368
+ )
369
+ )
370
+ try:
371
+ return await future
372
+ finally:
373
+ if request.timer is not None:
374
+ request.timer.cancel()
375
+ if self._input_request is request:
376
+ self._input_request = None
377
+ self._publish(InputResolvedEvent(request.request_id))
378
+
379
+ def _input_request_event(self):
380
+ request = self._input_request
381
+ if request is None:
382
+ return None
383
+ if request.kind == "questions":
384
+ return InputRequestedEvent(
385
+ request.request_id,
386
+ request.kind,
387
+ request.other,
388
+ question=request.payload["questions"][request.question_index],
389
+ )
390
+ return InputRequestedEvent(
391
+ request.request_id,
392
+ request.kind,
393
+ request.other,
394
+ permissions=request.payload,
395
+ )
396
+
397
+ def _publish_input_request(self):
398
+ self._publish(self._input_request_event())
399
+
400
+ def _answer_input(self, text):
401
+ request = self._input_request
402
+ if not text:
403
+ self._finish_input_request(None)
404
+ return
405
+ if request.kind == "permissions":
406
+ answer = text.lower()
407
+ granted = answer in {"t", "turn", "y", "yes", "s", "session"}
408
+ self._finish_input_request(
409
+ {
410
+ "permissions": (
411
+ request.payload.get("permissions", {}) if granted else {}
412
+ ),
413
+ "scope": "session" if answer in {"s", "session"} else "turn",
414
+ }
415
+ )
416
+ return
417
+ question = request.payload["questions"][request.question_index]
418
+ options = question["options"]
419
+ if not request.other and text.isdigit():
420
+ choice = int(text)
421
+ if choice == 0:
422
+ request.other = True
423
+ self._publish_input_request()
424
+ return
425
+ if 1 <= choice <= len(options):
426
+ text = options[choice - 1]["label"]
427
+ request.answers[question["id"]] = {"answers": [text]}
428
+ request.question_index += 1
429
+ request.other = False
430
+ if request.question_index == len(request.payload["questions"]):
431
+ self._finish_input_request({"answers": request.answers})
432
+ else:
433
+ self._publish_input_request()
434
+
435
+ def answer_input(self, request_id, answer):
436
+ if self._input_request is None or self._input_request.request_id != request_id:
437
+ raise ValueError("input request is no longer pending")
438
+ self._finish_input_request(answer)
439
+
440
+ def _finish_input_request(self, answer):
441
+ request, self._input_request = self._input_request, None
442
+ if request.timer is not None:
443
+ request.timer.cancel()
444
+ request.future.set_result(answer)
445
+ self._publish(InputResolvedEvent(request.request_id))
36
446
 
37
- async def submit_user_turn(self, text: 'str') -> 'TurnResult':
447
+ @property
448
+ def accepts_input(self) -> "bool":
449
+ return self.agent.accepts_input
450
+
451
+ @property
452
+ def is_busy(self) -> "bool":
453
+ return (
454
+ self.agent.is_running
455
+ or self._current_submission is not None
456
+ or bool(self._steer_queue or self._enqueue_queue)
457
+ or (not self.accepts_input and not self.agent.is_shutdown)
458
+ )
459
+
460
+ async def submit_user_turn(self, text: "str") -> "TurnResult":
38
461
  _submission_id, future = await self.enqueue_user_turn(text, queue="enqueue")
39
- result = await future
40
- assert result is not None
41
- return result
462
+ return await future
42
463
 
43
464
  async def enqueue_user_turn(
44
465
  self,
45
- text: 'str',
466
+ text: "str",
46
467
  queue: 'Literal["enqueue", "steer"]' = "enqueue",
47
- ) -> 'typing.Tuple[str, asyncio.Future[typing.Union[TurnResult, None]]]':
48
- future: 'asyncio.Future[typing.Union[TurnResult, None]]' = asyncio.get_running_loop().create_future()
49
- return await self._enqueue_user_turn_to_queue(
50
- text,
51
- future,
52
- queue=queue,
468
+ ) -> "typing.Tuple[str, asyncio.Future[TurnResult]]":
469
+ if queue not in {"enqueue", "steer"}:
470
+ raise ValueError(f"unknown submission queue: {queue}")
471
+ if not self.agent.accepts_input:
472
+ raise RuntimeError("agent is shutting down")
473
+ if queue == "steer":
474
+ self.agent.stop_asap()
475
+ future: "asyncio.Future[TurnResult]" = (
476
+ asyncio.get_running_loop().create_future()
53
477
  )
478
+ if queue == "steer" and self._steer_queue:
479
+ queued = self._steer_queue[-1]
480
+ queued.texts.append(text)
481
+ queued.futures.append(future)
482
+ return queued.submission_id, future
54
483
 
55
- async def shutdown(self) -> 'None':
56
- self._agent.shutdown()
57
- submission = Submission(id=uuid7_string(), op=ShutdownOp())
58
- future: 'asyncio.Future[typing.Union[TurnResult, None]]' = asyncio.get_running_loop().create_future()
59
- self._enqueue_queue.append(
60
- _QueuedSubmission(
61
- submission=submission,
62
- turn_id=submission.id,
63
- futures=[future],
64
- )
484
+ submission_id = uuid7_string()
485
+ current = self._current_submission if self.agent.is_running else None
486
+ queued = _QueuedSubmission(
487
+ submission_id=submission_id,
488
+ turn_id=(
489
+ current.turn_id
490
+ if queue == "steer" and current is not None
491
+ else submission_id
492
+ ),
493
+ texts=[text],
494
+ futures=[future],
65
495
  )
66
- self._queue_event.set()
67
- await future
68
-
69
- def cancel_current(self) -> 'None':
70
- exc = RuntimeError("submission interrupted")
71
- current_task = self._current_task
72
- if current_task is not None and not current_task.done():
73
- current_task.cancel()
74
- for queued in tuple(self._enqueue_queue):
75
- self._finish_submission_exception(queued, exc)
76
- for queued in tuple(self._steer_queue):
77
- self._finish_submission_exception(queued, exc)
78
- self._enqueue_queue.clear()
79
- self._steer_queue.clear()
80
- self._queue_event.set()
81
-
82
- async def run_forever(self) -> 'None':
496
+ target = self._steer_queue if queue == "steer" else self._enqueue_queue
497
+ target.append(queued)
498
+ asyncio.get_running_loop().call_soon(self._wake_worker)
499
+ return submission_id, future
500
+
501
+ async def _run_forever(self) -> "None":
502
+ self._queue_event = asyncio.Event()
83
503
  while True:
84
504
  queued = await self._next_submission()
85
- submission = queued.submission
505
+ if queued is None:
506
+ break
86
507
  self._current_submission = queued
87
508
  try:
88
- if isinstance(submission.op, UserTurnOp):
89
- self._current_task = asyncio.create_task(
90
- self._agent.run_turn(
91
- list(submission.op.texts),
92
- turn_id=queued.turn_id,
93
- )
94
- )
95
- try:
96
- result = await self._current_task
97
- except TurnInterrupted:
98
- self._finish_submission_exception(
99
- queued,
100
- RuntimeError("submission interrupted"),
101
- )
102
- continue
103
- except asyncio.CancelledError:
104
- self._finish_submission_exception(
105
- queued,
106
- RuntimeError("submission interrupted"),
107
- )
108
- continue
109
- self._finish_submission_result(queued, result)
110
- continue
111
-
112
- if isinstance(submission.op, ShutdownOp):
113
- self._agent.shutdown()
114
- self._finish_submission_result(queued, None)
115
- break
116
-
117
- self._finish_submission_exception(
118
- queued,
119
- RuntimeError(f"unsupported operation: {type(submission.op).__name__}"),
509
+ result = await self.agent.run_turn(
510
+ list(queued.texts), turn_id=queued.turn_id
120
511
  )
121
- except Exception as exc: # pragma: no cover - defensive wrapper
512
+ except TurnInterrupted:
513
+ self._finish_submission_exception(queued, SubmissionInterrupted())
514
+ except Exception as exc:
122
515
  self._finish_submission_exception(queued, exc)
516
+ else:
517
+ self._finish_submission_result(queued, result)
123
518
  finally:
124
- self._current_task = None
125
519
  self._current_submission = None
520
+ try:
521
+ async with self._get_command_lock():
522
+ await self._close_resources()
523
+ finally:
524
+ self.agent.shutdown()
525
+ self._publish(SessionClosedEvent())
126
526
 
127
- @staticmethod
128
- def operation_name(op: 'Operation') -> 'str':
129
- if isinstance(op, UserTurnOp):
130
- return "user_turn"
131
- if isinstance(op, ShutdownOp):
132
- return "shutdown"
133
- return type(op).__name__
134
-
135
- async def _enqueue_user_turn_to_queue(
136
- self,
137
- text: 'str',
138
- future: 'asyncio.Future[typing.Union[TurnResult, None]]',
139
- queue: 'Literal["enqueue", "steer"]',
140
- ) -> 'typing.Tuple[str, asyncio.Future[typing.Union[TurnResult, None]]]':
141
- if queue == "steer" and self._has_active_turn():
142
- self._agent.interrupt_asap = True
143
-
144
- async with self._queue_lock:
145
- if queue == "steer" and self._steer_queue:
146
- queued = self._steer_queue[-1]
147
- queued.submission.op.texts.append(text)
148
- queued.futures.append(future)
149
- return queued.submission.id, future
150
-
151
- submission = Submission(id=uuid7_string(), op=UserTurnOp(texts=[text]))
152
- current = self._current_submission if self._has_active_turn() else None
153
- turn_id = (
154
- current.turn_id if queue == "steer" and current is not None else submission.id
155
- )
156
- queued = _QueuedSubmission(
157
- submission=submission,
158
- turn_id=turn_id,
159
- futures=[future],
160
- )
161
- if queue == "steer":
162
- self._steer_queue.append(queued)
163
- else:
164
- self._enqueue_queue.append(queued)
165
- self._queue_event.set()
166
- return submission.id, future
527
+ async def _close_resources(self):
528
+ errors = []
529
+ environment = self.agent.tool_registry.runtime_environment
530
+ for handler in [environment.subagent_manager.shutdown] + self._close_handlers:
531
+ try:
532
+ await handler()
533
+ except Exception as exc:
534
+ errors.append(exc)
535
+ if errors:
536
+ for error in errors[1:]:
537
+ asyncio.get_running_loop().call_exception_handler(
538
+ {
539
+ "message": "Session close handler failed",
540
+ "exception": error,
541
+ }
542
+ )
543
+ raise errors[0]
167
544
 
168
- async def _next_submission(self) -> '_QueuedSubmission':
545
+ async def _next_submission(self) -> "typing.Union[_QueuedSubmission, None]":
169
546
  while True:
170
- if self._agent._turn_running and not self._has_queue_active_turn():
171
- await self._wait_for_agent_idle()
172
- continue
173
- async with self._queue_lock:
174
- queued: 'typing.Union[_QueuedSubmission, None]' = None
175
- if self._steer_queue:
176
- queued = self._steer_queue.popleft()
177
- elif self._enqueue_queue:
178
- queued = self._enqueue_queue.popleft()
179
- if queued is not None:
180
- if not self._steer_queue and not self._enqueue_queue:
181
- self._queue_event.clear()
182
- return queued
183
- self._queue_event.clear()
547
+ await self.agent.wait_until_idle()
548
+ if self._steer_queue:
549
+ return self._steer_queue.popleft()
550
+ if self._enqueue_queue:
551
+ return self._enqueue_queue.popleft()
552
+ if not self.accepts_input:
553
+ return None
554
+ self._queue_event.clear()
184
555
  await self._queue_event.wait()
185
556
 
186
557
  @staticmethod
187
558
  def _finish_submission_result(
188
- queued: '_QueuedSubmission',
189
- result: 'typing.Union[TurnResult, None]',
190
- ) -> 'None':
559
+ queued: "_QueuedSubmission",
560
+ result: "TurnResult",
561
+ ) -> "None":
191
562
  for future in queued.futures:
192
563
  if not future.done():
193
564
  future.set_result(result)
194
565
 
195
566
  @staticmethod
196
567
  def _finish_submission_exception(
197
- queued: '_QueuedSubmission',
198
- exc: 'Exception',
199
- ) -> 'None':
568
+ queued: "_QueuedSubmission",
569
+ exc: "Exception",
570
+ ) -> "None":
200
571
  for future in queued.futures:
201
572
  if not future.done():
202
573
  future.set_exception(exc)
203
574
 
204
- def _has_active_turn(self) -> 'bool':
205
- return self._has_queue_active_turn() or self._agent._turn_running
206
-
207
- def _has_queue_active_turn(self) -> 'bool':
208
- current_task = self._current_task
209
- return current_task is not None and not current_task.done()
575
+ def _handle_agent_event(self, event: "TurnEvent") -> "None":
576
+ queued = self._current_submission
577
+ if queued is not None and event.turn_id == queued.turn_id:
578
+ event = replace(
579
+ event, submission_id=event.submission_id or queued.submission_id
580
+ )
581
+ if isinstance(event, TurnStartedEvent):
582
+ self._active_turn = {
583
+ "turn_id": event.turn_id,
584
+ "submission_id": event.submission_id or event.turn_id,
585
+ "user_text": "\n".join(event.user_texts),
586
+ "user_texts": list(event.user_texts),
587
+ "assistant_text": "",
588
+ "completed_history": conversation_history_to_turns(self.agent.history),
589
+ }
590
+ elif self._active_turn is not None:
591
+ if isinstance(event, AssistantDeltaEvent):
592
+ self._active_turn["assistant_text"] += event.delta
593
+ elif isinstance(event, (StreamErrorEvent, ToolCompletedEvent)):
594
+ self._active_turn["assistant_text"] = ""
595
+ elif isinstance(event, AutoCompactCompletedEvent):
596
+ self._active_turn["completed_history"] = conversation_history_to_turns(
597
+ self.agent.history
598
+ )
599
+ elif isinstance(
600
+ event, (TurnCompletedEvent, TurnFailedEvent, TurnInterruptedEvent)
601
+ ):
602
+ self._active_turn = None
603
+ if isinstance(event, TurnStartedEvent) and not self.title:
604
+ prompt = "\n".join(event.user_texts)
605
+ if prompt:
606
+ self.title = shorten_title(prompt)
607
+ self.publish_state("auto_title")
608
+ if isinstance(event, TerminalEvent) and event.background_work_count is not None:
609
+ self._background_work_count = event.background_work_count
610
+ if self.agent.recorded_session_file_path != self._recorded_rollout_path:
611
+ self.publish_state("recording")
612
+ self._publish(event)
210
613
 
211
- async def _wait_for_agent_idle(self) -> 'None':
212
- while self._agent._turn_running:
213
- await asyncio.sleep(0.01)
614
+ def _publish(self, event: "Event"):
615
+ for handler in (self.event_handler,) + tuple(self._frontends.values()):
616
+ self._notify_handler(handler, event)
214
617
 
215
- def _handle_agent_event(self, event: 'AgentEvent') -> 'None':
216
- queued = self._current_submission
217
- if queued is None:
218
- self._event_handler(event)
219
- return
220
- payload = dict(event.payload)
221
- payload.setdefault("submission_id", queued.submission.id)
222
- payload.setdefault("turn_id", queued.turn_id)
223
- self._event_handler(
224
- AgentEvent(kind=event.kind, turn_id=event.turn_id, payload=payload)
225
- )
618
+ @staticmethod
619
+ def _notify_handler(handler, event):
620
+ try:
621
+ handler(event)
622
+ except (Exception, asyncio.CancelledError) as exc:
623
+ asyncio.get_running_loop().call_exception_handler(
624
+ {
625
+ "message": "Submission event observer failed: " + event.kind,
626
+ "exception": exc,
627
+ }
628
+ )