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.
Files changed (84) hide show
  1. pycodex/__init__.py +18 -14
  2. pycodex/agent.py +468 -462
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -436
  5. pycodex/compat.py +19 -5
  6. pycodex/context.py +222 -212
  7. pycodex/doctor.py +52 -48
  8. pycodex/events.py +857 -0
  9. pycodex/feishu_card.py +217 -163
  10. pycodex/feishu_link.py +43 -83
  11. pycodex/model.py +329 -252
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +90 -52
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +235 -803
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -174
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +4 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +201 -0
  23. pycodex/tools/close_agent_tool.py +2 -2
  24. pycodex/tools/code_mode_manager.py +77 -64
  25. pycodex/tools/exec_command_tool.py +26 -11
  26. pycodex/tools/exec_tool.py +4 -4
  27. pycodex/tools/grep_files_tool.py +12 -10
  28. pycodex/tools/ipython_tool.py +10 -13
  29. pycodex/tools/list_dir_tool.py +13 -9
  30. pycodex/tools/read_file_tool.py +29 -17
  31. pycodex/tools/request_permissions_tool.py +15 -5
  32. pycodex/tools/request_user_input_tool.py +13 -104
  33. pycodex/tools/resume_agent_tool.py +2 -2
  34. pycodex/tools/send_input_tool.py +11 -8
  35. pycodex/tools/shell_command_tool.py +7 -5
  36. pycodex/tools/shell_tool.py +7 -5
  37. pycodex/tools/spawn_agent_tool.py +7 -4
  38. pycodex/tools/unified_exec_manager.py +102 -69
  39. pycodex/tools/update_plan_tool.py +8 -5
  40. pycodex/tools/view_image_tool.py +13 -13
  41. pycodex/tools/wait_agent_tool.py +27 -4
  42. pycodex/tools/wait_tool.py +5 -4
  43. pycodex/tools/web_search_tool.py +4 -2
  44. pycodex/tools/write_stdin_tool.py +12 -11
  45. pycodex/utils/__init__.py +2 -17
  46. pycodex/utils/compactor.py +50 -66
  47. pycodex/utils/debug.py +2 -2
  48. pycodex/utils/dotenv.py +6 -7
  49. pycodex/utils/event_helpers.py +190 -0
  50. pycodex/utils/get_env.py +27 -70
  51. pycodex/utils/image_utils.py +76 -0
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +263 -161
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.0.dist-info/METADATA +704 -0
  56. python_codex-0.3.0.dist-info/RECORD +90 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +36 -31
  60. responses_server/config.py +25 -22
  61. responses_server/messages_api.py +96 -49
  62. responses_server/payload_processors.py +25 -19
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +196 -107
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +51 -13
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +470 -384
  71. workspace_server/workspace.html +859 -232
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +168 -100
  74. pycodex/collaboration.py +0 -20
  75. pycodex/interactive_session.py +0 -415
  76. pycodex/prompts/collaboration_default.md +0 -11
  77. pycodex/prompts/collaboration_plan.md +0 -128
  78. pycodex/utils/toolcall_visualize.py +0 -713
  79. pycodex/utils/visualize.py +0 -553
  80. python_codex-0.2.6.dist-info/METADATA +0 -441
  81. python_codex-0.2.6.dist-info/RECORD +0 -91
  82. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
pycodex/agent.py CHANGED
@@ -1,57 +1,78 @@
1
-
2
1
  import asyncio
3
2
  import json
4
- import re
3
+ import typing
4
+ from dataclasses import dataclass, replace
5
+ from pathlib import Path
5
6
  from typing import Callable
6
7
 
7
- from .context import ContextManager
8
- from .model import ModelClient
8
+ from .context import ContextConfig, ContextManager
9
+ from .events import (
10
+ AutoCompactCompletedEvent,
11
+ AutoCompactFailedEvent,
12
+ AutoCompactStartedEvent,
13
+ CompactCompletedEvent,
14
+ CompactFailedEvent,
15
+ CompactStartedEvent,
16
+ Event,
17
+ ModelCalledEvent,
18
+ ModelCompletedEvent,
19
+ ModelEvent,
20
+ StreamErrorEvent,
21
+ TerminalEvent,
22
+ TokenCountEvent,
23
+ ToolCompletedEvent,
24
+ ToolStartedEvent,
25
+ TurnCompletedEvent,
26
+ TurnEvent,
27
+ TurnFailedEvent,
28
+ TurnInterruptedEvent,
29
+ TurnStartedEvent,
30
+ )
31
+ from .model import (
32
+ DEFAULT_ORIGINATOR,
33
+ ContextLengthExceeded,
34
+ ModelClient,
35
+ ModelControl,
36
+ ResponsesIncompleteError,
37
+ )
9
38
  from .protocol import (
10
- AgentEvent,
11
39
  AssistantMessage,
12
40
  ConversationItem,
13
- ModelStreamEvent,
41
+ ModelResponse,
14
42
  ReasoningItem,
15
43
  ToolCall,
16
44
  ToolResult,
17
45
  TurnResult,
18
46
  UserMessage,
19
47
  )
20
- from .tools import ExecCommandTool, ToolContext, ToolRegistry, UnifiedExecManager
21
- from .utils.truncation import truncate_tool_results_for_history
48
+ from .tools import ToolContext, ToolRegistry
22
49
  from .utils import uuid7_string
23
- import typing
50
+ from .utils.session_persist import (
51
+ SessionRolloutRecorder,
52
+ load_resumed_session_path,
53
+ resolve_codex_home,
54
+ rollout_path_for_session,
55
+ )
56
+ from .utils.truncation import truncate_tool_result_for_history
24
57
 
