python-codex 0.1.13__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. pycodex/agent.py +71 -11
  2. pycodex/cli.py +16 -356
  3. pycodex/context.py +12 -0
  4. pycodex/feishu_card.py +76 -30
  5. pycodex/feishu_link.py +131 -11
  6. pycodex/interactive_session.py +397 -0
  7. pycodex/model.py +11 -22
  8. pycodex/protocol.py +0 -5
  9. pycodex/runtime.py +23 -0
  10. pycodex/runtime_services.py +2 -2
  11. pycodex/tools/agent_tool_schemas.py +1 -1
  12. pycodex/tools/apply_patch_tool.py +1 -1
  13. pycodex/tools/base_tool.py +1 -27
  14. pycodex/tools/close_agent_tool.py +11 -4
  15. pycodex/tools/code_mode_manager.py +1 -1
  16. pycodex/tools/exec_command_tool.py +40 -16
  17. pycodex/tools/exec_tool.py +18 -2
  18. pycodex/tools/grep_files_tool.py +19 -6
  19. pycodex/tools/ipython_tool.py +3 -2
  20. pycodex/tools/list_dir_tool.py +19 -6
  21. pycodex/tools/read_file_tool.py +39 -9
  22. pycodex/tools/request_permissions_tool.py +12 -1
  23. pycodex/tools/request_user_input_tool.py +28 -1
  24. pycodex/tools/send_input_tool.py +4 -2
  25. pycodex/tools/shell_command_tool.py +23 -6
  26. pycodex/tools/shell_tool.py +13 -4
  27. pycodex/tools/spawn_agent_tool.py +31 -8
  28. pycodex/tools/unified_exec_manager.py +49 -93
  29. pycodex/tools/update_plan_tool.py +14 -6
  30. pycodex/tools/view_image_tool.py +17 -16
  31. pycodex/tools/wait_agent_tool.py +15 -3
  32. pycodex/tools/wait_tool.py +18 -4
  33. pycodex/tools/web_search_tool.py +2 -1
  34. pycodex/tools/write_stdin_tool.py +42 -10
  35. pycodex/utils/compactor.py +7 -1
  36. pycodex/utils/session_persist.py +42 -1
  37. pycodex/utils/truncation.py +206 -0
  38. pycodex/utils/visualize.py +34 -15
  39. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/METADATA +4 -1
  40. python_codex-0.2.0.dist-info/RECORD +88 -0
  41. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/entry_points.txt +1 -0
  42. workspace_server/__init__.py +23 -0
  43. workspace_server/__main__.py +5 -0
  44. workspace_server/app.py +1347 -0
  45. workspace_server/workspace.html +866 -0
  46. pycodex/prompts/exec_tools.json +0 -411
  47. pycodex/prompts/subagent_tools.json +0 -163
  48. python_codex-0.1.13.dist-info/RECORD +0 -84
  49. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/WHEEL +0 -0
  50. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -21,15 +21,18 @@ from pathlib import Path
21
21
  from loguru import logger
22
22
 
23
23
  from ..compat import shlex_join, stream_writer_is_closing
24
+ from ..utils.truncation import (
25
+ DEFAULT_MAX_OUTPUT_TOKENS,
26
+ approx_token_count,
27
+ formatted_truncate_text,
28
+ )
24
29
  import typing
25
30
 
26
31
  DEFAULT_EXEC_YIELD_TIME_MS = 10_000
27
32
  DEFAULT_WRITE_STDIN_YIELD_TIME_MS = 250
28
- DEFAULT_MAX_OUTPUT_TOKENS = 10_000
29
33
  DEFAULT_LOGIN = True
30
34
  DEFAULT_TTY = False
31
35
  DEFAULT_SESSION_ID_START = 1000
32
- APPROX_BYTES_PER_TOKEN = 4
33
36
  UNIFIED_EXEC_OUTPUT_MAX_BYTES = 1024 * 1024
