python-codex 0.1.13__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycodex/agent.py +71 -11
- pycodex/cli.py +16 -356
- pycodex/context.py +12 -0
- pycodex/feishu_card.py +76 -30
- pycodex/feishu_link.py +131 -11
- pycodex/interactive_session.py +397 -0
- pycodex/model.py +11 -22
- pycodex/protocol.py +0 -5
- pycodex/runtime.py +23 -0
- pycodex/runtime_services.py +2 -2
- pycodex/tools/agent_tool_schemas.py +1 -1
- pycodex/tools/apply_patch_tool.py +1 -1
- pycodex/tools/base_tool.py +1 -27
- pycodex/tools/close_agent_tool.py +11 -4
- pycodex/tools/code_mode_manager.py +1 -1
- pycodex/tools/exec_command_tool.py +40 -16
- pycodex/tools/exec_tool.py +18 -2
- pycodex/tools/grep_files_tool.py +19 -6
- pycodex/tools/ipython_tool.py +3 -2
- pycodex/tools/list_dir_tool.py +19 -6
- pycodex/tools/read_file_tool.py +39 -9
- pycodex/tools/request_permissions_tool.py +12 -1
- pycodex/tools/request_user_input_tool.py +28 -1
- pycodex/tools/send_input_tool.py +4 -2
- pycodex/tools/shell_command_tool.py +23 -6
- pycodex/tools/shell_tool.py +13 -4
- pycodex/tools/spawn_agent_tool.py +31 -8
- pycodex/tools/unified_exec_manager.py +49 -93
- pycodex/tools/update_plan_tool.py +14 -6
- pycodex/tools/view_image_tool.py +17 -16
- pycodex/tools/wait_agent_tool.py +15 -3
- pycodex/tools/wait_tool.py +18 -4
- pycodex/tools/web_search_tool.py +2 -1
- pycodex/tools/write_stdin_tool.py +42 -10
- pycodex/utils/compactor.py +7 -1
- pycodex/utils/session_persist.py +42 -1
- pycodex/utils/truncation.py +206 -0
- pycodex/utils/visualize.py +34 -15
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/METADATA +4 -1
- python_codex-0.2.0.dist-info/RECORD +88 -0
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/entry_points.txt +1 -0
- workspace_server/__init__.py +23 -0
- workspace_server/__main__.py +5 -0
- workspace_server/app.py +1347 -0
- workspace_server/workspace.html +866 -0
- pycodex/prompts/exec_tools.json +0 -411
- pycodex/prompts/subagent_tools.json +0 -163
- python_codex-0.1.13.dist-info/RECORD +0 -84
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/WHEEL +0 -0
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
|
|
5
|
+
from .protocol import AgentEvent
|
|
6
|
+
from .runtime import CliSubmissionQueue
|
|
7
|
+
from .runtime_services import create_agent_runtime_environment
|
|
8
|
+
from .utils import CliSessionView, uuid7_string
|
|
9
|
+
from .utils.compactor import compact_agent
|
|
10
|
+
from .utils.session_persist import (
|
|
11
|
+
SessionRolloutRecorder,
|
|
12
|
+
conversation_history_to_turns,
|
|
13
|
+
list_resumable_sessions,
|
|
14
|
+
load_resumed_session,
|
|
15
|
+
resolve_codex_home,
|
|
16
|
+
)
|
|
17
|
+
import typing
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
EXIT_COMMANDS = {"/exit", "/quit"}
|
|
21
|
+
HISTORY_COMMAND = "/history"
|
|
22
|
+
TITLE_COMMAND = "/title"
|
|
23
|
+
MODEL_COMMAND = "/model"
|
|
24
|
+
QUEUE_COMMAND = "/queue"
|
|
25
|
+
RESUME_COMMAND = "/resume"
|
|
26
|
+
COMPACT_COMMAND = "/compact"
|
|
27
|
+
LINK_COMMAND = "/link"
|
|
28
|
+
UNLINK_COMMAND = "/unlink"
|
|
29
|
+
EXTRA_COMMANDS_LINE = (
|
|
30
|
+
"Extra commands: /history, /title, /model, /resume, /compact, /link, /unlink"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def format_turn_output(result, json_mode: "bool") -> "str":
|
|
35
|
+
if json_mode:
|
|
36
|
+
return json.dumps(asdict(result), ensure_ascii=False, indent=2)
|
|
37
|
+
return result.output_text or ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def prompt_request_user_input(
|
|
41
|
+
view,
|
|
42
|
+
payload: "typing.Dict[str, object]",
|
|
43
|
+
) -> "typing.Union[typing.Dict[str, object], None]":
|
|
44
|
+
view.finish_stream()
|
|
45
|
+
view.write_line("[request_user_input] waiting for user response")
|
|
46
|
+
answers: "typing.Dict[str, typing.Dict[str, typing.List[str]]]" = {}
|
|
47
|
+
for question in payload.get("questions", []):
|
|
48
|
+
if not isinstance(question, dict):
|
|
49
|
+
continue
|
|
50
|
+
header = str(question.get("header", "")).strip()
|
|
51
|
+
question_text = str(question.get("question", "")).strip()
|
|
52
|
+
question_id = str(question.get("id", "")).strip()
|
|
53
|
+
if header:
|
|
54
|
+
view.write_line(f"[{header}] {question_text}")
|
|
55
|
+
else:
|
|
56
|
+
view.write_line(question_text)
|
|
57
|
+
|
|
58
|
+
options = question.get("options") or []
|
|
59
|
+
if isinstance(options, list):
|
|
60
|
+
for index, option in enumerate(options, start=1):
|
|
61
|
+
if not isinstance(option, dict):
|
|
62
|
+
continue
|
|
63
|
+
label = str(option.get("label", "")).strip()
|
|
64
|
+
description = str(option.get("description", "")).strip()
|
|
65
|
+
view.write_line(f" {index}. {label} - {description}")
|
|
66
|
+
view.write_line(" 0. Other")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
raw_answer = await view.get_prompt("answer> ")
|
|
70
|
+
except EOFError:
|
|
71
|
+
return None
|
|
72
|
+
answer_text = raw_answer.strip()
|
|
73
|
+
if not answer_text:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
selected_answer = answer_text
|
|
77
|
+
if answer_text.isdigit() and isinstance(options, list):
|
|
78
|
+
choice = int(answer_text)
|
|
79
|
+
if 1 <= choice <= len(options):
|
|
80
|
+
option = options[choice - 1]
|
|
81
|
+
if isinstance(option, dict):
|
|
82
|
+
selected_answer = (
|
|
83
|
+
str(option.get("label", "")).strip() or answer_text
|
|
84
|
+
)
|
|
85
|
+
elif choice == 0:
|
|
86
|
+
try:
|
|
87
|
+
raw_answer = await view.get_prompt("other> ")
|
|
88
|
+
except EOFError:
|
|
89
|
+
return None
|
|
90
|
+
selected_answer = raw_answer.strip()
|
|
91
|
+
if not selected_answer:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
answers[question_id] = {"answers": [selected_answer]}
|
|
95
|
+
|
|
96
|
+
return {"answers": answers}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def prompt_request_permissions(
|
|
100
|
+
view,
|
|
101
|
+
payload: "typing.Dict[str, object]",
|
|
102
|
+
) -> "typing.Union[typing.Dict[str, object], None]":
|
|
103
|
+
view.finish_stream()
|
|
104
|
+
view.write_line("[request_permissions] user approval required")
|
|
105
|
+
reason = payload.get("reason")
|
|
106
|
+
if reason:
|
|
107
|
+
view.write_line(f"Reason: {reason}")
|
|
108
|
+
view.write_line("Requested permissions:")
|
|
109
|
+
view.write_line(
|
|
110
|
+
json.dumps(payload.get("permissions", {}), ensure_ascii=False, indent=2)
|
|
111
|
+
)
|
|
112
|
+
view.write_line("Choose: [n] deny / [t] grant for turn / [s] grant for session")
|
|
113
|
+
try:
|
|
114
|
+
raw_answer = await view.get_prompt("permissions> ")
|
|
115
|
+
except EOFError:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
answer = raw_answer.strip().lower()
|
|
119
|
+
if answer in {"t", "turn", "y", "yes"}:
|
|
120
|
+
return {
|
|
121
|
+
"permissions": payload.get("permissions", {}),
|
|
122
|
+
"scope": "turn",
|
|
123
|
+
}
|
|
124
|
+
if answer in {"s", "session"}:
|
|
125
|
+
return {
|
|
126
|
+
"permissions": payload.get("permissions", {}),
|
|
127
|
+
"scope": "session",
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
"permissions": {},
|
|
131
|
+
"scope": "turn",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def run_interactive_session(
|
|
136
|
+
queue: "CliSubmissionQueue",
|
|
137
|
+
json_mode: "bool",
|
|
138
|
+
config_path: "typing.Union[str, None]" = None,
|
|
139
|
+
view=None,
|
|
140
|
+
view_factory=None,
|
|
141
|
+
show_banner: "bool" = True,
|
|
142
|
+
) -> "int":
|
|
143
|
+
worker = asyncio.create_task(queue.run_forever())
|
|
144
|
+
context_window_tokens = queue._agent._context_manager.resolve_model_context_window()
|
|
145
|
+
if view is None:
|
|
146
|
+
factory = view_factory or CliSessionView
|
|
147
|
+
view = factory()
|
|
148
|
+
view.set_context_window_tokens(context_window_tokens)
|
|
149
|
+
model_client = queue._agent._model_client
|
|
150
|
+
codex_home = resolve_codex_home(config_path)
|
|
151
|
+
queue.set_event_handler(view.handle_event)
|
|
152
|
+
pending_turn_tasks: "typing.Set[asyncio.Task[None]]" = set()
|
|
153
|
+
runtime_environment = queue._agent.runtime_environment
|
|
154
|
+
if runtime_environment is None:
|
|
155
|
+
runtime_environment = create_agent_runtime_environment()
|
|
156
|
+
queue._agent.runtime_environment = runtime_environment
|
|
157
|
+
runtime_environment.request_user_input_manager.set_handler(
|
|
158
|
+
lambda payload: prompt_request_user_input(view, payload)
|
|
159
|
+
)
|
|
160
|
+
runtime_environment.request_permissions_manager.set_handler(
|
|
161
|
+
lambda payload: prompt_request_permissions(view, payload)
|
|
162
|
+
)
|
|
163
|
+
if show_banner:
|
|
164
|
+
view.write_line("pycodex interactive mode. Type /exit to quit.")
|
|
165
|
+
view.write_line(EXTRA_COMMANDS_LINE)
|
|
166
|
+
feishu_link = None
|
|
167
|
+
try:
|
|
168
|
+
|
|
169
|
+
def has_pending_turn_tasks() -> "bool":
|
|
170
|
+
pending_turn_tasks.difference_update(
|
|
171
|
+
task for task in tuple(pending_turn_tasks) if task.done()
|
|
172
|
+
)
|
|
173
|
+
return bool(pending_turn_tasks)
|
|
174
|
+
|
|
175
|
+
async def run_manual_compact() -> "None":
|
|
176
|
+
current_agent = queue._agent
|
|
177
|
+
if not current_agent.history:
|
|
178
|
+
view.write_line("Nothing to compact.")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
compact_turn_id = uuid7_string()
|
|
182
|
+
|
|
183
|
+
def handle_compact_stream_event(event) -> "None":
|
|
184
|
+
if event.kind not in {"token_count", "stream_error"}:
|
|
185
|
+
return
|
|
186
|
+
view.handle_event(
|
|
187
|
+
AgentEvent(
|
|
188
|
+
kind=event.kind,
|
|
189
|
+
turn_id=compact_turn_id,
|
|
190
|
+
payload=dict(event.payload),
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
view.write_line("Compacting conversation history...")
|
|
195
|
+
compact_result = await compact_agent(
|
|
196
|
+
current_agent,
|
|
197
|
+
handle_compact_stream_event,
|
|
198
|
+
True,
|
|
199
|
+
)
|
|
200
|
+
if compact_result is None:
|
|
201
|
+
view.write_line("Nothing to compact.")
|
|
202
|
+
return
|
|
203
|
+
view.load_session_history(
|
|
204
|
+
getattr(view, "_title", None),
|
|
205
|
+
conversation_history_to_turns(compact_result.history),
|
|
206
|
+
)
|
|
207
|
+
view.write_line(compact_result.display_text())
|
|
208
|
+
|
|
209
|
+
async def wait_for_turn_result(future) -> "None":
|
|
210
|
+
try:
|
|
211
|
+
result = await future
|
|
212
|
+
except Exception as exc: # pragma: no cover - defensive surface
|
|
213
|
+
if str(exc) == "submission interrupted":
|
|
214
|
+
return
|
|
215
|
+
view.show_error(str(exc))
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
if json_mode:
|
|
219
|
+
view.write_line(format_turn_output(result, True))
|
|
220
|
+
|
|
221
|
+
while True:
|
|
222
|
+
try:
|
|
223
|
+
raw_line = await view.poll_prompt()
|
|
224
|
+
except EOFError:
|
|
225
|
+
break
|
|
226
|
+
if raw_line is None:
|
|
227
|
+
await asyncio.sleep(0.05)
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
prompt_text = raw_line.strip()
|
|
231
|
+
if not prompt_text:
|
|
232
|
+
continue
|
|
233
|
+
if prompt_text in EXIT_COMMANDS:
|
|
234
|
+
break
|
|
235
|
+
if prompt_text == HISTORY_COMMAND:
|
|
236
|
+
view.show_history()
|
|
237
|
+
continue
|
|
238
|
+
if prompt_text == TITLE_COMMAND:
|
|
239
|
+
view.show_title()
|
|
240
|
+
continue
|
|
241
|
+
if prompt_text.startswith(f"{TITLE_COMMAND} "):
|
|
242
|
+
title = prompt_text[len(TITLE_COMMAND) :].strip()
|
|
243
|
+
if not title:
|
|
244
|
+
view.write_line("Usage: /title <title>")
|
|
245
|
+
continue
|
|
246
|
+
set_session_title = getattr(view, "set_session_title", None)
|
|
247
|
+
if callable(set_session_title):
|
|
248
|
+
set_session_title(title)
|
|
249
|
+
else:
|
|
250
|
+
view.write_line(f"Session: {title}")
|
|
251
|
+
continue
|
|
252
|
+
if prompt_text == RESUME_COMMAND:
|
|
253
|
+
sessions = list_resumable_sessions(codex_home)
|
|
254
|
+
if not sessions:
|
|
255
|
+
view.write_line("No resumable sessions found.")
|
|
256
|
+
continue
|
|
257
|
+
view.write_line("Available sessions:")
|
|
258
|
+
for index, session in enumerate(sessions, start=1):
|
|
259
|
+
view.write_line(f"[{index}] {session['preview']}")
|
|
260
|
+
continue
|
|
261
|
+
if prompt_text.startswith(f"{RESUME_COMMAND} "):
|
|
262
|
+
if has_pending_turn_tasks():
|
|
263
|
+
view.write_line(
|
|
264
|
+
"Cannot resume while work is running or queued."
|
|
265
|
+
)
|
|
266
|
+
continue
|
|
267
|
+
resume_target = prompt_text[len(RESUME_COMMAND) :].strip()
|
|
268
|
+
try:
|
|
269
|
+
resumed = load_resumed_session(codex_home, resume_target)
|
|
270
|
+
queue._agent.replace_history(resumed["history"])
|
|
271
|
+
if hasattr(model_client, "_session_id"):
|
|
272
|
+
model_client._session_id = str(resumed["session_id"])
|
|
273
|
+
queue._agent.set_rollout_recorder(
|
|
274
|
+
SessionRolloutRecorder.resume(resumed["rollout_path"])
|
|
275
|
+
)
|
|
276
|
+
view.load_session_history(
|
|
277
|
+
str(resumed["title"]),
|
|
278
|
+
tuple(resumed["turns"]),
|
|
279
|
+
)
|
|
280
|
+
show_resumed_session = getattr(view, "show_resumed_session", None)
|
|
281
|
+
if callable(show_resumed_session):
|
|
282
|
+
show_resumed_session(str(resumed["title"]))
|
|
283
|
+
else:
|
|
284
|
+
view.write_line(f"Resumed session: {resumed['title']}")
|
|
285
|
+
view.show_history()
|
|
286
|
+
except Exception as exc: # pragma: no cover - defensive surface
|
|
287
|
+
view.show_error(str(exc))
|
|
288
|
+
continue
|
|
289
|
+
if prompt_text == COMPACT_COMMAND:
|
|
290
|
+
if has_pending_turn_tasks():
|
|
291
|
+
view.write_line(
|
|
292
|
+
"Cannot compact while work is running or queued."
|
|
293
|
+
)
|
|
294
|
+
continue
|
|
295
|
+
try:
|
|
296
|
+
await run_manual_compact()
|
|
297
|
+
except Exception as exc: # pragma: no cover - defensive surface
|
|
298
|
+
view.show_error(str(exc))
|
|
299
|
+
continue
|
|
300
|
+
if prompt_text.startswith(f"{LINK_COMMAND} "):
|
|
301
|
+
link_target = prompt_text[len(LINK_COMMAND) :].strip()
|
|
302
|
+
if not link_target:
|
|
303
|
+
view.write_line("Usage: /link <feishu-email|open_id|chat_id>")
|
|
304
|
+
continue
|
|
305
|
+
if feishu_link:
|
|
306
|
+
view.write_line("A Feishu card is already linked. Use /unlink first.")
|
|
307
|
+
continue
|
|
308
|
+
try:
|
|
309
|
+
from .feishu_link import PycodexRuntimeLink
|
|
310
|
+
|
|
311
|
+
view.write_line(f"Linking Feishu card to current session: {link_target}")
|
|
312
|
+
link = await PycodexRuntimeLink(
|
|
313
|
+
queue,
|
|
314
|
+
link_target,
|
|
315
|
+
).start_async()
|
|
316
|
+
feishu_link = link
|
|
317
|
+
view.write_line(
|
|
318
|
+
"Linked Feishu card: session_key={0} message_id={1}".format(
|
|
319
|
+
link.session_key,
|
|
320
|
+
link.message_id or "-",
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
except Exception as exc: # pragma: no cover - defensive surface
|
|
324
|
+
view.show_error(str(exc))
|
|
325
|
+
continue
|
|
326
|
+
if prompt_text == UNLINK_COMMAND:
|
|
327
|
+
if not feishu_link:
|
|
328
|
+
view.write_line("No Feishu card is linked.")
|
|
329
|
+
continue
|
|
330
|
+
feishu_link.detach()
|
|
331
|
+
feishu_link = None
|
|
332
|
+
view.write_line("Unlinked Feishu card.")
|
|
333
|
+
continue
|
|
334
|
+
if prompt_text.startswith(f"{QUEUE_COMMAND} "):
|
|
335
|
+
queued_text = prompt_text[len(QUEUE_COMMAND) :].strip()
|
|
336
|
+
if not queued_text:
|
|
337
|
+
view.write_line("Usage: /queue <message>")
|
|
338
|
+
continue
|
|
339
|
+
try:
|
|
340
|
+
submission_id, future = await queue.enqueue_user_turn(
|
|
341
|
+
queued_text, queue="enqueue"
|
|
342
|
+
)
|
|
343
|
+
view.show_steer_queued(submission_id, queued_text)
|
|
344
|
+
turn_task = asyncio.create_task(wait_for_turn_result(future))
|
|
345
|
+
pending_turn_tasks.add(turn_task)
|
|
346
|
+
except Exception as exc: # pragma: no cover - defensive surface
|
|
347
|
+
view.show_error(str(exc))
|
|
348
|
+
continue
|
|
349
|
+
if prompt_text == MODEL_COMMAND:
|
|
350
|
+
view.write_line(
|
|
351
|
+
f"Current model: {getattr(model_client, 'model', None) or 'unavailable'}"
|
|
352
|
+
)
|
|
353
|
+
models = await model_client.list_models()
|
|
354
|
+
view.write_line(f"Available models: {', '.join(models)}")
|
|
355
|
+
continue
|
|
356
|
+
if prompt_text.startswith(f"{MODEL_COMMAND} "):
|
|
357
|
+
if has_pending_turn_tasks():
|
|
358
|
+
view.write_line(
|
|
359
|
+
"Cannot change model while work is running or queued in steer mode."
|
|
360
|
+
)
|
|
361
|
+
continue
|
|
362
|
+
model_name = prompt_text[len(MODEL_COMMAND) :].strip()
|
|
363
|
+
if not model_name:
|
|
364
|
+
view.write_line("Usage: /model <model>")
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
model_client.model = model_name
|
|
368
|
+
view.write_line(f"Switched model to {model_name}.")
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
steered = has_pending_turn_tasks()
|
|
373
|
+
submission_id, future = await queue.enqueue_user_turn(
|
|
374
|
+
prompt_text,
|
|
375
|
+
queue="steer",
|
|
376
|
+
)
|
|
377
|
+
if steered:
|
|
378
|
+
view.schedule_steer_inserted(submission_id, prompt_text)
|
|
379
|
+
turn_task = asyncio.create_task(wait_for_turn_result(future))
|
|
380
|
+
pending_turn_tasks.add(turn_task)
|
|
381
|
+
continue
|
|
382
|
+
except Exception as exc: # pragma: no cover - defensive surface
|
|
383
|
+
view.show_error(str(exc))
|
|
384
|
+
continue
|
|
385
|
+
finally:
|
|
386
|
+
if feishu_link:
|
|
387
|
+
feishu_link.detach()
|
|
388
|
+
feishu_link.stop()
|
|
389
|
+
runtime_environment.request_user_input_manager.set_handler(None)
|
|
390
|
+
runtime_environment.request_permissions_manager.set_handler(None)
|
|
391
|
+
await queue.shutdown()
|
|
392
|
+
await worker
|
|
393
|
+
if pending_turn_tasks:
|
|
394
|
+
await asyncio.gather(*pending_turn_tasks, return_exceptions=True)
|
|
395
|
+
view.close()
|
|
396
|
+
|
|
397
|
+
return 0
|
pycodex/model.py
CHANGED
|
@@ -298,13 +298,20 @@ class ResponsesModelClient:
|
|
|
298
298
|
prompt,
|
|
299
299
|
event_handler,
|
|
300
300
|
)
|
|
301
|
-
except ResponsesRetryableError as exc:
|
|
302
|
-
if
|
|
301
|
+
except (ResponsesRetryableError, ResponsesIncompleteError) as exc:
|
|
302
|
+
if (
|
|
303
|
+
isinstance(exc, ResponsesRetryableError)
|
|
304
|
+
and _is_context_length_error_message(str(exc))
|
|
305
|
+
):
|
|
303
306
|
raise ResponsesApiError(str(exc)) from exc
|
|
304
307
|
if retries >= max_retries:
|
|
305
308
|
raise
|
|
306
309
|
retries += 1
|
|
307
|
-
delay_seconds =
|
|
310
|
+
delay_seconds = (
|
|
311
|
+
exc.retry_delay_seconds
|
|
312
|
+
if isinstance(exc, ResponsesRetryableError)
|
|
313
|
+
else None
|
|
314
|
+
)
|
|
308
315
|
if delay_seconds is None:
|
|
309
316
|
delay_seconds = self._retry_delay_seconds(retries)
|
|
310
317
|
event_handler(
|
|
@@ -507,7 +514,6 @@ class ResponsesModelClient:
|
|
|
507
514
|
diagnostics: 'typing.Union[_StreamDiagnostics, None]' = None,
|
|
508
515
|
) -> 'ModelResponse':
|
|
509
516
|
items: 'typing.List[typing.Union[typing.Union[AssistantMessage, ToolCall], ReasoningItem]]' = []
|
|
510
|
-
output_text_deltas: 'typing.List[str]' = []
|
|
511
517
|
saw_completed = False
|
|
512
518
|
last_event_type = ""
|
|
513
519
|
|
|
@@ -526,7 +532,6 @@ class ResponsesModelClient:
|
|
|
526
532
|
diagnostics.last_event_type = last_event_type
|
|
527
533
|
|
|
528
534
|
if event_type == "response.output_text.delta":
|
|
529
|
-
output_text_deltas.append(str(payload.get("delta", "")))
|
|
530
535
|
event_handler(
|
|
531
536
|
ModelStreamEvent(
|
|
532
537
|
kind="assistant_delta",
|
|
@@ -607,10 +612,7 @@ class ResponsesModelClient:
|
|
|
607
612
|
break
|
|
608
613
|
|
|
609
614
|
if event_type == "response.incomplete":
|
|
610
|
-
partial_items =
|
|
611
|
-
items,
|
|
612
|
-
output_text_deltas,
|
|
613
|
-
)
|
|
615
|
+
partial_items = tuple(items)
|
|
614
616
|
reason = self._response_incomplete_reason(payload)
|
|
615
617
|
raise ResponsesIncompleteError(
|
|
616
618
|
self._format_response_incomplete_error(
|
|
@@ -632,19 +634,6 @@ class ResponsesModelClient:
|
|
|
632
634
|
|
|
633
635
|
return ModelResponse(items=items)
|
|
634
636
|
|
|
635
|
-
def _items_with_partial_output_text(
|
|
636
|
-
self,
|
|
637
|
-
items: 'typing.Sequence[typing.Union[typing.Union[AssistantMessage, ToolCall], ReasoningItem]]',
|
|
638
|
-
output_text_deltas: 'typing.Sequence[str]',
|
|
639
|
-
) -> 'typing.Tuple[typing.Union[typing.Union[AssistantMessage, ToolCall], ReasoningItem], ...]':
|
|
640
|
-
partial_items = list(items)
|
|
641
|
-
if any(isinstance(item, AssistantMessage) for item in partial_items):
|
|
642
|
-
return tuple(partial_items)
|
|
643
|
-
partial_text = "".join(output_text_deltas).strip()
|
|
644
|
-
if partial_text:
|
|
645
|
-
partial_items.append(AssistantMessage(text=partial_text))
|
|
646
|
-
return tuple(partial_items)
|
|
647
|
-
|
|
648
637
|
def _parse_output_item(
|
|
649
638
|
self,
|
|
650
639
|
item: 'typing.Dict[str, object]',
|
pycodex/protocol.py
CHANGED
|
@@ -40,11 +40,8 @@ class ToolSpec:
|
|
|
40
40
|
options: 'typing.Union[JSONDict, None]' = None
|
|
41
41
|
output_schema: 'typing.Union[JSONDict, None]' = None
|
|
42
42
|
supports_parallel: 'bool' = True
|
|
43
|
-
raw_payload: 'typing.Union[JSONDict, None]' = None
|
|
44
43
|
|
|
45
44
|
def serialize(self) -> 'JSONDict':
|
|
46
|
-
if self.raw_payload is not None:
|
|
47
|
-
return deepcopy(self.raw_payload)
|
|
48
45
|
if self.tool_type == "web_search":
|
|
49
46
|
payload = {"type": "web_search"}
|
|
50
47
|
if self.options is not None:
|
|
@@ -71,8 +68,6 @@ class ToolSpec:
|
|
|
71
68
|
"parameters": self.input_schema,
|
|
72
69
|
"strict": False,
|
|
73
70
|
}
|
|
74
|
-
if self.output_schema is not None:
|
|
75
|
-
payload["output_schema"] = self.output_schema
|
|
76
71
|
return payload
|
|
77
72
|
|
|
78
73
|
|
pycodex/runtime.py
CHANGED
|
@@ -65,6 +65,19 @@ class CliSubmissionQueue:
|
|
|
65
65
|
self._queue_event.set()
|
|
66
66
|
await future
|
|
67
67
|
|
|
68
|
+
def cancel_current(self) -> 'None':
|
|
69
|
+
exc = RuntimeError("submission interrupted")
|
|
70
|
+
current_task = self._current_task
|
|
71
|
+
if current_task is not None and not current_task.done():
|
|
72
|
+
current_task.cancel()
|
|
73
|
+
for queued in tuple(self._enqueue_queue):
|
|
74
|
+
self._finish_submission_exception(queued, exc)
|
|
75
|
+
for queued in tuple(self._steer_queue):
|
|
76
|
+
self._finish_submission_exception(queued, exc)
|
|
77
|
+
self._enqueue_queue.clear()
|
|
78
|
+
self._steer_queue.clear()
|
|
79
|
+
self._queue_event.set()
|
|
80
|
+
|
|
68
81
|
async def run_forever(self) -> 'None':
|
|
69
82
|
while True:
|
|
70
83
|
queued = await self._next_submission()
|
|
@@ -152,6 +165,9 @@ class CliSubmissionQueue:
|
|
|
152
165
|
|
|
153
166
|
async def _next_submission(self) -> '_QueuedSubmission':
|
|
154
167
|
while True:
|
|
168
|
+
if self._agent._turn_running and not self._has_queue_active_turn():
|
|
169
|
+
await self._wait_for_agent_idle()
|
|
170
|
+
continue
|
|
155
171
|
async with self._queue_lock:
|
|
156
172
|
queued: 'typing.Union[_QueuedSubmission, None]' = None
|
|
157
173
|
if self._steer_queue:
|
|
@@ -184,9 +200,16 @@ class CliSubmissionQueue:
|
|
|
184
200
|
future.set_exception(exc)
|
|
185
201
|
|
|
186
202
|
def _has_active_turn(self) -> 'bool':
|
|
203
|
+
return self._has_queue_active_turn() or self._agent._turn_running
|
|
204
|
+
|
|
205
|
+
def _has_queue_active_turn(self) -> 'bool':
|
|
187
206
|
current_task = self._current_task
|
|
188
207
|
return current_task is not None and not current_task.done()
|
|
189
208
|
|
|
209
|
+
async def _wait_for_agent_idle(self) -> 'None':
|
|
210
|
+
while self._agent._turn_running:
|
|
211
|
+
await asyncio.sleep(0.01)
|
|
212
|
+
|
|
190
213
|
def _handle_agent_event(self, event: 'AgentEvent') -> 'None':
|
|
191
214
|
queued = self._current_submission
|
|
192
215
|
if queued is None:
|
pycodex/runtime_services.py
CHANGED
|
@@ -285,7 +285,7 @@ class SubAgentManager:
|
|
|
285
285
|
async def close_agent(self, agent_id: 'str') -> 'typing.Dict[str, object]':
|
|
286
286
|
managed = self._agents.get(agent_id)
|
|
287
287
|
if managed is None:
|
|
288
|
-
return {"
|
|
288
|
+
return {"previous_status": "not_found"}
|
|
289
289
|
previous_status = self._status_payload(managed)
|
|
290
290
|
if not managed.worker_task.done():
|
|
291
291
|
managed.queue._agent.interrupt_asap = True
|
|
@@ -295,7 +295,7 @@ class SubAgentManager:
|
|
|
295
295
|
managed.pending_submission_ids.clear()
|
|
296
296
|
async with self._condition:
|
|
297
297
|
self._condition.notify_all()
|
|
298
|
-
return {"
|
|
298
|
+
return {"previous_status": previous_status}
|
|
299
299
|
|
|
300
300
|
def _next_nickname(self) -> 'str':
|
|
301
301
|
if not self._available_nicknames:
|
|
@@ -35,7 +35,7 @@ add_line: \"+\" /(.*)/ LF -> line
|
|
|
35
35
|
change_move: \"*** Move to: \" filename LF
|
|
36
36
|
change: (change_context | change_line)+ eof_line?
|
|
37
37
|
change_context: (\"@@\" | \"@@ \" /(.+)/) LF
|
|
38
|
-
change_line: (\"+\" | \"-\" | \" \"
|
|
38
|
+
change_line: (\"+\" | \"-\" | \" \") /(.*)/ LF
|
|
39
39
|
eof_line: \"*** End of File\" LF
|
|
40
40
|
|
|
41
41
|
%import common.LF
|
pycodex/tools/base_tool.py
CHANGED
|
@@ -11,37 +11,15 @@ Expected behavior:
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
import inspect
|
|
14
|
+
import json
|
|
14
15
|
from abc import ABC, abstractmethod
|
|
15
16
|
from dataclasses import dataclass
|
|
16
|
-
from functools import lru_cache
|
|
17
|
-
import json
|
|
18
|
-
from pathlib import Path
|
|
19
17
|
import traceback
|
|
20
18
|
|
|
21
19
|
from ..protocol import ConversationItem, JSONDict, JSONValue, ToolCall, ToolResult, ToolSpec
|
|
22
20
|
from ..utils import get_debug_dir
|
|
23
21
|
import typing
|
|
24
22
|
|
|
25
|
-
EXEC_TOOLS_SNAPSHOT_PATH = (
|
|
26
|
-
Path(__file__).resolve().parent.parent / "prompts" / "exec_tools.json"
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@lru_cache(maxsize=1)
|
|
31
|
-
def _load_exec_tool_payloads() -> 'typing.Dict[str, JSONDict]':
|
|
32
|
-
payloads: 'typing.Dict[str, JSONDict]' = {}
|
|
33
|
-
raw_payloads = EXEC_TOOLS_SNAPSHOT_PATH.read_text(encoding="utf-8")
|
|
34
|
-
for payload in json.loads(raw_payloads):
|
|
35
|
-
if not isinstance(payload, dict):
|
|
36
|
-
continue
|
|
37
|
-
name = payload.get("name")
|
|
38
|
-
if isinstance(name, str):
|
|
39
|
-
payloads[name] = payload
|
|
40
|
-
continue
|
|
41
|
-
if payload.get("type") == "web_search":
|
|
42
|
-
payloads["web_search"] = payload
|
|
43
|
-
return payloads
|
|
44
|
-
|
|
45
23
|
|
|
46
24
|
@dataclass(frozen=True, )
|
|
47
25
|
class ToolContext:
|
|
@@ -82,15 +60,11 @@ class BaseTool(ABC):
|
|
|
82
60
|
options=self.options,
|
|
83
61
|
output_schema=self.output_schema,
|
|
84
62
|
supports_parallel=self.supports_parallel,
|
|
85
|
-
raw_payload=self.raw_payload(),
|
|
86
63
|
)
|
|
87
64
|
|
|
88
65
|
def serialize(self) -> 'JSONDict':
|
|
89
66
|
return self.spec().serialize()
|
|
90
67
|
|
|
91
|
-
def raw_payload(self) -> 'typing.Union[JSONDict, None]':
|
|
92
|
-
return _load_exec_tool_payloads().get(self.name)
|
|
93
|
-
|
|
94
68
|
@abstractmethod
|
|
95
69
|
async def run(self, context: 'ToolContext', args: 'JSONValue') -> 'JSONValue':
|
|
96
70
|
raise NotImplementedError
|
|
@@ -5,7 +5,7 @@ Original Codex mapping:
|
|
|
5
5
|
|
|
6
6
|
Expected behavior:
|
|
7
7
|
- Shut down a spawned agent when it is no longer needed.
|
|
8
|
-
- Return the agent status observed
|
|
8
|
+
- Return the agent status observed before shutdown was requested.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from ..protocol import JSONDict, JSONValue
|
|
@@ -16,9 +16,12 @@ from .base_tool import BaseTool, ToolContext
|
|
|
16
16
|
CLOSE_AGENT_OUTPUT_SCHEMA = {
|
|
17
17
|
"type": "object",
|
|
18
18
|
"properties": {
|
|
19
|
-
"
|
|
19
|
+
"previous_status": {
|
|
20
|
+
"description": "The agent status observed before shutdown was requested.",
|
|
21
|
+
"allOf": [AGENT_STATUS_SCHEMA],
|
|
22
|
+
},
|
|
20
23
|
},
|
|
21
|
-
"required": ["
|
|
24
|
+
"required": ["previous_status"],
|
|
22
25
|
"additionalProperties": False,
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -26,7 +29,11 @@ CLOSE_AGENT_OUTPUT_SCHEMA = {
|
|
|
26
29
|
class CloseAgentTool(BaseTool):
|
|
27
30
|
name = "close_agent"
|
|
28
31
|
description = (
|
|
29
|
-
"Close an agent
|
|
32
|
+
"Close an agent and any open descendants when they are no longer "
|
|
33
|
+
"needed, and return the target agent's previous status before shutdown "
|
|
34
|
+
"was requested. Completed agents remain open and count toward the "
|
|
35
|
+
"concurrency limit until closed. Don't keep agents open for too long "
|
|
36
|
+
"if they are not needed anymore."
|
|
30
37
|
)
|
|
31
38
|
input_schema = {
|
|
32
39
|
"type": "object",
|
|
@@ -21,11 +21,11 @@ from loguru import logger
|
|
|
21
21
|
|
|
22
22
|
from ..compat import is_ascii, stream_writer_is_closing
|
|
23
23
|
from ..protocol import JSONDict, JSONValue, ToolCall
|
|
24
|
+
from ..utils.truncation import DEFAULT_MAX_OUTPUT_TOKENS
|
|
24
25
|
from .base_tool import StructuredToolOutput, ToolContext, ToolRegistry
|
|
25
26
|
import typing
|
|
26
27
|
|
|
27
28
|
DEFAULT_WAIT_YIELD_TIME_MS = 10_000
|
|
28
|
-
DEFAULT_MAX_OUTPUT_TOKENS = 10_000
|
|
29
29
|
CHARS_PER_TOKEN = 4
|
|
30
30
|
EXEC_PRAGMA_PREFIX = "// @exec:"
|
|
31
31
|
WAIT_COMPLETION_GRACE_SECONDS = 0.02
|