25
58
  if typing.TYPE_CHECKING:
26
- from .utils.session_persist import SessionRolloutRecorder
27
- from .runtime_services import AgentRuntimeEnvironment
59
+ from .utils.compactor import CompactResult
28
60
 
29
61
 
30
- EventHandler = Callable[[AgentEvent], None]
31
- BASE_EVENT_HANDLER: 'EventHandler' = lambda _event: None
32
- _REQUESTED_TOKENS_RE = re.compile(
33
- r"requested\s+([0-9,]+)\s+tokens",
34
- re.IGNORECASE,
35
- )
36
- _REQUESTED_TOKEN_SPLIT_RE = re.compile(
37
- r"\(([0-9,]+)\s+in\s+the\s+messages,\s+([0-9,]+)\s+in\s+the\s+completion\)",
38
- re.IGNORECASE,
39
- )
40
- _MAX_CONTEXT_TOKENS_RE = re.compile(
41
- r"maximum\s+context\s+length\s+is\s+([0-9,]+)\s+tokens",
42
- re.IGNORECASE,
43
- )
44
- _CONTEXT_LENGTH_ERROR_MARKERS = (
45
- "context_length_exceeded",
46
- "maximum context length",
47
- "exceeds the context window",
48
- "exceeded the context window",
49
- )
50
- TERMINAL_TURN_EVENTS = {"turn_completed", "turn_failed", "turn_interrupted"}
62
+ EventHandler = Callable[[Event], None]
63
+ BASE_EVENT_HANDLER: "EventHandler" = lambda _event: None
51
64
 
52
65
 
53
66
  class TurnInterrupted(RuntimeError):
54
- pass
67
+ def __init__(self):
68
+ super().__init__("turn interrupted")
69
+
70
+
71
+ @dataclass
72
+ class _TurnState:
73
+ turn_id: "str"
74
+ iteration: "int" = 0
75
+ output_text: "typing.Union[str, None]" = None
55
76
 
56
77
 
57
78
  class Agent:
@@ -65,324 +86,392 @@ class Agent:
65
86
 
66
87
  def __init__(
67
88
  self,
68
- model_client: 'ModelClient',
69
- tool_registry: 'ToolRegistry',
70
- context_manager: 'typing.Union[ContextManager, None]' = None,
71
- parallel_tool_calls: 'bool' = True,
72
- event_handler: 'EventHandler' = BASE_EVENT_HANDLER,
73
- initial_history: 'typing.Tuple[ConversationItem, ...]' = (),
74
- rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]' = None,
75
- runtime_environment: 'AgentRuntimeEnvironment' = None,
76
- ) -> 'None':
77
- self._model_client = model_client
78
- self._tool_registry = tool_registry
79
- self._context_manager = context_manager or ContextManager()
80
- self._parallel_tool_calls = parallel_tool_calls
81
- self._event_handler = event_handler
82
- self._history: 'typing.List[ConversationItem]' = list(initial_history)
83
- self._rollout_recorder = rollout_recorder
84
- self._auto_compact_token_limit = (
85
- self._context_manager.resolve_auto_compact_token_limit()
86
- )
87
- self._last_total_usage_tokens: 'typing.Union[int, None]' = None
88
- self.runtime_environment = runtime_environment
89
- self.interrupt_asap = False
90
- self._turn_running = False
91
- exec_command_tool = self._tool_registry.get_tool("exec_command")
92
- self._exec_manager = (
93
- exec_command_tool._manager
94
- if isinstance(exec_command_tool, ExecCommandTool)
95
- else None
89
+ model_client: "ModelClient",
90
+ tool_registry: "ToolRegistry",
91
+ context_config: "ContextConfig",
92
+ parallel_tool_calls: "bool" = True,
93
+ event_handler: "EventHandler" = BASE_EVENT_HANDLER,
94
+ initial_history: "typing.Tuple[ConversationItem, ...]" = (),
95
+ session_file_path: "typing.Union[str, Path, None]" = None,
96
+ session_id: "typing.Union[str, None]" = None,
97
+ ) -> "None":
98
+ self.model_client = model_client
99
+ self.tool_registry = tool_registry
100
+ self.context_manager = ContextManager(
101
+ replace(context_config, model=model_client.model)
96
102
  )
97
- if self._exec_manager is not None:
98
- self._exec_manager.set_notify_hook(self.maybe_invoke)
103
+ self._parallel_tool_calls = parallel_tool_calls
104
+ self.event_handler = event_handler
105
+ self._history: "typing.List[ConversationItem]" = list(initial_history)
106
+ self._configure_recording(session_id or uuid7_string(), session_file_path)
107
+ self._last_total_usage_tokens: "typing.Union[int, None]" = None
108
+ self._idle: "typing.Union[asyncio.Event, None]" = None
109
+ self.is_shutdown = False
110
+ self.accepts_input = True
111
+ self._stop_requested = False
112
+ for tool in self.tool_registry.tools():
113
+ tool.bind_agent(self)
99
114
 
100
115
  @property
101
- def history(self) -> 'typing.Tuple[ConversationItem, ...]':
116
+ def history(self) -> "typing.Tuple[ConversationItem, ...]":
102
117
  return tuple(self._history)
103
118
 
