python-codex 0.2.7__py3-none-any.whl → 0.3.0__py3-none-any.whl

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