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.
- python_agent_harness/__init__.py +20 -0
- python_agent_harness/__main__.py +5 -0
- python_agent_harness/agent.py +703 -0
- python_agent_harness/cli.py +273 -0
- python_agent_harness/client.py +832 -0
- python_agent_harness/commands.py +181 -0
- python_agent_harness/config.py +464 -0
- python_agent_harness/context_manager.py +100 -0
- python_agent_harness/diffrender.py +84 -0
- python_agent_harness/mcp/__init__.py +21 -0
- python_agent_harness/mcp/client.py +161 -0
- python_agent_harness/mcp/config.py +130 -0
- python_agent_harness/mcp/manager.py +290 -0
- python_agent_harness/models.py +149 -0
- python_agent_harness/persistence.py +297 -0
- python_agent_harness/planmode.py +112 -0
- python_agent_harness/prompts/agent.md +362 -0
- python_agent_harness/prompts/build-switch.md +5 -0
- python_agent_harness/prompts/commands/explain.md +13 -0
- python_agent_harness/prompts/compact.md +33 -0
- python_agent_harness/prompts/initialize.md +66 -0
- python_agent_harness/prompts/plan-mode.md +70 -0
- python_agent_harness/prompts/plan.md +26 -0
- python_agent_harness/prompts/review.md +100 -0
- python_agent_harness/prompts/subagent.md +208 -0
- python_agent_harness/prompts/summary.md +11 -0
- python_agent_harness/prompts/task-completion-rules.md +50 -0
- python_agent_harness/prompts/title.md +44 -0
- python_agent_harness/prompts.py +498 -0
- python_agent_harness/session.py +781 -0
- python_agent_harness/subagent.py +61 -0
- python_agent_harness/token_estimator.py +125 -0
- python_agent_harness/tool_runner.py +247 -0
- python_agent_harness/tools/__init__.py +56 -0
- python_agent_harness/tools/agent_tool.py +75 -0
- python_agent_harness/tools/base.py +147 -0
- python_agent_harness/tools/bash.py +298 -0
- python_agent_harness/tools/edit.py +272 -0
- python_agent_harness/tools/filesystem.py +180 -0
- python_agent_harness/tools/glob.py +161 -0
- python_agent_harness/tools/grep.py +149 -0
- python_agent_harness/tools/insert.py +61 -0
- python_agent_harness/tools/mcp.py +203 -0
- python_agent_harness/tools/mkdir.py +30 -0
- python_agent_harness/tools/planexit.py +45 -0
- python_agent_harness/tools/question.py +70 -0
- python_agent_harness/tools/read.py +104 -0
- python_agent_harness/tools/skill.py +32 -0
- python_agent_harness/tools/todo.py +60 -0
- python_agent_harness/tools/write.py +56 -0
- python_agent_harness/tui/__init__.py +68 -0
- python_agent_harness/tui/commands.py +652 -0
- python_agent_harness/tui/core.py +385 -0
- python_agent_harness/tui/input.py +412 -0
- python_agent_harness/tui/render.py +535 -0
- python_agent_harness-1.5.0.dist-info/METADATA +251 -0
- python_agent_harness-1.5.0.dist-info/RECORD +61 -0
- python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
- python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
- python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
- 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
|