104
- def set_event_handler(
105
- self, event_handler: 'EventHandler' = BASE_EVENT_HANDLER
106
- ) -> 'None':
107
- self._event_handler = event_handler
119
+ @property
120
+ def session_file_path(self) -> "typing.Union[Path, None]":
121
+ recorder = self._rollout_recorder
122
+ return recorder.rollout_path if recorder is not None else None
108
123
 
109
- def replace_history(
110
- self,
111
- history: 'typing.Iterable[ConversationItem]',
112
- ) -> 'None':
113
- self._history = list(history)
124
+ @property
125
+ def recorded_session_file_path(self) -> "typing.Union[Path, None]":
126
+ recorder = self._rollout_recorder
127
+ if recorder is None or recorder._session_meta is not None:
128
+ return None
129
+ return recorder.rollout_path
114
130
 
115
- def set_rollout_recorder(
116
- self,
117
- rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]',
118
- ) -> 'None':
119
- self._rollout_recorder = rollout_recorder
131
+ @property
132
+ def is_running(self) -> "bool":
133
+ return self._idle is not None
134
+
135
+ async def wait_until_idle(self) -> "None":
136
+ while self.is_running:
137
+ await self._idle.wait()
138
+
139
+ def stop_asap(self) -> "None":
140
+ if self.is_running:
141
+ self._stop_requested = True
142
+
143
+ @property
144
+ def model_name(self) -> "str":
145
+ return self.model_client.model
146
+
147
+ def set_model(self, model: "str") -> "None":
148
+ if self.is_running:
149
+ raise RuntimeError("cannot change model while agent is running")
150
+ client = typing.cast(ModelControl, self.model_client)
151
+ client.model = model
152
+ self.context_manager.set_model(model)
153
+ self._last_total_usage_tokens = None
120
154
 
121
- def ask(self, text: 'str') -> 'TurnResult':
155
+ def resume(
156
+ self, session_file_path: "typing.Union[str, Path, None]" = None
157
+ ) -> "typing.Union[typing.Dict[str, object], None]":
158
+ if self.is_running:
159
+ raise RuntimeError("cannot restore session while agent is running")
160
+ resumed = None
161
+ if session_file_path is not None:
162
+ resumed = load_resumed_session_path(session_file_path)
163
+ session_id = str(resumed["session_id"])
164
+ if not session_id:
165
+ raise ValueError("session file has no session ID")
166
+ restored_history = list(resumed["history"])
167
+ self._configure_recording(session_id, resumed["rollout_path"], resume=True)
168
+ self._history = restored_history
169
+ self._last_total_usage_tokens = None
170
+ self.is_shutdown = False
171
+ self.accepts_input = True
172
+ for tool in self.tool_registry.tools():
173
+ tool.bind_agent(self)
174
+ return resumed
175
+
176
+ def ask(self, text: "str") -> "TurnResult":
122
177
  from .utils.async_bridge import run_async
123
178
 
124
179
  return run_async(self.run_turn([text]))
125
180
 
126
- def _raise_if_interrupt_requested(
127
- self,
128
- turn_id: 'str',
129
- iteration: 'int',
130
- output_text: 'typing.Union[str, None]' = None,
131
- ) -> 'None':
132
- if self.interrupt_asap:
133
- self.interrupt_asap = False
134
- payload: 'typing.Dict[str, object]' = {"iteration": iteration}
135
- if output_text is not None:
136
- payload["output_text"] = output_text
137
- self._emit("turn_interrupted", turn_id, **payload)
138
- raise TurnInterrupted("turn interrupted")
139
-
140
- async def run_turn(
141
- self, texts: 'typing.List[str]', turn_id: 'typing.Union[str, None]' = None
142
- ) -> 'TurnResult':
143
- self._turn_running = True
144
- turn_id = turn_id or uuid7_string()
145
- self.interrupt_asap = False
146
- new_user_messages = [UserMessage(text=text) for text in texts]
181
+ def fork(self) -> "None":
182
+ if self.is_running:
183
+ raise RuntimeError("cannot fork session while agent is running")
184
+ session_id = uuid7_string()
185
+ path = None
186
+ if self._rollout_recorder is not None:
187
+ path = rollout_path_for_session(
188
+ self.context_manager._config.codex_home or resolve_codex_home(),
189
+ session_id,
190
+ )
191
+ self._configure_recording(session_id, path)
147
192
 
148
- self._emit(
149
- "turn_started",
150
- turn_id,
151
- user_text="\n".join(texts),
152
- user_texts=list(texts),
153
- )
154
- await self._maybe_auto_compact(turn_id, phase="pre_turn")
155
- self._history.extend(new_user_messages)
156
- self._persist_history_items(new_user_messages)
193
+ def _configure_recording(
194
+ self,
195
+ session_id: "str",
196
+ session_file_path: "typing.Union[str, Path, None]" = None,
197
+ resume: "bool" = False,
198
+ ) -> "None":
199
+ if session_file_path is None:
200
+ recorder = None
201
+ elif resume:
202
+ recorder = SessionRolloutRecorder.resume(session_file_path)
203
+ else:
204
+ recorder = SessionRolloutRecorder.create(
205
+ self.context_manager._config.codex_home or resolve_codex_home(),
206
+ session_id,
207
+ self.context_manager.cwd,
208
+ getattr(self.model_client, "_originator", DEFAULT_ORIGINATOR),
209
+ getattr(
210
+ getattr(self.model_client, "_config", None), "provider_name", None
211
+ ),
212
+ self.context_manager.resolve_base_instructions(),
213
+ session_file_path,
214
+ )
215
+ if hasattr(self.model_client, "_session_id"):
216
+ self.model_client._session_id = session_id
217
+ self.session_id = session_id
218
+ self._rollout_recorder = recorder
157
219
 
