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.
Files changed (62) hide show
  1. pycodex/__init__.py +139 -2
  2. pycodex/agent.py +290 -0
  3. pycodex/cli.py +641 -0
  4. pycodex/collaboration.py +21 -0
  5. pycodex/context.py +580 -0
  6. pycodex/doctor.py +360 -0
  7. pycodex/model.py +533 -0
  8. pycodex/prompts/collaboration_default.md +11 -0
  9. pycodex/prompts/collaboration_plan.md +128 -0
  10. pycodex/prompts/default_base_instructions.md +275 -0
  11. pycodex/prompts/exec_tools.json +411 -0
  12. pycodex/prompts/models.json +847 -0
  13. pycodex/prompts/permissions/approval_policy/never.md +1 -0
  14. pycodex/prompts/permissions/approval_policy/on_failure.md +1 -0
  15. pycodex/prompts/permissions/approval_policy/on_request.md +57 -0
  16. pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +33 -0
  17. pycodex/prompts/permissions/approval_policy/unless_trusted.md +1 -0
  18. pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +1 -0
  19. pycodex/prompts/permissions/sandbox_mode/read_only.md +1 -0
  20. pycodex/prompts/permissions/sandbox_mode/workspace_write.md +1 -0
  21. pycodex/prompts/subagent_tools.json +163 -0
  22. pycodex/protocol.py +347 -0
  23. pycodex/runtime.py +200 -0
  24. pycodex/runtime_services.py +408 -0
  25. pycodex/tools/__init__.py +58 -0
  26. pycodex/tools/agent_tool_schemas.py +70 -0
  27. pycodex/tools/apply_patch_tool.py +363 -0
  28. pycodex/tools/base_tool.py +168 -0
  29. pycodex/tools/close_agent_tool.py +55 -0
  30. pycodex/tools/code_mode_manager.py +519 -0
  31. pycodex/tools/exec_command_tool.py +96 -0
  32. pycodex/tools/exec_runtime.js +161 -0
  33. pycodex/tools/exec_tool.py +48 -0
  34. pycodex/tools/grep_files_tool.py +150 -0
  35. pycodex/tools/list_dir_tool.py +135 -0
  36. pycodex/tools/read_file_tool.py +217 -0
  37. pycodex/tools/request_permissions_tool.py +95 -0
  38. pycodex/tools/request_user_input_tool.py +167 -0
  39. pycodex/tools/resume_agent_tool.py +56 -0
  40. pycodex/tools/send_input_tool.py +106 -0
  41. pycodex/tools/shell_command_tool.py +107 -0
  42. pycodex/tools/shell_tool.py +112 -0
  43. pycodex/tools/spawn_agent_tool.py +97 -0
  44. pycodex/tools/unified_exec_manager.py +380 -0
  45. pycodex/tools/update_plan_tool.py +79 -0
  46. pycodex/tools/view_image_tool.py +111 -0
  47. pycodex/tools/wait_agent_tool.py +75 -0
  48. pycodex/tools/wait_tool.py +68 -0
  49. pycodex/tools/web_search_tool.py +30 -0
  50. pycodex/tools/write_stdin_tool.py +75 -0
  51. pycodex/utils/__init__.py +40 -0
  52. pycodex/utils/dotenv.py +64 -0
  53. pycodex/utils/get_env.py +218 -0
  54. pycodex/utils/random_ids.py +19 -0
  55. pycodex/utils/visualize.py +978 -0
  56. python_codex-0.1.0.dist-info/METADATA +267 -0
  57. python_codex-0.1.0.dist-info/RECORD +60 -0
  58. python_codex-0.1.0.dist-info/entry_points.txt +2 -0
  59. python_codex-0.1.0.dist-info/licenses/LICENSE +201 -0
  60. python_codex-0.0.1.dist-info/METADATA +0 -30
  61. python_codex-0.0.1.dist-info/RECORD +0 -4
  62. {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)