python-codex 0.0.1__py3-none-any.whl → 0.1.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/__init__.py +139 -2
- pycodex/agent.py +290 -0
- pycodex/cli.py +641 -0
- pycodex/collaboration.py +21 -0
- pycodex/context.py +580 -0
- pycodex/doctor.py +360 -0
- pycodex/model.py +533 -0
- pycodex/prompts/collaboration_default.md +11 -0
- pycodex/prompts/collaboration_plan.md +128 -0
- pycodex/prompts/default_base_instructions.md +275 -0
- pycodex/prompts/exec_tools.json +411 -0
- pycodex/prompts/models.json +847 -0
- pycodex/prompts/permissions/approval_policy/never.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_failure.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_request.md +57 -0
- pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +33 -0
- pycodex/prompts/permissions/approval_policy/unless_trusted.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/read_only.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/workspace_write.md +1 -0
- pycodex/prompts/subagent_tools.json +163 -0
- pycodex/protocol.py +347 -0
- pycodex/runtime.py +200 -0
- pycodex/runtime_services.py +408 -0
- pycodex/tools/__init__.py +58 -0
- pycodex/tools/agent_tool_schemas.py +70 -0
- pycodex/tools/apply_patch_tool.py +363 -0
- pycodex/tools/base_tool.py +168 -0
- pycodex/tools/close_agent_tool.py +55 -0
- pycodex/tools/code_mode_manager.py +519 -0
- pycodex/tools/exec_command_tool.py +96 -0
- pycodex/tools/exec_runtime.js +161 -0
- pycodex/tools/exec_tool.py +48 -0
- pycodex/tools/grep_files_tool.py +150 -0
- pycodex/tools/list_dir_tool.py +135 -0
- pycodex/tools/read_file_tool.py +217 -0
- pycodex/tools/request_permissions_tool.py +95 -0
- pycodex/tools/request_user_input_tool.py +167 -0
- pycodex/tools/resume_agent_tool.py +56 -0
- pycodex/tools/send_input_tool.py +106 -0
- pycodex/tools/shell_command_tool.py +107 -0
- pycodex/tools/shell_tool.py +112 -0
- pycodex/tools/spawn_agent_tool.py +97 -0
- pycodex/tools/unified_exec_manager.py +380 -0
- pycodex/tools/update_plan_tool.py +79 -0
- pycodex/tools/view_image_tool.py +111 -0
- pycodex/tools/wait_agent_tool.py +75 -0
- pycodex/tools/wait_tool.py +68 -0
- pycodex/tools/web_search_tool.py +30 -0
- pycodex/tools/write_stdin_tool.py +75 -0
- pycodex/utils/__init__.py +40 -0
- pycodex/utils/dotenv.py +64 -0
- pycodex/utils/get_env.py +218 -0
- pycodex/utils/random_ids.py +19 -0
- pycodex/utils/visualize.py +978 -0
- python_codex-0.1.0.dist-info/METADATA +267 -0
- python_codex-0.1.0.dist-info/RECORD +60 -0
- python_codex-0.1.0.dist-info/entry_points.txt +2 -0
- python_codex-0.1.0.dist-info/licenses/LICENSE +201 -0
- python_codex-0.0.1.dist-info/METADATA +0 -30
- python_codex-0.0.1.dist-info/RECORD +0 -4
- {python_codex-0.0.1.dist-info → python_codex-0.1.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Shared schemas for Codex-aligned collaboration tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
COLLAB_INPUT_ITEMS_SCHEMA = {
|
|
6
|
+
"type": "array",
|
|
7
|
+
"description": (
|
|
8
|
+
"Structured input items. Use this to pass explicit mentions (for "
|
|
9
|
+
"example app:// connector paths)."
|
|
10
|
+
),
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"properties": {
|
|
14
|
+
"type": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "Input item type: text, image, local_image, skill, or mention.",
|
|
17
|
+
},
|
|
18
|
+
"text": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Text content when type is text.",
|
|
21
|
+
},
|
|
22
|
+
"image_url": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Image URL when type is image.",
|
|
25
|
+
},
|
|
26
|
+
"path": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"description": (
|
|
29
|
+
"Path when type is local_image/skill, or structured mention target "
|
|
30
|
+
"such as app://<connector-id> or plugin://<plugin-name>@<marketplace-name> "
|
|
31
|
+
"when type is mention."
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
"name": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "Display name when type is skill or mention.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
"additionalProperties": False,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
AGENT_STATUS_SCHEMA = {
|
|
44
|
+
"oneOf": [
|
|
45
|
+
{
|
|
46
|
+
"type": "string",
|
|
47
|
+
"enum": ["pending_init", "running", "shutdown", "not_found"],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {
|
|
52
|
+
"completed": {
|
|
53
|
+
"type": ["string", "null"],
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"required": ["completed"],
|
|
57
|
+
"additionalProperties": False,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": {
|
|
62
|
+
"errored": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"required": ["errored"],
|
|
67
|
+
"additionalProperties": False,
|
|
68
|
+
},
|
|
69
|
+
]
|
|
70
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""`apply_patch` tool for the Python Codex prototype.
|
|
2
|
+
|
|
3
|
+
Original Codex mapping:
|
|
4
|
+
- Corresponds to the original Codex `apply_patch` freeform/custom tool.
|
|
5
|
+
|
|
6
|
+
Expected behavior:
|
|
7
|
+
- Accept the same freeform patch envelope used by Codex main.
|
|
8
|
+
- Verify the whole patch before mutating the filesystem so failed patches do not
|
|
9
|
+
leave partial edits behind.
|
|
10
|
+
- Apply add/delete/update/move operations inside the workspace and return the
|
|
11
|
+
same success/error text shape Codex expects.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from loguru import logger
|
|
20
|
+
|
|
21
|
+
from ..protocol import JSONValue
|
|
22
|
+
from .base_tool import BaseTool, ToolContext
|
|
23
|
+
|
|
24
|
+
APPLY_PATCH_LARK_GRAMMAR = """start: begin_patch hunk+ end_patch
|
|
25
|
+
begin_patch: \"*** Begin Patch\" LF
|
|
26
|
+
end_patch: \"*** End Patch\" LF?
|
|
27
|
+
|
|
28
|
+
hunk: add_hunk | delete_hunk | update_hunk
|
|
29
|
+
add_hunk: \"*** Add File: \" filename LF add_line+
|
|
30
|
+
delete_hunk: \"*** Delete File: \" filename LF
|
|
31
|
+
update_hunk: \"*** Update File: \" filename LF change_move? change?
|
|
32
|
+
|
|
33
|
+
filename: /(.+)/
|
|
34
|
+
add_line: \"+\" /(.*)/ LF -> line
|
|
35
|
+
|
|
36
|
+
change_move: \"*** Move to: \" filename LF
|
|
37
|
+
change: (change_context | change_line)+ eof_line?
|
|
38
|
+
change_context: (\"@@\" | \"@@ \" /(.+)/) LF
|
|
39
|
+
change_line: (\"+\" | \"-\" | \" \" ) /(.*)/ LF
|
|
40
|
+
eof_line: \"*** End of File\" LF
|
|
41
|
+
|
|
42
|
+
%import common.LF
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ApplyPatchError(RuntimeError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class _AddFileOp:
|
|
52
|
+
path: str
|
|
53
|
+
content: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class _DeleteFileOp:
|
|
58
|
+
path: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class _UpdateSection:
|
|
63
|
+
lines: tuple[str, ...]
|
|
64
|
+
anchor_end_of_file: bool = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class _UpdateFileOp:
|
|
69
|
+
path: str
|
|
70
|
+
move_to: str | None
|
|
71
|
+
sections: tuple[_UpdateSection, ...]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ApplyPatchTool(BaseTool):
|
|
75
|
+
name = "apply_patch"
|
|
76
|
+
description = (
|
|
77
|
+
"Use the `apply_patch` tool to edit files. This is a FREEFORM tool, "
|
|
78
|
+
"so do not wrap the patch in JSON."
|
|
79
|
+
)
|
|
80
|
+
tool_type = "custom"
|
|
81
|
+
format = {
|
|
82
|
+
"type": "grammar",
|
|
83
|
+
"syntax": "lark",
|
|
84
|
+
"definition": APPLY_PATCH_LARK_GRAMMAR,
|
|
85
|
+
}
|
|
86
|
+
supports_parallel = False
|
|
87
|
+
|
|
88
|
+
def __init__(self, cwd: str | Path | None = None) -> None:
|
|
89
|
+
self._workspace_root = Path(cwd or Path.cwd()).resolve()
|
|
90
|
+
|
|
91
|
+
async def run(self, context: ToolContext, args: JSONValue) -> JSONValue:
|
|
92
|
+
del context
|
|
93
|
+
patch_text = str(args)
|
|
94
|
+
logger.debug("apply_patch workspace={} bytes={}", self._workspace_root, len(patch_text))
|
|
95
|
+
try:
|
|
96
|
+
operations = self._parse_patch(patch_text)
|
|
97
|
+
return self._format_result(self._apply_operations(operations), exit_code=0)
|
|
98
|
+
except ApplyPatchError as exc:
|
|
99
|
+
return self._format_result(str(exc), exit_code=1)
|
|
100
|
+
|
|
101
|
+
def _parse_patch(self, patch_text: str) -> list[_AddFileOp | _DeleteFileOp | _UpdateFileOp]:
|
|
102
|
+
lines = patch_text.splitlines()
|
|
103
|
+
if not lines:
|
|
104
|
+
raise ApplyPatchError("patch rejected: empty patch")
|
|
105
|
+
if lines[0] != "*** Begin Patch":
|
|
106
|
+
raise ApplyPatchError(
|
|
107
|
+
"apply_patch verification failed: missing '*** Begin Patch' header"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
operations: list[_AddFileOp | _DeleteFileOp | _UpdateFileOp] = []
|
|
111
|
+
index = 1
|
|
112
|
+
while index < len(lines):
|
|
113
|
+
line = lines[index]
|
|
114
|
+
if line == "*** End Patch":
|
|
115
|
+
if not operations:
|
|
116
|
+
raise ApplyPatchError("patch rejected: empty patch")
|
|
117
|
+
for trailing in lines[index + 1 :]:
|
|
118
|
+
if trailing.strip():
|
|
119
|
+
raise ApplyPatchError(
|
|
120
|
+
"apply_patch verification failed: unexpected content after '*** End Patch'"
|
|
121
|
+
)
|
|
122
|
+
return operations
|
|
123
|
+
|
|
124
|
+
if line.startswith("*** Add File: "):
|
|
125
|
+
path = line.removeprefix("*** Add File: ")
|
|
126
|
+
index += 1
|
|
127
|
+
content_lines: list[str] = []
|
|
128
|
+
while index < len(lines) and not lines[index].startswith("*** "):
|
|
129
|
+
entry = lines[index]
|
|
130
|
+
if not entry.startswith("+"):
|
|
131
|
+
raise ApplyPatchError(
|
|
132
|
+
f"apply_patch verification failed: {entry!r} is not a valid add line"
|
|
133
|
+
)
|
|
134
|
+
content_lines.append(entry[1:])
|
|
135
|
+
index += 1
|
|
136
|
+
if not content_lines:
|
|
137
|
+
raise ApplyPatchError(
|
|
138
|
+
f"apply_patch verification failed: add for {path} is missing file content"
|
|
139
|
+
)
|
|
140
|
+
operations.append(_AddFileOp(path=path, content=self._join_lines(content_lines)))
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
if line.startswith("*** Delete File: "):
|
|
144
|
+
path = line.removeprefix("*** Delete File: ")
|
|
145
|
+
operations.append(_DeleteFileOp(path=path))
|
|
146
|
+
index += 1
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if line.startswith("*** Update File: "):
|
|
150
|
+
path = line.removeprefix("*** Update File: ")
|
|
151
|
+
index += 1
|
|
152
|
+
move_to = None
|
|
153
|
+
if index < len(lines) and lines[index].startswith("*** Move to: "):
|
|
154
|
+
move_to = lines[index].removeprefix("*** Move to: ")
|
|
155
|
+
index += 1
|
|
156
|
+
|
|
157
|
+
sections: list[_UpdateSection] = []
|
|
158
|
+
current_lines: list[str] = []
|
|
159
|
+
saw_hunk_header = False
|
|
160
|
+
anchor_end_of_file = False
|
|
161
|
+
while index < len(lines):
|
|
162
|
+
entry = lines[index]
|
|
163
|
+
if entry.startswith("*** ") and entry != "*** End of File":
|
|
164
|
+
break
|
|
165
|
+
if entry == "@@" or entry.startswith("@@ "):
|
|
166
|
+
if saw_hunk_header:
|
|
167
|
+
sections.append(
|
|
168
|
+
_UpdateSection(
|
|
169
|
+
lines=tuple(current_lines),
|
|
170
|
+
anchor_end_of_file=anchor_end_of_file,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
current_lines = []
|
|
174
|
+
anchor_end_of_file = False
|
|
175
|
+
saw_hunk_header = True
|
|
176
|
+
index += 1
|
|
177
|
+
continue
|
|
178
|
+
if entry == "*** End of File":
|
|
179
|
+
if not saw_hunk_header:
|
|
180
|
+
raise ApplyPatchError(
|
|
181
|
+
"apply_patch verification failed: '*** End of File' must follow a hunk"
|
|
182
|
+
)
|
|
183
|
+
anchor_end_of_file = True
|
|
184
|
+
index += 1
|
|
185
|
+
continue
|
|
186
|
+
if not saw_hunk_header:
|
|
187
|
+
raise ApplyPatchError(
|
|
188
|
+
f"apply_patch verification failed: {entry!r} is not a valid hunk header"
|
|
189
|
+
)
|
|
190
|
+
if not entry or entry[0] not in {" ", "+", "-"}:
|
|
191
|
+
raise ApplyPatchError(
|
|
192
|
+
f"apply_patch verification failed: {entry!r} is not a valid change line"
|
|
193
|
+
)
|
|
194
|
+
current_lines.append(entry)
|
|
195
|
+
index += 1
|
|
196
|
+
|
|
197
|
+
if not saw_hunk_header:
|
|
198
|
+
raise ApplyPatchError(
|
|
199
|
+
f"apply_patch verification failed: update for {path} is missing a hunk"
|
|
200
|
+
)
|
|
201
|
+
sections.append(
|
|
202
|
+
_UpdateSection(
|
|
203
|
+
lines=tuple(current_lines),
|
|
204
|
+
anchor_end_of_file=anchor_end_of_file,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
operations.append(
|
|
208
|
+
_UpdateFileOp(
|
|
209
|
+
path=path,
|
|
210
|
+
move_to=move_to,
|
|
211
|
+
sections=tuple(sections),
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
raise ApplyPatchError(
|
|
217
|
+
f"apply_patch verification failed: {line!r} is not a valid hunk header"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
raise ApplyPatchError("apply_patch verification failed: missing '*** End Patch' footer")
|
|
221
|
+
|
|
222
|
+
def _apply_operations(
|
|
223
|
+
self,
|
|
224
|
+
operations: list[_AddFileOp | _DeleteFileOp | _UpdateFileOp],
|
|
225
|
+
) -> str:
|
|
226
|
+
preview: dict[Path, str | None] = {}
|
|
227
|
+
summaries: dict[Path, str] = {}
|
|
228
|
+
|
|
229
|
+
for operation in operations:
|
|
230
|
+
if isinstance(operation, _AddFileOp):
|
|
231
|
+
path = self._resolve_workspace_path(operation.path)
|
|
232
|
+
preview[path] = operation.content
|
|
233
|
+
summaries[path] = "A"
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
if isinstance(operation, _DeleteFileOp):
|
|
237
|
+
path = self._resolve_workspace_path(operation.path)
|
|
238
|
+
self._read_preview_file(path, preview)
|
|
239
|
+
preview[path] = None
|
|
240
|
+
summaries[path] = "D"
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
path = self._resolve_workspace_path(operation.path)
|
|
244
|
+
original = self._read_preview_file(path, preview)
|
|
245
|
+
updated = self._apply_update(path, original, operation.sections)
|
|
246
|
+
destination = path
|
|
247
|
+
if operation.move_to is not None:
|
|
248
|
+
destination = self._resolve_workspace_path(operation.move_to)
|
|
249
|
+
preview[path] = None
|
|
250
|
+
summaries.pop(path, None)
|
|
251
|
+
preview[destination] = updated
|
|
252
|
+
summaries[destination] = "M"
|
|
253
|
+
|
|
254
|
+
self._write_preview(preview)
|
|
255
|
+
return self._format_success(summaries)
|
|
256
|
+
|
|
257
|
+
def _read_preview_file(self, path: Path, preview: dict[Path, str | None]) -> str:
|
|
258
|
+
if path in preview:
|
|
259
|
+
content = preview[path]
|
|
260
|
+
if content is None:
|
|
261
|
+
raise ApplyPatchError(
|
|
262
|
+
f"apply_patch verification failed: Failed to read {path.relative_to(self._workspace_root)}"
|
|
263
|
+
)
|
|
264
|
+
return content
|
|
265
|
+
|
|
266
|
+
if not path.exists() or not path.is_file():
|
|
267
|
+
raise ApplyPatchError(
|
|
268
|
+
f"apply_patch verification failed: Failed to read {path.relative_to(self._workspace_root)}"
|
|
269
|
+
)
|
|
270
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
271
|
+
|
|
272
|
+
def _apply_update(
|
|
273
|
+
self,
|
|
274
|
+
path: Path,
|
|
275
|
+
original_text: str,
|
|
276
|
+
sections: tuple[_UpdateSection, ...],
|
|
277
|
+
) -> str:
|
|
278
|
+
lines = original_text.splitlines()
|
|
279
|
+
cursor = 0
|
|
280
|
+
for section in sections:
|
|
281
|
+
old_block = [line[1:] for line in section.lines if line[:1] in {" ", "-"}]
|
|
282
|
+
new_block = [line[1:] for line in section.lines if line[:1] in {" ", "+"}]
|
|
283
|
+
if not old_block and not new_block:
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
match_index = self._find_match(lines, old_block, cursor, section.anchor_end_of_file)
|
|
287
|
+
if match_index is None:
|
|
288
|
+
raise ApplyPatchError(
|
|
289
|
+
"apply_patch verification failed: Failed to find expected lines in "
|
|
290
|
+
f"{path.relative_to(self._workspace_root)}"
|
|
291
|
+
)
|
|
292
|
+
lines[match_index : match_index + len(old_block)] = new_block
|
|
293
|
+
cursor = match_index + len(new_block)
|
|
294
|
+
return self._join_lines(lines)
|
|
295
|
+
|
|
296
|
+
def _find_match(
|
|
297
|
+
self,
|
|
298
|
+
lines: list[str],
|
|
299
|
+
old_block: list[str],
|
|
300
|
+
cursor: int,
|
|
301
|
+
anchor_end_of_file: bool,
|
|
302
|
+
) -> int | None:
|
|
303
|
+
if anchor_end_of_file:
|
|
304
|
+
start = len(lines) - len(old_block)
|
|
305
|
+
if start >= 0 and lines[start : start + len(old_block)] == old_block:
|
|
306
|
+
return start
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
if not old_block:
|
|
310
|
+
return cursor
|
|
311
|
+
|
|
312
|
+
limit = len(lines) - len(old_block) + 1
|
|
313
|
+
for start in range(max(cursor, 0), max(limit, 0)):
|
|
314
|
+
if lines[start : start + len(old_block)] == old_block:
|
|
315
|
+
return start
|
|
316
|
+
for start in range(0, max(limit, 0)):
|
|
317
|
+
if lines[start : start + len(old_block)] == old_block:
|
|
318
|
+
return start
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
def _write_preview(self, preview: dict[Path, str | None]) -> None:
|
|
322
|
+
for path, content in preview.items():
|
|
323
|
+
if content is None:
|
|
324
|
+
if path.exists():
|
|
325
|
+
path.unlink()
|
|
326
|
+
continue
|
|
327
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
path.write_text(content, encoding="utf-8")
|
|
329
|
+
|
|
330
|
+
def _format_success(self, summaries: dict[Path, str]) -> str:
|
|
331
|
+
buckets = {"A": [], "M": [], "D": []}
|
|
332
|
+
for path, status in summaries.items():
|
|
333
|
+
buckets[status].append(path.relative_to(self._workspace_root).as_posix())
|
|
334
|
+
lines = ["Success. Updated the following files:"]
|
|
335
|
+
for status in ("A", "M", "D"):
|
|
336
|
+
for rel_path in sorted(buckets[status]):
|
|
337
|
+
lines.append(f"{status} {rel_path}")
|
|
338
|
+
return "\n".join(lines) + "\n"
|
|
339
|
+
|
|
340
|
+
def _format_result(self, output: str, exit_code: int) -> str:
|
|
341
|
+
return (
|
|
342
|
+
f"Exit code: {exit_code}\n"
|
|
343
|
+
"Wall time: 0 seconds\n"
|
|
344
|
+
"Output:\n"
|
|
345
|
+
f"{output}"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def _resolve_workspace_path(self, path_text: str) -> Path:
|
|
349
|
+
path = Path(path_text)
|
|
350
|
+
resolved = path if path.is_absolute() else self._workspace_root / path
|
|
351
|
+
resolved = resolved.resolve()
|
|
352
|
+
try:
|
|
353
|
+
resolved.relative_to(self._workspace_root)
|
|
354
|
+
except ValueError as exc:
|
|
355
|
+
raise ApplyPatchError(
|
|
356
|
+
"patch rejected: writing outside of the project; rejected by user approval settings"
|
|
357
|
+
) from exc
|
|
358
|
+
return resolved
|
|
359
|
+
|
|
360
|
+
def _join_lines(self, lines: list[str]) -> str:
|
|
361
|
+
if not lines:
|
|
362
|
+
return ""
|
|
363
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Shared tool abstractions for the Python Codex prototype.
|
|
2
|
+
|
|
3
|
+
Original Codex mapping:
|
|
4
|
+
- Corresponds to the registry/handler layer behind Rust Codex tool routing
|
|
5
|
+
rather than one single end-user tool.
|
|
6
|
+
|
|
7
|
+
Expected behavior:
|
|
8
|
+
- `BaseTool` defines the contract every local Python tool must implement.
|
|
9
|
+
- `ToolRegistry` stores concrete tool instances, exposes tool specs to the
|
|
10
|
+
model, and dispatches `ToolCall` executions back into `ToolResult`s.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import inspect
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from functools import lru_cache
|
|
19
|
+
import json
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ..protocol import ConversationItem, JSONDict, JSONValue, ToolCall, ToolResult, ToolSpec
|
|
23
|
+
|
|
24
|
+
EXEC_TOOLS_SNAPSHOT_PATH = (
|
|
25
|
+
Path(__file__).resolve().parent.parent / "prompts" / "exec_tools.json"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@lru_cache(maxsize=1)
|
|
30
|
+
def _load_exec_tool_payloads() -> dict[str, JSONDict]:
|
|
31
|
+
payloads: dict[str, JSONDict] = {}
|
|
32
|
+
for payload in json.loads(EXEC_TOOLS_SNAPSHOT_PATH.read_text()):
|
|
33
|
+
if not isinstance(payload, dict):
|
|
34
|
+
continue
|
|
35
|
+
name = payload.get("name")
|
|
36
|
+
if isinstance(name, str):
|
|
37
|
+
payloads[name] = payload
|
|
38
|
+
continue
|
|
39
|
+
if payload.get("type") == "web_search":
|
|
40
|
+
payloads["web_search"] = payload
|
|
41
|
+
return payloads
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ToolContext:
|
|
46
|
+
turn_id: str
|
|
47
|
+
history: tuple[ConversationItem, ...]
|
|
48
|
+
collaboration_mode: str = "default"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class StructuredToolOutput:
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
output: JSONValue,
|
|
55
|
+
content_items: tuple[JSONDict, ...] | list[JSONDict] | None = None,
|
|
56
|
+
success: bool | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.output = output
|
|
59
|
+
self.content_items = None if content_items is None else tuple(content_items)
|
|
60
|
+
self.success = success
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BaseTool(ABC):
|
|
64
|
+
name: str
|
|
65
|
+
description: str
|
|
66
|
+
input_schema: JSONDict | None = None
|
|
67
|
+
tool_type: str = "function"
|
|
68
|
+
format: JSONDict | None = None
|
|
69
|
+
options: JSONDict | None = None
|
|
70
|
+
output_schema: JSONDict | None = None
|
|
71
|
+
supports_parallel: bool = True
|
|
72
|
+
|
|
73
|
+
def spec(self) -> ToolSpec:
|
|
74
|
+
return ToolSpec(
|
|
75
|
+
name=self.name,
|
|
76
|
+
description=self.description,
|
|
77
|
+
input_schema=self.input_schema,
|
|
78
|
+
tool_type=self.tool_type,
|
|
79
|
+
format=self.format,
|
|
80
|
+
options=self.options,
|
|
81
|
+
output_schema=self.output_schema,
|
|
82
|
+
supports_parallel=self.supports_parallel,
|
|
83
|
+
raw_payload=self.raw_payload(),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def serialize(self) -> JSONDict:
|
|
87
|
+
return self.spec().serialize()
|
|
88
|
+
|
|
89
|
+
def raw_payload(self) -> JSONDict | None:
|
|
90
|
+
return _load_exec_tool_payloads().get(self.name)
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
async def run(self, context: ToolContext, args: JSONValue) -> JSONValue:
|
|
94
|
+
raise NotImplementedError
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ToolRegistry:
|
|
98
|
+
def __init__(self) -> None:
|
|
99
|
+
self._tools: dict[str, BaseTool] = {}
|
|
100
|
+
|
|
101
|
+
def register(self, tool: BaseTool) -> None:
|
|
102
|
+
self._tools[tool.name] = tool
|
|
103
|
+
|
|
104
|
+
def model_visible_specs(self) -> list[ToolSpec]:
|
|
105
|
+
return [tool.spec() for tool in self._tools.values()]
|
|
106
|
+
|
|
107
|
+
def supports_parallel(self, tool_name: str) -> bool:
|
|
108
|
+
tool = self._tools.get(tool_name)
|
|
109
|
+
return False if tool is None else tool.supports_parallel
|
|
110
|
+
|
|
111
|
+
async def execute(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
112
|
+
tool = self._tools.get(call.name)
|
|
113
|
+
if tool is None:
|
|
114
|
+
return ToolResult(
|
|
115
|
+
call_id=call.call_id,
|
|
116
|
+
name=call.name,
|
|
117
|
+
output={"error": f"unknown tool: {call.name}"},
|
|
118
|
+
is_error=True,
|
|
119
|
+
tool_type=call.tool_type,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
maybe_result = tool.run(context, call.arguments)
|
|
124
|
+
if inspect.isawaitable(maybe_result):
|
|
125
|
+
output = await maybe_result
|
|
126
|
+
else:
|
|
127
|
+
output = maybe_result
|
|
128
|
+
if isinstance(output, StructuredToolOutput):
|
|
129
|
+
return ToolResult(
|
|
130
|
+
call_id=call.call_id,
|
|
131
|
+
name=call.name,
|
|
132
|
+
output=output.output,
|
|
133
|
+
content_items=output.content_items,
|
|
134
|
+
success=output.success,
|
|
135
|
+
tool_type=call.tool_type,
|
|
136
|
+
)
|
|
137
|
+
return ToolResult(
|
|
138
|
+
call_id=call.call_id,
|
|
139
|
+
name=call.name,
|
|
140
|
+
output=output,
|
|
141
|
+
tool_type=call.tool_type,
|
|
142
|
+
)
|
|
143
|
+
except Exception as exc: # pragma: no cover - defensive wrapper
|
|
144
|
+
return ToolResult(
|
|
145
|
+
call_id=call.call_id,
|
|
146
|
+
name=call.name,
|
|
147
|
+
output={"error": f"{type(exc).__name__}: {exc}"},
|
|
148
|
+
is_error=True,
|
|
149
|
+
tool_type=call.tool_type,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def __contains__(self, tool_name: str) -> bool:
|
|
153
|
+
return tool_name in self._tools
|
|
154
|
+
|
|
155
|
+
def __len__(self) -> int:
|
|
156
|
+
return len(self._tools)
|
|
157
|
+
|
|
158
|
+
def names(self) -> tuple[str, ...]:
|
|
159
|
+
return tuple(self._tools)
|
|
160
|
+
|
|
161
|
+
def get_tool(self, tool_name: str) -> BaseTool | None:
|
|
162
|
+
return self._tools.get(tool_name)
|
|
163
|
+
|
|
164
|
+
def tools(self) -> tuple[BaseTool, ...]:
|
|
165
|
+
return tuple(self._tools.values())
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
Registry = ToolRegistry
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""`close_agent` tool for the Python Codex prototype.
|
|
2
|
+
|
|
3
|
+
Original Codex mapping:
|
|
4
|
+
- Corresponds to the original Codex `close_agent` collaboration tool.
|
|
5
|
+
|
|
6
|
+
Expected behavior:
|
|
7
|
+
- Shut down a spawned agent when it is no longer needed.
|
|
8
|
+
- Return the agent status observed at close time.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ..protocol import JSONDict, JSONValue
|
|
14
|
+
from ..runtime_services import SubAgentManager
|
|
15
|
+
from .agent_tool_schemas import AGENT_STATUS_SCHEMA
|
|
16
|
+
from .base_tool import BaseTool, ToolContext
|
|
17
|
+
|
|
18
|
+
CLOSE_AGENT_OUTPUT_SCHEMA = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"status": AGENT_STATUS_SCHEMA,
|
|
22
|
+
},
|
|
23
|
+
"required": ["status"],
|
|
24
|
+
"additionalProperties": False,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CloseAgentTool(BaseTool):
|
|
29
|
+
name = "close_agent"
|
|
30
|
+
description = (
|
|
31
|
+
"Close an agent when it is no longer needed and return its status."
|
|
32
|
+
)
|
|
33
|
+
input_schema = {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"id": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Agent id to close (from spawn_agent).",
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"required": ["id"],
|
|
42
|
+
"additionalProperties": False,
|
|
43
|
+
}
|
|
44
|
+
output_schema = CLOSE_AGENT_OUTPUT_SCHEMA
|
|
45
|
+
supports_parallel = False
|
|
46
|
+
|
|
47
|
+
def __init__(self, subagent_manager: SubAgentManager) -> None:
|
|
48
|
+
self._subagent_manager = subagent_manager
|
|
49
|
+
|
|
50
|
+
async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
|
|
51
|
+
del context
|
|
52
|
+
agent_id = str(args.get("id", "")).strip()
|
|
53
|
+
if not agent_id:
|
|
54
|
+
return "Error: `id` is required."
|
|
55
|
+
return await self._subagent_manager.close_agent(agent_id)
|