158
- last_assistant_message: 'typing.Union[str, None]' = None
159
- final_response_items: 'typing.Tuple[\n typing.Union[typing.Union[AssistantMessage, ToolCall], ReasoningItem], ...\n]' = ()
220
+ async def compact(
221
+ self,
222
+ prune_tool_results_on_context_error: "bool" = True,
223
+ ) -> "typing.Union[CompactResult, None]":
224
+ if self.is_shutdown:
225
+ raise RuntimeError("agent is shutdown")
226
+ if self.is_running:
227
+ raise RuntimeError("cannot compact while agent is running")
228
+ self._idle = asyncio.Event()
229
+ try:
230
+ return await self._compact_history(
231
+ uuid7_string(),
232
+ "manual",
233
+ None,
234
+ None,
235
+ prune_tool_results_on_context_error,
236
+ )
237
+ finally:
238
+ self._idle.set()
239
+ self._idle = None
160
240
 
161
- iteration = 0
241
+ async def run_turn(
242
+ self, texts: "typing.List[str]", turn_id: "typing.Union[str, None]" = None
243
+ ) -> "TurnResult":
244
+ if self.is_shutdown:
245
+ raise RuntimeError("agent is shutdown")
246
+ if self.is_running:
247
+ raise RuntimeError("agent already has an active turn")
248
+ self._stop_requested = False
249
+ self._idle = asyncio.Event()
250
+ turn = _TurnState(turn_id or uuid7_string())
162
251
  try:
252
+ self._emit(TurnStartedEvent(turn.turn_id, tuple(texts)))
163
253
  while True:
164
- self._raise_if_interrupt_requested(
165
- turn_id,
166
- iteration,
167
- output_text=last_assistant_message,
168
- )
169
- await self._maybe_auto_compact(turn_id, phase="mid_turn")
170
- iteration += 1
171
- response = await self._complete_model_request(
172
- turn_id,
173
- iteration,
174
- )
175
- final_response_items = tuple(response.items)
254
+ phase = "pre_turn" if turn.iteration == 0 else "mid_turn"
255
+ await self._maybe_auto_compact(turn.turn_id, phase)
256
+ if turn.iteration == 0:
257
+ self._append_history(UserMessage(text=text) for text in texts)
258
+ response = await self._sample(turn)
176
259
  self._emit(
177
- "model_completed",
178
- turn_id,
179
- iteration=iteration,
180
- item_count=len(response.items),
260
+ ModelCompletedEvent(
261
+ turn.turn_id, turn.iteration, len(response.items)
262
+ )
181
263
  )
182
-
183
- recorded_items = self._record_model_response_items(response.items)
184
- tool_calls = recorded_items[1]
185
- if recorded_items[2] is not None:
186
- last_assistant_message = recorded_items[2]
187
-
264
+ self._append_history(response.items)
265
+ tool_calls = []
266
+ for item in response.items:
267
+ if isinstance(item, AssistantMessage):
268
+ turn.output_text = item.text
269
+ elif isinstance(item, ToolCall):
270
+ tool_calls.append(item)
271
+
272
+ if tool_calls:
273
+ await self._execute_tool_batch(turn.turn_id, tool_calls)
274
+ if self._stop_requested:
275
+ raise TurnInterrupted()
188
276
  if not tool_calls:
189
- self._raise_if_interrupt_requested(
190
- turn_id,
191
- iteration,
192
- output_text=last_assistant_message,
193
- )
194
- self._emit(
195
- "turn_completed",
196
- turn_id,
197
- iteration=iteration,
198
- output_text=last_assistant_message,
199
- )
200
- self._turn_running = False
201
- return TurnResult(
202
- turn_id=turn_id,
203
- output_text=last_assistant_message,
204
- iterations=iteration,
205
- response_items=final_response_items,
206
- history=tuple(self._history),
207
- )
277
+ break
208
278
 
