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,217 @@
|
|
|
1
|
+
"""`read_file` tool for the Python Codex prototype.
|
|
2
|
+
|
|
3
|
+
Original Codex mapping:
|
|
4
|
+
- Corresponds to the original Codex `read_file` tool.
|
|
5
|
+
|
|
6
|
+
Expected behavior:
|
|
7
|
+
- Read a local file with 1-indexed line numbers.
|
|
8
|
+
- Support the original tool's core slice mode and the indentation-aware block
|
|
9
|
+
mode used to inspect code structure without shelling out to `sed` or `cat`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections import deque
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..protocol import JSONDict, JSONValue
|
|
18
|
+
from .base_tool import BaseTool, ToolContext
|
|
19
|
+
|
|
20
|
+
MAX_LINE_LENGTH = 500
|
|
21
|
+
TAB_WIDTH = 4
|
|
22
|
+
COMMENT_PREFIXES = ("#", "//", "--")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReadFileTool(BaseTool):
|
|
26
|
+
name = "read_file"
|
|
27
|
+
description = (
|
|
28
|
+
"Reads a local file with 1-indexed line numbers, supporting slice and "
|
|
29
|
+
"indentation-aware block modes."
|
|
30
|
+
)
|
|
31
|
+
input_schema = {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": {
|
|
34
|
+
"file_path": {"type": "string"},
|
|
35
|
+
"offset": {"type": "integer"},
|
|
36
|
+
"limit": {"type": "integer"},
|
|
37
|
+
"mode": {"type": "string"},
|
|
38
|
+
"indentation": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"anchor_line": {"type": "integer"},
|
|
42
|
+
"max_levels": {"type": "integer"},
|
|
43
|
+
"include_siblings": {"type": "boolean"},
|
|
44
|
+
"include_header": {"type": "boolean"},
|
|
45
|
+
"max_lines": {"type": "integer"},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
"required": ["file_path"],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
|
|
53
|
+
del context
|
|
54
|
+
file_path = Path(str(args.get("file_path", "")))
|
|
55
|
+
offset = int(args.get("offset", 1))
|
|
56
|
+
limit = int(args.get("limit", 2000))
|
|
57
|
+
mode = str(args.get("mode", "slice"))
|
|
58
|
+
indentation = args.get("indentation") or {}
|
|
59
|
+
|
|
60
|
+
if not file_path.is_absolute():
|
|
61
|
+
return "Error: `file_path` must be an absolute path."
|
|
62
|
+
if offset <= 0:
|
|
63
|
+
return "Error: `offset` must be a 1-indexed line number."
|
|
64
|
+
if limit <= 0:
|
|
65
|
+
return "Error: `limit` must be greater than zero."
|
|
66
|
+
if not file_path.exists():
|
|
67
|
+
return f"Error: `{file_path}` does not exist."
|
|
68
|
+
if not file_path.is_file():
|
|
69
|
+
return f"Error: `{file_path}` is not a file."
|
|
70
|
+
|
|
71
|
+
if mode == "indentation":
|
|
72
|
+
return self._read_indentation(file_path, offset, limit, indentation)
|
|
73
|
+
return self._read_slice(file_path, offset, limit)
|
|
74
|
+
|
|
75
|
+
def _read_slice(self, file_path: Path, offset: int, limit: int) -> str:
|
|
76
|
+
lines = file_path.read_text(errors="replace").splitlines()
|
|
77
|
+
if offset > len(lines):
|
|
78
|
+
return "Error: `offset` exceeds file length."
|
|
79
|
+
|
|
80
|
+
selected = lines[offset - 1 : offset - 1 + limit]
|
|
81
|
+
return "\n".join(
|
|
82
|
+
f"L{line_number}: {self._format_line(text)}"
|
|
83
|
+
for line_number, text in enumerate(selected, start=offset)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _read_indentation(
|
|
87
|
+
self,
|
|
88
|
+
file_path: Path,
|
|
89
|
+
offset: int,
|
|
90
|
+
limit: int,
|
|
91
|
+
indentation: JSONDict,
|
|
92
|
+
) -> str:
|
|
93
|
+
lines = self._collect_line_records(file_path)
|
|
94
|
+
anchor_line = int(indentation.get("anchor_line", offset))
|
|
95
|
+
max_levels = int(indentation.get("max_levels", 0))
|
|
96
|
+
include_siblings = bool(indentation.get("include_siblings", False))
|
|
97
|
+
include_header = bool(indentation.get("include_header", True))
|
|
98
|
+
max_lines = int(indentation.get("max_lines", limit))
|
|
99
|
+
|
|
100
|
+
if anchor_line <= 0:
|
|
101
|
+
return "Error: `anchor_line` must be a 1-indexed line number."
|
|
102
|
+
if anchor_line > len(lines):
|
|
103
|
+
return "Error: `anchor_line` exceeds file length."
|
|
104
|
+
if max_lines <= 0:
|
|
105
|
+
return "Error: `max_lines` must be greater than zero."
|
|
106
|
+
|
|
107
|
+
anchor_index = anchor_line - 1
|
|
108
|
+
effective_indents = self._compute_effective_indents(lines)
|
|
109
|
+
anchor_indent = effective_indents[anchor_index]
|
|
110
|
+
min_indent = 0 if max_levels == 0 else max(anchor_indent - max_levels * TAB_WIDTH, 0)
|
|
111
|
+
final_limit = min(limit, max_lines, len(lines))
|
|
112
|
+
|
|
113
|
+
if final_limit == 1:
|
|
114
|
+
record = lines[anchor_index]
|
|
115
|
+
return f"L{record['number']}: {record['display']}"
|
|
116
|
+
|
|
117
|
+
upper = anchor_index - 1
|
|
118
|
+
lower = anchor_index + 1
|
|
119
|
+
upper_min_indent_count = 0
|
|
120
|
+
lower_min_indent_count = 0
|
|
121
|
+
selected = deque([lines[anchor_index]])
|
|
122
|
+
|
|
123
|
+
while len(selected) < final_limit:
|
|
124
|
+
progressed = 0
|
|
125
|
+
|
|
126
|
+
if upper >= 0:
|
|
127
|
+
if effective_indents[upper] >= min_indent:
|
|
128
|
+
selected.appendleft(lines[upper])
|
|
129
|
+
progressed += 1
|
|
130
|
+
if effective_indents[upper] == min_indent and not include_siblings:
|
|
131
|
+
allow_header_comment = include_header and lines[upper]["is_comment"]
|
|
132
|
+
can_take_line = allow_header_comment or upper_min_indent_count == 0
|
|
133
|
+
if can_take_line:
|
|
134
|
+
upper_min_indent_count += 1
|
|
135
|
+
else:
|
|
136
|
+
selected.popleft()
|
|
137
|
+
progressed -= 1
|
|
138
|
+
upper = -1
|
|
139
|
+
if progressed == 0 and lower >= len(lines):
|
|
140
|
+
break
|
|
141
|
+
continue
|
|
142
|
+
upper -= 1
|
|
143
|
+
else:
|
|
144
|
+
upper = -1
|
|
145
|
+
|
|
146
|
+
if len(selected) >= final_limit:
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
if lower < len(lines):
|
|
150
|
+
if effective_indents[lower] >= min_indent:
|
|
151
|
+
selected.append(lines[lower])
|
|
152
|
+
progressed += 1
|
|
153
|
+
if effective_indents[lower] == min_indent and not include_siblings:
|
|
154
|
+
if lower_min_indent_count > 0:
|
|
155
|
+
selected.pop()
|
|
156
|
+
progressed -= 1
|
|
157
|
+
lower = len(lines)
|
|
158
|
+
if progressed == 0 and upper < 0:
|
|
159
|
+
break
|
|
160
|
+
continue
|
|
161
|
+
lower_min_indent_count += 1
|
|
162
|
+
lower += 1
|
|
163
|
+
else:
|
|
164
|
+
lower = len(lines)
|
|
165
|
+
|
|
166
|
+
if progressed == 0:
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
self._trim_empty_lines(selected)
|
|
170
|
+
return "\n".join(
|
|
171
|
+
f"L{record['number']}: {record['display']}" for record in selected
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def _collect_line_records(self, file_path: Path) -> list[dict[str, object]]:
|
|
175
|
+
records = []
|
|
176
|
+
for number, raw in enumerate(file_path.read_text(errors="replace").splitlines(), start=1):
|
|
177
|
+
records.append(
|
|
178
|
+
{
|
|
179
|
+
"number": number,
|
|
180
|
+
"raw": raw,
|
|
181
|
+
"display": self._format_line(raw),
|
|
182
|
+
"indent": self._measure_indent(raw),
|
|
183
|
+
"is_comment": raw.strip().startswith(COMMENT_PREFIXES),
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
return records
|
|
187
|
+
|
|
188
|
+
def _compute_effective_indents(self, records: list[dict[str, object]]) -> list[int]:
|
|
189
|
+
effective = []
|
|
190
|
+
previous_indent = 0
|
|
191
|
+
for record in records:
|
|
192
|
+
if not str(record["raw"]).strip():
|
|
193
|
+
effective.append(previous_indent)
|
|
194
|
+
else:
|
|
195
|
+
previous_indent = int(record["indent"])
|
|
196
|
+
effective.append(previous_indent)
|
|
197
|
+
return effective
|
|
198
|
+
|
|
199
|
+
def _measure_indent(self, line: str) -> int:
|
|
200
|
+
total = 0
|
|
201
|
+
for character in line:
|
|
202
|
+
if character == " ":
|
|
203
|
+
total += 1
|
|
204
|
+
elif character == "\t":
|
|
205
|
+
total += TAB_WIDTH
|
|
206
|
+
else:
|
|
207
|
+
break
|
|
208
|
+
return total
|
|
209
|
+
|
|
210
|
+
def _format_line(self, text: str) -> str:
|
|
211
|
+
return text[:MAX_LINE_LENGTH]
|
|
212
|
+
|
|
213
|
+
def _trim_empty_lines(self, records: deque[dict[str, object]]) -> None:
|
|
214
|
+
while records and not str(records[0]["raw"]).strip():
|
|
215
|
+
records.popleft()
|
|
216
|
+
while records and not str(records[-1]["raw"]).strip():
|
|
217
|
+
records.pop()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""`request_permissions` tool for the Python Codex prototype.
|
|
2
|
+
|
|
3
|
+
Original Codex mapping:
|
|
4
|
+
- Corresponds to the original Codex `request_permissions` collaboration tool.
|
|
5
|
+
|
|
6
|
+
Expected behavior:
|
|
7
|
+
- Ask the user to approve additional filesystem or network permissions.
|
|
8
|
+
- Wait for an interactive response from the client/UI layer.
|
|
9
|
+
- Return the granted permission profile and scope so later tool calls can use it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ..protocol import JSONDict, JSONValue
|
|
15
|
+
from ..runtime_services import RequestPermissionsManager
|
|
16
|
+
from .base_tool import BaseTool, ToolContext
|
|
17
|
+
|
|
18
|
+
NETWORK_PERMISSIONS_SCHEMA = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"enabled": {
|
|
22
|
+
"type": "boolean",
|
|
23
|
+
"description": "Set to true to request network access.",
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"additionalProperties": False,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
FILE_SYSTEM_PERMISSIONS_SCHEMA = {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"read": {
|
|
33
|
+
"type": "array",
|
|
34
|
+
"items": {"type": "string"},
|
|
35
|
+
"description": "Absolute paths to grant read access to.",
|
|
36
|
+
},
|
|
37
|
+
"write": {
|
|
38
|
+
"type": "array",
|
|
39
|
+
"items": {"type": "string"},
|
|
40
|
+
"description": "Absolute paths to grant write access to.",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
"additionalProperties": False,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
REQUEST_PERMISSION_PROFILE_SCHEMA = {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"network": NETWORK_PERMISSIONS_SCHEMA,
|
|
50
|
+
"file_system": FILE_SYSTEM_PERMISSIONS_SCHEMA,
|
|
51
|
+
},
|
|
52
|
+
"additionalProperties": False,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RequestPermissionsTool(BaseTool):
|
|
57
|
+
name = "request_permissions"
|
|
58
|
+
description = (
|
|
59
|
+
"Request additional filesystem or network permissions from the user "
|
|
60
|
+
"and wait for a response."
|
|
61
|
+
)
|
|
62
|
+
input_schema = {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"reason": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"description": "Optional short explanation for why additional permissions are needed.",
|
|
68
|
+
},
|
|
69
|
+
"permissions": REQUEST_PERMISSION_PROFILE_SCHEMA,
|
|
70
|
+
},
|
|
71
|
+
"required": ["permissions"],
|
|
72
|
+
"additionalProperties": False,
|
|
73
|
+
}
|
|
74
|
+
supports_parallel = False
|
|
75
|
+
|
|
76
|
+
def __init__(self, request_manager: RequestPermissionsManager) -> None:
|
|
77
|
+
self._request_manager = request_manager
|
|
78
|
+
|
|
79
|
+
async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
|
|
80
|
+
del context
|
|
81
|
+
permissions = args.get("permissions")
|
|
82
|
+
if not isinstance(permissions, dict):
|
|
83
|
+
return "Error: `permissions` must be an object."
|
|
84
|
+
if not permissions:
|
|
85
|
+
return "Error: request_permissions requires at least one permission."
|
|
86
|
+
|
|
87
|
+
response = await self._request_manager.request(
|
|
88
|
+
{
|
|
89
|
+
"reason": None if args.get("reason") in (None, "") else str(args.get("reason")),
|
|
90
|
+
"permissions": permissions,
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
if response is None:
|
|
94
|
+
return "Error: request_permissions was cancelled before receiving a response."
|
|
95
|
+
return response
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""`request_user_input` tool for the Python Codex prototype.
|
|
2
|
+
|
|
3
|
+
Original Codex mapping:
|
|
4
|
+
- Corresponds to the original Codex `request_user_input` collaboration tool.
|
|
5
|
+
|
|
6
|
+
Expected behavior:
|
|
7
|
+
- In upstream Codex this tool is only actually usable in Plan mode.
|
|
8
|
+
- In the current `pycodex` default-mode path, runtime invocation returns the
|
|
9
|
+
same fixed unavailable error string as upstream Codex.
|
|
10
|
+
- In Plan mode, the tool validates its question payload, forces `isOther=true`
|
|
11
|
+
on every question, and returns a JSON-string `function_call_output` with
|
|
12
|
+
`success=true`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
from ..collaboration import collaboration_mode_display_name
|
|
20
|
+
from ..protocol import JSONDict, JSONValue
|
|
21
|
+
from ..runtime_services import RequestUserInputManager
|
|
22
|
+
from .base_tool import BaseTool, StructuredToolOutput, ToolContext
|
|
23
|
+
|
|
24
|
+
REQUEST_USER_INPUT_QUESTION_SCHEMA = {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"id": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Stable identifier for mapping answers (snake_case).",
|
|
30
|
+
},
|
|
31
|
+
"header": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Short header label shown in the UI (12 or fewer chars).",
|
|
34
|
+
},
|
|
35
|
+
"question": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": "Single-sentence prompt shown to the user.",
|
|
38
|
+
},
|
|
39
|
+
"options": {
|
|
40
|
+
"type": "array",
|
|
41
|
+
"description": (
|
|
42
|
+
"Provide 2-3 mutually exclusive choices. Put the recommended option "
|
|
43
|
+
"first and suffix its label with \"(Recommended)\". Do not include "
|
|
44
|
+
"an \"Other\" option in this list; the client will add a free-form "
|
|
45
|
+
"Other option automatically."
|
|
46
|
+
),
|
|
47
|
+
"items": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"properties": {
|
|
50
|
+
"label": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "User-facing label (1-5 words).",
|
|
53
|
+
},
|
|
54
|
+
"description": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "One short sentence explaining impact/tradeoff if selected.",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
"required": ["label", "description"],
|
|
60
|
+
"additionalProperties": False,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
"required": ["id", "header", "question", "options"],
|
|
65
|
+
"additionalProperties": False,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def request_user_input_is_available(
|
|
70
|
+
mode: str,
|
|
71
|
+
default_mode_request_user_input: bool = False,
|
|
72
|
+
) -> bool:
|
|
73
|
+
normalized = mode.strip().lower()
|
|
74
|
+
return normalized == "plan" or (
|
|
75
|
+
default_mode_request_user_input and normalized == "default"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def request_user_input_unavailable_message(
|
|
80
|
+
mode: str,
|
|
81
|
+
default_mode_request_user_input: bool = False,
|
|
82
|
+
) -> str | None:
|
|
83
|
+
if request_user_input_is_available(mode, default_mode_request_user_input):
|
|
84
|
+
return None
|
|
85
|
+
return (
|
|
86
|
+
"request_user_input is unavailable in "
|
|
87
|
+
f"{collaboration_mode_display_name(mode)} mode"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def request_user_input_tool_description(
|
|
92
|
+
default_mode_request_user_input: bool = False,
|
|
93
|
+
) -> str:
|
|
94
|
+
if default_mode_request_user_input:
|
|
95
|
+
allowed_modes = "Default or Plan mode"
|
|
96
|
+
else:
|
|
97
|
+
allowed_modes = "Plan mode"
|
|
98
|
+
return (
|
|
99
|
+
"Request user input for one to three short questions and wait for the "
|
|
100
|
+
f"response. This tool is only available in {allowed_modes}."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RequestUserInputTool(BaseTool):
|
|
105
|
+
name = "request_user_input"
|
|
106
|
+
description = request_user_input_tool_description()
|
|
107
|
+
input_schema = {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"properties": {
|
|
110
|
+
"questions": {
|
|
111
|
+
"type": "array",
|
|
112
|
+
"description": "Questions to show the user. Prefer 1 and do not exceed 3",
|
|
113
|
+
"items": REQUEST_USER_INPUT_QUESTION_SCHEMA,
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
"required": ["questions"],
|
|
117
|
+
"additionalProperties": False,
|
|
118
|
+
}
|
|
119
|
+
supports_parallel = False
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
request_manager: RequestUserInputManager,
|
|
124
|
+
default_mode_request_user_input: bool = False,
|
|
125
|
+
) -> None:
|
|
126
|
+
self._request_manager = request_manager
|
|
127
|
+
self._default_mode_request_user_input = default_mode_request_user_input
|
|
128
|
+
self.description = request_user_input_tool_description(
|
|
129
|
+
default_mode_request_user_input
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
|
|
133
|
+
unavailable = request_user_input_unavailable_message(
|
|
134
|
+
context.collaboration_mode,
|
|
135
|
+
self._default_mode_request_user_input,
|
|
136
|
+
)
|
|
137
|
+
if unavailable is not None:
|
|
138
|
+
return unavailable
|
|
139
|
+
|
|
140
|
+
questions = args.get("questions")
|
|
141
|
+
if not isinstance(questions, list):
|
|
142
|
+
raise ValueError("questions must be a list")
|
|
143
|
+
|
|
144
|
+
if any(
|
|
145
|
+
not isinstance(question, dict)
|
|
146
|
+
or not isinstance(question.get("options"), list)
|
|
147
|
+
or not question["options"]
|
|
148
|
+
for question in questions
|
|
149
|
+
):
|
|
150
|
+
return "request_user_input requires non-empty options for every question"
|
|
151
|
+
|
|
152
|
+
request_payload = {
|
|
153
|
+
"questions": [
|
|
154
|
+
{
|
|
155
|
+
**question,
|
|
156
|
+
"isOther": True,
|
|
157
|
+
}
|
|
158
|
+
for question in questions
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
response = await self._request_manager.request(request_payload)
|
|
162
|
+
if response is None:
|
|
163
|
+
return "request_user_input was cancelled before receiving a response"
|
|
164
|
+
return StructuredToolOutput(
|
|
165
|
+
json.dumps(response, ensure_ascii=False, separators=(",", ":")),
|
|
166
|
+
success=True,
|
|
167
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""`resume_agent` tool for the Python Codex prototype.
|
|
2
|
+
|
|
3
|
+
Original Codex mapping:
|
|
4
|
+
- Corresponds to the original Codex `resume_agent` collaboration tool.
|
|
5
|
+
|
|
6
|
+
Expected behavior:
|
|
7
|
+
- Restart a previously closed local sub-agent runtime.
|
|
8
|
+
- Return the agent's current status payload.
|
|
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
|
+
RESUME_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 ResumeAgentTool(BaseTool):
|
|
29
|
+
name = "resume_agent"
|
|
30
|
+
description = (
|
|
31
|
+
"Resume a previously closed agent by id so it can receive send_input "
|
|
32
|
+
"and wait_agent calls."
|
|
33
|
+
)
|
|
34
|
+
input_schema = {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {
|
|
37
|
+
"id": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"description": "Agent id to resume.",
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"required": ["id"],
|
|
43
|
+
"additionalProperties": False,
|
|
44
|
+
}
|
|
45
|
+
output_schema = RESUME_AGENT_OUTPUT_SCHEMA
|
|
46
|
+
supports_parallel = False
|
|
47
|
+
|
|
48
|
+
def __init__(self, subagent_manager: SubAgentManager) -> None:
|
|
49
|
+
self._subagent_manager = subagent_manager
|
|
50
|
+
|
|
51
|
+
async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
|
|
52
|
+
del context
|
|
53
|
+
agent_id = str(args.get("id", "")).strip()
|
|
54
|
+
if not agent_id:
|
|
55
|
+
return "Error: `id` is required."
|
|
56
|
+
return await self._subagent_manager.resume_agent(agent_id)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""`send_input` tool for the Python Codex prototype.
|
|
2
|
+
|
|
3
|
+
Original Codex mapping:
|
|
4
|
+
- Corresponds to the original Codex `send_input` collaboration tool.
|
|
5
|
+
|
|
6
|
+
Expected behavior:
|
|
7
|
+
- Send a follow-up message to an existing spawned agent.
|
|
8
|
+
- Optionally interrupt the agent's current turn before queueing the new input.
|
|
9
|
+
- Return the submission id for the queued input.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ..protocol import JSONDict, JSONValue
|
|
15
|
+
from ..runtime_services import SubAgentManager
|
|
16
|
+
from .agent_tool_schemas import COLLAB_INPUT_ITEMS_SCHEMA
|
|
17
|
+
from .base_tool import BaseTool, ToolContext
|
|
18
|
+
|
|
19
|
+
SEND_INPUT_OUTPUT_SCHEMA = {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"submission_id": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Identifier for the queued input submission.",
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"required": ["submission_id"],
|
|
28
|
+
"additionalProperties": False,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SendInputTool(BaseTool):
|
|
33
|
+
name = "send_input"
|
|
34
|
+
description = (
|
|
35
|
+
"Send a message to an existing agent. Use interrupt=true to redirect "
|
|
36
|
+
"work immediately."
|
|
37
|
+
)
|
|
38
|
+
input_schema = {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"id": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Agent id to message (from spawn_agent).",
|
|
44
|
+
},
|
|
45
|
+
"message": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Legacy plain-text message to send to the agent. Use either message or items.",
|
|
48
|
+
},
|
|
49
|
+
"items": COLLAB_INPUT_ITEMS_SCHEMA,
|
|
50
|
+
"interrupt": {
|
|
51
|
+
"type": "boolean",
|
|
52
|
+
"description": "When true, stop the agent's current task and handle this immediately. When false (default), queue this message.",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"required": ["id"],
|
|
56
|
+
"additionalProperties": False,
|
|
57
|
+
}
|
|
58
|
+
output_schema = SEND_INPUT_OUTPUT_SCHEMA
|
|
59
|
+
supports_parallel = False
|
|
60
|
+
|
|
61
|
+
def __init__(self, subagent_manager: SubAgentManager) -> None:
|
|
62
|
+
self._subagent_manager = subagent_manager
|
|
63
|
+
|
|
64
|
+
async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
|
|
65
|
+
del context
|
|
66
|
+
agent_id = str(args.get("id", "")).strip()
|
|
67
|
+
if not agent_id:
|
|
68
|
+
return "Error: `id` is required."
|
|
69
|
+
items = args.get("items")
|
|
70
|
+
if items is not None and not isinstance(items, list):
|
|
71
|
+
return "Error: `items` must be a list when provided."
|
|
72
|
+
prompt_text = self._compose_prompt(
|
|
73
|
+
self._optional_string(args, "message"),
|
|
74
|
+
items,
|
|
75
|
+
)
|
|
76
|
+
if not prompt_text:
|
|
77
|
+
return "Error: `message` or `items` is required."
|
|
78
|
+
return await self._subagent_manager.send_input(
|
|
79
|
+
agent_id,
|
|
80
|
+
prompt_text,
|
|
81
|
+
interrupt=bool(args.get("interrupt", False)),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _compose_prompt(
|
|
85
|
+
self,
|
|
86
|
+
message: str | None,
|
|
87
|
+
items: list[dict[str, object]] | None,
|
|
88
|
+
) -> str:
|
|
89
|
+
parts: list[str] = []
|
|
90
|
+
if message:
|
|
91
|
+
parts.append(message.strip())
|
|
92
|
+
for item in items or []:
|
|
93
|
+
item_type = str(item.get("type", ""))
|
|
94
|
+
if item_type == "text":
|
|
95
|
+
text = str(item.get("text", "")).strip()
|
|
96
|
+
if text:
|
|
97
|
+
parts.append(text)
|
|
98
|
+
else:
|
|
99
|
+
parts.append(str(item))
|
|
100
|
+
return "\n\n".join(part for part in parts if part)
|
|
101
|
+
|
|
102
|
+
def _optional_string(self, args: JSONDict, key: str) -> str | None:
|
|
103
|
+
value = args.get(key)
|
|
104
|
+
if value in (None, ""):
|
|
105
|
+
return None
|
|
106
|
+
return str(value)
|