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.
- pycodex/agent.py +71 -11
- pycodex/cli.py +16 -356
- pycodex/context.py +12 -0
- pycodex/feishu_card.py +76 -30
- pycodex/feishu_link.py +131 -11
- pycodex/interactive_session.py +397 -0
- pycodex/model.py +11 -22
- pycodex/protocol.py +0 -5
- pycodex/runtime.py +23 -0
- pycodex/runtime_services.py +2 -2
- pycodex/tools/agent_tool_schemas.py +1 -1
- pycodex/tools/apply_patch_tool.py +1 -1
- pycodex/tools/base_tool.py +1 -27
- pycodex/tools/close_agent_tool.py +11 -4
- pycodex/tools/code_mode_manager.py +1 -1
- pycodex/tools/exec_command_tool.py +40 -16
- pycodex/tools/exec_tool.py +18 -2
- pycodex/tools/grep_files_tool.py +19 -6
- pycodex/tools/ipython_tool.py +3 -2
- pycodex/tools/list_dir_tool.py +19 -6
- pycodex/tools/read_file_tool.py +39 -9
- pycodex/tools/request_permissions_tool.py +12 -1
- pycodex/tools/request_user_input_tool.py +28 -1
- pycodex/tools/send_input_tool.py +4 -2
- pycodex/tools/shell_command_tool.py +23 -6
- pycodex/tools/shell_tool.py +13 -4
- pycodex/tools/spawn_agent_tool.py +31 -8
- pycodex/tools/unified_exec_manager.py +49 -93
- pycodex/tools/update_plan_tool.py +14 -6
- pycodex/tools/view_image_tool.py +17 -16
- pycodex/tools/wait_agent_tool.py +15 -3
- pycodex/tools/wait_tool.py +18 -4
- pycodex/tools/web_search_tool.py +2 -1
- pycodex/tools/write_stdin_tool.py +42 -10
- pycodex/utils/compactor.py +7 -1
- pycodex/utils/session_persist.py +42 -1
- pycodex/utils/truncation.py +206 -0
- pycodex/utils/visualize.py +34 -15
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/METADATA +4 -1
- python_codex-0.2.0.dist-info/RECORD +88 -0
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/entry_points.txt +1 -0
- workspace_server/__init__.py +23 -0
- workspace_server/__main__.py +5 -0
- workspace_server/app.py +1347 -0
- workspace_server/workspace.html +866 -0
- pycodex/prompts/exec_tools.json +0 -411
- pycodex/prompts/subagent_tools.json +0 -163
- python_codex-0.1.13.dist-info/RECORD +0 -84
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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
|
|
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
|
|
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
|
|
25
|
-
"
|
|
26
|
-
"
|
|
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": {
|
|
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": {
|
|
42
|
+
"step": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "Task step text.",
|
|
45
|
+
},
|
|
39
46
|
"status": {
|
|
40
47
|
"type": "string",
|
|
41
|
-
"
|
|
48
|
+
"enum": ["pending", "in_progress", "completed"],
|
|
49
|
+
"description": "Step status.",
|
|
42
50
|
},
|
|
43
51
|
},
|
|
44
52
|
"required": ["step", "status"],
|
pycodex/tools/view_image_tool.py
CHANGED
|
@@ -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
|
|
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":
|
|
32
|
-
"
|
|
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
|
|
44
|
-
"
|
|
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
|
-
"
|
|
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 =
|
|
76
|
-
elif detail_value
|
|
77
|
-
detail =
|
|
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
|
|
81
|
-
|
|
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,))
|
pycodex/tools/wait_agent_tool.py
CHANGED
|
@@ -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": "
|
|
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 =
|
|
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
|
+
)
|
pycodex/tools/wait_tool.py
CHANGED
|
@@ -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": "
|
|
46
|
+
"description": "Wait before yielding more output. Defaults to 10000 ms.",
|
|
33
47
|
},
|
|
34
48
|
"max_tokens": {
|
|
35
49
|
"type": "integer",
|
|
36
|
-
"description": "
|
|
50
|
+
"description": "Output token budget for this wait call. Defaults to 10000 tokens.",
|
|
37
51
|
},
|
|
38
52
|
"terminate": {
|
|
39
53
|
"type": "boolean",
|
|
40
|
-
"description": "
|
|
54
|
+
"description": "True stops the running exec cell; false or omitted waits for output.",
|
|
41
55
|
},
|
|
42
56
|
},
|
|
43
57
|
"required": ["cell_id"],
|
pycodex/tools/web_search_tool.py
CHANGED
|
@@ -16,10 +16,11 @@ from .base_tool import BaseTool, ToolContext
|
|
|
16
16
|
|
|
17
17
|
class WebSearchTool(BaseTool):
|
|
18
18
|
name = "web_search"
|
|
19
|
-
description = "
|
|
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": "
|
|
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
|
|
40
|
+
"description": "Bytes to write to stdin. Defaults to empty, which polls without writing.",
|
|
36
41
|
},
|
|
37
42
|
"yield_time_ms": {
|
|
38
|
-
"type": "
|
|
39
|
-
"description": "
|
|
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": "
|
|
43
|
-
"description": "
|
|
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=
|
|
64
|
-
yield_time_ms=
|
|
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)
|
pycodex/utils/compactor.py
CHANGED
|
@@ -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(
|
|
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':
|
pycodex/utils/session_persist.py
CHANGED
|
@@ -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
|
-
|
|
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():
|