209
- tool_results = await self._execute_tool_batch(turn_id, tool_calls)
210
- tool_results = truncate_tool_results_for_history(tool_results)
211
- self._history.extend(tool_results)
212
- self._persist_history_items(tool_results)
213
- follow_up_messages = self._build_follow_up_messages(tool_results)
214
- self._history.extend(follow_up_messages)
215
- self._persist_history_items(follow_up_messages)
216
- self._raise_if_interrupt_requested(
217
- turn_id,
218
- iteration,
219
- output_text=last_assistant_message,
279
+ self._emit(
280
+ TurnCompletedEvent(
281
+ turn.turn_id,
282
+ turn.iteration,
283
+ turn.output_text,
284
+ self._background_work_count(TurnCompletedEvent),
220
285
  )
286
+ )
287
+ result = TurnResult(
288
+ turn_id=turn.turn_id,
289
+ output_text=turn.output_text,
290
+ iterations=turn.iteration,
291
+ response_items=tuple(response.items),
292
+ history=self.history,
293
+ )
294
+ return result
221
295
  except TurnInterrupted:
222
- self._turn_running = False
223
- raise
224
- except asyncio.CancelledError:
225
- self._turn_running = False
296
+ self._emit(
297
+ TurnInterruptedEvent(
298
+ turn.turn_id,
299
+ turn.iteration,
300
+ turn.output_text,
301
+ self._background_work_count(TurnInterruptedEvent),
302
+ )
303
+ )
226
304
  raise
227
305
  except Exception as exc:
228
- context_usage = _usage_from_context_length_error(str(exc))
229
- if context_usage is not None:
230
- self._remember_token_usage(context_usage)
231
- self._emit("token_count", turn_id, usage=context_usage)
306
+ if isinstance(exc, ContextLengthExceeded) and exc.usage is not None:
307
+ self._remember_token_usage(exc.usage)
308
+ self._emit(TokenCountEvent(exc.usage, turn.turn_id))
232
309
  self._emit(
233
- "turn_failed",
234
- turn_id,
235
- iteration=iteration,
236
- error=str(exc),
237
- error_type=type(exc).__name__,
310
+ TurnFailedEvent(
311
+ turn.turn_id,
312
+ turn.iteration,
313
+ str(exc),
314
+ type(exc).__name__,
315
+ self._background_work_count(TurnFailedEvent),
316
+ )
238
317
  )
239
- self._turn_running = False
240
318
  raise
319
+ finally:
320
+ self._stop_requested = False
321
+ self._idle.set()
322
+ self._idle = None
323
+
324
+ async def _sample(self, turn: "_TurnState") -> "ModelResponse":
325
+ for attempt in range(2):
326
+ if self._stop_requested:
327
+ raise TurnInterrupted()
328
+ if attempt == 0:
329
+ turn.iteration += 1
330
+ try:
331
+ return await self._complete_model_request(turn.turn_id, turn.iteration)
332
+ except ContextLengthExceeded as exc:
333
+ if attempt == 1:
334
+ raise
335
+ if exc.usage is not None:
336
+ self._remember_token_usage(exc.usage)
337
+ self._emit(TokenCountEvent(exc.usage, turn.turn_id))
338
+ await self._compact_history(
339
+ turn.turn_id,
340
+ phase="context_length_exceeded",
341
+ total_tokens=(
342
+ exc.usage.get("total_tokens") if exc.usage is not None else None
343
+ ),
344
+ token_limit=exc.token_limit,
345
+ prune_tool_results_on_context_error=True,
346
+ )
241
347
 
242
- async def maybe_invoke(self, event: 'typing.Dict[str, object]') -> 'bool':
243
- if self._turn_running or event.get("type") != "exec_command_completed":
348
+ async def maybe_invoke(self, event: "typing.Dict[str, object]") -> "bool":
349
+ if self.is_running or not self.accepts_input:
244
350
  return False
245
- payload = {
246
- "session_id": event.get("session_id"),
247
- "exit_code": event.get("exit_code"),
248
- "command": event.get("command"),
249
- }
351
+ tag = event["type"]
352
+ if not isinstance(tag, str) or not tag.isidentifier():
353
+ raise ValueError("invoke event type must be an identifier")
354
+ payload = {key: value for key, value in event.items() if key != "type"}
250
355
  text = (
251
- "<exec_command_completed>\n"
356
+ f"<{tag}>\n"
252
357
  f"{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n"
253
- "</exec_command_completed>"
254
- )
255
- self._turn_running = True
256
- task = asyncio.create_task(self.run_turn([text]))
257
- task.add_done_callback(
258
- lambda task: None if task.cancelled() else task.exception()
358
+ f"</{tag}>"
259
359
  )
360
+ await self.run_turn([text])
260
361
  return True
261
362
 
363
+ def shutdown(self) -> "None":
364
+ if self.is_shutdown:
365
+ return
366
+ self.accepts_input = False
367
+ self.is_shutdown = True
368
+ for tool in self.tool_registry.tools():
369
+ tool.shutdown()
370
+
262
371
  async def _execute_tool_batch(
263
372
  self,
264
- turn_id: 'str',
265
- tool_calls: 'typing.List[ToolCall]',
266
- ) -> 'typing.List[ToolResult]':
267
- results: 'typing.List[ToolResult]' = []
268
- parallel_batch: 'typing.List[ToolCall]' = []
269
-
373
+ turn_id: "str",
374
+ tool_calls: "typing.List[ToolCall]",
375
+ ) -> "None":
376
+ batches: "typing.List[typing.List[ToolCall]]" = []
377
+ parallel_batch: "typing.List[ToolCall]" = []
270
378
  for call in tool_calls:
271
379
  can_run_parallel = (
272
380
  self._parallel_tool_calls
273
- and self._tool_registry.supports_parallel(call.name)
381
+ and self.tool_registry.supports_parallel(call.name)
274
382
  )
275
383
  if can_run_parallel:
276
384
  parallel_batch.append(call)
277
385
  continue
278
386
 
279
387
  if parallel_batch:
280
- prior_results = tuple(results)
281
- results.extend(
282
- await asyncio.gather(
283
- *(
284
- self._run_single_tool(turn_id, batched_call, prior_results)
285
- for batched_call in parallel_batch
286
- )
287
- )
288
- )
388
+ batches.append(parallel_batch)
289
389
  parallel_batch = []
290
- results.append(await self._run_single_tool(turn_id, call, tuple(results)))
291
-
390
+ batches.append([call])
292
391
  if parallel_batch:
