python-codex 0.1.12__py3-none-any.whl → 0.1.14__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 (53) hide show
  1. pycodex/__init__.py +10 -8
  2. pycodex/agent.py +118 -29
  3. pycodex/cli.py +97 -387
  4. pycodex/compat.py +8 -4
  5. pycodex/feishu_card.py +739 -0
  6. pycodex/feishu_link.py +462 -0
  7. pycodex/interactive_session.py +397 -0
  8. pycodex/model.py +71 -7
  9. pycodex/prompts/models.json +4 -4
  10. pycodex/protocol.py +17 -22
  11. pycodex/runtime.py +22 -14
  12. pycodex/runtime_services.py +47 -25
  13. pycodex/tools/agent_tool_schemas.py +1 -1
  14. pycodex/tools/apply_patch_tool.py +12 -13
  15. pycodex/tools/base_tool.py +1 -27
  16. pycodex/tools/close_agent_tool.py +11 -4
  17. pycodex/tools/exec_command_tool.py +40 -16
  18. pycodex/tools/exec_tool.py +18 -2
  19. pycodex/tools/grep_files_tool.py +19 -6
  20. pycodex/tools/ipython_tool.py +145 -0
  21. pycodex/tools/list_dir_tool.py +19 -6
  22. pycodex/tools/read_file_tool.py +39 -9
  23. pycodex/tools/request_permissions_tool.py +12 -1
  24. pycodex/tools/request_user_input_tool.py +28 -1
  25. pycodex/tools/send_input_tool.py +4 -2
  26. pycodex/tools/shell_command_tool.py +23 -6
  27. pycodex/tools/shell_tool.py +13 -4
  28. pycodex/tools/spawn_agent_tool.py +31 -8
  29. pycodex/tools/unified_exec_manager.py +45 -1
  30. pycodex/tools/update_plan_tool.py +14 -6
  31. pycodex/tools/view_image_tool.py +17 -16
  32. pycodex/tools/wait_agent_tool.py +15 -3
  33. pycodex/tools/wait_tool.py +18 -4
  34. pycodex/tools/web_search_tool.py +2 -1
  35. pycodex/tools/write_stdin_tool.py +42 -10
  36. pycodex/utils/__init__.py +2 -13
  37. pycodex/utils/async_bridge.py +54 -0
  38. pycodex/utils/compactor.py +29 -10
  39. pycodex/utils/session_persist.py +57 -38
  40. pycodex/utils/toolcall_visualize.py +713 -0
  41. pycodex/utils/visualize.py +253 -872
  42. {python_codex-0.1.12.dist-info → python_codex-0.1.14.dist-info}/METADATA +4 -1
  43. python_codex-0.1.14.dist-info/RECORD +87 -0
  44. {python_codex-0.1.12.dist-info → python_codex-0.1.14.dist-info}/entry_points.txt +1 -0
  45. workspace_server/__init__.py +21 -0
  46. workspace_server/__main__.py +5 -0
  47. workspace_server/app.py +983 -0
  48. workspace_server/workspace.html +790 -0
  49. pycodex/prompts/exec_tools.json +0 -411
  50. pycodex/prompts/subagent_tools.json +0 -163
  51. python_codex-0.1.12.dist-info/RECORD +0 -79
  52. {python_codex-0.1.12.dist-info → python_codex-0.1.14.dist-info}/WHEEL +0 -0
  53. {python_codex-0.1.12.dist-info → python_codex-0.1.14.dist-info}/licenses/LICENSE +0 -0
pycodex/__init__.py CHANGED
@@ -2,12 +2,13 @@ from .compat import patch_asyncio
2
2
 
3
3
  patch_asyncio()
4
4
 
5
- from .agent import AgentLoop
5
+ from .agent import Agent
6
6
  from .context import ContextConfig, ContextManager
