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,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)