293
- prior_results = tuple(results)
294
- results.extend(
295
- await asyncio.gather(
296
- *(
297
- self._run_single_tool(turn_id, batched_call, prior_results)
298
- for batched_call in parallel_batch
299
- )
300
- )
392
+ batches.append(parallel_batch)
393
+
394
+ results: "typing.List[ToolResult]" = []
395
+ for batch in batches:
396
+ context = ToolContext(
397
+ turn_id=turn_id,
398
+ history=self.history,
399
+ )
400
+ if len(batch) == 1:
401
+ results.append(await self._run_single_tool(turn_id, batch[0], context))
402
+ continue
403
+ outcomes = await asyncio.gather(
404
+ *(self._run_single_tool(turn_id, call, context) for call in batch),
405
+ return_exceptions=True,
301
406
  )
302
- return results
407
+ for outcome in outcomes:
408
+ if isinstance(outcome, BaseException):
409
+ raise outcome
410
+ results.append(outcome)
411
+ self._append_history(self.tool_registry.follow_up_messages(results))
303
412
 
304
413
  async def _run_single_tool(
305
414
  self,
306
- turn_id: 'str',
307
- call: 'ToolCall',
308
- prior_results: 'typing.Tuple[ToolResult, ...]' = (),
309
- ) -> 'ToolResult':
310
- payload: 'typing.Dict[str, object]' = {
311
- "tool_name": call.name,
312
- "call_id": call.call_id,
313
- "call": call,
314
- }
315
- self._emit("tool_started", turn_id, **payload)
316
- result = await self._tool_registry.execute(
317
- call,
318
- ToolContext(
319
- turn_id=turn_id,
320
- history=tuple(self._history) + prior_results,
321
- collaboration_mode=self._context_manager.collaboration_mode,
322
- ),
323
- )
324
- payload["result"] = result
325
- payload["is_error"] = result.is_error
326
- self._emit("tool_completed", turn_id, **payload)
415
+ turn_id: "str",
416
+ call: "ToolCall",
417
+ context: "ToolContext",
418
+ ) -> "ToolResult":
419
+ self._emit(ToolStartedEvent(turn_id, call))
420
+ result = await self.tool_registry.execute(call, context)
421
+ self._append_history([truncate_tool_result_for_history(result)])
422
+ self._emit(ToolCompletedEvent(turn_id, call, result))
327
423
  return result
328
424
 
329
- def _emit(self, kind: 'str', turn_id: 'str', **payload: 'object') -> 'None':
330
- if kind in TERMINAL_TURN_EVENTS:
331
- payload["background_exec_count"] = self._background_exec_count()
332
- self._event_handler(
333
- AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
334
- )
335
-
336
- def _background_exec_count(self) -> 'int':
337
- manager: 'typing.Union[UnifiedExecManager, None]' = self._exec_manager
338
- if manager is None:
339
- return 0
340
- return manager.running_session_count()
425
+ def _emit(self, event: "TurnEvent") -> "None":
426
+ handlers = [self.event_handler]
427
+ if isinstance(event, (TerminalEvent, TurnStartedEvent, CompactStartedEvent)):
428
+ handlers = [
429
+ tool.handle_agent_event for tool in self.tool_registry.tools()
430
+ ] + handlers
431
+ for handler in handlers:
432
+ try:
433
+ handler(event)
434
+ except Exception as exc:
435
+ asyncio.get_running_loop().call_exception_handler(
436
+ {
437
+ "message": "Agent event observer failed: " + event.kind,
438
+ "exception": exc,
439
+ }
440
+ )
341
441
 
342
- def _persist_history_items(
343
- self,
344
- items: 'typing.Iterable[ConversationItem]',
345
- ) -> 'None':
346
- recorder = self._rollout_recorder
347
- if recorder is None:
348
- return
442
+ def _background_work_count(self, event_type) -> "typing.Union[int, None]":
349
443
  try:
350
- recorder.append_history_items(items)
351
- except Exception: # pragma: no cover - persistence should not break turns
352
- return
444
+ return sum(
445
+ tool.background_work_count(
446
+ event_type in (TurnCompletedEvent, CompactCompletedEvent)
447
+ )
448
+ for tool in self.tool_registry.tools()
449
+ )
450
+ except Exception as exc:
451
+ asyncio.get_running_loop().call_exception_handler(
452
+ {
453
+ "message": "Agent background-work observer failed: "
454
+ + event_type.kind,
455
+ "exception": exc,
456
+ }
457
+ )
458
+ return None
353
459
 
354
- def _record_model_response_items(
460
+ def _append_history(
355
461
  self,
356
- items: 'typing.Iterable[object]',
357
- ) -> 'typing.Tuple[typing.Tuple[ConversationItem, ...], typing.List[ToolCall], typing.Union[str, None]]':
358
- persisted_response_items: 'typing.List[ConversationItem]' = []
359
- tool_calls: 'typing.List[ToolCall]' = []
360
- last_assistant_message = None
361
- for item in items:
362
- if not isinstance(item, (AssistantMessage, ToolCall, ReasoningItem)):
363
- continue
364
- self._history.append(item)
365
- persisted_response_items.append(item)
366
- if isinstance(item, AssistantMessage):
367
- last_assistant_message = item.text
368
- elif isinstance(item, ToolCall):
369
- tool_calls.append(item)
370
- self._persist_history_items(persisted_response_items)
371
- return tuple(persisted_response_items), tool_calls, last_assistant_message
372
-
373
- def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
374
- if event.kind == "token_count":
375
- self._remember_token_usage(event.payload.get("usage"))
376
- if event.kind == "assistant_delta":
377
- self._emit("assistant_delta", turn_id, **event.payload)
378
- elif event.kind == "tool_call":
379
- self._emit("tool_called", turn_id, **event.payload)
380
- elif event.kind == "token_count":
381
- self._emit("token_count", turn_id, **event.payload)
382
- elif event.kind == "stream_error":
383
- self._emit("stream_error", turn_id, **event.payload)
384
-
385
- def _remember_token_usage(self, usage: 'object') -> 'None':
462
+ items: "typing.Iterable[ConversationItem]",
463
+ ) -> "None":
464
+ items = tuple(items)
465
+ if self._rollout_recorder is not None:
466
+ self._rollout_recorder.append_history_items(items, self._history)
467
+ self._history.extend(items)
468
+
469
+ def _handle_model_stream_event(self, turn_id: "str", event: "ModelEvent") -> "None":
470
+ if isinstance(event, TokenCountEvent):
471
+ self._remember_token_usage(event.usage)
472
+ self._emit(replace(event, turn_id=turn_id))
473
+
474
+ def _remember_token_usage(self, usage: "object") -> "None":
386
475
  if not isinstance(usage, dict):
