python-agent-harness 1.5.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 (61) hide show
  1. python_agent_harness/__init__.py +20 -0
  2. python_agent_harness/__main__.py +5 -0
  3. python_agent_harness/agent.py +703 -0
  4. python_agent_harness/cli.py +273 -0
  5. python_agent_harness/client.py +832 -0
  6. python_agent_harness/commands.py +181 -0
  7. python_agent_harness/config.py +464 -0
  8. python_agent_harness/context_manager.py +100 -0
  9. python_agent_harness/diffrender.py +84 -0
  10. python_agent_harness/mcp/__init__.py +21 -0
  11. python_agent_harness/mcp/client.py +161 -0
  12. python_agent_harness/mcp/config.py +130 -0
  13. python_agent_harness/mcp/manager.py +290 -0
  14. python_agent_harness/models.py +149 -0
  15. python_agent_harness/persistence.py +297 -0
  16. python_agent_harness/planmode.py +112 -0
  17. python_agent_harness/prompts/agent.md +362 -0
  18. python_agent_harness/prompts/build-switch.md +5 -0
  19. python_agent_harness/prompts/commands/explain.md +13 -0
  20. python_agent_harness/prompts/compact.md +33 -0
  21. python_agent_harness/prompts/initialize.md +66 -0
  22. python_agent_harness/prompts/plan-mode.md +70 -0
  23. python_agent_harness/prompts/plan.md +26 -0
  24. python_agent_harness/prompts/review.md +100 -0
  25. python_agent_harness/prompts/subagent.md +208 -0
  26. python_agent_harness/prompts/summary.md +11 -0
  27. python_agent_harness/prompts/task-completion-rules.md +50 -0
  28. python_agent_harness/prompts/title.md +44 -0
  29. python_agent_harness/prompts.py +498 -0
  30. python_agent_harness/session.py +781 -0
  31. python_agent_harness/subagent.py +61 -0
  32. python_agent_harness/token_estimator.py +125 -0
  33. python_agent_harness/tool_runner.py +247 -0
  34. python_agent_harness/tools/__init__.py +56 -0
  35. python_agent_harness/tools/agent_tool.py +75 -0
  36. python_agent_harness/tools/base.py +147 -0
  37. python_agent_harness/tools/bash.py +298 -0
  38. python_agent_harness/tools/edit.py +272 -0
  39. python_agent_harness/tools/filesystem.py +180 -0
  40. python_agent_harness/tools/glob.py +161 -0
  41. python_agent_harness/tools/grep.py +149 -0
  42. python_agent_harness/tools/insert.py +61 -0
  43. python_agent_harness/tools/mcp.py +203 -0
  44. python_agent_harness/tools/mkdir.py +30 -0
  45. python_agent_harness/tools/planexit.py +45 -0
  46. python_agent_harness/tools/question.py +70 -0
  47. python_agent_harness/tools/read.py +104 -0
  48. python_agent_harness/tools/skill.py +32 -0
  49. python_agent_harness/tools/todo.py +60 -0
  50. python_agent_harness/tools/write.py +56 -0
  51. python_agent_harness/tui/__init__.py +68 -0
  52. python_agent_harness/tui/commands.py +652 -0
  53. python_agent_harness/tui/core.py +385 -0
  54. python_agent_harness/tui/input.py +412 -0
  55. python_agent_harness/tui/render.py +535 -0
  56. python_agent_harness-1.5.0.dist-info/METADATA +251 -0
  57. python_agent_harness-1.5.0.dist-info/RECORD +61 -0
  58. python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
  59. python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
  60. python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
  61. python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,61 @@
