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,149 @@
|
|
|
1
|
+
"""Grep tool: git grep, then rg, then plain grep.
|
|
2
|
+
|
|
3
|
+
Grep mirrors `gptel-agent-harness-tools--grep`: git grep (passing the
|
|
4
|
+
regex via `-e`), then rg, then plain grep. Oversized results are
|
|
5
|
+
spilled to a temp file (see `filesystem._spool`), so no matches are
|
|
6
|
+
ever silently lost.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
from .base import Tool, ToolContext
|
|
16
|
+
from .filesystem import _git_root, _spool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Grep(Tool):
|
|
20
|
+
name = "Grep"
|
|
21
|
+
description = (
|
|
22
|
+
"Search file contents with a regular expression. "
|
|
23
|
+
"Use this for content search; use Glob for filename search. "
|
|
24
|
+
"Oversized results are spilled to a temp file (see the 'Stored in:' "
|
|
25
|
+
"path); use Read to view the full output."
|
|
26
|
+
)
|
|
27
|
+
parameters = {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"regex": {"type": "string", "description": "Regular expression to search for"},
|
|
31
|
+
"path": {"type": "string", "description": "File or directory to search in"},
|
|
32
|
+
"glob": {"type": "string", "description": "Optional file pattern filter (e.g. *.py)"},
|
|
33
|
+
"context_lines": {
|
|
34
|
+
"type": "integer",
|
|
35
|
+
"description": "Lines of context (0-15)",
|
|
36
|
+
"maximum": 15,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
"required": ["regex", "path"],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def run(self, args: dict, ctx: ToolContext) -> str:
|
|
43
|
+
regex = args["regex"]
|
|
44
|
+
path = os.path.abspath(args["path"])
|
|
45
|
+
if not os.path.isdir(path) and not os.path.isfile(path):
|
|
46
|
+
return f"Error: path {args['path']} is not readable"
|
|
47
|
+
glob = args.get("glob")
|
|
48
|
+
context = args.get("context_lines")
|
|
49
|
+
if context is not None:
|
|
50
|
+
context = max(0, min(15, int(context)))
|
|
51
|
+
|
|
52
|
+
git_root = _git_root(path) if os.path.isdir(path) else None
|
|
53
|
+
if git_root:
|
|
54
|
+
rel = os.path.relpath(path, git_root)
|
|
55
|
+
pathspec = rel
|
|
56
|
+
if glob and os.path.isdir(path):
|
|
57
|
+
pathspec = os.path.join(rel, glob).replace(os.sep, "/")
|
|
58
|
+
cmd = [
|
|
59
|
+
"git",
|
|
60
|
+
"grep",
|
|
61
|
+
"--line-number",
|
|
62
|
+
"--no-color",
|
|
63
|
+
"--max-count=1000",
|
|
64
|
+
"--untracked",
|
|
65
|
+
"-P",
|
|
66
|
+
"-e",
|
|
67
|
+
regex,
|
|
68
|
+
"--",
|
|
69
|
+
pathspec,
|
|
70
|
+
]
|
|
71
|
+
if context:
|
|
72
|
+
cmd = cmd[:3] + [f"-C{context}"] + cmd[3:]
|
|
73
|
+
try:
|
|
74
|
+
proc = subprocess.run(
|
|
75
|
+
cmd,
|
|
76
|
+
cwd=git_root,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
text=True,
|
|
79
|
+
encoding="utf-8",
|
|
80
|
+
errors="replace",
|
|
81
|
+
timeout=60,
|
|
82
|
+
)
|
|
83
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
84
|
+
proc = None
|
|
85
|
+
if proc is not None and proc.returncode in (0, 1):
|
|
86
|
+
return _grep_out(proc, "git")
|
|
87
|
+
if shutil.which("rg"):
|
|
88
|
+
cmd = [
|
|
89
|
+
"rg",
|
|
90
|
+
"--sort=modified",
|
|
91
|
+
"--max-count=1000",
|
|
92
|
+
"--heading",
|
|
93
|
+
"--line-number",
|
|
94
|
+
"-e",
|
|
95
|
+
regex,
|
|
96
|
+
path,
|
|
97
|
+
]
|
|
98
|
+
if context:
|
|
99
|
+
cmd = cmd[:1] + [f"--context={context}"] + cmd[1:]
|
|
100
|
+
if glob:
|
|
101
|
+
cmd = cmd[:1] + [f"--glob={glob}"] + cmd[1:]
|
|
102
|
+
try:
|
|
103
|
+
proc = subprocess.run(
|
|
104
|
+
cmd,
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
encoding="utf-8",
|
|
108
|
+
errors="replace",
|
|
109
|
+
timeout=60,
|
|
110
|
+
)
|
|
111
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
112
|
+
proc = None
|
|
113
|
+
if proc is not None and proc.returncode in (0, 1):
|
|
114
|
+
return _grep_out(proc, "rg")
|
|
115
|
+
if shutil.which("grep"):
|
|
116
|
+
cmd = [
|
|
117
|
+
"grep",
|
|
118
|
+
"--recursive",
|
|
119
|
+
"--max-count=1000",
|
|
120
|
+
"--line-number",
|
|
121
|
+
"--regexp",
|
|
122
|
+
regex,
|
|
123
|
+
path,
|
|
124
|
+
]
|
|
125
|
+
if context:
|
|
126
|
+
cmd = cmd[:1] + [f"--context={context}"] + cmd[1:]
|
|
127
|
+
if glob:
|
|
128
|
+
cmd = cmd[:1] + [f"--include={glob}"] + cmd[1:]
|
|
129
|
+
try:
|
|
130
|
+
proc = subprocess.run(
|
|
131
|
+
cmd,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
encoding="utf-8",
|
|
135
|
+
errors="replace",
|
|
136
|
+
timeout=60,
|
|
137
|
+
)
|
|
138
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
139
|
+
proc = None
|
|
140
|
+
if proc is not None:
|
|
141
|
+
return _grep_out(proc, "grep")
|
|
142
|
+
return "Error: ripgrep/grep/git-grep not available, this tool cannot be used"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _grep_out(proc: subprocess.CompletedProcess, backend: str) -> str:
|
|
146
|
+
text = proc.stdout
|
|
147
|
+
if proc.returncode >= 2:
|
|
148
|
+
text = f"Error: search failed with exit-code {proc.returncode}. Tool output:\n\n{text}"
|
|
149
|
+
return _spool(text, "grep")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Insert tool: insert text at a line number in an existing file.
|
|
2
|
+
|
|
3
|
+
Records a unified diff for the TUI so the change is visible in the
|
|
4
|
+
conversation panel like any other file edit.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from ..diffrender import unified_diff
|
|
12
|
+
from .base import Tool, ToolContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Insert(Tool):
|
|
16
|
+
name = "Insert"
|
|
17
|
+
description = (
|
|
18
|
+
"Insert text at a specific line number in an existing file. "
|
|
19
|
+
"line_number 0 = beginning, -1 = end."
|
|
20
|
+
)
|
|
21
|
+
parameters = {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"path": {"type": "string", "description": "File path"},
|
|
25
|
+
"line_number": {
|
|
26
|
+
"type": "integer",
|
|
27
|
+
"description": "Line after which to insert (0=start, -1=end)",
|
|
28
|
+
},
|
|
29
|
+
"new_str": {"type": "string", "description": "Text to insert"},
|
|
30
|
+
},
|
|
31
|
+
"required": ["path", "line_number", "new_str"],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def run(self, args: dict, ctx: ToolContext) -> str:
|
|
35
|
+
path = os.path.realpath(os.path.abspath(args["path"]))
|
|
36
|
+
try:
|
|
37
|
+
with open(path, encoding="utf-8") as f:
|
|
38
|
+
old_content = f.read()
|
|
39
|
+
lines = old_content.splitlines(keepends=True)
|
|
40
|
+
except OSError as e:
|
|
41
|
+
return f"Error: cannot read {path}: {e}"
|
|
42
|
+
ln = int(args["line_number"])
|
|
43
|
+
new_str = args["new_str"]
|
|
44
|
+
if not new_str.endswith("\n"):
|
|
45
|
+
new_str += "\n"
|
|
46
|
+
if ln == -1 or ln >= len(lines):
|
|
47
|
+
lines.append(new_str)
|
|
48
|
+
elif ln == 0:
|
|
49
|
+
lines.insert(0, new_str)
|
|
50
|
+
else:
|
|
51
|
+
lines.insert(ln, new_str)
|
|
52
|
+
new_content = "".join(lines)
|
|
53
|
+
try:
|
|
54
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
55
|
+
f.write(new_content)
|
|
56
|
+
except OSError as e:
|
|
57
|
+
return f"Error: {e}"
|
|
58
|
+
diff_text = unified_diff(old_content, new_content, path)
|
|
59
|
+
if diff_text:
|
|
60
|
+
ctx.record_diff(diff_text)
|
|
61
|
+
return f"Successfully inserted text at line {ln} in {path}"
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""MCP tool adapter: MCP server tools as normal harness tools.
|
|
2
|
+
|
|
3
|
+
An MCP server's tools become :class:`Tool` instances named
|
|
4
|
+
``mcp__<server>__<tool>`` (unambiguous namespacing — two servers can
|
|
5
|
+
both expose ``search`` without colliding). The agent loop never sees
|
|
6
|
+
MCP: these tools go through the same ToolRegistry as built-ins, so the
|
|
7
|
+
agent's existing retry / supervision / sanitization machinery applies
|
|
8
|
+
unchanged.
|
|
9
|
+
|
|
10
|
+
Results are normalized to the harness's string tool-result form
|
|
11
|
+
(:func:`normalize_mcp_result`), and every MCP failure surfaces as a
|
|
12
|
+
plain ``Error: ...`` string — never an SDK exception — so the agent
|
|
13
|
+
sees::
|
|
14
|
+
|
|
15
|
+
Tool mcp__github__search failed: connection refused
|
|
16
|
+
|
|
17
|
+
Concurrency mirrors the harness policy: servers configured with
|
|
18
|
+
``parallel = true`` run their tools in the background (a
|
|
19
|
+
``PendingToolResult``, like Bash/Agent); the default is conservative
|
|
20
|
+
serial execution. The harness, not the MCP protocol, retains authority
|
|
21
|
+
over this.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import threading
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from ..mcp.manager import MCPManager, MCPToolSpec
|
|
31
|
+
from .base import PendingToolResult, Tool, ToolContext
|
|
32
|
+
|
|
33
|
+
MCP_PREFIX = "mcp__"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def mcp_tool_name(server: str, tool: str) -> str:
|
|
37
|
+
"""The namespaced harness name for an MCP tool."""
|
|
38
|
+
return f"{MCP_PREFIX}{server}__{tool}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_mcp_result(result: dict[str, Any]) -> str:
|
|
42
|
+
"""MCP CallToolResult (plain dict) → harness tool-result string.
|
|
43
|
+
|
|
44
|
+
Maps the MCP content blocks onto the harness's single-string tool
|
|
45
|
+
result: text blocks become text; image/audio/resource blocks become
|
|
46
|
+
readable placeholders (the payload is base64 and must not flood the
|
|
47
|
+
context); structured content is JSON-serialized. A server-reported
|
|
48
|
+
error becomes an ``Error: ...`` string.
|
|
49
|
+
"""
|
|
50
|
+
if result.get("is_error"):
|
|
51
|
+
text = _render_content(result)
|
|
52
|
+
return (
|
|
53
|
+
f"Error: MCP tool reported an error: {text}"
|
|
54
|
+
if text
|
|
55
|
+
else ("Error: MCP tool reported an error")
|
|
56
|
+
)
|
|
57
|
+
text = _render_content(result)
|
|
58
|
+
structured = result.get("structured_content")
|
|
59
|
+
if structured is not None:
|
|
60
|
+
rendered = json.dumps(structured, ensure_ascii=False, default=str)
|
|
61
|
+
text = f"{text}\n{rendered}" if text else rendered
|
|
62
|
+
return text if text else "(no result)"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _render_content(result: dict[str, Any]) -> str:
|
|
66
|
+
parts: list[str] = []
|
|
67
|
+
for block in result.get("content") or []:
|
|
68
|
+
rendered = _render_block(block) if isinstance(block, dict) else str(block)
|
|
69
|
+
if rendered:
|
|
70
|
+
parts.append(rendered)
|
|
71
|
+
return "\n".join(parts)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _render_block(block: dict[str, Any]) -> str:
|
|
75
|
+
kind = block.get("type")
|
|
76
|
+
if kind == "text":
|
|
77
|
+
return str(block.get("text", ""))
|
|
78
|
+
if kind == "image":
|
|
79
|
+
data = block.get("data") or ""
|
|
80
|
+
return (
|
|
81
|
+
f"[image omitted: mimeType={block.get('mime_type')}, {len(data)} chars of base64 data]"
|
|
82
|
+
)
|
|
83
|
+
if kind == "audio":
|
|
84
|
+
data = block.get("data") or ""
|
|
85
|
+
return (
|
|
86
|
+
f"[audio omitted: mimeType={block.get('mime_type')}, {len(data)} chars of base64 data]"
|
|
87
|
+
)
|
|
88
|
+
if kind == "resource":
|
|
89
|
+
return _render_resource(block)
|
|
90
|
+
if kind == "embedded_resource":
|
|
91
|
+
resource = block.get("resource")
|
|
92
|
+
inner = _render_resource(resource) if isinstance(resource, dict) else str(resource)
|
|
93
|
+
return f"[embedded resource: {inner}]"
|
|
94
|
+
return str(block)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _render_resource(resource: dict[str, Any]) -> str:
|
|
98
|
+
uri = resource.get("uri") or "(no uri)"
|
|
99
|
+
if resource.get("type") == "text_resource" and resource.get("text"):
|
|
100
|
+
return f"[resource {uri}]\n{resource['text']}"
|
|
101
|
+
data = resource.get("data")
|
|
102
|
+
if data:
|
|
103
|
+
return (
|
|
104
|
+
f"[resource {uri} omitted: mimeType={resource.get('mime_type')}, "
|
|
105
|
+
f"{len(str(data))} chars]"
|
|
106
|
+
)
|
|
107
|
+
return f"[resource {uri}]"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class MCPTool(Tool):
|
|
111
|
+
"""A tool exposed by an MCP server, namespaced ``mcp__<server>__<tool>``.
|
|
112
|
+
|
|
113
|
+
``run`` executes through the manager: serial by default, or in the
|
|
114
|
+
background (``PendingToolResult``) when the server is configured
|
|
115
|
+
with ``parallel = true``.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
spec: MCPToolSpec,
|
|
121
|
+
manager: MCPManager,
|
|
122
|
+
*,
|
|
123
|
+
parallel: bool = False,
|
|
124
|
+
timeout: float | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
self.server_name = spec.server
|
|
127
|
+
self.mcp_name = spec.name
|
|
128
|
+
self._manager = manager
|
|
129
|
+
self._parallel = parallel
|
|
130
|
+
self._timeout = timeout
|
|
131
|
+
self.name = mcp_tool_name(spec.server, spec.name)
|
|
132
|
+
self.description = spec.description or f"MCP tool {spec.name} (server {spec.server})"
|
|
133
|
+
schema = spec.input_schema if isinstance(spec.input_schema, dict) else {}
|
|
134
|
+
self.parameters = _schema_from_input(schema)
|
|
135
|
+
|
|
136
|
+
def run(self, args: dict[str, Any], ctx: ToolContext) -> str | PendingToolResult:
|
|
137
|
+
if not self._parallel:
|
|
138
|
+
return self._execute(args, ctx)
|
|
139
|
+
pending = PendingToolResult()
|
|
140
|
+
threading.Thread(
|
|
141
|
+
target=lambda: pending.deliver(self._execute(args, ctx)),
|
|
142
|
+
daemon=True,
|
|
143
|
+
).start()
|
|
144
|
+
return pending
|
|
145
|
+
|
|
146
|
+
def _execute(self, args: dict[str, Any], ctx: ToolContext | None) -> str:
|
|
147
|
+
# The session's cancel event is polled by the manager while the
|
|
148
|
+
# SDK call is in flight, so Ctrl-C unblocks a hung server call
|
|
149
|
+
# instead of wedging the loop thread (serial) or the background
|
|
150
|
+
# worker (parallel).
|
|
151
|
+
cancel_check = None
|
|
152
|
+
if ctx is not None:
|
|
153
|
+
event = ctx.cancel_event
|
|
154
|
+
if event is not None and hasattr(event, "is_set"):
|
|
155
|
+
cancel_check = event.is_set
|
|
156
|
+
try:
|
|
157
|
+
result = self._manager.call_tool(
|
|
158
|
+
self.server_name,
|
|
159
|
+
self.mcp_name,
|
|
160
|
+
args or {},
|
|
161
|
+
timeout=self._timeout,
|
|
162
|
+
cancel_check=cancel_check,
|
|
163
|
+
)
|
|
164
|
+
except Exception as e: # noqa: BLE001 - errors become tool results
|
|
165
|
+
return f"Error: tool {self.name} failed — {e}"
|
|
166
|
+
return normalize_mcp_result(result)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _schema_from_input(schema: dict[str, Any]) -> dict[str, Any]:
|
|
170
|
+
"""MCP input schema → harness JSON-schema parameters.
|
|
171
|
+
|
|
172
|
+
The schema is passed through as-is (it already is a JSON object
|
|
173
|
+
schema); only the container is normalized so a schema without
|
|
174
|
+
``properties``/``required`` still serializes cleanly.
|
|
175
|
+
"""
|
|
176
|
+
parameters: dict[str, Any] = {"type": "object"}
|
|
177
|
+
properties = schema.get("properties")
|
|
178
|
+
if isinstance(properties, dict):
|
|
179
|
+
parameters["properties"] = properties
|
|
180
|
+
required = schema.get("required")
|
|
181
|
+
if isinstance(required, list) and required:
|
|
182
|
+
parameters["required"] = [str(r) for r in required]
|
|
183
|
+
return parameters
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def mcp_tools_from_manager(manager: MCPManager) -> list[MCPTool]:
|
|
187
|
+
"""Build harness tools for every tool discovered by MANAGER.
|
|
188
|
+
|
|
189
|
+
Concurrency flags come from the server config (``parallel``); a
|
|
190
|
+
per-server timeout is applied to every call when configured.
|
|
191
|
+
"""
|
|
192
|
+
tools: list[MCPTool] = []
|
|
193
|
+
for spec in manager.tool_specs():
|
|
194
|
+
server_config = manager.config.servers.get(spec.server)
|
|
195
|
+
tools.append(
|
|
196
|
+
MCPTool(
|
|
197
|
+
spec,
|
|
198
|
+
manager,
|
|
199
|
+
parallel=bool(server_config and server_config.parallel),
|
|
200
|
+
timeout=server_config.timeout if server_config else None,
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return tools
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Mkdir tool: create a directory (including parents)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from .base import Tool, ToolContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Mkdir(Tool):
|
|
11
|
+
name = "Mkdir"
|
|
12
|
+
description = "Create a new directory (including parents)."
|
|
13
|
+
parameters = {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"parent": {"type": "string", "description": "Parent directory"},
|
|
17
|
+
"name": {"type": "string", "description": "Directory name to create"},
|
|
18
|
+
},
|
|
19
|
+
"required": ["parent", "name"],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
def run(self, args: dict, ctx: ToolContext) -> str:
|
|
23
|
+
parent = args["parent"]
|
|
24
|
+
name = args["name"]
|
|
25
|
+
path = os.path.realpath(os.path.abspath(os.path.join(parent, name)))
|
|
26
|
+
try:
|
|
27
|
+
os.makedirs(path, exist_ok=True)
|
|
28
|
+
return f"Directory {name} created/verified in {parent}"
|
|
29
|
+
except OSError as e:
|
|
30
|
+
return f"Error: {e}"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""PlanExit tool: ask the user to approve switching from plan to build.
|
|
2
|
+
|
|
3
|
+
Synchronous (mirrors gptel's PlanExit tool, which is NOT ``:async t``):
|
|
4
|
+
``run`` blocks until the user answers and returns the outcome as a
|
|
5
|
+
plain string — it executes one at a time, in call order, like every
|
|
6
|
+
other non-Bash/non-Agent tool.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .base import Tool, ToolContext
|
|
12
|
+
|
|
13
|
+
DESCRIPTION = (
|
|
14
|
+
"Use this tool when you have completed the planning phase and are "
|
|
15
|
+
"ready to exit plan mode.\n\n"
|
|
16
|
+
"This tool will ask the user whether they want to switch to the build "
|
|
17
|
+
"agent and start implementing the plan. Do NOT use the Question tool "
|
|
18
|
+
'to ask "Is this plan okay?" — that is what this tool is for.\n\n'
|
|
19
|
+
"Call this tool:\n"
|
|
20
|
+
"- After you have written a complete plan to the plan file\n"
|
|
21
|
+
"- After you have clarified any questions with the user\n"
|
|
22
|
+
"- When you are confident the plan is ready for implementation\n\n"
|
|
23
|
+
"Do NOT call this tool:\n"
|
|
24
|
+
"- Before you have created or finalized the plan\n"
|
|
25
|
+
"- If you still have unanswered questions about the implementation\n"
|
|
26
|
+
"- If the user has indicated they want to continue planning\n\n"
|
|
27
|
+
"On approval, the session switches to build mode (file edits become "
|
|
28
|
+
"allowed) and you should proceed to execute the approved plan. On "
|
|
29
|
+
"rejection, you remain in the read-only plan phase and should continue "
|
|
30
|
+
"refining the plan."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PlanExit(Tool):
|
|
35
|
+
name = "PlanExit"
|
|
36
|
+
description = DESCRIPTION
|
|
37
|
+
parameters = {"type": "object", "properties": {}}
|
|
38
|
+
|
|
39
|
+
def run(self, args: dict, ctx: ToolContext) -> str:
|
|
40
|
+
# containment boundary: a failure in the approval prompt
|
|
41
|
+
# becomes an error string for the model, never a crash
|
|
42
|
+
try:
|
|
43
|
+
return ctx.plan_exit()
|
|
44
|
+
except Exception as e: # noqa: BLE001 - error string for the model
|
|
45
|
+
return f"Error: PlanExit failed — {e}"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Question tool: ask the user one or more questions during execution.
|
|
2
|
+
|
|
3
|
+
Synchronous (mirrors gptel's Question tool, which is NOT ``:async t``):
|
|
4
|
+
``run`` blocks until the user answers and returns the answers as a
|
|
5
|
+
plain string — it executes one at a time, in call order, like every
|
|
6
|
+
other non-Bash/non-Agent tool.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .base import Tool, ToolContext
|
|
12
|
+
|
|
13
|
+
DESCRIPTION = (
|
|
14
|
+
"Ask the user one or more questions during execution.\n\n"
|
|
15
|
+
"Use this tool when you need to:\n"
|
|
16
|
+
"1. Gather user preferences or requirements\n"
|
|
17
|
+
"2. Clarify ambiguous instructions\n"
|
|
18
|
+
"3. Get decisions on implementation choices as you work\n"
|
|
19
|
+
"4. Offer choices to the user about what direction to take\n\n"
|
|
20
|
+
"Each question can have predefined options for the user to select from. "
|
|
21
|
+
'By default, a "Type your own answer" option is added; set custom to '
|
|
22
|
+
"false to disable it. Set multiple to true to allow selecting more than "
|
|
23
|
+
"one option.\n\n"
|
|
24
|
+
"If no options are provided, the user will be prompted for free-text "
|
|
25
|
+
"input.\n\n"
|
|
26
|
+
"If you recommend a specific option, make that the first option in the "
|
|
27
|
+
'list and add "(Recommended)" at the end of the label.'
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
PARAMETERS = {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {
|
|
33
|
+
"questions": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"question": {"type": "string"},
|
|
39
|
+
"options": {"type": "array", "items": {"type": "string"}},
|
|
40
|
+
"multiple": {"type": "boolean"},
|
|
41
|
+
"custom": {"type": "boolean"},
|
|
42
|
+
},
|
|
43
|
+
"required": ["question"],
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"required": ["questions"],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Question(Tool):
|
|
52
|
+
name = "Question"
|
|
53
|
+
description = DESCRIPTION
|
|
54
|
+
parameters = PARAMETERS
|
|
55
|
+
|
|
56
|
+
def run(self, args: dict, ctx: ToolContext) -> str:
|
|
57
|
+
raw = args.get("questions")
|
|
58
|
+
if isinstance(raw, list):
|
|
59
|
+
questions = raw
|
|
60
|
+
elif isinstance(raw, dict) and isinstance(raw.get("questions"), list):
|
|
61
|
+
questions = raw["questions"]
|
|
62
|
+
else:
|
|
63
|
+
return "Error: questions must be an array"
|
|
64
|
+
|
|
65
|
+
# containment boundary: a failure in the interactive prompt
|
|
66
|
+
# becomes an error string for the model, never a crash
|
|
67
|
+
try:
|
|
68
|
+
return ctx.ask_questions(questions)
|
|
69
|
+
except Exception as e: # noqa: BLE001 - error string for the model
|
|
70
|
+
return f"Error: Question failed — {e}"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Read tool: whole-file reads with a size limit, streamed line ranges.
|
|
2
|
+
|
|
3
|
+
Read mirrors `gptel-agent--read-file-lines`: whole-file reads are
|
|
4
|
+
refused above READ_SIZE_LIMIT (400 KB, matching
|
|
5
|
+
`gptel-agent-read-file-size-threshold`), and line ranges are streamed
|
|
6
|
+
instead of loading the whole file into memory. Oversized range
|
|
7
|
+
results are spilled to a temp file like Glob/Grep results, so nothing
|
|
8
|
+
is ever silently truncated.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from .base import Tool, ToolContext
|
|
16
|
+
from .filesystem import READ_SIZE_LIMIT, _spool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Read(Tool):
|
|
20
|
+
name = "Read"
|
|
21
|
+
description = (
|
|
22
|
+
"Read file contents between specified line numbers `start_line` and "
|
|
23
|
+
"`end_line`, with both ends included.\n\n"
|
|
24
|
+
'Consider using the "Grep" tool to find the right range to read first.\n\n'
|
|
25
|
+
"Reads the whole file if the line range is not provided.\n\n"
|
|
26
|
+
f"Files over {READ_SIZE_LIMIT // 1024} KB in size can only be read by "
|
|
27
|
+
"specifying a line range.\n"
|
|
28
|
+
"Very large line ranges are spilled to a temp file (see the 'Stored "
|
|
29
|
+
"in:' path); use Read to view the full output."
|
|
30
|
+
)
|
|
31
|
+
parameters = {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": {
|
|
34
|
+
"file_path": {"type": "string", "description": "The path to the file to be read"},
|
|
35
|
+
"start_line": {
|
|
36
|
+
"type": "integer",
|
|
37
|
+
"description": "The line to start reading from, defaults to the start of the file",
|
|
38
|
+
},
|
|
39
|
+
"end_line": {
|
|
40
|
+
"type": "integer",
|
|
41
|
+
"description": "The line up to which to read, defaults to the end of the file.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
"required": ["file_path"],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def run(self, args: dict, ctx: ToolContext) -> str:
|
|
48
|
+
path = args["file_path"]
|
|
49
|
+
full = os.path.realpath(os.path.abspath(path))
|
|
50
|
+
if os.path.isdir(full):
|
|
51
|
+
return f"Error: cannot read {path}: is a directory"
|
|
52
|
+
try:
|
|
53
|
+
size = os.path.getsize(full)
|
|
54
|
+
except OSError as e:
|
|
55
|
+
return f"Error: cannot read {path}: {e}"
|
|
56
|
+
start = args.get("start_line")
|
|
57
|
+
end = args.get("end_line")
|
|
58
|
+
if start is None and end is None:
|
|
59
|
+
if size > READ_SIZE_LIMIT:
|
|
60
|
+
return (
|
|
61
|
+
f"Error: File is too large ({size // 1024} KB > "
|
|
62
|
+
f"{READ_SIZE_LIMIT // 1024} KB). Please specify a line "
|
|
63
|
+
"range to read"
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
with open(full, encoding="utf-8", errors="replace") as f:
|
|
67
|
+
return f.read()
|
|
68
|
+
except OSError as e:
|
|
69
|
+
return f"Error: cannot read {path}: {e}"
|
|
70
|
+
start = int(start or 1)
|
|
71
|
+
if start < 1:
|
|
72
|
+
start = 1
|
|
73
|
+
if end is not None:
|
|
74
|
+
end = int(end)
|
|
75
|
+
if start > end:
|
|
76
|
+
return f"Error: start_line {start} > end_line {end}"
|
|
77
|
+
# Stream the file line by line instead of loading it whole, so
|
|
78
|
+
# huge files can be read in ranges with constant memory.
|
|
79
|
+
selected: list[str] = []
|
|
80
|
+
total: int | None = None # exact line count once EOF is reached
|
|
81
|
+
reached_eof = True
|
|
82
|
+
try:
|
|
83
|
+
with open(full, encoding="utf-8", errors="replace") as f:
|
|
84
|
+
for lineno, line in enumerate(f, 1):
|
|
85
|
+
if end is not None and lineno > end:
|
|
86
|
+
reached_eof = False
|
|
87
|
+
break
|
|
88
|
+
total = lineno
|
|
89
|
+
if lineno >= start:
|
|
90
|
+
selected.append(line)
|
|
91
|
+
except OSError as e:
|
|
92
|
+
return f"Error: cannot read {path}: {e}"
|
|
93
|
+
if end is not None and reached_eof and total is not None:
|
|
94
|
+
end = min(end, total) # clamp to the real file end
|
|
95
|
+
if total is None:
|
|
96
|
+
total = 0 # empty file
|
|
97
|
+
if start > total:
|
|
98
|
+
return f"Error: start_line {start} > end_line {end if end is not None else total}"
|
|
99
|
+
end_eff = end if end is not None else total
|
|
100
|
+
if total is not None and reached_eof:
|
|
101
|
+
header = f"Showing lines {start}-{end_eff} of {total}:\n\n"
|
|
102
|
+
else:
|
|
103
|
+
header = f"Showing lines {start}-{end_eff}:\n\n"
|
|
104
|
+
return _spool(header + "".join(selected), "read")
|