387
476
  return
388
477
  try:
@@ -392,67 +481,45 @@ class Agent:
392
481
 
393
482
  async def _complete_model_request(
394
483
  self,
395
- turn_id: 'str',
396
- iteration: 'int',
397
- ) -> 'typing.Any':
398
- attempted_context_compact = False
399
- while True:
400
- prompt = self._context_manager.build_prompt(
401
- self._history,
402
- self._tool_registry.model_visible_specs(),
403
- self._parallel_tool_calls,
404
- turn_id=turn_id,
405
- )
406
- self._emit(
407
- "model_called",
408
- turn_id,
409
- iteration=iteration,
410
- history_size=len(prompt.input),
411
- tool_count=len(prompt.tools),
484
+ turn_id: "str",
485
+ iteration: "int",
486
+ ) -> "ModelResponse":
487
+ prompt = self.context_manager.build_prompt(
488
+ self._history,
489
+ self.tool_registry.model_visible_specs(),
490
+ self._parallel_tool_calls,
491
+ turn_id=turn_id,
492
+ )
493
+ self._emit(
494
+ ModelCalledEvent(turn_id, iteration, len(prompt.input), len(prompt.tools))
495
+ )
496
+ try:
497
+ return await self.model_client.complete(
498
+ prompt,
499
+ lambda event: self._handle_model_stream_event(turn_id, event),
412
500
  )
413
- try:
414
- return await self._model_client.complete(
415
- prompt,
416
- lambda event: self._handle_model_stream_event(turn_id, event),
501
+ except ResponsesIncompleteError as exc:
502
+ if exc.reason == "max_output_tokens":
503
+ self._append_history(
504
+ item
505
+ for item in exc.partial_items
506
+ if isinstance(item, (AssistantMessage, ReasoningItem))
417
507
  )
418
- except Exception as exc:
419
- error_message = str(exc)
420
- if (
421
- not _is_context_length_error_message(error_message)
422
- or attempted_context_compact
423
- ):
424
- raise
425
- attempted_context_compact = True
426
- context_usage = _usage_from_context_length_error(error_message)
427
- if context_usage is not None:
428
- self._remember_token_usage(context_usage)
429
- self._emit("token_count", turn_id, usage=context_usage)
430
- await self._run_auto_compact(
431
- turn_id,
432
- phase="context_length_exceeded",
433
- total_tokens=(
434
- context_usage.get("total_tokens")
435
- if context_usage is not None
436
- else None
437
- ),
438
- token_limit=_context_length_error_token_limit(error_message),
439
- prune_tool_results_on_context_error=True,
440
- )
441
- self._raise_if_interrupt_requested(turn_id, iteration)
508
+ raise
442
509
 
443
510
  async def _maybe_auto_compact(
444
511
  self,
445
- turn_id: 'str',
446
- phase: 'str',
447
- ) -> 'None':
448
- limit = self._auto_compact_token_limit
512
+ turn_id: "str",
513
+ phase: "str",
514
+ ) -> "None":
515
+ limit = self.context_manager.resolve_auto_compact_token_limit()
449
516
  total_tokens = self._last_total_usage_tokens
450
517
  if limit is None or total_tokens is None:
451
518
  return
452
519
  if total_tokens < limit or not self._history:
453
520
  return
454
521
 