1
+ """Sub-agent runner: delegated agent tasks with error containment.
2
+
3
+ Mirrors gptel-agent-harness-agent.el: unexpected response shapes become
4
+ error strings fed back to the parent instead of crashing it. In plan
5
+ mode, sub-agents receive the read-only reminder.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from . import config
11
+ from .agent import run_agent_loop
12
+ from .models import Message
13
+ from .prompts import load_agent_prompt
14
+
15
+
16
+ def _subagent_system_prompt(session: object) -> str | None:
17
+ """The sub-agent's system prompt: its OWN prompt only.
18
+
19
+ Never falls back to the parent's `system_prompt` (which carries the
20
+ parent's project context and task-completion rules) — a sub-agent
21
+ must not inherit any context from the parent. When the session has
22
+ no sub-agent prompt configured, the default bundled one is used.
23
+ """
24
+ own = getattr(session, "subagent_system_prompt", None)
25
+ if own:
26
+ return own
27
+ return load_agent_prompt(config.DEFAULT_SUBAGENT_PROMPT_FILE)
28
+
29
+
30
+ def run_subagent(
31
+ parent_session: object,
32
+ description: str,
33
+ prompt: str,
34
+ client: object | None = None,
35
+ ) -> str:
36
+ """Run a sub-agent task; return a result string (never raises).
37
+
38
+ ``client`` (when given) is the per-invocation dedicated client
39
+ (see ``Session.run_subagent``); the loop falls back to the
40
+ session's shared sub-agent client otherwise.
41
+ """
42
+ session = parent_session
43
+ try:
44
+ messages = [Message(role="user", content=prompt)]
45
+ # NOTE: the plan-mode read-only reminder is injected by the agent
46
+ # loop itself (`AgentLoop._inject_pending_prompts`, once per
47
+ # sub-agent loop) — do NOT insert it here as well, or it appears
48
+ # twice in the request.
49
+ result = run_agent_loop(
50
+ session=session,
51
+ messages=messages,
52
+ top_level=False,
53
+ system=_subagent_system_prompt(session),
54
+ max_rounds=config.SUBAGENT_MAX_ROUNDS,
55
+ client=client,
56
+ )
57
+ if isinstance(result, str):
58
+ return result
59
+ return f"Error: Task {description!r} returned an unexpected response {result!r}"
60
+ except Exception as e: # noqa: BLE001 - containment boundary
61
+ return f'Error: Task "{description}" returned an unexpected response — {e}'
@@ -0,0 +1,125 @@
1
+ """Token estimation and calibration, ported from gptel-agent-harness.el.
2
+
3
+ Estimates are heuristic (Latin ~4 chars/token, CJK ~2 chars/token);
4
+ a calibration factor derived from API-reported input tokens is applied
5
+ to reduce drift.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+
13
+ from . import config
14
+
15
+
16
+ def is_cjk_char(c: str) -> bool:
17
+ """Return True if C is a CJK or full-width character."""
18
+ cp = ord(c)
19
+ return (
20
+ 0x3000 <= cp <= 0x9FFF # CJK + kana + punctuation
21
+ or 0xF900 <= cp <= 0xFAFF # CJK compat ideographs
22
+ or 0xFF00 <= cp <= 0xFFEF # full-width forms
23
+ or 0x20000 <= cp <= 0x2FA1F # CJK extensions B-F
24
+ )
25
+
26
+
27
+ # The same ranges as `is_cjk_char`, as a single compiled character class.
28
+ # Scanning for CJK runs in C (the regex engine) instead of a per-character
29
+ # Python loop, so large payloads count CJK chars much faster.
30
+ _CJK_RE = re.compile(r"[\u3000-\u9fff\uf900-\ufaff\uff00-\uffef\U00020000-\U0002fa1f]")
31
+
32
+
33
+ def estimate_tokens(text: str) -> int:
34
+ """Estimate tokens in TEXT: Latin ~4 chars/token, CJK ~2 chars/token."""
35
+ if not text:
36
+ return 0
37
+ cjk = len(_CJK_RE.findall(text))
38
+ latin = len(text) - cjk
39
+ return round(latin / 4.0 + cjk / 2.0)
40
+
41
+
42
+ def context_window_for(model: str) -> int:
43
+ """Return the context window for MODEL, or a safe fallback.
44
+
45
+ Entries are matched in order using substring matching, so more
46
+ specific patterns must come before general ones (see config).
47
+ """
48
+ lowered = model.lower()
49
+ for pattern, size in config.CONTEXT_WINDOWS:
50
+ if pattern in lowered:
51
+ return size
52
+ return config.DEFAULT_CONTEXT_WINDOW
53
+
54
+
55
+ class TokenCalibrator:
56
+ """Calibration factor: actual_tokens / estimated_tokens.
57
+
58
+ Updated after each response using the API-reported input token
59
+ count. Applied to future estimations to reduce drift. Clamped
60
+ to [CALIBRATION_MIN, CALIBRATION_MAX] to avoid pathological values.
61
+ """
62
+
63
+ def __init__(self) -> None:
64
+ self.factor = 1.0
65
+ self.last_raw_estimate: int | None = None
66
+
67
+ def update(self, actual_input: int | None) -> None:
68
+ raw = self.last_raw_estimate
69
+ if actual_input is None or actual_input <= 0 or raw is None or raw <= 0:
70
+ return
71
+ ratio = actual_input / float(raw)
72
+ ratio = max(config.CALIBRATION_MIN, min(config.CALIBRATION_MAX, ratio))
73
+ self.factor = ratio
74
+
75
+ def calibrate(self, estimated: int) -> int:
76
+ return round(estimated * self.factor)
77
+
78
+
79
+ def payload_text(system: object, messages: list[dict], tools: list[dict]) -> str:
80
+ """Serialize the full prompt payload into one plain-text buffer."""
81
+ buf: list[str] = []
82
+ if isinstance(system, str):
83
+ buf.append(system)
84
+ elif isinstance(system, dict) and isinstance(system.get("parts"), list):
85
+ for part in system["parts"]:
86
+ if isinstance(part, dict) and isinstance(part.get("text"), str):
87
+ buf.append(part["text"])
88
+ elif isinstance(system, list):
89
+ for s in system:
90
+ if isinstance(s, str):
91
+ buf.append(s)
92
+ elif isinstance(s, dict) and isinstance(s.get("text"), str):
93
+ buf.append(s["text"])
94
+ for msg in messages:
95
+ content = msg.get("content")
96
+ if isinstance(content, str):
97
+ buf.append(content)
98
+ elif isinstance(content, list):
99
+ for p in content:
100
+ if isinstance(p, str):
101
+ buf.append(p)
102
+ elif isinstance(p, dict):
103
+ for key in ("thinking", "text", "arguments"):
104
+ v = p.get(key)
105
+ if isinstance(v, str):
106
+ buf.append(v)
107
+ break
108
+ reasoning = msg.get("reasoning_content")
109
+ if isinstance(reasoning, str):
110
+ buf.append(reasoning)
111
+ for tc in msg.get("tool_calls") or []:
112
+ fn = tc.get("function", {}) if isinstance(tc, dict) else {}
113
+ name = fn.get("name") if isinstance(fn, dict) else None
114
+ args = fn.get("arguments") if isinstance(fn, dict) else None
115
+ if isinstance(name, str):
116
+ buf.append(name)
117
+ if isinstance(args, str):
118
+ buf.append(args)
119
+ for tool in tools:
120
+ buf.append(json.dumps(tool, separators=(",", ":")))
121
+ return "\n".join(buf)
122
+
123
+
124
+ def estimate_payload_tokens(system: object, messages: list[dict], tools: list[dict]) -> int:
125
+ return estimate_tokens(payload_text(system, messages, tools))
@@ -0,0 +1,247 @@
1
+ """Tool execution for the agent FSM: run/deliver tool calls, salvage history.
2
+
3
+ Extracted from agent.py (no logic changes): the FSM driver delegates
4
+ tool plumbing to ``ToolRunner``, which reads/writes the loop's shared
5
+ state (``messages`` / ``pending`` / ``info``) through the loop
6
+ reference. Tool calls are always executed and delivered via the
7
+ loop's ``_execute_tool_call`` / ``_deliver_tool_result`` methods so
8
+ subclass or test overrides of those methods keep working.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from typing import Any
16
+
17
+ from . import config
18
+ from .models import Message, ToolCall
19
+ from .tools.base import PendingToolResult
20
+
21
+ NIL_RESULT_PLACEHOLDER = (
22
+ "Error: tool produced no result (it may have been interrupted or failed to return)."
23
+ )
24
+
25
+
26
+ def sanitize_tool_result(result: object) -> str:
27
+ """Sanitize a tool result for the model.
28
+
29
+ - str (incl. empty) kept as-is
30
+ - None -> error placeholder (backends reject JSON null content)
31
+ - anything else -> str()
32
+ """
33
+ if result is None:
34
+ return NIL_RESULT_PLACEHOLDER
35
+ if isinstance(result, str):
36
+ return result
37
+ return str(result)
38
+
39
+
40
+ class ToolRunner:
41
+ """Runs and delivers tool calls for one agent loop.
42
+
43
+ Mirrors gptel's `gptel--handle-tool-use': synchronous tools
44
+ (Read, Edit, Glob, ...) execute ONE AT A TIME, in call order;
45
+ asynchronous tools (Bash, Agent — those whose ``run`` returns a
46
+ ``PendingToolResult``) are dispatched in line and run concurrently
47
+ in the background, their results awaited afterwards, again in
48
+ original call order.
49
+ """
50
+
51
+ def __init__(self, loop: Any) -> None:
52
+ self.loop = loop
53
+
54
+ def execute_tool_call(self, call: ToolCall) -> str | PendingToolResult:
55
+ loop = self.loop
56
+ if not loop.top_level and call.name in config.SUBAGENT_EXCLUDED_TOOLS:
57
+ # defense in depth: a hallucinated call must never reach the
58
+ # registry — the spec was filtered, so refuse it here too
59
+ return f"Error: {call.name} is not available to sub-agents — it is a parent-only tool"
60
+ args = call.arguments
61
+ if isinstance(args, str):
62
+ try:
63
+ args = json.loads(args)
64
+ except json.JSONDecodeError:
65
+ args = {}
66
+ if not isinstance(args, dict):
67
+ args = {}
68
+ # Validate required parameters from the tool schema
69
+ tool = loop.session.registry.get(call.name)
70
+ if tool is not None:
71
+ required = tool.parameters.get("required", [])
72
+ missing = [k for k in required if k not in args]
73
+ if missing:
74
+ return f"Error: {call.name} is missing required argument(s): {', '.join(missing)}"
75
+ return loop.session.execute_tool(call.name, args, call_id=call.id)
76
+
77
+ def deliver_tool_result(self, p: ToolCall, result: str) -> None:
78
+ """Append one tool result message for call P (parent thread only)."""
79
+ loop = self.loop
80
+ p.result = result
81
+ if hasattr(loop.session, "take_diff"):
82
+ p.diff = loop.session.take_diff(p.id)
83
+ loop.messages.append(
84
+ Message(
85
+ role="tool",
86
+ content=result,
87
+ tool_call_id=p.id,
88
+ name=p.name,
89
+ )
90
+ )
91
+ if loop.top_level and not loop._is_cancelled():
92
+ # only the top-level loop mirrors its messages onto the
93
+ # shared session: a sub-agent runs inside the parent's
94
+ # tool round and must never clobber the parent's
95
+ # conversation history (the TUI renders from it)
96
+ loop.session.last_messages = list(loop.messages)
97
+
98
+ def run_tools(self, calls: list[ToolCall], results: dict[str, str]) -> None:
99
+ """Run CALLS in model-emitted order, filling RESULTS.
100
+
101
+ Mirrors gptel's `gptel--handle-tool-use': synchronous tools
102
+ (Read, Edit, Glob, ...) execute ONE AT A TIME, in call order;
103
+ asynchronous tools (Bash, Agent — those whose ``run`` returns a
104
+ ``PendingToolResult``) are dispatched in line and run
105
+ concurrently in the background, their results awaited
106
+ afterwards, again in original call order. Delivery happens
107
+ later, in original tool-call order, by the caller.
108
+
109
+ A cancel landing before a call starts skips it (tools have side
110
+ effects); a call already running — or an async tool already
111
+ dispatched — cannot be stopped, but its result stays local to
112
+ the (dead) run.
113
+ """
114
+ loop = self.loop
115
+ async_calls: list[tuple[ToolCall, PendingToolResult]] = []
116
+ for p in calls:
117
+ # A cancel landing while a call is still QUEUED must skip
118
+ # it (tools have side effects): the sequential loop checks
119
+ # before every call, so a call that has not started yet must
120
+ # not run after Ctrl-C.
121
+ if loop._is_cancelled():
122
+ results[p.id] = "Error: tool call cancelled (user aborted the run)."
123
+ continue
124
+ # Notify the TUI which tool is currently executing so the
125
+ # status bar can show the active tool name beside the spinner.
126
+ if loop.top_level:
127
+ loop.session.notify("tool_running", p.name)
128
+ start = time.monotonic()
129
+ try:
130
+ result = loop._execute_tool_call(p)
131
+ except Exception as e: # noqa: BLE001 - containment boundary
132
+ p.elapsed = time.monotonic() - start
133
+ results[p.id] = f"Error: tool {p.name!r} crashed during execution — {e}"
134
+ continue
135
+ if isinstance(result, PendingToolResult):
136
+ # async tool (e.g. Bash): run() spawned the work and
137
+ # returned its handle immediately; await the real result
138
+ # after the sequential loop so sibling calls keep
139
+ # executing in the meantime
140
+ async_calls.append((p, result))
141
+ else:
142
+ p.elapsed = time.monotonic() - start
143
+ results[p.id] = sanitize_tool_result(result)
144
+ for p, pending in async_calls:
145
+ start = time.monotonic()
146
+ try:
147
+ result = pending.wait()
148
+ except Exception as e: # noqa: BLE001 - containment boundary
149
+ results[p.id] = f"Error: tool {p.name!r} crashed during execution — {e}"
150
+ else:
151
+ p.elapsed = time.monotonic() - start
152
+ results[p.id] = sanitize_tool_result(result)
153
+
154
+ def execute_pending(self) -> None:
155
+ """TOOL state: run the round's pending tool calls.
156
+
157
+ The assistant message carrying the tool calls was already
158
+ appended by the WAIT state. Results land in
159
+ ``self.info["tool_result"]`` and are delivered by the TRET
160
+ state in original tool-call order.
161
+
162
+ Synchronous tools run ONE AT A TIME in model-emitted order
163
+ (gptel-style); asynchronous tools (Bash, Agent) are dispatched
164
+ in line and run concurrently in the background.
165
+ """
166
+ loop = self.loop
167
+ pending = list(loop.pending)
168
+ if not pending:
169
+ return
170
+ if loop._is_cancelled():
171
+ # Ctrl-C before the round started: do not run tools (they
172
+ # have side effects) and do not touch shared state — a stale
173
+ # worker must never mirror its partial history over the next
174
+ # run's `session.last_messages`.
175
+ return
176
+ results: dict[str, str] = {}
177
+ loop._run_tools(pending, results)
178
+ if loop._is_cancelled():
179
+ # cancelled mid-round: tools already submitted may have run
180
+ # (their side effects are done), but the results stay local
181
+ # to this (dead) run
182
+ loop.pending = []
183
+ return
184
+ loop.info["tool_result"] = results
185
+
186
+ def deliver_results(self) -> None:
187
+ """TRET state: deliver the round's results to the conversation.
188
+
189
+ Results are appended as tool messages in the original
190
+ tool-call order regardless of execution order. On cancel the
191
+ partial delivery is discarded — the shared history salvage
192
+ cuts the dangling round so no tool call is left unanswered.
193
+ """
194
+ loop = self.loop
195
+ pending = list(loop.pending)
196
+ if not pending:
197
+ return
198
+ if loop._is_cancelled():
199
+ loop.pending = []
200
+ return
201
+ results = loop.info.get("tool_result", {})
202
+ for p in pending:
203
+ if loop._is_cancelled():
204
+ loop.pending = []
205
+ return
206
+ loop._deliver_tool_result(p, results[p.id])
207
+ # Notify per-tool so the TUI rebuilds history progressively
208
+ # (each tool result appears as soon as it is committed to
209
+ # the conversation, instead of all at once at the end)
210
+ if loop.top_level:
211
+ loop.session.notify("tools")
212
+ loop.pending = []
213
+
214
+ def salvage_messages(self) -> list[Message]:
215
+ """Longest valid prefix of ``self.messages`` for the shared history.
216
+
217
+ A cancelled run may end mid-tool-round: the assistant message
218
+ carrying the tool calls is present but some (or all) results are
219
+ missing. Committing that as-is would hand the next turn an
220
+ invalid request (a tool call without its response), so cut back
221
+ to the last complete round — the model redoes the dangling work
222
+ on the next turn.
223
+ """
224
+ loop = self.loop
225
+ msgs = loop.messages
226
+ open_round: int | None = None
227
+ pending: dict[str, bool] = {}
228
+ for i, m in enumerate(msgs):
229
+ if m.role == "assistant":
230
+ if m.tool_calls:
231
+ if open_round is not None:
232
+ return msgs[:open_round]
233
+ open_round = i
234
+ pending = {tc.id: False for tc in m.tool_calls}
235
+ elif open_round is not None:
236
+ return msgs[:open_round]
237
+ elif m.role == "tool":
238
+ if m.tool_call_id in pending:
239
+ pending[m.tool_call_id] = True
240
+ if all(pending.values()):
241
+ open_round = None
242
+ pending = {}
243
+ elif open_round is not None:
244
+ return msgs[:open_round]
245
+ if open_round is not None:
246
+ return msgs[:open_round]
247
+ return msgs
@@ -0,0 +1,56 @@
1
+ """Tool registry with default tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .agent_tool import AgentTool
6
+ from .base import PendingToolResult, Registry, Tool, ToolContext
7
+ from .bash import Bash
8
+ from .filesystem import Edit, GlobTool, Grep, Insert, Mkdir, Read, Write
9
+ from .mcp import MCPTool, mcp_tools_from_manager, normalize_mcp_result
10
+ from .planexit import PlanExit
11
+ from .question import Question
12
+ from .skill import Skill
13
+ from .todo import TodoWrite
14
+
15
+ __all__ = [
16
+ "PendingToolResult",
17
+ "Registry",
18
+ "Tool",
19
+ "ToolContext",
20
+ "AgentTool",
21
+ "Bash",
22
+ "Edit",
23
+ "GlobTool",
24
+ "Grep",
25
+ "Insert",
26
+ "MCPTool",
27
+ "Mkdir",
28
+ "PlanExit",
29
+ "Question",
30
+ "Read",
31
+ "Skill",
32
+ "TodoWrite",
33
+ "Write",
34
+ "mcp_tools_from_manager",
35
+ "normalize_mcp_result",
36
+ ]
37
+
38
+
39
+ def default_registry() -> Registry:
40
+ reg = Registry()
41
+ for tool in (
42
+ AgentTool(),
43
+ TodoWrite(),
44
+ GlobTool(),
45
+ Grep(),
46
+ Read(),
47
+ Insert(),
48
+ Edit(),
49
+ Write(),
50
+ Mkdir(),
51
+ Bash(),
52
+ Skill(),
53
+ Question(),
54
+ ):
55
+ reg.register(tool)
56
+ return reg
@@ -0,0 +1,75 @@
1
+ """Agent tool: spawn sub-agents for delegated work.
2
+
3
+ Asynchronous (mirrors ``:async t``): ``run`` returns a
4
+ ``PendingToolResult`` immediately and a background thread runs the
5
+ sub-agent loop, delivering the result string when it finishes — a
6
+ long-running sub-agent never blocks the parent's sequential tool loop.
7
+
8
+ Sub-agents run the same agent loop with a fresh loop instance; their
9
+ backend/model can be overridden (see config). Results flow back to the
10
+ parent as a single tool result string. Errors are contained: an
11
+ unexpected sub-agent response becomes an error string fed to the parent,
12
+ never a crash.
13
+
14
+ Tool execution mirrors gptel: synchronous tools (Read, Edit, ...) run
15
+ ONE AT A TIME in model-emitted order, while asynchronous tools — Agent
16
+ and Bash — are dispatched in line and run concurrently in the
17
+ background. Each sub-agent is fully isolated (own loop, own history,
18
+ own stream), so independent tasks can be delegated in parallel.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import threading
24
+
25
+ from .base import PendingToolResult, Tool, ToolContext
26
+
27
+ DESCRIPTION = (
28
+ "Launch a specialized sub-agent to handle complex, multi-step tasks "
29
+ "autonomously. Sub-agents run independently and return results in one "
30
+ "message. Use for open-ended searches, complex research, or when "
31
+ "uncertain about finding results in the first few tries.\n\n"
32
+ "Multiple Agent calls issued in the same round run concurrently (like "
33
+ "Bash), while other tools execute one by one — delegate independent "
34
+ "tasks in parallel for efficiency."
35
+ )
36
+
37
+ PARAMETERS = {
38
+ "type": "object",
39
+ "properties": {
40
+ "subagent_type": {
41
+ "type": "string",
42
+ "description": "Type of sub-agent: 'subagent' or 'gptel-opencode-agent'",
43
+ },
44
+ "description": {"type": "string", "description": "Short 3-5 word description of the task"},
45
+ "prompt": {"type": "string", "description": "The detailed task for the sub-agent"},
46
+ },
47
+ "required": ["subagent_type", "description", "prompt"],
48
+ }
49
+
50
+
51
+ class AgentTool(Tool):
52
+ name = "Agent"
53
+ description = DESCRIPTION
54
+ parameters = PARAMETERS
55
+
56
+ def run(self, args: dict, ctx: ToolContext) -> str | PendingToolResult:
57
+ prompt = args.get("prompt", "")
58
+ description = args.get("description", "task")
59
+ subagent_type = args.get("subagent_type", "subagent")
60
+ if not prompt:
61
+ return "Error: prompt must not be empty"
62
+
63
+ pending = PendingToolResult()
64
+
65
+ def worker() -> None:
66
+ # containment boundary: a sub-agent failure becomes an error
67
+ # string for the parent, never a crash in the delivery thread
68
+ try:
69
+ result = ctx.run_subagent(subagent_type, description, prompt)
70
+ except Exception as e: # noqa: BLE001 - error string for the parent
71
+ result = f"Error: Task {description!r} failed — {e}"
72
+ pending.deliver(result)
73
+
74
+ threading.Thread(target=worker, daemon=True, name="subagent-tool").start()
75
+ return pending