python-codex 0.2.6__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycodex/__init__.py +18 -14
- pycodex/agent.py +468 -462
- pycodex/bootstrap.py +417 -0
- pycodex/cli.py +236 -436
- pycodex/compat.py +19 -5
- pycodex/context.py +222 -212
- pycodex/doctor.py +52 -48
- pycodex/events.py +857 -0
- pycodex/feishu_card.py +217 -163
- pycodex/feishu_link.py +43 -83
- pycodex/model.py +329 -252
- pycodex/model_metadata.py +19 -7
- pycodex/portable.py +90 -52
- pycodex/portable_server.py +32 -24
- pycodex/prompts/models.json +235 -803
- pycodex/protocol.py +177 -137
- pycodex/runtime.py +579 -174
- pycodex/runtime_services.py +204 -157
- pycodex/tools/__init__.py +4 -1
- pycodex/tools/apply_patch_tool.py +69 -48
- pycodex/tools/base_tool.py +89 -42
- pycodex/tools/clock_tool.py +201 -0
- pycodex/tools/close_agent_tool.py +2 -2
- pycodex/tools/code_mode_manager.py +77 -64
- pycodex/tools/exec_command_tool.py +26 -11
- pycodex/tools/exec_tool.py +4 -4
- pycodex/tools/grep_files_tool.py +12 -10
- pycodex/tools/ipython_tool.py +10 -13
- pycodex/tools/list_dir_tool.py +13 -9
- pycodex/tools/read_file_tool.py +29 -17
- pycodex/tools/request_permissions_tool.py +15 -5
- pycodex/tools/request_user_input_tool.py +13 -104
- pycodex/tools/resume_agent_tool.py +2 -2
- pycodex/tools/send_input_tool.py +11 -8
- pycodex/tools/shell_command_tool.py +7 -5
- pycodex/tools/shell_tool.py +7 -5
- pycodex/tools/spawn_agent_tool.py +7 -4
- pycodex/tools/unified_exec_manager.py +102 -69
- pycodex/tools/update_plan_tool.py +8 -5
- pycodex/tools/view_image_tool.py +13 -13
- pycodex/tools/wait_agent_tool.py +27 -4
- pycodex/tools/wait_tool.py +5 -4
- pycodex/tools/web_search_tool.py +4 -2
- pycodex/tools/write_stdin_tool.py +12 -11
- pycodex/utils/__init__.py +2 -17
- pycodex/utils/compactor.py +50 -66
- pycodex/utils/debug.py +2 -2
- pycodex/utils/dotenv.py +6 -7
- pycodex/utils/event_helpers.py +190 -0
- pycodex/utils/get_env.py +27 -70
- pycodex/utils/image_utils.py +76 -0
- pycodex/utils/random_ids.py +1 -2
- pycodex/utils/session_persist.py +263 -161
- pycodex/utils/truncation.py +21 -45
- python_codex-0.3.0.dist-info/METADATA +704 -0
- python_codex-0.3.0.dist-info/RECORD +90 -0
- responses_server/__init__.py +1 -5
- responses_server/__main__.py +0 -1
- responses_server/app.py +36 -31
- responses_server/config.py +25 -22
- responses_server/messages_api.py +96 -49
- responses_server/payload_processors.py +25 -19
- responses_server/server.py +11 -11
- responses_server/session_store.py +14 -11
- responses_server/stream_router.py +196 -107
- responses_server/tools/custom_adapter.py +17 -16
- responses_server/tools/web_search.py +39 -36
- responses_server/trajectory_dump.py +51 -13
- workspace_server/__main__.py +0 -1
- workspace_server/app.py +470 -384
- workspace_server/workspace.html +859 -232
- workspace_server/workspaces.html +94 -95
- workspace_server/workspaces.py +168 -100
- pycodex/collaboration.py +0 -20
- pycodex/interactive_session.py +0 -415
- pycodex/prompts/collaboration_default.md +0 -11
- pycodex/prompts/collaboration_plan.md +0 -128
- pycodex/utils/toolcall_visualize.py +0 -713
- pycodex/utils/visualize.py +0 -553
- python_codex-0.2.6.dist-info/METADATA +0 -441
- python_codex-0.2.6.dist-info/RECORD +0 -91
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.6.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())
|