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/events.py ADDED
@@ -0,0 +1,857 @@
1
+ """Typed events, plain-text views and stateful presentation without frontend I/O."""
2
+
3
+ import json
4
+ import re
5
+ import shlex
6
+ import typing
7
+ from dataclasses import dataclass
8
+ from typing import ClassVar
9
+
10
+ from .protocol import JSONDict, ToolCall, ToolResult
11
+ from .utils.event_helpers import (
12
+ agent_status_summary,
13
+ colorize_cli_message,
14
+ colorize_tool_message,
15
+ compact_summary,
16
+ format_command_result,
17
+ format_error,
18
+ percent_of_context_window_remaining,
19
+ short_id,
20
+ shorten_title,
21
+ truncate_text,
22
+ )
23
+
24
+ DEFAULT_MAIN_PROMPT = "pycodex> "
25
+ IDLE_SLEEPING_STATUS = "idle: sleeping"
26
+
27
+
28
+ class EventDisplay:
29
+ """Per-subscriber presentation state with log, status and prompt executors."""
30
+
31
+ def __init__(
32
+ self,
33
+ log,
34
+ set_status,
35
+ set_prompt,
36
+ color_enabled=False,
37
+ context_window_tokens=None,
38
+ ):
39
+ self.log = log
40
+ self.set_status = set_status
41
+ self.set_prompt = set_prompt
42
+ self.color_enabled = color_enabled
43
+ self.title = None
44
+ self.stream_buffer = ""
45
+ self.queued_steer_prompts = {}
46
+ self.inserted_steer_prompts = {}
47
+ self.agent_names = {}
48
+ self.closed = False
49
+ self.set_context_window_tokens(context_window_tokens)
50
+
51
+ def start(self, commands):
52
+ self.log(
53
+ "pycodex interactive mode. Type /exit or press Ctrl+C to quit; "
54
+ "press Ctrl+C again while closing to force exit."
55
+ )
56
+ self.log(format_command_result({"kind": "help", "commands": commands}))
57
+
58
+ def write(self, text, kind=""):
59
+ self.log(colorize_cli_message(text, kind, self.color_enabled))
60
+
61
+ def finish_stream(self):
62
+ if self.stream_buffer:
63
+ self.log("assistant> " + self.stream_buffer)
64
+ self.stream_buffer = ""
65
+
66
+ def show_error(self, text):
67
+ self.finish_stream()
68
+ self.write(format_error(text), "error")
69
+
70
+ def show_title(self):
71
+ self.finish_stream()
72
+ self.log("Session: " + (self.title or "untitled"))
73
+
74
+ def begin_turn(self, event):
75
+ self.finish_stream()
76
+ self.set_status(event.kind)
77
+ submission_id = event.submission_id or event.turn_id
78
+ for prompts in (self.inserted_steer_prompts, self.queued_steer_prompts):
79
+ for prompt in prompts.pop(submission_id, []):
80
+ self.write("[steer] inserted: " + prompt, "status")
81
+ text = event.visualize().strip()
82
+ if text:
83
+ self.write("user> " + text, "assistant")
84
+
85
+ def set_idle_status(self, background_count):
86
+ self.set_status(IDLE_SLEEPING_STATUS if (background_count or 0) > 0 else None)
87
+
88
+ def set_context_window_tokens(self, context_window_tokens):
89
+ self.context_window_tokens = context_window_tokens
90
+ self.context_remaining_percent = (
91
+ 100 if context_window_tokens is not None else None
92
+ )
93
+
94
+ def update_context_window(self, usage):
95
+ if self.context_window_tokens is None:
96
+ return
97
+ if not isinstance(usage, dict):
98
+ self.context_remaining_percent = None
99
+ return
100
+ try:
101
+ total_tokens = int(usage["total_tokens"])
102
+ except (KeyError, TypeError, ValueError):
103
+ self.context_remaining_percent = None
104
+ return
105
+ self.context_remaining_percent = percent_of_context_window_remaining(
106
+ total_tokens,
107
+ self.context_window_tokens,
108
+ )
109
+
110
+ def main_prompt(self):
111
+ if self.context_remaining_percent is None:
112
+ return DEFAULT_MAIN_PROMPT
113
+ return f"pyco({self.context_remaining_percent}%)> "
114
+
115
+ def remember_agent_name(self, summary):
116
+ if " (" not in summary or not summary.endswith(")"):
117
+ return
118
+ nickname, rest = summary.rsplit(" (", 1)
119
+ agent_short_id = rest[:-1].strip()
120
+ nickname = nickname.strip()
121
+ if nickname and agent_short_id:
122
+ self.agent_names[agent_short_id] = nickname
123
+
124
+ def replace_agent_ids_with_names(self, message):
125
+ for agent_short_id, nickname in sorted(
126
+ self.agent_names.items(),
127
+ key=lambda item: len(item[0]),
128
+ reverse=True,
129
+ ):
130
+ message = message.replace(agent_short_id, nickname)
131
+ return message
132
+
133
+ @staticmethod
134
+ def status_frame(text, frame_index):
135
+ if text is None:
136
+ return None
137
+ frames = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
138
+ return f"{frames[frame_index % len(frames)]} {text}"
139
+
140
+
141
+ class Event:
142
+ kind: ClassVar[str]
143
+
144
+ def visualize(self) -> "str":
145
+ return ""
146
+
147
+ def render(self, display: "EventDisplay") -> "None":
148
+ pass
149
+
150
+
151
+ class TurnEvent(Event):
152
+ turn_id: "str"
153
+ submission_id: "typing.Union[str, None]"
154
+
155
+
156
+ class ModelEvent(TurnEvent):
157
+ """Model stream notification; Agent supplies turn identity before dispatch."""
158
+
159
+
160
+ class TerminalEvent(TurnEvent):
161
+ background_work_count: "typing.Union[int, None]"
162
+
163
+ def render(self, display: "EventDisplay") -> "None":
164
+ display.set_idle_status(self.background_work_count)
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class TurnStartedEvent(TurnEvent):
169
+ turn_id: "str"
170
+ user_texts: "typing.Tuple[str, ...]"
171
+ submission_id: "typing.Union[str, None]" = None
172
+ kind: ClassVar[str] = "turn_started"
173
+
174
+ def visualize(self) -> "str":
175
+ return "\n".join(self.user_texts)
176
+
177
+ def render(self, display: "EventDisplay") -> "None":
178
+ display.begin_turn(self)
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class TurnCompletedEvent(TerminalEvent):
183
+ turn_id: "str"
184
+ iteration: "int"
185
+ output_text: "typing.Union[str, None]"
186
+ background_work_count: "typing.Union[int, None]"
187
+ submission_id: "typing.Union[str, None]" = None
188
+ kind: ClassVar[str] = "turn_completed"
189
+
190
+ def visualize(self) -> "str":
191
+ return self.output_text or ""
192
+
193
+ def render(self, display: "EventDisplay") -> "None":
194
+ text = self.visualize()
195
+ if text:
196
+ if text.startswith(display.stream_buffer):
197
+ display.stream_buffer += text[len(display.stream_buffer) :]
198
+ else:
199
+ display.stream_buffer += text
200
+ display.finish_stream()
201
+ super().render(display)
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class TurnFailedEvent(TerminalEvent):
206
+ turn_id: "str"
207
+ iteration: "int"
208
+ error: "str"
209
+ error_type: "str"
210
+ background_work_count: "typing.Union[int, None]"
211
+ submission_id: "typing.Union[str, None]" = None
212
+ kind: ClassVar[str] = "turn_failed"
213
+
214
+ def visualize(self) -> "str":
215
+ return self.error
216
+
217
+ def render(self, display: "EventDisplay") -> "None":
218
+ display.show_error(self.visualize())
219
+ super().render(display)
220
+
221
+
222
+ @dataclass(frozen=True)
223
+ class ModelCalledEvent(TurnEvent):
224
+ turn_id: "str"
225
+ iteration: "int"
226
+ history_size: "int"
227
+ tool_count: "int"
228
+ submission_id: "typing.Union[str, None]" = None
229
+ kind: ClassVar[str] = "model_called"
230
+
231
+ def render(self, display: "EventDisplay") -> "None":
232
+ display.finish_stream()
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class ModelCompletedEvent(TurnEvent):
237
+ turn_id: "str"
238
+ iteration: "int"
239
+ item_count: "int"
240
+ submission_id: "typing.Union[str, None]" = None
241
+ kind: ClassVar[str] = "model_completed"
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class AssistantDeltaEvent(ModelEvent):
246
+ delta: "str"
247
+ turn_id: "str" = ""
248
+ submission_id: "typing.Union[str, None]" = None
249
+ kind: ClassVar[str] = "assistant_delta"
250
+
251
+ def visualize(self) -> "str":
252
+ return self.delta
253
+
254
+ def render(self, display: "EventDisplay") -> "None":
255
+ display.stream_buffer += self.visualize()
256
+ display.set_status("talking")
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class TokenCountEvent(ModelEvent):
261
+ usage: "typing.Union[JSONDict, None]"
262
+ turn_id: "str" = ""
263
+ submission_id: "typing.Union[str, None]" = None
264
+ kind: ClassVar[str] = "token_count"
265
+
266
+ def render(self, display: "EventDisplay") -> "None":
267
+ display.update_context_window(self.usage)
268
+ display.set_prompt(display.main_prompt())
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class StreamErrorEvent(ModelEvent):
273
+ message: "str"
274
+ attempt: "int"
275
+ max_retries: "int"
276
+ delay_seconds: "float"
277
+ error: "str"
278
+ turn_id: "str" = ""
279
+ submission_id: "typing.Union[str, None]" = None
280
+ kind: ClassVar[str] = "stream_error"
281
+
282
+ def visualize(self) -> "str":
283
+ return self.message
284
+
285
+ def render(self, display: "EventDisplay") -> "None":
286
+ display.stream_buffer = ""
287
+ display.write("[status] " + self.visualize(), "status")
288
+ display.set_status("reconnecting")
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class ToolCalledEvent(ModelEvent):
293
+ call_id: "str"
294
+ tool_name: "str"
295
+ action_type: "typing.Union[str, None]" = None
296
+ query: "typing.Union[str, None]" = None
297
+ queries: "typing.Union[typing.Tuple[str, ...], None]" = None
298
+ url: "typing.Union[str, None]" = None
299
+ pattern: "typing.Union[str, None]" = None
300
+ turn_id: "str" = ""
301
+ submission_id: "typing.Union[str, None]" = None
302
+ kind: ClassVar[str] = "tool_called"
303
+
304
+ def visualize(self) -> "str":
305
+ if self.tool_name != "web_search":
306
+ return ""
307
+ if self.action_type == "search":
308
+ query = (self.query or "").strip()
309
+ if not query and self.queries:
310
+ query = self.queries[0].strip()
311
+ return (
312
+ f"[web_search] searched: {query}" if query else "[web_search] searched"
313
+ )
314
+ if self.action_type == "open_page":
315
+ url = (self.url or "").strip()
316
+ return f"[web_search] opened: {url}" if url else "[web_search] opened"
317
+ if self.action_type == "find_in_page":
318
+ pattern = (self.pattern or "").strip()
319
+ url = (self.url or "").strip()
320
+ if pattern and url:
321
+ return f"[web_search] found: {pattern} @ {url}"
322
+ if pattern:
323
+ return f"[web_search] found: {pattern}"
324
+ return "[web_search] found in page"
325
+ return "[web_search] browsing"
326
+
327
+ def render(self, display: "EventDisplay") -> "None":
328
+ display.finish_stream()
329
+ message = self.visualize()
330
+ if message:
331
+ display.log(colorize_tool_message(message, display.color_enabled))
332
+
333
+
334
+ @dataclass(frozen=True)
335
+ class ToolStartedEvent(TurnEvent):
336
+ turn_id: "str"
337
+ call: "ToolCall"
338
+ submission_id: "typing.Union[str, None]" = None
339
+ kind: ClassVar[str] = "tool_started"
340
+
341
+ def visualize(self) -> "str":
342
+ return f"calling {self.call.name}({self.call.arguments})"
343
+
344
+ def render(self, display: "EventDisplay") -> "None":
345
+ display.finish_stream()
346
+ display.set_status(shorten_title(self.visualize(), 72))
347
+
348
+
349
+ @dataclass(frozen=True)
350
+ class ToolCompletedEvent(TurnEvent):
351
+ turn_id: "str"
352
+ call: "ToolCall"
353
+ result: "ToolResult"
354
+ submission_id: "typing.Union[str, None]" = None
355
+ kind: ClassVar[str] = "tool_completed"
356
+
357
+ def render(self, display: "EventDisplay") -> "None":
358
+ display.finish_stream()
359
+ name = self.call.name
360
+ message = self.visualize()
361
+ if name:
362
+ display.set_status("called " + name)
363
+ if name in {"wait_agent", "send_input", "resume_agent", "close_agent"}:
364
+ message = display.replace_agent_ids_with_names(message)
365
+ if name == "spawn_agent":
366
+ summary = message
367
+ prefix = "[spawn_agent] spawned "
368
+ if summary.startswith(prefix):
369
+ summary = summary[len(prefix) :]
370
+ display.remember_agent_name(summary)
371
+ for line in message.splitlines() or [""]:
372
+ display.log(colorize_tool_message(line, display.color_enabled, name))
373
+
374
+ def visualize(self) -> "str":
375
+ name = self.call.name
376
+ summary = self._summary()
377
+ if self.result.is_error:
378
+ return f"[error] {name} failed" + (f": {summary}" if summary else "")
379
+ if name == "spawn_agent":
380
+ return "[spawn_agent] spawned" + (f" {summary}" if summary else "")
381
+ if name == "update_plan":
382
+ lines = ["[update_plan] " + (summary or "Plan updated")]
383
+ plan = (
384
+ self.call.arguments.get("plan")
385
+ if isinstance(self.call.arguments, dict)
386
+ else None
387
+ )
388
+ if isinstance(plan, list):
389
+ for item in plan:
390
+ if not isinstance(item, dict):
391
+ continue
392
+ step = str(item.get("step", "")).strip()
393
+ status = str(item.get("status", "")).strip()
394
+ if step:
395
+ marker = (
396
+ "[x]"
397
+ if status == "completed"
398
+ else ("[>]" if status == "in_progress" else "[ ]")
399
+ )
400
+ lines.append(f" {marker} {step}")
401
+ return "\n".join(lines)
402
+ return f"[{name}]" + (f" {summary}" if summary else "")
403
+
404
+ def _summary(self) -> "str":
405
+ name = self.call.name
406
+ arguments = self.call.arguments if isinstance(self.call.arguments, dict) else {}
407
+ output = self.result.output
408
+ call_summary = ""
409
+ result_summary = None
410
+ if name == "exec_command":
411
+ command = arguments.get("cmd")
412
+ if command not in (None, ""):
413
+ command = str(command)
414
+ call_summary = truncate_text(command, 200)
415
+ lines = command.splitlines()
416
+ match = re.search(
417
+ r"\bpython(?:\d+(?:\.\d+)?)?\s+-\s+<<[-]?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1",
418
+ lines[0] if lines else "",
419
+ )
420
+ if match is not None:
421
+ for index, line in enumerate(lines[1:], 1):
422
+ if line.strip() == match.group(2):
423
+ call_summary = "\n".join(lines[: index + 1])
424
+ break
425
+ marker = "Process running with session ID "
426
+ for line in self.result.output_text().splitlines():
427
+ line = line.strip()
428
+ if line.startswith(marker) and line[len(marker) :].strip():
429
+ result_summary = "session_id=" + line[len(marker) :].strip()
430
+ break
431
+ elif name == "shell_command":
432
+ command = arguments.get("command")
433
+ if command not in (None, ""):
434
+ call_summary = truncate_text(str(command), 200)
435
+ elif name == "shell":
436
+ command = arguments.get("command")
437
+ if isinstance(command, list) and command:
438
+ call_summary = truncate_text(
439
+ " ".join(shlex.quote(str(part)) for part in command), 200
440
+ )
441
+ elif name == "write_stdin":
442
+ session_id = arguments.get("session_id")
443
+ if session_id not in (None, ""):
444
+ session_id = str(session_id)
445
+ chars = arguments.get("chars") or ""
446
+ call_summary = (
447
+ f"session {session_id} <- {truncate_text(str(chars), 32)}"
448
+ if chars
449
+ else f"poll session {session_id}"
450
+ )
451
+ elif name in {"read_file", "list_dir", "view_image"}:
452
+ key = (
453
+ "file_path"
454
+ if name == "read_file"
455
+ else ("dir_path" if name == "list_dir" else "path")
456
+ )
457
+ path = arguments.get(key)
458
+ if path not in (None, ""):
459
+ call_summary = truncate_text(str(path), 200)
460
+ if name == "view_image" and isinstance(output, list):
461
+ result_summary = f"{len(output)} image item(s)"
462
+ elif name == "grep_files":
463
+ pattern, path = arguments.get("pattern"), arguments.get("path")
464
+ if pattern not in (None, ""):
465
+ call_summary = truncate_text(
466
+ f"{pattern} @ {path}" if path not in (None, "") else str(pattern),
467
+ 200,
468
+ )
469
+ elif name == "update_plan":
470
+ plan = arguments.get("plan")
471
+ if isinstance(plan, list):
472
+ total = len(plan)
473
+ completed = 0
474
+ in_progress = 0
475
+ for item in plan:
476
+ if isinstance(item, dict):
477
+ status = str(item.get("status", "")).strip()
478
+ completed += status == "completed"
479
+ in_progress += status == "in_progress"
480
+ if not total:
481
+ return "0 steps"
482
+ if completed >= total:
483
+ return f"Done {completed}/{total}"
484
+ if in_progress:
485
+ return f"Working on {completed + in_progress}/{total}"
486
+ return f"Planned {completed}/{total}"
487
+ if isinstance(output, dict) and isinstance(output.get("plan"), list):
488
+ return f"{len(output['plan'])} steps"
489
+ elif name == "spawn_agent":
490
+ if isinstance(output, dict):
491
+ agent_id = str(output.get("agent_id", "")).strip()
492
+ nickname = str(output.get("nickname", "")).strip()
493
+ if nickname and agent_id:
494
+ return f"{nickname} ({short_id(agent_id)})"
495
+ elif name == "send_input":
496
+ agent_id, message = arguments.get("id"), arguments.get("message")
497
+ prefix = f"{short_id(str(agent_id))} <- " if agent_id else ""
498
+ call_summary = (
499
+ prefix + truncate_text(str(message), 40)
500
+ if message not in (None, "")
501
+ else prefix.rstrip()
502
+ )
503
+ if isinstance(output, dict):
504
+ submission_id = str(output.get("submission_id", "")).strip()
505
+ if submission_id:
506
+ result_summary = "queued " + short_id(submission_id)
507
+ elif name == "wait_agent":
508
+ if isinstance(output, dict):
509
+ if output.get("timed_out") is True:
510
+ return "timed out"
511
+ status = output.get("status")
512
+ if isinstance(status, dict):
513
+ parts = [
514
+ f"{short_id(agent_id)}={agent_status_summary(agent_status)}"
515
+ for agent_id, agent_status in status.items()
516
+ if isinstance(agent_id, str)
517
+ ]
518
+ if parts:
519
+ return truncate_text(", ".join(parts))
520
+ elif name in {"resume_agent", "close_agent"}:
521
+ agent_id = arguments.get("id")
522
+ if agent_id not in (None, ""):
523
+ call_summary = short_id(str(agent_id))
524
+ if isinstance(output, dict):
525
+ result_summary = agent_status_summary(output.get("status"))
526
+ if result_summary is None:
527
+ if isinstance(output, (dict, list)):
528
+ result_summary = truncate_text(
529
+ json.dumps(output, ensure_ascii=False, separators=(",", ":"))
530
+ )
531
+ else:
532
+ lines = [
533
+ line.strip() for line in self.result.output_text().splitlines()
534
+ ]
535
+ if "Output:" in lines:
536
+ result_summary = next(
537
+ (line for line in lines[lines.index("Output:") + 1 :] if line),
538
+ None,
539
+ )
540
+ if result_summary is None:
541
+ result_summary = next(
542
+ (
543
+ line
544
+ for line in lines
545
+ if line
546
+ and not line.startswith(
547
+ ("Exit code:", "Wall time:", "Command:")
548
+ )
549
+ ),
550
+ "",
551
+ )
552
+ result_summary = truncate_text(result_summary)
553
+ if call_summary and result_summary:
554
+ return f"{call_summary} -> {result_summary}"
555
+ return call_summary or result_summary
556
+
557
+
558
+ @dataclass(frozen=True)
559
+ class CompactStartedEvent(TurnEvent):
560
+ turn_id: "str"
561
+ phase: "str"
562
+ total_tokens: "typing.Union[int, None]"
563
+ token_limit: "typing.Union[int, None]"
564
+ submission_id: "typing.Union[str, None]" = None
565
+ kind: ClassVar[str] = "compact_started"
566
+
567
+ def visualize(self) -> "str":
568
+ return "Compacting conversation history..."
569
+
570
+ def render(self, display: "EventDisplay") -> "None":
571
+ display.log(self.visualize())
572
+ display.set_status("compacting")
573
+
574
+
575
+ @dataclass(frozen=True)
576
+ class CompactCompletedEvent(TerminalEvent):
577
+ turn_id: "str"
578
+ phase: "str"
579
+ total_tokens: "typing.Union[int, None]"
580
+ token_limit: "typing.Union[int, None]"
581
+ original_item_count: "int"
582
+ retained_item_count: "int"
583
+ pruned_tool_results: "int"
584
+ background_work_count: "typing.Union[int, None]"
585
+ submission_id: "typing.Union[str, None]" = None
586
+ kind: ClassVar[str] = "compact_completed"
587
+
588
+ @property
589
+ def summary(self) -> "str":
590
+ return compact_summary(
591
+ self.original_item_count, self.retained_item_count, self.pruned_tool_results
592
+ )
593
+
594
+ def visualize(self) -> "str":
595
+ return self.summary
596
+
597
+
598
+ @dataclass(frozen=True)
599
+ class CompactFailedEvent(TerminalEvent):
600
+ turn_id: "str"
601
+ phase: "str"
602
+ total_tokens: "typing.Union[int, None]"
603
+ token_limit: "typing.Union[int, None]"
604
+ error: "str"
605
+ error_type: "str"
606
+ background_work_count: "typing.Union[int, None]"
607
+ submission_id: "typing.Union[str, None]" = None
608
+ kind: ClassVar[str] = "compact_failed"
609
+
610
+ def visualize(self) -> "str":
611
+ return self.error
612
+
613
+
614
+ @dataclass(frozen=True)
615
+ class AutoCompactStartedEvent(TurnEvent):
616
+ turn_id: "str"
617
+ phase: "str"
618
+ total_tokens: "typing.Union[int, None]"
619
+ token_limit: "typing.Union[int, None]"
620
+ submission_id: "typing.Union[str, None]" = None
621
+ kind: ClassVar[str] = "auto_compact_started"
622
+
623
+ def visualize(self) -> "str":
624
+ if self.total_tokens is not None and self.token_limit is not None:
625
+ return (
626
+ f"[status] auto-compact: {self.total_tokens}/{self.token_limit} tokens"
627
+ )
628
+ return "[status] auto-compact"
629
+
630
+ def render(self, display: "EventDisplay") -> "None":
631
+ display.finish_stream()
632
+ display.write(self.visualize(), "status")
633
+ display.set_status("compacting")
634
+
635
+
636
+ @dataclass(frozen=True)
637
+ class AutoCompactCompletedEvent(TurnEvent):
638
+ turn_id: "str"
639
+ phase: "str"
640
+ total_tokens: "typing.Union[int, None]"
641
+ token_limit: "typing.Union[int, None]"
642
+ original_item_count: "int"
643
+ retained_item_count: "int"
644
+ pruned_tool_results: "int"
645
+ submission_id: "typing.Union[str, None]" = None
646
+ kind: ClassVar[str] = "auto_compact_completed"
647
+
648
+ @property
649
+ def summary(self) -> "str":
650
+ return compact_summary(
651
+ self.original_item_count, self.retained_item_count, self.pruned_tool_results
652
+ )
653
+
654
+ def visualize(self) -> "str":
655
+ return "[status] " + self.summary
656
+
657
+ def render(self, display: "EventDisplay") -> "None":
658
+ display.finish_stream()
659
+ display.write(self.visualize(), "status")
660
+ display.set_status("compacted")
661
+
662
+
663
+ @dataclass(frozen=True)
664
+ class AutoCompactFailedEvent(TurnEvent):
665
+ turn_id: "str"
666
+ phase: "str"
667
+ total_tokens: "typing.Union[int, None]"
668
+ token_limit: "typing.Union[int, None]"
669
+ error: "str"
670
+ error_type: "str"
671
+ submission_id: "typing.Union[str, None]" = None
672
+ kind: ClassVar[str] = "auto_compact_failed"
673
+
674
+ def visualize(self) -> "str":
675
+ error = self.error.strip()
676
+ return "[error] auto-compact failed" + (f": {error}" if error else "")
677
+
678
+ def render(self, display: "EventDisplay") -> "None":
679
+ display.finish_stream()
680
+ display.write(self.visualize(), "error")
681
+
682
+
683
+ @dataclass(frozen=True)
684
+ class TurnInterruptedEvent(TerminalEvent):
685
+ turn_id: "str"
686
+ iteration: "int"
687
+ output_text: "typing.Union[str, None]"
688
+ background_work_count: "typing.Union[int, None]"
689
+ submission_id: "typing.Union[str, None]" = None
690
+ kind: ClassVar[str] = "turn_interrupted"
691
+
692
+ def visualize(self) -> "str":
693
+ return self.output_text or ""
694
+
695
+ def render(self, display: "EventDisplay") -> "None":
696
+ display.finish_stream()
697
+ super().render(display)
698
+
699
+
700
+ @dataclass(frozen=True)
701
+ class SessionStateEvent(Event):
702
+ reason: "str"
703
+ state: "typing.Dict[str, object]"
704
+ kind: ClassVar[str] = "session_state"
705
+
706
+ def render(self, display: "EventDisplay") -> "None":
707
+ state = self.state
708
+ display.title = state["title"] or None
709
+ if self.reason == "admission" and not state["accepts_input"]:
710
+ display.write(
711
+ "[closing] Waiting for accepted work and cleanup to finish.", "status"
712
+ )
713
+ if self.reason in {"attach", "history", "model"}:
714
+ display.set_context_window_tokens(state["context_window"])
715
+ if state["usage_tokens"] is not None:
716
+ display.update_context_window({"total_tokens": state["usage_tokens"]})
717
+ if self.reason in {"attach", "history"}:
718
+ display.finish_stream()
719
+ display.queued_steer_prompts.clear()
720
+ display.inserted_steer_prompts.clear()
721
+ active = state["active_turn"]
722
+ if active is not None:
723
+ TurnStartedEvent(
724
+ active["turn_id"],
725
+ tuple(active["user_texts"]),
726
+ active["submission_id"],
727
+ ).render(display)
728
+ display.stream_buffer = active["assistant_text"]
729
+ if self.reason == "auto_title":
730
+ display.show_title()
731
+ if self.reason == "attach" and state["input_request"] is not None:
732
+ state["input_request"].render(display)
733
+
734
+
735
+ @dataclass(frozen=True)
736
+ class SessionClosedEvent(Event):
737
+ kind: ClassVar[str] = "session_closed"
738
+
739
+ def render(self, display: "EventDisplay") -> "None":
740
+ display.closed = True
741
+ display.finish_stream()
742
+
743
+
744
+ @dataclass(frozen=True)
745
+ class CommandCompletedEvent(Event):
746
+ submission_id: "str"
747
+ command: "str"
748
+ result: "typing.Dict[str, object]"
749
+ sender: "str"
750
+ kind: ClassVar[str] = "command_completed"
751
+
752
+ def visualize(self) -> "str":
753
+ return format_command_result(self.result)
754
+
755
+ def render(self, display: "EventDisplay") -> "None":
756
+ for line in self.visualize().splitlines():
757
+ display.log(line)
758
+
759
+
760
+ @dataclass(frozen=True)
761
+ class CommandFailedEvent(Event):
762
+ submission_id: "str"
763
+ command: "str"
764
+ error: "str"
765
+ sender: "str"
766
+ kind: ClassVar[str] = "command_failed"
767
+
768
+ def visualize(self) -> "str":
769
+ return self.error
770
+
771
+ def render(self, display: "EventDisplay") -> "None":
772
+ display.show_error(self.visualize())
773
+
774
+
775
+ @dataclass(frozen=True)
776
+ class InputQueuedEvent(Event):
777
+ submission_id: "str"
778
+ prompt: "str"
779
+ queue: "str"
780
+ sender: "str"
781
+ explicit_queue: "bool"
782
+ was_busy: "bool"
783
+ kind: ClassVar[str] = "input_queued"
784
+
785
+ def render(self, display: "EventDisplay") -> "None":
786
+ preview = shorten_title(self.prompt, 72)
787
+ if self.explicit_queue:
788
+ display.queued_steer_prompts.setdefault(self.submission_id, []).append(
789
+ preview
790
+ )
791
+ display.write("[steer] queued: " + preview, "status")
792
+ elif self.was_busy:
793
+ display.inserted_steer_prompts.setdefault(self.submission_id, []).append(
794
+ preview
795
+ )
796
+
797
+
798
+ @dataclass(frozen=True)
799
+ class InputRequestedEvent(Event):
800
+ request_id: "str"
801
+ request_kind: "str"
802
+ other: "bool"
803
+ question: "typing.Union[JSONDict, None]" = None
804
+ permissions: "typing.Union[JSONDict, None]" = None
805
+ kind: ClassVar[str] = "input_requested"
806
+
807
+ def render(self, display: "EventDisplay") -> "None":
808
+ display.finish_stream()
809
+ for line in self.visualize().splitlines():
810
+ display.log(line)
811
+ display.set_status(None)
812
+ display.set_prompt(
813
+ "permissions> "
814
+ if self.request_kind == "permissions"
815
+ else ("other> " if self.other else "answer> ")
816
+ )
817
+
818
+ def visualize(self) -> "str":
819
+ if self.request_kind == "permissions":
820
+ lines = ["[request_permissions] user approval required"]
821
+ if self.permissions.get("reason"):
822
+ lines.append("Reason: " + self.permissions["reason"])
823
+ return "\n".join(
824
+ lines
825
+ + [
826
+ "Requested permissions:",
827
+ json.dumps(
828
+ self.permissions.get("permissions", {}),
829
+ ensure_ascii=False,
830
+ indent=2,
831
+ ),
832
+ "Choose: [n] deny / [t] grant for turn / [s] grant for session",
833
+ ]
834
+ )
835
+ if self.other:
836
+ return "Enter your answer (blank to cancel):"
837
+ question = self.question
838
+ return "\n".join(
839
+ [
840
+ "[request_user_input] waiting for user response",
841
+ "[{0}] {1}".format(question["header"], question["question"]),
842
+ ]
843
+ + [
844
+ " {0}. {1} - {2}".format(index, option["label"], option["description"])
845
+ for index, option in enumerate(question["options"], 1)
846
+ ]
847
+ + [" 0. Other"]
848
+ )
849
+
850
+
851
+ @dataclass(frozen=True)
852
+ class InputResolvedEvent(Event):
853
+ request_id: "str"
854
+ kind: ClassVar[str] = "input_resolved"
855
+
856
+ def render(self, display: "EventDisplay") -> "None":
857
+ display.set_prompt(display.main_prompt())