455
- await self._run_auto_compact(
522
+ await self._compact_history(
456
523
  turn_id,
457
524
  phase=phase,
458
525
  total_tokens=total_tokens,
@@ -460,134 +527,73 @@ class Agent:
460
527
  prune_tool_results_on_context_error=True,
461
528
  )
462
529
 
463
- async def _run_auto_compact(
530
+ async def _compact_history(
464
531
  self,
465
- turn_id: 'str',
466
- phase: 'str',
467
- total_tokens: 'typing.Union[int, None]' = None,
468
- token_limit: 'typing.Union[int, None]' = None,
469
- prune_tool_results_on_context_error: 'bool' = False,
470
- ) -> 'None':
471
- from .utils.compactor import compact_agent
472
-
473
- payload: 'typing.Dict[str, object]' = {"phase": phase}
474
- if total_tokens is not None:
475
- payload["total_tokens"] = total_tokens
476
- if token_limit is not None:
477
- payload["token_limit"] = token_limit
478
- self._emit(
479
- "auto_compact_started",
480
- turn_id,
481
- **payload,
532
+ turn_id: "str",
533
+ phase: "str",
534
+ total_tokens: "typing.Union[int, None]" = None,
535
+ token_limit: "typing.Union[int, None]" = None,
536
+ prune_tool_results_on_context_error: "bool" = False,
537
+ ) -> "typing.Union[CompactResult, None]":
538
+ from .utils.compactor import compact_history
539
+
540
+ if not self._history:
541
+ return None
542
+ details = (turn_id, phase, total_tokens, token_limit)
543
+ started_type = (
544
+ CompactStartedEvent if phase == "manual" else AutoCompactStartedEvent
482
545
  )
546
+ self._emit(started_type(*details))
483
547
 
484
- def handle_compact_stream_event(event: 'ModelStreamEvent') -> 'None':
485
- if event.kind == "stream_error":
486
- self._emit("stream_error", turn_id, **event.payload)
548
+ def handle_compact_stream_event(event: "ModelEvent") -> "None":
549
+ if isinstance(event, StreamErrorEvent) or (
550
+ phase == "manual" and isinstance(event, TokenCountEvent)
551
+ ):
552
+ self._emit(replace(event, turn_id=turn_id))
487
553
 
488
554
  try:
489
- compact_result = await compact_agent(
490
- self,
555
+ recorder = self._rollout_recorder
556
+ compact_result = await compact_history(
557
+ self.history,
558
+ self.model_client,
559
+ self.context_manager,
491
560
  handle_compact_stream_event,
492
561
  prune_tool_results_on_context_error,
493
- )
494
- except Exception as exc:
495
- failed_payload = dict(payload)
496
- failed_payload.update(
497
- {
498
- "error": str(exc),
499
- "error_type": type(exc).__name__,
500
- }
501
- )
502
- self._emit(
503
- "auto_compact_failed",
562
+ str(recorder.rollout_path) if recorder is not None else None,
504
563
  turn_id,
505
- **failed_payload,
506
564
  )
565
+ if recorder is not None:
566
+ recorder.append_compacted_history(compact_result.history, self._history)
567
+ self._history = list(compact_result.history)
568
+ self._last_total_usage_tokens = None
569
+ except Exception as exc:
570
+ if phase == "manual":
571
+ self._emit(
572
+ CompactFailedEvent(
573
+ *details,
574
+ str(exc),
575
+ type(exc).__name__,
576
+ self._background_work_count(CompactFailedEvent),
577
+ )
578
+ )
579
+ else:
580
+ self._emit(
581
+ AutoCompactFailedEvent(*details, str(exc), type(exc).__name__)
582
+ )
507
583
  raise
508
584
 
509
- self._last_total_usage_tokens = None
510
- if compact_result is None:
511
- return
512
- completed_payload = dict(payload)
513
- completed_payload.update(
514
- {
515
- "original_item_count": compact_result.original_item_count,
516
- "retained_item_count": compact_result.retained_item_count,
517
- "summary": compact_result.display_text(),
518
- }
585
+ completed = details + (
586
+ compact_result.original_item_count,
587
+ compact_result.retained_item_count,
588
+ compact_result.pruned_tool_results,
519
589
  )
520
- if compact_result.pruned_tool_results:
521
- completed_payload["pruned_tool_results"] = compact_result.pruned_tool_results
522
- self._emit(
523
- "auto_compact_completed",
524
- turn_id,
525
- **completed_payload,
526
- )
527
-
528
- def _build_follow_up_messages(
529
- self,
530
- tool_results: 'typing.List[ToolResult]',
531
- ) -> 'typing.List[UserMessage]':
532
- follow_ups: 'typing.List[UserMessage]' = []
533
- for result in tool_results:
534
- statuses = None
535
- if (
536
- result.name == "wait_agent"
537
- and not result.is_error
538
- and isinstance(result.output, dict)
539
- ):
540
- statuses = result.output.get("status")
541
- if isinstance(statuses, dict):
542
- for agent_id, status in statuses.items():
543
- if isinstance(agent_id, str) and isinstance(status, dict):
544
- payload = {
545
- "agent_id": agent_id,
546
- "status": status,
547
- }
548
- follow_ups.append(
549
- UserMessage(
550
- text=(
551
- "<subagent_notification>\n"
552
- f"{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n"
553
- "</subagent_notification>"
554
- )
555
- )
556
- )
557
- return follow_ups
558
-
559
-
560
- def _usage_from_context_length_error(
561
- message: 'str',
562
- ) -> 'typing.Union[typing.Dict[str, int], None]':
563
- if not _is_context_length_error_message(message):
564
- return None
565
-
566
- requested_match = _REQUESTED_TOKENS_RE.search(message)
567
- if requested_match is None:
568
- return None
569
-
570
- usage = {"total_tokens": _parse_token_count(requested_match.group(1))}
571
- split_match = _REQUESTED_TOKEN_SPLIT_RE.search(message)
572
- if split_match is not None:
573
- usage["input_tokens"] = _parse_token_count(split_match.group(1))
574
- usage["output_tokens"] = _parse_token_count(split_match.group(2))
575
- else:
576
- usage["input_tokens"] = usage["total_tokens"]
577
- return usage
578
-
579
-
580
- def _is_context_length_error_message(message: 'str') -> 'bool':
581
- lower = message.lower()
582
- return any(marker in lower for marker in _CONTEXT_LENGTH_ERROR_MARKERS)
583
-
584
-
585
- def _context_length_error_token_limit(message: 'str') -> 'typing.Union[int, None]':
586
- limit_match = _MAX_CONTEXT_TOKENS_RE.search(message)
587
- if limit_match is None:
588
- return None
589
- return _parse_token_count(limit_match.group(1))
590
-
591
-
592
- def _parse_token_count(value: 'str') -> 'int':
593
- return int(value.replace(",", ""))
590
+ if phase == "manual":
591
+ self._emit(
592
+ CompactCompletedEvent(
593
+ *completed,
594
+ self._background_work_count(CompactCompletedEvent),
595
+ )
596
+ )
597
+ else:
598
+ self._emit(AutoCompactCompletedEvent(*completed))
599
+ return compact_result