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.
Files changed (50) hide show
  1. pycodex/agent.py +71 -11
  2. pycodex/cli.py +16 -356
  3. pycodex/context.py +12 -0
  4. pycodex/feishu_card.py +76 -30
  5. pycodex/feishu_link.py +131 -11
  6. pycodex/interactive_session.py +397 -0
  7. pycodex/model.py +11 -22
  8. pycodex/protocol.py +0 -5
  9. pycodex/runtime.py +23 -0
  10. pycodex/runtime_services.py +2 -2
  11. pycodex/tools/agent_tool_schemas.py +1 -1
  12. pycodex/tools/apply_patch_tool.py +1 -1
  13. pycodex/tools/base_tool.py +1 -27
  14. pycodex/tools/close_agent_tool.py +11 -4
  15. pycodex/tools/code_mode_manager.py +1 -1
  16. pycodex/tools/exec_command_tool.py +40 -16
  17. pycodex/tools/exec_tool.py +18 -2
  18. pycodex/tools/grep_files_tool.py +19 -6
  19. pycodex/tools/ipython_tool.py +3 -2
  20. pycodex/tools/list_dir_tool.py +19 -6
  21. pycodex/tools/read_file_tool.py +39 -9
  22. pycodex/tools/request_permissions_tool.py +12 -1
  23. pycodex/tools/request_user_input_tool.py +28 -1
  24. pycodex/tools/send_input_tool.py +4 -2
  25. pycodex/tools/shell_command_tool.py +23 -6
  26. pycodex/tools/shell_tool.py +13 -4
  27. pycodex/tools/spawn_agent_tool.py +31 -8
  28. pycodex/tools/unified_exec_manager.py +49 -93
  29. pycodex/tools/update_plan_tool.py +14 -6
  30. pycodex/tools/view_image_tool.py +17 -16
  31. pycodex/tools/wait_agent_tool.py +15 -3
  32. pycodex/tools/wait_tool.py +18 -4
  33. pycodex/tools/web_search_tool.py +2 -1
  34. pycodex/tools/write_stdin_tool.py +42 -10
  35. pycodex/utils/compactor.py +7 -1
  36. pycodex/utils/session_persist.py +42 -1
  37. pycodex/utils/truncation.py +206 -0
  38. pycodex/utils/visualize.py +34 -15
  39. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/METADATA +4 -1
  40. python_codex-0.2.0.dist-info/RECORD +88 -0
  41. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/entry_points.txt +1 -0
  42. workspace_server/__init__.py +23 -0
  43. workspace_server/__main__.py +5 -0
  44. workspace_server/app.py +1347 -0
  45. workspace_server/workspace.html +866 -0
  46. pycodex/prompts/exec_tools.json +0 -411
  47. pycodex/prompts/subagent_tools.json +0 -163
  48. python_codex-0.1.13.dist-info/RECORD +0 -84
  49. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/WHEEL +0 -0
  50. {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 _is_context_length_error_message(str(exc)):
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 = exc.retry_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 = self._items_with_partial_output_text(
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:
@@ -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 {"status": "not_found"}
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 {"status": previous_status}
298
+ return {"previous_status": previous_status}
299
299
 
300
300
  def _next_nickname(self) -> 'str':
301
301
  if not self._available_nicknames:
@@ -42,7 +42,7 @@ AGENT_STATUS_SCHEMA = {
42
42
  "oneOf": [
43
43
  {
44
44
  "type": "string",
45
- "enum": ["pending_init", "running", "shutdown", "not_found"],
45
+ "enum": ["pending_init", "running", "interrupted", "shutdown", "not_found"],
46
46
  },
47
47
  {
48
48
  "type": "object",
@@ -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: (\"+\" | \"-\" | \" \" ) /(.*)/ LF
38
+ change_line: (\"+\" | \"-\" | \" \") /(.*)/ LF
39
39
  eof_line: \"*** End of File\" LF
40
40
 
41
41
  %import common.LF
@@ -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 at close time.
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
- "status": AGENT_STATUS_SCHEMA,
19
+ "previous_status": {
20
+ "description": "The agent status observed before shutdown was requested.",
21
+ "allOf": [AGENT_STATUS_SCHEMA],
22
+ },
20
23
  },
21
- "required": ["status"],
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 when it is no longer needed and return its status."
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