7
7
  from .model import (
8
8
  ModelClient,
9
9
  NOOP_MODEL_STREAM_EVENT_HANDLER,
10
10
  ResponsesApiError,
11
+ ResponsesIncompleteError,
11
12
  ResponsesModelClient,
12
13
  ResponsesProviderConfig,
13
14
  )
@@ -26,14 +27,14 @@ from .protocol import (
26
27
  TurnResult,
27
28
  UserMessage,
28
29
  )
29
- from .runtime import AgentRuntime
30
+ from .runtime import CliSubmissionQueue
30
31
  from .runtime_services import (
31
32
  PlanStore,
32
33
  RequestPermissionsManager,
33
34
  RequestUserInputManager,
34
35
  SubAgentManager,
35
- create_runtime_environment,
36
- get_runtime_environment,
36
+ create_agent_runtime_environment,
37
+ get_agent_runtime_environment,
37
38
  )
38
39
  from .tools import (
39
40
  ApplyPatchTool,
@@ -90,13 +91,13 @@ def debug(stop: 'bool' = False):
90
91
 
91
92
  __all__ = [
92
93
  "AgentEvent",
93
- "AgentLoop",
94
- "AgentRuntime",
94
+ "Agent",
95
+ "CliSubmissionQueue",
95
96
  "ApplyPatchTool",
96
97
  "AssistantMessage",
97
98
  "BaseTool",
98
99
  "CloseAgentTool",
99
- "create_runtime_environment",
100
+ "create_agent_runtime_environment",
100
101
  "CodeModeManager",
101
102
  "ContextConfig",
102
103
  "ContextManager",
@@ -120,6 +121,7 @@ __all__ = [
120
121
  "RequestUserInputManager",
121
122
  "ResumeAgentTool",
122
123
  "ResponsesApiError",
124
+ "ResponsesIncompleteError",
123
125
  "ResponsesModelClient",
124
126
  "ResponsesProviderConfig",
125
127
  "SendInputTool",
@@ -142,5 +144,5 @@ __all__ = [
142
144
  "WaitTool",
143
145
  "WebSearchTool",
144
146
  "WriteStdinTool",
145
- "get_runtime_environment",
147
+ "get_agent_runtime_environment",
146
148
  ]
pycodex/agent.py CHANGED
@@ -5,7 +5,7 @@ import re
5
5
  from typing import Callable
6
6
 
7
7
  from .context import ContextManager
8
- from .model import ModelClient
8
+ from .model import ModelClient, ResponsesIncompleteError
9
9
  from .protocol import (
10
10
  AgentEvent,
11
11
  AssistantMessage,
@@ -17,16 +17,17 @@ from .protocol import (
17
17
  TurnResult,
18
18
  UserMessage,
19
19
  )
20
- from .tools import ToolContext, ToolRegistry
20
+ from .tools import ExecCommandTool, ToolContext, ToolRegistry, UnifiedExecManager
21
21
  from .utils import uuid7_string
22
22
  import typing
23
23
 
24
24
  if typing.TYPE_CHECKING:
25
25
  from .utils.session_persist import SessionRolloutRecorder
26
+ from .runtime_services import AgentRuntimeEnvironment
26
27
 
27
28
 
28
29
  EventHandler = Callable[[AgentEvent], None]
29
- NOOP_EVENT_HANDLER: 'EventHandler' = lambda _event: None
30
+ BASE_EVENT_HANDLER: 'EventHandler' = lambda _event: None
30
31
  _REQUESTED_TOKENS_RE = re.compile(
31
32
  r"requested\s+([0-9,]+)\s+tokens",
32
33
  re.IGNORECASE,
@@ -39,13 +40,20 @@ _MAX_CONTEXT_TOKENS_RE = re.compile(
39
40
  r"maximum\s+context\s+length\s+is\s+([0-9,]+)\s+tokens",
40
41
  re.IGNORECASE,
41
42
  )
43
+ _CONTEXT_LENGTH_ERROR_MARKERS = (
44
+ "context_length_exceeded",
45
+ "maximum context length",
46
+ "exceeds the context window",
47
+ "exceeded the context window",
48
+ )
49
+ TERMINAL_TURN_EVENTS = {"turn_completed", "turn_failed", "turn_interrupted"}
42
50
 
43
51
 
44
52
  class TurnInterrupted(RuntimeError):
45
53
  pass
46
54
 
47
55
 
48
- class AgentLoop:
56
+ class Agent:
49
57
  """Minimal Python port of Codex's turn loop.
50
58
 
51
59
  The core idea mirrors the Rust implementation:
@@ -60,9 +68,10 @@ class AgentLoop:
60
68
  tool_registry: 'ToolRegistry',
61
69
  context_manager: 'typing.Union[ContextManager, None]' = None,
62
70
  parallel_tool_calls: 'bool' = True,
63
- event_handler: 'EventHandler' = NOOP_EVENT_HANDLER,
71
+ event_handler: 'EventHandler' = BASE_EVENT_HANDLER,
64
72
  initial_history: 'typing.Tuple[ConversationItem, ...]' = (),
65
73
  rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]' = None,
74
+ runtime_environment: 'AgentRuntimeEnvironment' = None,
66
75
  ) -> 'None':
67
76
  self._model_client = model_client
68
77
  self._tool_registry = tool_registry
@@ -75,14 +84,24 @@ class AgentLoop:
75
84
  self._context_manager.resolve_auto_compact_token_limit()
76
85
  )
77
86
  self._last_total_usage_tokens: 'typing.Union[int, None]' = None
87
+ self.runtime_environment = runtime_environment
78
88
  self.interrupt_asap = False
89
+ self._turn_running = False
90
+ exec_command_tool = self._tool_registry.get_tool("exec_command")
91
+ self._exec_manager = (
92
+ exec_command_tool._manager
93
+ if isinstance(exec_command_tool, ExecCommandTool)
94
+ else None
95
+ )
96
+ if self._exec_manager is not None:
97
+ self._exec_manager.set_notify_hook(self.maybe_invoke)
79
98
 
80
99
  @property
81
100
  def history(self) -> 'typing.Tuple[ConversationItem, ...]':
82
101
  return tuple(self._history)
83
102
 
84
103
  def set_event_handler(
85
- self, event_handler: 'EventHandler' = NOOP_EVENT_HANDLER
104
+ self, event_handler: 'EventHandler' = BASE_EVENT_HANDLER
86
105
  ) -> 'None':
87
106
  self._event_handler = event_handler
88
107
 
@@ -98,6 +117,11 @@ class AgentLoop:
98
117
  ) -> 'None':
99
118
  self._rollout_recorder = rollout_recorder
100
119
 
120
+ def ask(self, text: 'str') -> 'TurnResult':
121
+ from .utils.async_bridge import run_async
122
+
123
+ return run_async(self.run_turn([text]))
124
+
101
125
  def _raise_if_interrupt_requested(
102
126
  self,
103
127
  turn_id: 'str',
@@ -115,6 +139,7 @@ class AgentLoop:
115
139
  async def run_turn(
116
140
  self, texts: 'typing.List[str]', turn_id: 'typing.Union[str, None]' = None
117
141
  ) -> 'TurnResult':
142
+ self._turn_running = True
118
143
  turn_id = turn_id or uuid7_string()
119
144
  self.interrupt_asap = False
120
145
  new_user_messages = [UserMessage(text=text) for text in texts]
@@ -154,16 +179,10 @@ class AgentLoop:
154
179
  item_count=len(response.items),
155
180
  )
156
181
 
157
- tool_calls: 'typing.List[ToolCall]' = []
158
- persisted_response_items: 'typing.List[ConversationItem]' = []
159
- for item in response.items:
160
- self._history.append(item)
161
- persisted_response_items.append(item)
162
- if isinstance(item, AssistantMessage):
163
- last_assistant_message = item.text
164
- elif isinstance(item, ToolCall):
165
- tool_calls.append(item)
166
- self._persist_history_items(persisted_response_items)
182
+ recorded_items = self._record_model_response_items(response.items)
183
+ tool_calls = recorded_items[1]
184
+ if recorded_items[2] is not None:
185
+ last_assistant_message = recorded_items[2]
167
186
 
168
187
  if not tool_calls:
169
188
  self._raise_if_interrupt_requested(
@@ -177,6 +196,7 @@ class AgentLoop:
177
196
  iteration=iteration,
178
197
  output_text=last_assistant_message,
179
198
  )
199
+ self._turn_running = False
180
200
  return TurnResult(
181
201
  turn_id=turn_id,
182
202
  output_text=last_assistant_message,
@@ -197,6 +217,7 @@ class AgentLoop:
197
217
  output_text=last_assistant_message,
198
218
  )
199
219
  except TurnInterrupted:
220
+ self._turn_running = False
200
221
  raise
201
222
  except Exception as exc:
202
223
  context_usage = _usage_from_context_length_error(str(exc))
@@ -210,8 +231,29 @@ class AgentLoop:
210
231
  error=str(exc),
211
232
  error_type=type(exc).__name__,
212
233
  )
234
+ self._turn_running = False
213
235
  raise
214
236
 
237
+ async def maybe_invoke(self, event: 'typing.Dict[str, object]') -> 'bool':
238
+ if self._turn_running or event.get("type") != "exec_command_completed":
239
+ return False
240
+ payload = {
241
+ "session_id": event.get("session_id"),
242
+ "exit_code": event.get("exit_code"),
243
+ "command": event.get("command"),
244
+ }
245
+ text = (
246
+ "<exec_command_completed>\n"
247
+ f"{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n"
248
+ "</exec_command_completed>"
249
+ )
250
+ self._turn_running = True
251
+ task = asyncio.create_task(self.run_turn([text]))
252
+ task.add_done_callback(
253
+ lambda task: None if task.cancelled() else task.exception()
254
+ )
255
+ return True
256
+
215
257
  async def _execute_tool_batch(
216
258
  self,
217
259
  turn_id: 'str',
@@ -280,10 +322,18 @@ class AgentLoop:
280
322
  return result
281
323
 
282
324
  def _emit(self, kind: 'str', turn_id: 'str', **payload: 'object') -> 'None':
325
+ if kind in TERMINAL_TURN_EVENTS:
326
+ payload["background_exec_count"] = self._background_exec_count()
283
327
  self._event_handler(
284
328
  AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
285
329
  )
286
330
 
331
+ def _background_exec_count(self) -> 'int':
332
+ manager: 'typing.Union[UnifiedExecManager, None]' = self._exec_manager
333
+ if manager is None:
334
+ return 0
335
+ return manager.running_session_count()
336
+
287
337
  def _persist_history_items(
288
338
  self,
289
339
  items: 'typing.Iterable[ConversationItem]',
@@ -296,6 +346,28 @@ class AgentLoop:
296
346
  except Exception: # pragma: no cover - persistence should not break turns
297
347
  return
298
348
 
349
+ def _record_model_response_items(
350
+ self,
351
+ items: 'typing.Iterable[object]',
352
+ include_tool_calls: 'bool' = True,
353
+ ) -> 'typing.Tuple[typing.Tuple[ConversationItem, ...], typing.List[ToolCall], typing.Union[str, None]]':
354
+ persisted_response_items: 'typing.List[ConversationItem]' = []
355
+ tool_calls: 'typing.List[ToolCall]' = []
356
+ last_assistant_message = None
357
+ for item in items:
358
+ if isinstance(item, ToolCall) and not include_tool_calls:
359
+ continue
360
+ if not isinstance(item, (AssistantMessage, ToolCall, ReasoningItem)):
361
+ continue
362
+ self._history.append(item)
363
+ persisted_response_items.append(item)
364
+ if isinstance(item, AssistantMessage):
365
+ last_assistant_message = item.text
366
+ elif isinstance(item, ToolCall):
367
+ tool_calls.append(item)
368
+ self._persist_history_items(persisted_response_items)
369
+ return tuple(persisted_response_items), tool_calls, last_assistant_message
370
+
299
371
  def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
300
372
  if event.kind == "token_count":
301
373
  self._remember_token_usage(event.payload.get("usage"))
@@ -341,18 +413,34 @@ class AgentLoop:
341
413
  prompt,
342
414
  lambda event: self._handle_model_stream_event(turn_id, event),
343
415
  )
416
+ except ResponsesIncompleteError as exc:
417
+ if exc.reason == "max_output_tokens":
418
+ self._record_model_response_items(
419
+ exc.partial_items,
420
+ include_tool_calls=False,
421
+ )
422
+ raise
344
423
  except Exception as exc:
345
- context_usage = _usage_from_context_length_error(str(exc))
346
- if context_usage is None or attempted_context_compact:
424
+ error_message = str(exc)
425
+ if (
426
+ not _is_context_length_error_message(error_message)
427
+ or attempted_context_compact
428
+ ):
347
429
  raise
348
430
  attempted_context_compact = True
349
- self._remember_token_usage(context_usage)
350
- self._emit("token_count", turn_id, usage=context_usage)
431
+ context_usage = _usage_from_context_length_error(error_message)
432
+ if context_usage is not None:
433
+ self._remember_token_usage(context_usage)
434
+ self._emit("token_count", turn_id, usage=context_usage)
351
435
  await self._run_auto_compact(
352
436
  turn_id,
353
437
  phase="context_length_exceeded",
354
- total_tokens=context_usage.get("total_tokens"),
355
- token_limit=_context_length_error_token_limit(str(exc)),
438
+ total_tokens=(
439
+ context_usage.get("total_tokens")
440
+ if context_usage is not None
441
+ else None
442
+ ),
443
+ token_limit=_context_length_error_token_limit(error_message),
356
444
  prune_tool_results_on_context_error=True,
357
445
  )
358
446
  self._raise_if_interrupt_requested(turn_id, iteration)
@@ -385,7 +473,7 @@ class AgentLoop:
385
473
  token_limit: 'typing.Union[int, None]' = None,
386
474
  prune_tool_results_on_context_error: 'bool' = False,
387
475
  ) -> 'None':
388
- from .utils.compactor import compact_agent_loop
476
+ from .utils.compactor import compact_agent
389
477
 
390
478
  payload: 'typing.Dict[str, object]' = {"phase": phase}
391
479
  if total_tokens is not None:
@@ -403,7 +491,7 @@ class AgentLoop:
403
491
  self._emit("stream_error", turn_id, **event.payload)
404
492
 
405
493
  try:
406
- compact_result = await compact_agent_loop(
494
+ compact_result = await compact_agent(
407
495
  self,
408
496
  handle_compact_stream_event,
409
497
  prune_tool_results_on_context_error,
@@ -477,11 +565,7 @@ class AgentLoop:
477
565
  def _usage_from_context_length_error(
478
566
  message: 'str',
479
567
  ) -> 'typing.Union[typing.Dict[str, int], None]':
480
- lower = message.lower()
481
- if (
482
- "context_length_exceeded" not in lower
483
- and "maximum context length" not in lower
484
- ):
568
+ if not _is_context_length_error_message(message):
485
569
  return None
486
570
 
487
571
  requested_match = _REQUESTED_TOKENS_RE.search(message)
@@ -498,6 +582,11 @@ def _usage_from_context_length_error(
498
582
  return usage
499
583
 
500
584
 
585
+ def _is_context_length_error_message(message: 'str') -> 'bool':
586
+ lower = message.lower()
587
+ return any(marker in lower for marker in _CONTEXT_LENGTH_ERROR_MARKERS)
588
+
589
+
501
590
  def _context_length_error_token_limit(message: 'str') -> 'typing.Union[int, None]':
502
591
  limit_match = _MAX_CONTEXT_TOKENS_RE.search(message)
503
592
  if limit_match is None: