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