34
37
  UNIFIED_EXEC_OUTPUT_SCHEMA = {
35
38
  "type": "object",
@@ -63,94 +66,6 @@ UNIFIED_EXEC_OUTPUT_SCHEMA = {
63
66
  "additionalProperties": False,
64
67
  }
65
68
 
66
-
67
- def _approx_token_count(text: 'str') -> 'int':
68
- if not text:
69
- return 0
70
- byte_length = len(text.encode("utf-8"))
71
- return max(1, (byte_length + APPROX_BYTES_PER_TOKEN - 1) // APPROX_BYTES_PER_TOKEN)
72
-
73
-
74
- def _approx_bytes_for_tokens(token_count: 'int') -> 'int':
75
- return max(token_count, 0) * APPROX_BYTES_PER_TOKEN
76
-
77
-
78
- def _approx_tokens_from_byte_count(byte_count: 'int') -> 'int':
79
- if byte_count <= 0:
80
- return 0
81
- return (byte_count + APPROX_BYTES_PER_TOKEN - 1) // APPROX_BYTES_PER_TOKEN
82
-
83
-
84
- def _split_budget(byte_budget: 'int') -> 'typing.Tuple[int, int]':
85
- left_budget = byte_budget // 2
86
- return left_budget, byte_budget - left_budget
87
-
88
-
89
- def _split_string(
90
- text: 'str',
91
- beginning_bytes: 'int',
92
- end_bytes: 'int',
93
- ) -> 'typing.Tuple[str, str]':
94
- if not text:
95
- return "", ""
96
-
97
- total_bytes = len(text.encode("utf-8"))
98
- tail_start_target = max(total_bytes - end_bytes, 0)
99
- prefix_end = 0
100
- suffix_start = len(text)
101
- suffix_started = False
102
- current_byte = 0
103
-
104
- for index, char in enumerate(text):
105
- char_bytes = len(char.encode("utf-8"))
106
- char_start = current_byte
107
- char_end = current_byte + char_bytes
108
- if char_end <= beginning_bytes:
109
- prefix_end = index + 1
110
- current_byte = char_end
111
- continue
112
- if char_start >= tail_start_target:
113
- if not suffix_started:
114
- suffix_start = index
115
- suffix_started = True
116
- current_byte = char_end
117
- continue
118
- current_byte = char_end
119
-
120
- if suffix_start < prefix_end:
121
- suffix_start = prefix_end
122
-
123
- return text[:prefix_end], text[suffix_start:]
124
-
125
-
126
- def _truncate_text(text: 'str', max_tokens: 'int') -> 'str':
127
- if not text:
128
- return ""
129
-
130
- max_bytes = _approx_bytes_for_tokens(max_tokens)
131
- total_bytes = len(text.encode("utf-8"))
132
- if total_bytes <= max_bytes:
133
- return text
134
-
135
- removed_tokens = _approx_tokens_from_byte_count(total_bytes - max_bytes)
136
- marker = f"\u2026{removed_tokens} tokens truncated\u2026"
137
- if max_bytes == 0:
138
- return marker
139
-
140
- left_budget, right_budget = _split_budget(max_bytes)
141
- prefix, suffix = _split_string(text, left_budget, right_budget)
142
- return f"{prefix}{marker}{suffix}"
143
-
144
-
145
- def _formatted_truncate_text(text: 'str', max_tokens: 'int') -> 'str':
146
- byte_budget = _approx_bytes_for_tokens(max_tokens)
147
- if len(text.encode("utf-8")) <= byte_budget:
148
- return text
149
-
150
- total_lines = len(text.splitlines())
151
- return f"Total output lines: {total_lines}\n\n{_truncate_text(text, max_tokens)}"
152
-
153
-
154
69
  @dataclass
155
70
  class _HeadTailBuffer:
156
71
  max_bytes: 'int' = UNIFIED_EXEC_OUTPUT_MAX_BYTES
@@ -207,6 +122,20 @@ class UnifiedExecManager:
207
122
  self._next_session_id = DEFAULT_SESSION_ID_START
208
123
  self._sessions: 'typing.Dict[int, UnifiedExecSession]' = {}
209
124
  self._lock = asyncio.Lock()
125
+ self._notify_hook: 'typing.Union[typing.Callable[[typing.Dict[str, object]], typing.Awaitable[typing.Any]], None]' = None
126
+
127
+ def set_notify_hook(
128
+ self,
129
+ callback: 'typing.Union[typing.Callable[[typing.Dict[str, object]], typing.Awaitable[typing.Any]], None]',
130
+ ) -> 'None':
131
+ self._notify_hook = callback
132
+
133
+ def running_session_count(self) -> 'int':
134
+ return sum(
135
+ 1
136
+ for session in self._sessions.values()
137
+ if session.process.returncode is None
138
+ )
210
139
 
211
140
  async def exec_command(
212
141
  self,
@@ -250,11 +179,15 @@ class UnifiedExecManager:
250
179
  async with self._lock:
251
180
  self._sessions[session_id] = session
252
181
 
253
- return await self._wait_and_snapshot(
182
+ output = await self._wait_and_snapshot(
254
183
  session_id,
255
184
  max(yield_time_ms, 1),
256
185
  max_output_tokens,
257
186
  )
187
+ session = await self._get_session(session_id)
188
+ if session is not None:
189
+ asyncio.create_task(self._notify_when_session_completes(session_id))
190
+ return output
258
191
 
259
192
  async def write_stdin(
260
193
  self,
@@ -367,6 +300,29 @@ class UnifiedExecManager:
367
300
  session.output_event.set()
368
301
  session.output_event.set()
369
302
 
303
+ async def _notify_when_session_completes(self, session_id: 'int') -> 'None':
304
+ callback = self._notify_hook
305
+ if callback is None:
306
+ return
307
+ session = await self._get_session(session_id)
308
+ if session is None:
309
+ return
310
+ await session.process.wait()
311
+ async with self._lock:
312
+ if self._sessions.get(session_id) is not session:
313
+ return
314
+ try:
315
+ await callback(
316
+ {
317
+ "type": "exec_command_completed",
318
+ "session_id": session_id,
319
+ "exit_code": session.process.returncode,
320
+ "command": session.command_display,
321
+ }
322
+ )
323
+ except Exception: # pragma: no cover - background notification must not break tools
324
+ return
325
+
370
326
  def _resolve_workdir(self, workdir: 'typing.Union[str, None]') -> 'Path':
371
327
  if not workdir:
372
328
  return self._default_cwd
@@ -390,11 +346,11 @@ class UnifiedExecManager:
390
346
  return [shell_path, "-lc" if login else "-c", cmd]
391
347
 
392
348
  def _estimate_token_count(self, output: 'str') -> 'typing.Union[int, None]':
393
- return _approx_token_count(output)
349
+ return approx_token_count(output)
394
350
 
395
351
  def _truncate_output(self, output: 'str', max_output_tokens: 'typing.Union[int, None]') -> 'str':
396
352
  token_budget = DEFAULT_MAX_OUTPUT_TOKENS if max_output_tokens is None else max_output_tokens
397
- return _formatted_truncate_text(output, max(token_budget, 0))
353
+ return formatted_truncate_text(output, max(token_budget, 0))
398
354
 
399
355
  def _tty_echo(self, chars: 'str') -> 'bytes':
400
356
  normalized = chars.replace("\n", "\r\n")
@@ -21,24 +21,32 @@ VALID_PLAN_STATUSES = {"pending", "in_progress", "completed"}
21
21
  class UpdatePlanTool(BaseTool):
22
22
  name = "update_plan"
23
23
  description = (
24
- "Updates the task plan. Provide an optional explanation and a list of "
25
- "plan items, each with a step and status. At most one step can be "
26
- "in_progress at a time."
24
+ "Updates the task plan.\n"
25
+ "Provide an optional explanation and a list of plan items, each with a "
26
+ "step and status.\n"
27
+ "At most one step can be in_progress at a time.\n"
27
28
  )
28
29
  input_schema = {
29
30
  "type": "object",
30
31
  "properties": {
31
- "explanation": {"type": "string"},
32
+ "explanation": {
33
+ "type": "string",
34
+ "description": "Optional explanation for this plan update.",
35
+ },
32
36
  "plan": {
33
37
  "type": "array",
34
38
  "description": "The list of steps",
35
39
  "items": {
36
40
  "type": "object",
37
41
  "properties": {
38
- "step": {"type": "string"},
42
+ "step": {
43
+ "type": "string",
44
+ "description": "Task step text.",
45
+ },
39
46
  "status": {
40
47
  "type": "string",
41
- "description": "One of: pending, in_progress, completed",
48
+ "enum": ["pending", "in_progress", "completed"],
49
+ "description": "Step status.",
42
50
  },
43
51
  },
44
52
  "required": ["step", "status"],
@@ -6,8 +6,8 @@ Original Codex mapping:
6
6
  Expected behavior:
7
7
  - Load a local image file and turn it into a data URL that can be attached back
8
8
  into the next model request.
9
- - Accept the documented `path` argument plus the optional `detail: "original"`
10
- hint.
9
+ - Accept the documented `path` argument plus optional `detail: "high" |
10
+ "original"` hint.
11
11
  - Return both the JSON object result and the structured `input_image` content
12
12
  item that Codex uses when feeding image tool output back to the model.
13
13
  """
@@ -28,8 +28,9 @@ VIEW_IMAGE_OUTPUT_SCHEMA = {
28
28
  "description": "Data URL for the loaded image.",
29
29
  },
30
30
  "detail": {
31
- "type": ["string", "null"],
32
- "description": "Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`.",
31
+ "type": "string",
32
+ "enum": ["high", "original"],
33
+ "description": "Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.",
33
34
  },
34
35
  },
35
36
  "required": ["image_url", "detail"],
@@ -40,20 +41,20 @@ VIEW_IMAGE_OUTPUT_SCHEMA = {
40
41
  class ViewImageTool(BaseTool):
41
42
  name = "view_image"
42
43
  description = (
43
- "View a local image from the filesystem (only use if given a full "
44
- "filepath by the user, and the image isn't already attached to the "
45
- "thread context within <image ...> tags)."
44
+ "View a local image file from the filesystem when visual inspection is "
45
+ "needed. Use this for images already available on disk."
46
46
  )
47
47
  input_schema = {
48
48
  "type": "object",
49
49
  "properties": {
50
50
  "path": {
51
51
  "type": "string",
52
- "description": "Local filesystem path to an image file",
52
+ "description": "Local filesystem path to an image file.",
53
53
  },
54
54
  "detail": {
55
55
  "type": "string",
56
- "description": "Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit.",
56
+ "enum": ["high", "original"],
57
+ "description": "Image detail level. Defaults to `high`; use `original` to preserve exact resolution.",
57
58
  },
58
59
  },
59
60
  "required": ["path"],
@@ -72,13 +73,14 @@ class ViewImageTool(BaseTool):
72
73
 
73
74
  detail_value = args.get("detail")
74
75
  if detail_value in (None, ""):
75
- detail = None
76
- elif detail_value == "original":
77
- detail = "original"
76
+ detail = "high"
77
+ elif detail_value in ("high", "original"):
78
+ detail = str(detail_value)
78
79
  else:
79
80
  return (
80
- "Error: `detail` only supports `original`; omit `detail` for default "
81
- f"behavior, got `{detail_value}`."
81
+ "Error: `detail` only supports `high` or `original`; omit "
82
+ "`detail` for default high resized behavior, got "
83
+ f"`{detail_value}`."
82
84
  )
83
85
 
84
86
  path = Path(path_value)
@@ -104,7 +106,6 @@ class ViewImageTool(BaseTool):
104
106
  image_item: 'JSONDict' = {
105
107
  "type": "input_image",
106
108
  "image_url": image_url,
109
+ "detail": detail,
107
110
  }
108
- if detail is not None:
109
- image_item["detail"] = detail
110
111
  return StructuredToolOutput(output=output, content_items=(image_item,))
@@ -14,6 +14,10 @@ from ..runtime_services import SubAgentManager
14
14
  from .agent_tool_schemas import AGENT_STATUS_SCHEMA
15
15
  from .base_tool import BaseTool, ToolContext
16
16
 
17
+ DEFAULT_WAIT_AGENT_TIMEOUT_MS = 30_000
18
+ MIN_WAIT_AGENT_TIMEOUT_MS = 10_000
19
+ MAX_WAIT_AGENT_TIMEOUT_MS = 3_600_000
20
+
17
21
  WAIT_AGENT_OUTPUT_SCHEMA = {
18
22
  "type": "object",
19
23
  "properties": {
@@ -37,7 +41,8 @@ class WaitAgentTool(BaseTool):
37
41
  description = (
38
42
  "Wait for agents to reach a final status. Completed statuses may "
39
43
  "include the agent's final message. Returns empty status when timed "
40
- "out."
44
+ "out. Once the agent reaches a final status, a notification message "
45
+ "will be received containing the same completed status."
41
46
  )
42
47
  input_schema = {
43
48
  "type": "object",
@@ -49,7 +54,7 @@ class WaitAgentTool(BaseTool):
49
54
  },
50
55
  "timeout_ms": {
51
56
  "type": "integer",
52
- "description": "Optional timeout in milliseconds.",
57
+ "description": "Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000. Prefer longer waits (minutes) to avoid busy polling.",
53
58
  },
54
59
  },
55
60
  "required": ["ids"],
@@ -69,5 +74,12 @@ class WaitAgentTool(BaseTool):
69
74
  agent_ids = [str(item).strip() for item in ids if str(item).strip()]
70
75
  if not agent_ids:
71
76
  return "Error: `ids` must include at least one non-empty id."
72
- timeout_ms = int(args.get("timeout_ms", 30_000))
77
+ timeout_ms = self._timeout_ms(args)
73
78
  return await self._subagent_manager.wait_agents(agent_ids, timeout_ms)
79
+
80
+ def _timeout_ms(self, args: 'JSONDict') -> 'int':
81
+ value = int(args.get("timeout_ms", DEFAULT_WAIT_AGENT_TIMEOUT_MS))
82
+ return min(
83
+ max(value, MIN_WAIT_AGENT_TIMEOUT_MS),
84
+ MAX_WAIT_AGENT_TIMEOUT_MS,
85
+ )
@@ -18,7 +18,21 @@ import typing
18
18
  class WaitTool(BaseTool):
19
19
  name = "wait"
20
20
  description = (
21
- "Waits on a yielded `exec` cell and returns new output or completion."
21
+ "Waits on a yielded `exec` cell and returns new output or completion.\n"
22
+ "- Use `wait` only after `exec` returns `Script running with cell ID ...`.\n"
23
+ "- `cell_id` identifies the running `exec` cell to resume.\n"
24
+ "- `yield_time_ms` controls how long to wait for more output before "
25
+ "yielding again. Defaults to 10000 ms.\n"
26
+ "- `max_tokens` limits how much new output this wait call returns. "
27
+ "Defaults to 10000 tokens.\n"
28
+ "- `terminate: true` stops the running cell; false or omitted waits "
29
+ "for output.\n"
30
+ "- `wait` returns only the new output since the last yield, or the "
31
+ "final completion or termination result for that cell.\n"
32
+ "- If the cell is still running, `wait` may yield again with the same "
33
+ "`cell_id`.\n"
34
+ "- If the cell has already finished, `wait` returns the completed "
35
+ "result and closes the cell."
22
36
  )
23
37
  input_schema = {
24
38
  "type": "object",
@@ -29,15 +43,15 @@ class WaitTool(BaseTool):
29
43
  },
30
44
  "yield_time_ms": {
31
45
  "type": "integer",
32
- "description": "How long to wait (in milliseconds) for more output before yielding again.",
46
+ "description": "Wait before yielding more output. Defaults to 10000 ms.",
33
47
  },
34
48
  "max_tokens": {
35
49
  "type": "integer",
36
- "description": "Maximum number of output tokens to return for this wait call.",
50
+ "description": "Output token budget for this wait call. Defaults to 10000 tokens.",
37
51
  },
38
52
  "terminate": {
39
53
  "type": "boolean",
40
- "description": "Whether to terminate the running exec cell.",
54
+ "description": "True stops the running exec cell; false or omitted waits for output.",
41
55
  },
42
56
  },
43
57
  "required": ["cell_id"],
@@ -16,10 +16,11 @@ from .base_tool import BaseTool, ToolContext
16
16
 
17
17
  class WebSearchTool(BaseTool):
18
18
  name = "web_search"
19
- description = "Provider-native web search tool declaration."
19
+ description = ""
20
20
  tool_type = "web_search"
21
21
  options = {
22
22
  "external_web_access": True,
23
+ "search_content_types": ["text", "image"],
23
24
  }
24
25
  supports_parallel = False
25
26
 
@@ -19,6 +19,11 @@ from .unified_exec_manager import (
19
19
  )
20
20
  import typing
21
21
 
22
+ MIN_WRITE_YIELD_TIME_MS = 250
23
+ MAX_WRITE_YIELD_TIME_MS = 30_000
24
+ DEFAULT_WRITE_STDIN_POLL_YIELD_TIME_MS = 5_000
25
+ MAX_WRITE_STDIN_POLL_YIELD_TIME_MS = 300_000
26
+
22
27
 
23
28
  class WriteStdinTool(BaseTool):
24
29
  name = "write_stdin"
@@ -27,20 +32,20 @@ class WriteStdinTool(BaseTool):
27
32
  "type": "object",
28
33
  "properties": {
29
34
  "session_id": {
30
- "type": "integer",
35
+ "type": "number",
31
36
  "description": "Identifier of the running unified exec session.",
32
37
  },
33
38
  "chars": {
34
39
  "type": "string",
35
- "description": "Bytes to write to stdin (may be empty to poll).",
40
+ "description": "Bytes to write to stdin. Defaults to empty, which polls without writing.",
36
41
  },
37
42
  "yield_time_ms": {
38
- "type": "integer",
39
- "description": "How long to wait (in milliseconds) for output before yielding.",
43
+ "type": "number",
44
+ "description": "Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.",
40
45
  },
41
46
  "max_output_tokens": {
42
- "type": "integer",
43
- "description": "Maximum number of tokens to return. Excess output will be truncated.",
47
+ "type": "number",
48
+ "description": "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.",
44
49
  },
45
50
  },
46
51
  "required": ["session_id"],
@@ -57,18 +62,45 @@ class WriteStdinTool(BaseTool):
57
62
  session_id = args.get("session_id")
58
63
  if session_id is None:
59
64
  return "Error: `session_id` is required."
65
+ chars = str(args.get("chars", ""))
60
66
 
61
67
  return await self._manager.write_stdin(
62
68
  session_id=int(session_id),
63
- chars=str(args.get("chars", "")),
64
- yield_time_ms=int(
65
- args.get("yield_time_ms", DEFAULT_WRITE_STDIN_YIELD_TIME_MS)
66
- ),
69
+ chars=chars,
70
+ yield_time_ms=self._yield_time_ms(args, chars),
67
71
  max_output_tokens=self._optional_int(args, "max_output_tokens"),
68
72
  )
69
73
 
74
+ def _yield_time_ms(self, args: 'JSONDict', chars: 'str') -> 'int':
75
+ if chars:
76
+ return self._bounded_int(
77
+ args,
78
+ "yield_time_ms",
79
+ DEFAULT_WRITE_STDIN_YIELD_TIME_MS,
80
+ MIN_WRITE_YIELD_TIME_MS,
81
+ MAX_WRITE_YIELD_TIME_MS,
82
+ )
83
+ return self._bounded_int(
84
+ args,
85
+ "yield_time_ms",
86
+ DEFAULT_WRITE_STDIN_POLL_YIELD_TIME_MS,
87
+ DEFAULT_WRITE_STDIN_POLL_YIELD_TIME_MS,
88
+ MAX_WRITE_STDIN_POLL_YIELD_TIME_MS,
89
+ )
90
+
70
91
  def _optional_int(self, args: 'JSONDict', key: 'str') -> 'typing.Union[int, None]':
71
92
  value = args.get(key)
72
93
  if value in (None, ""):
73
94
  return None
74
95
  return int(value)
96
+
97
+ def _bounded_int(
98
+ self,
99
+ args: 'JSONDict',
100
+ key: 'str',
101
+ default: 'int',
102
+ minimum: 'int',
103
+ maximum: 'int',
104
+ ) -> 'int':
105
+ value = int(args.get(key, default))
106
+ return min(max(value, minimum), maximum)
@@ -37,6 +37,7 @@ SUMMARY_PREFIX = (
37
37
  COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000
38
38
  _APPROX_CHARS_PER_TOKEN = 4
39
39
  _SUBAGENT_NOTIFICATION_PREFIX = "<subagent_notification>\n"
40
+ _EXEC_COMMAND_COMPLETED_PREFIX = "<exec_command_completed>\n"
40
41
 
41
42
 
42
43
  @dataclass(frozen=True)
@@ -253,7 +254,12 @@ def _pluralize(noun: 'str', count: 'int') -> 'str':
253
254
 
254
255
 
255
256
  def _is_synthetic_user_message(text: 'str') -> 'bool':
256
- return text.startswith(_SUBAGENT_NOTIFICATION_PREFIX)
257
+ return text.startswith(
258
+ (
259
+ _SUBAGENT_NOTIFICATION_PREFIX,
260
+ _EXEC_COMMAND_COMPLETED_PREFIX,
261
+ )
262
+ )
257
263
 
258
264
 
259
265
  def _is_context_length_error(message: 'str') -> 'bool':
@@ -198,7 +198,18 @@ def load_resumed_session(
198
198
  session = sessions[resume_index - 1]
199
199
  thread_id = session["thread_id"]
200
200
  rollout_path = Path(session["rollout_path"])
201
- thread_name = _latest_thread_names_by_id(codex_home).get(thread_id)
201
+ return load_resumed_session_path(
202
+ rollout_path,
203
+ thread_name=_latest_thread_names_by_id(codex_home).get(thread_id),
204
+ )
205
+
206
+
207
+ def load_resumed_session_path(
208
+ rollout_path: 'typing.Union[str, Path]',
209
+ thread_name: 'typing.Union[str, None]' = None,
210
+ ) -> 'typing.Dict[str, object]':
211
+ rollout_path = Path(rollout_path)
212
+ thread_id = _thread_id_from_rollout_path(rollout_path) or ""
202
213
  session_id = thread_id
203
214
  history: 'typing.List[ConversationItem]' = []
204
215
  saw_user_turn = False
@@ -245,6 +256,10 @@ def load_resumed_session(
245
256
  if not history:
246
257
  raise ValueError(f"No resumable history found in {rollout_path}")
247
258
 
259
+ history = _trim_incomplete_tool_call_tail(history)
260
+ if not history:
261
+ raise ValueError(f"No resumable history found in {rollout_path}")
262
+
248
263
  turns = conversation_history_to_turns(history)
249
264
  title = thread_name or (shorten_title(turns[0][0]) if turns else thread_id)
250
265
  return {
@@ -277,6 +292,32 @@ def conversation_history_to_turns(
277
292
  return tuple(turns)
278
293
 
279
294
 
295
+ def _trim_incomplete_tool_call_tail(
296
+ history: 'typing.List[ConversationItem]',
297
+ ) -> 'typing.List[ConversationItem]':
298
+ pending_call_ids: 'typing.Set[str]' = set()
299
+ call_indexes: 'typing.Dict[str, int]' = {}
300
+
301
+ for index, item in enumerate(history):
302
+ if isinstance(item, ToolCall):
303
+ pending_call_ids.add(item.call_id)
304
+ call_indexes[item.call_id] = index
305
+ continue
306
+ if isinstance(item, ToolResult):
307
+ pending_call_ids.discard(item.call_id)
308
+
309
+ if not pending_call_ids:
310
+ return history
311
+
312
+ trim_start = min(call_indexes[call_id] for call_id in pending_call_ids)
313
+ while trim_start > 0 and isinstance(
314
+ history[trim_start - 1],
315
+ (AssistantMessage, ReasoningItem, ToolCall),
316
+ ):
317
+ trim_start -= 1
318
+ return history[:trim_start]
319
+
320
+
280
321
  def _latest_thread_names_by_id(codex_home: 'Path') -> 'typing.Dict[str, str]':
281
322
  index_path = codex_home / SESSION_INDEX_FILENAME
282
323
  if not index_path.exists():