ferrum-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ferrum/__init__.py +6 -0
- ferrum/agent.py +387 -0
- ferrum/cli.py +479 -0
- ferrum/config.py +178 -0
- ferrum/context.py +379 -0
- ferrum/model.py +302 -0
- ferrum/patch.py +65 -0
- ferrum/prompts/system.md +89 -0
- ferrum/safety.py +124 -0
- ferrum/tools.py +296 -0
- ferrum/verifier.py +227 -0
- ferrum_cli-0.1.0.dist-info/METADATA +74 -0
- ferrum_cli-0.1.0.dist-info/RECORD +17 -0
- ferrum_cli-0.1.0.dist-info/WHEEL +5 -0
- ferrum_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ferrum_cli-0.1.0.dist-info/licenses/LICENSE +211 -0
- ferrum_cli-0.1.0.dist-info/top_level.txt +1 -0
ferrum/patch.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Exact-text patches against a single file, with stale-source detection.
|
|
2
|
+
|
|
3
|
+
The model must copy the old source verbatim; we verify it still matches
|
|
4
|
+
the file before anything is written.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PatchError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StaleSourceError(PatchError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AmbiguousPatchError(PatchError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Patch:
|
|
27
|
+
rel: str # project-relative, for display
|
|
28
|
+
old: str
|
|
29
|
+
new: str
|
|
30
|
+
description: str = ""
|
|
31
|
+
|
|
32
|
+
def occurrences(self, current: str) -> int:
|
|
33
|
+
return current.count(self.old)
|
|
34
|
+
|
|
35
|
+
def verify(self, current: str) -> None:
|
|
36
|
+
count = self.occurrences(current)
|
|
37
|
+
if count == 0:
|
|
38
|
+
raise StaleSourceError(
|
|
39
|
+
f"old text not found in {self.rel}: the source changed "
|
|
40
|
+
"since analysis, or the old text was not copied verbatim"
|
|
41
|
+
)
|
|
42
|
+
if count > 1:
|
|
43
|
+
raise AmbiguousPatchError(
|
|
44
|
+
f"old text matches {count} times in {self.rel}; "
|
|
45
|
+
"include more surrounding context"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def apply(self, current: str) -> str:
|
|
49
|
+
self.verify(current)
|
|
50
|
+
return current.replace(self.old, self.new, 1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def format_unified_diff(original: str, updated: str, rel: str) -> str:
|
|
54
|
+
lines = difflib.unified_diff(
|
|
55
|
+
original.splitlines(keepends=True),
|
|
56
|
+
updated.splitlines(keepends=True),
|
|
57
|
+
fromfile=f"a/{rel}",
|
|
58
|
+
tofile=f"b/{rel}",
|
|
59
|
+
)
|
|
60
|
+
text = "".join(lines)
|
|
61
|
+
if not text:
|
|
62
|
+
return f"(no text change in {rel})"
|
|
63
|
+
if not text.endswith("\n"):
|
|
64
|
+
text += "\n"
|
|
65
|
+
return text
|
ferrum/prompts/system.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Ferrum system prompt
|
|
2
|
+
|
|
3
|
+
You are **Ferrum**, a coding harness for low-level and systems programming.
|
|
4
|
+
You work inside a real repository on behalf of a developer who asked for a
|
|
5
|
+
specific, bounded task.
|
|
6
|
+
|
|
7
|
+
**You cannot answer from memory.** Before any conclusion, call the tools:
|
|
8
|
+
`list_files` to see the project, `read_file` to read the relevant source,
|
|
9
|
+
`search_code` to find references. Every claim must come from files you
|
|
10
|
+
actually read in this session, cited as `path:line`. Inventing functions,
|
|
11
|
+
line numbers, or behavior is the worst possible answer.
|
|
12
|
+
|
|
13
|
+
Your job is to inspect, understand, make the smallest change that fixes the
|
|
14
|
+
problem, and prove the change works.
|
|
15
|
+
|
|
16
|
+
## Non-negotiable rules
|
|
17
|
+
|
|
18
|
+
1. **Inspect before you modify.** Never edit code you have not read. Read the
|
|
19
|
+
surrounding code, not just the line you think is wrong.
|
|
20
|
+
2. **Evidence over guessing.** Cite file paths and line numbers
|
|
21
|
+
(`main.c:42`). If you have not read a file, do not describe its contents.
|
|
22
|
+
3. **Minimal changes.** Prefer the smallest diff that fixes the reported
|
|
23
|
+
problem. Do not reformat, rename, refactor, or "improve" unrelated code.
|
|
24
|
+
4. **Respect the existing architecture.** Match the project's style,
|
|
25
|
+
idioms, naming, error-handling conventions, and build setup. If the code
|
|
26
|
+
base uses goto-based cleanup, keep using it. Do not introduce new
|
|
27
|
+
dependencies.
|
|
28
|
+
5. **Never claim a fix is verified unless it was actually verified.** Only a
|
|
29
|
+
build or test that really ran and passed makes a fix "verified". Saying
|
|
30
|
+
"fixed" after merely applying a patch is forbidden.
|
|
31
|
+
|
|
32
|
+
## Think like a systems programmer
|
|
33
|
+
|
|
34
|
+
- **Memory:** every pointer has an owner and a lifetime. Ask: who allocates,
|
|
35
|
+
who frees, who may hold a dangling reference? Check for use-after-free,
|
|
36
|
+
double free, buffer overruns, off-by-one indexing, integer overflow and
|
|
37
|
+
truncation, uninitialized reads, and stack lifetimes that outlive their
|
|
38
|
+
frame.
|
|
39
|
+
- **Undefined behavior:** signed overflow, strict aliasing violations,
|
|
40
|
+
shift counts >= width, unaligned access, data races, reading beyond a
|
|
41
|
+
NUL terminator, violating the strict-aliasing rule with type punning.
|
|
42
|
+
- **Ownership and lifetimes (Rust/C++):** who owns the value, what does
|
|
43
|
+
`&mut` exclude, where does a borrow or reference escape its scope, does a
|
|
44
|
+
move leave a usable object behind?
|
|
45
|
+
- **ABI/API compatibility:** struct layout and packing, calling conventions,
|
|
46
|
+
enum sizes, exported symbol visibility, header include order, `extern "C"`
|
|
47
|
+
linkage, versioned interfaces, and callers you cannot see.
|
|
48
|
+
- **Compiler diagnostics are free evidence.** Read warnings and errors
|
|
49
|
+
closely; they often point directly at the bug. Mention diagnostics you saw
|
|
50
|
+
and what they imply.
|
|
51
|
+
|
|
52
|
+
## Workflow
|
|
53
|
+
|
|
54
|
+
**Inspect → Understand → Patch → Verify.**
|
|
55
|
+
|
|
56
|
+
1. **Inspect.** Use the read-only tools (`list_files`, `read_file`,
|
|
57
|
+
`search_code`) to find the relevant code. Look for the build system and
|
|
58
|
+
the tests so you know how the change will be checked later.
|
|
59
|
+
2. **Understand.** State the likely cause as a hypothesis backed by the code
|
|
60
|
+
you actually read. Explain the failure mechanism, not just the symptom.
|
|
61
|
+
3. **Patch.** Produce one minimal `apply_patch` with the exact old source and
|
|
62
|
+
the exact new source. The old source must be copied verbatim from the
|
|
63
|
+
file, including indentation, so the patch can be verified against the
|
|
64
|
+
file. One concern per patch.
|
|
65
|
+
4. **Verify.** After the patch is applied, the harness builds and tests the
|
|
66
|
+
project. Feed on compiler and test output: if verification fails,
|
|
67
|
+
diagnose from that output and iterate with another minimal patch. If you
|
|
68
|
+
cannot make it pass, say so plainly.
|
|
69
|
+
|
|
70
|
+
If your backend does not send native tool calls, invoke a tool by writing
|
|
71
|
+
exactly one JSON object in your reply: `{"name": "<tool>", "arguments": {...}}`.
|
|
72
|
+
|
|
73
|
+
## Honesty and reporting
|
|
74
|
+
|
|
75
|
+
- Distinguish clearly between *observed* (you read it), *inferred* (you
|
|
76
|
+
reasoned about it), and *verified* (a command actually ran and passed).
|
|
77
|
+
- If the evidence is ambiguous, say what else you would check.
|
|
78
|
+
- If you are not sure a patch fixes the root cause, say exactly that.
|
|
79
|
+
- Never invent tool output, file contents, line numbers, or test results.
|
|
80
|
+
|
|
81
|
+
## Tone
|
|
82
|
+
|
|
83
|
+
Be terse and concrete. Prefer:
|
|
84
|
+
|
|
85
|
+
> Found a NULL dereference at main.c:42: `ptr` is used after `read_input()`
|
|
86
|
+
> returned NULL on EOF. The likely cause is a missing error check.
|
|
87
|
+
|
|
88
|
+
over prose about how programming works. The developer reading you already
|
|
89
|
+
knows how to program; they want the diagnosis and the fix.
|
ferrum/safety.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Guards for poking around in someone else's repository.
|
|
2
|
+
|
|
3
|
+
Every path is resolved against the project root and checked against a deny
|
|
4
|
+
list: no .. escapes, no .env files, no binaries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
|
|
11
|
+
# Directories that never hold anything we want to show the model.
|
|
12
|
+
DENIED_DIRS = frozenset(
|
|
13
|
+
{
|
|
14
|
+
".git",
|
|
15
|
+
".hg",
|
|
16
|
+
".svn",
|
|
17
|
+
"__pycache__",
|
|
18
|
+
".venv",
|
|
19
|
+
"venv",
|
|
20
|
+
".tox",
|
|
21
|
+
".mypy_cache",
|
|
22
|
+
".pytest_cache",
|
|
23
|
+
".ruff_cache",
|
|
24
|
+
".idea",
|
|
25
|
+
".vscode",
|
|
26
|
+
"node_modules",
|
|
27
|
+
"target",
|
|
28
|
+
"build",
|
|
29
|
+
"dist",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Generated binaries and other junk that isn't source.
|
|
34
|
+
DENIED_SUFFIXES = (
|
|
35
|
+
".pyc",
|
|
36
|
+
".pyo",
|
|
37
|
+
".o",
|
|
38
|
+
".obj",
|
|
39
|
+
".so",
|
|
40
|
+
".dll",
|
|
41
|
+
".dylib",
|
|
42
|
+
".a",
|
|
43
|
+
".lib",
|
|
44
|
+
".exe",
|
|
45
|
+
".pdb",
|
|
46
|
+
".class",
|
|
47
|
+
".png",
|
|
48
|
+
".jpg",
|
|
49
|
+
".jpeg",
|
|
50
|
+
".gif",
|
|
51
|
+
".ico",
|
|
52
|
+
".pdf",
|
|
53
|
+
".zip",
|
|
54
|
+
".gz",
|
|
55
|
+
".tar",
|
|
56
|
+
".7z",
|
|
57
|
+
".woff",
|
|
58
|
+
".woff2",
|
|
59
|
+
".ttf",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Might contain secrets. Never hand these to a model.
|
|
63
|
+
SENSITIVE_PATTERNS = (
|
|
64
|
+
".env",
|
|
65
|
+
".env.*",
|
|
66
|
+
"*.pem",
|
|
67
|
+
"*.key",
|
|
68
|
+
"*.pfx",
|
|
69
|
+
"*.p12",
|
|
70
|
+
"id_rsa*",
|
|
71
|
+
"id_ed25519*",
|
|
72
|
+
"id_ecdsa*",
|
|
73
|
+
"*.keystore",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SafetyError(Exception):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PathEscapeError(SafetyError):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_within(root: Path, path: Path) -> bool:
|
|
86
|
+
"""True if path resolves to somewhere inside root."""
|
|
87
|
+
try:
|
|
88
|
+
root = root.resolve()
|
|
89
|
+
path = path.resolve()
|
|
90
|
+
except OSError:
|
|
91
|
+
return False
|
|
92
|
+
return path == root or root in path.parents
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def safe_join(root: Path, *parts: str) -> Path:
|
|
96
|
+
"""Join parts onto root, refusing anything that lands outside it."""
|
|
97
|
+
root = root.resolve()
|
|
98
|
+
target = root
|
|
99
|
+
for part in parts:
|
|
100
|
+
piece = str(part)
|
|
101
|
+
if not piece:
|
|
102
|
+
continue
|
|
103
|
+
p = Path(piece)
|
|
104
|
+
if p.is_absolute() or p.drive or p.root:
|
|
105
|
+
raise PathEscapeError(f"absolute path is not allowed here: {piece!r}")
|
|
106
|
+
target = target / p
|
|
107
|
+
target = target.resolve()
|
|
108
|
+
if target != root and root not in target.parents:
|
|
109
|
+
raise PathEscapeError(
|
|
110
|
+
f"path escapes project root: {'/'.join(parts)!r} -> {target}"
|
|
111
|
+
)
|
|
112
|
+
return target
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_denied(rel_path: str | PurePosixPath) -> bool:
|
|
116
|
+
"""True if this root-relative path should never reach the model."""
|
|
117
|
+
rel = PurePosixPath(str(rel_path).replace("\\", "/"))
|
|
118
|
+
if any(part in DENIED_DIRS for part in rel.parts):
|
|
119
|
+
return True
|
|
120
|
+
if any(rel.match(p) or PurePosixPath(rel.as_posix().lower()).match(p)
|
|
121
|
+
for p in SENSITIVE_PATTERNS):
|
|
122
|
+
return True
|
|
123
|
+
name = rel.name.lower()
|
|
124
|
+
return any(name.endswith(suffix) for suffix in DENIED_SUFFIXES)
|
ferrum/tools.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""The tools the model can call. Everything here only reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Any, ClassVar
|
|
12
|
+
|
|
13
|
+
from ferrum.config import Config
|
|
14
|
+
from ferrum.context import collect_files, limit_entries
|
|
15
|
+
from ferrum.safety import PathEscapeError, is_denied, safe_join
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
MAX_LINE_CHARS = 300
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolError(Exception):
|
|
23
|
+
"""A normal, reportable failure: bad path, denied file, that sort of thing."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ToolResult:
|
|
28
|
+
ok: bool
|
|
29
|
+
text: str
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def success(cls, text: str) -> ToolResult:
|
|
33
|
+
return cls(ok=True, text=text)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def failure(cls, text: str) -> ToolResult:
|
|
37
|
+
return cls(ok=False, text=text)
|
|
38
|
+
|
|
39
|
+
def to_model_message(self) -> str:
|
|
40
|
+
return self.text if self.ok else f"ERROR: {self.text}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Tool(ABC):
|
|
44
|
+
name: ClassVar[str] = ""
|
|
45
|
+
description: ClassVar[str] = ""
|
|
46
|
+
parameters: ClassVar[dict[str, Any]] = {"type": "object", "properties": {}}
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def execute(self, arguments: Mapping[str, Any]) -> ToolResult:
|
|
50
|
+
"""Do the thing. Expected failures should raise ToolError."""
|
|
51
|
+
|
|
52
|
+
def schema(self) -> dict[str, Any]:
|
|
53
|
+
"""OpenAI-style function schema, which is what most APIs expect."""
|
|
54
|
+
return {
|
|
55
|
+
"type": "function",
|
|
56
|
+
"function": {
|
|
57
|
+
"name": self.name,
|
|
58
|
+
"description": self.description,
|
|
59
|
+
"parameters": self.parameters,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ToolRegistry:
|
|
65
|
+
"""Looks tools up by name and turns their mistakes into error results."""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self._tools: dict[str, Tool] = {}
|
|
69
|
+
|
|
70
|
+
def register(self, tool: Tool) -> None:
|
|
71
|
+
if not tool.name:
|
|
72
|
+
raise ValueError("tool must define a non-empty name")
|
|
73
|
+
if tool.name in self._tools:
|
|
74
|
+
raise ValueError(f"tool already registered: {tool.name}")
|
|
75
|
+
self._tools[tool.name] = tool
|
|
76
|
+
|
|
77
|
+
def get(self, name: str) -> Tool | None:
|
|
78
|
+
return self._tools.get(name)
|
|
79
|
+
|
|
80
|
+
def names(self) -> list[str]:
|
|
81
|
+
return list(self._tools)
|
|
82
|
+
|
|
83
|
+
def schemas(self) -> list[dict[str, Any]]:
|
|
84
|
+
return [tool.schema() for tool in self._tools.values()]
|
|
85
|
+
|
|
86
|
+
def execute(self, name: str, arguments: Mapping[str, Any]) -> ToolResult:
|
|
87
|
+
tool = self._tools.get(name)
|
|
88
|
+
if tool is None:
|
|
89
|
+
return ToolResult.failure(
|
|
90
|
+
f"unknown tool {name!r}; available tools: "
|
|
91
|
+
+ (", ".join(sorted(self._tools)) or "(none)")
|
|
92
|
+
)
|
|
93
|
+
if not isinstance(arguments, Mapping):
|
|
94
|
+
return ToolResult.failure(
|
|
95
|
+
f"tool {name!r} expects an object of arguments, "
|
|
96
|
+
f"got {type(arguments).__name__}"
|
|
97
|
+
)
|
|
98
|
+
try:
|
|
99
|
+
return tool.execute(arguments)
|
|
100
|
+
except ToolError as exc:
|
|
101
|
+
log.info("tool %s reported: %s", name, exc)
|
|
102
|
+
return ToolResult.failure(str(exc))
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
log.exception("tool %s crashed", name)
|
|
105
|
+
return ToolResult.failure(f"internal tool error: {exc}")
|
|
106
|
+
|
|
107
|
+
def __len__(self) -> int:
|
|
108
|
+
return len(self._tools)
|
|
109
|
+
|
|
110
|
+
def __contains__(self, name: object) -> bool:
|
|
111
|
+
return name in self._tools
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _require_string(arguments: Mapping[str, Any], key: str) -> str:
|
|
115
|
+
value = arguments.get(key)
|
|
116
|
+
if not isinstance(value, str) or not value.strip():
|
|
117
|
+
raise ToolError(f"{key} is required and must be a non-empty string")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ListFiles(Tool):
|
|
122
|
+
name = "list_files"
|
|
123
|
+
description = (
|
|
124
|
+
"List source and build files in the project, respecting .gitignore "
|
|
125
|
+
"and size limits. Returns one relative path per line."
|
|
126
|
+
)
|
|
127
|
+
parameters: ClassVar[dict[str, Any]] = {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"properties": {
|
|
130
|
+
"path": {
|
|
131
|
+
"type": "string",
|
|
132
|
+
"description": "Optional subdirectory relative to the project root.",
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
def __init__(self, root: Path, config: Config | None = None) -> None:
|
|
138
|
+
self.root = root
|
|
139
|
+
self.config = config or Config()
|
|
140
|
+
|
|
141
|
+
def execute(self, arguments: Mapping[str, Any]) -> ToolResult:
|
|
142
|
+
sub = arguments.get("path") or ""
|
|
143
|
+
if not isinstance(sub, str):
|
|
144
|
+
raise ToolError("path must be a string")
|
|
145
|
+
try:
|
|
146
|
+
start = safe_join(self.root, sub) if sub else self.root
|
|
147
|
+
except PathEscapeError as exc:
|
|
148
|
+
raise ToolError(str(exc)) from exc
|
|
149
|
+
if not start.is_dir():
|
|
150
|
+
raise ToolError(f"not a directory: {sub}")
|
|
151
|
+
entries = limit_entries(
|
|
152
|
+
collect_files(self.root, self.config, start=start), self.config
|
|
153
|
+
)
|
|
154
|
+
if not entries:
|
|
155
|
+
return ToolResult.success("(no files matched)")
|
|
156
|
+
paths = [entry.path for entry in entries]
|
|
157
|
+
return ToolResult.success("\n".join(paths) + f"\n\n{len(paths)} file(s)")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ReadFile(Tool):
|
|
161
|
+
name = "read_file"
|
|
162
|
+
description = (
|
|
163
|
+
"Read a single project file as UTF-8 text. Paths are relative to the "
|
|
164
|
+
"project root; secrets, binaries and oversized files are refused. "
|
|
165
|
+
"Line endings are normalized to \\n."
|
|
166
|
+
)
|
|
167
|
+
parameters: ClassVar[dict[str, Any]] = {
|
|
168
|
+
"type": "object",
|
|
169
|
+
"properties": {
|
|
170
|
+
"path": {
|
|
171
|
+
"type": "string",
|
|
172
|
+
"description": "File path relative to the project root.",
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
"required": ["path"],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
def __init__(self, root: Path, config: Config | None = None) -> None:
|
|
179
|
+
self.root = root
|
|
180
|
+
self.config = config or Config()
|
|
181
|
+
|
|
182
|
+
def execute(self, arguments: Mapping[str, Any]) -> ToolResult:
|
|
183
|
+
rel = _require_string(arguments, "path")
|
|
184
|
+
if is_denied(PurePosixPath(rel.replace("\\", "/"))):
|
|
185
|
+
raise ToolError(f"refused: {rel} is excluded (secret, binary, or ignored)")
|
|
186
|
+
try:
|
|
187
|
+
target = safe_join(self.root, rel)
|
|
188
|
+
except PathEscapeError as exc:
|
|
189
|
+
raise ToolError(str(exc)) from exc
|
|
190
|
+
if target.is_dir():
|
|
191
|
+
raise ToolError(f"is a directory: {rel} (use list_files)")
|
|
192
|
+
if not target.exists():
|
|
193
|
+
raise ToolError(f"file not found: {rel}")
|
|
194
|
+
try:
|
|
195
|
+
size = target.stat().st_size
|
|
196
|
+
if size > self.config.max_file_bytes:
|
|
197
|
+
raise ToolError(
|
|
198
|
+
f"file too large: {rel} ({size} bytes > limit "
|
|
199
|
+
f"{self.config.max_file_bytes})"
|
|
200
|
+
)
|
|
201
|
+
data = target.read_bytes()
|
|
202
|
+
except OSError as exc:
|
|
203
|
+
raise ToolError(f"cannot read {rel}: {exc}") from exc
|
|
204
|
+
if b"\x00" in data[:8192]:
|
|
205
|
+
raise ToolError(f"refused: {rel} looks like a binary file")
|
|
206
|
+
try:
|
|
207
|
+
text = data.decode("utf-8")
|
|
208
|
+
except UnicodeDecodeError as exc:
|
|
209
|
+
raise ToolError(f"not valid UTF-8 text: {rel}") from exc
|
|
210
|
+
return ToolResult.success(text.replace("\r\n", "\n").replace("\r", "\n"))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class SearchCode(Tool):
|
|
214
|
+
name = "search_code"
|
|
215
|
+
description = (
|
|
216
|
+
"Search project source files for a string or regular expression. "
|
|
217
|
+
"Returns matches as path:line: content. Case-sensitive by default."
|
|
218
|
+
)
|
|
219
|
+
parameters: ClassVar[dict[str, Any]] = {
|
|
220
|
+
"type": "object",
|
|
221
|
+
"properties": {
|
|
222
|
+
"pattern": {
|
|
223
|
+
"type": "string",
|
|
224
|
+
"description": "Text or regular expression to search for.",
|
|
225
|
+
},
|
|
226
|
+
"path": {
|
|
227
|
+
"type": "string",
|
|
228
|
+
"description": "Optional subdirectory to restrict the search.",
|
|
229
|
+
},
|
|
230
|
+
"regex": {
|
|
231
|
+
"type": "boolean",
|
|
232
|
+
"description": "Treat pattern as a regular expression (default false).",
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
"required": ["pattern"],
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
def __init__(self, root: Path, config: Config | None = None) -> None:
|
|
239
|
+
self.root = root
|
|
240
|
+
self.config = config or Config()
|
|
241
|
+
|
|
242
|
+
def execute(self, arguments: Mapping[str, Any]) -> ToolResult:
|
|
243
|
+
pattern = _require_string(arguments, "pattern")
|
|
244
|
+
sub = arguments.get("path") or ""
|
|
245
|
+
if not isinstance(sub, str):
|
|
246
|
+
raise ToolError("path must be a string")
|
|
247
|
+
regex_mode = arguments.get("regex", False)
|
|
248
|
+
if not isinstance(regex_mode, bool):
|
|
249
|
+
raise ToolError("regex must be a boolean")
|
|
250
|
+
try:
|
|
251
|
+
start = safe_join(self.root, sub) if sub else self.root
|
|
252
|
+
except PathEscapeError as exc:
|
|
253
|
+
raise ToolError(str(exc)) from exc
|
|
254
|
+
if not start.is_dir():
|
|
255
|
+
raise ToolError(f"not a directory: {sub}")
|
|
256
|
+
try:
|
|
257
|
+
rx = re.compile(pattern if regex_mode else re.escape(pattern))
|
|
258
|
+
except re.error as exc:
|
|
259
|
+
raise ToolError(f"invalid regular expression: {exc}") from exc
|
|
260
|
+
|
|
261
|
+
matches: list[str] = []
|
|
262
|
+
truncated = False
|
|
263
|
+
for entry in collect_files(self.root, self.config, start=start):
|
|
264
|
+
try:
|
|
265
|
+
content = (self.root / entry.path).read_text(
|
|
266
|
+
encoding="utf-8", errors="replace"
|
|
267
|
+
)
|
|
268
|
+
except OSError:
|
|
269
|
+
continue
|
|
270
|
+
for lineno, line in enumerate(content.splitlines(), start=1):
|
|
271
|
+
if rx.search(line):
|
|
272
|
+
snippet = line.strip()
|
|
273
|
+
if len(snippet) > MAX_LINE_CHARS:
|
|
274
|
+
snippet = snippet[: MAX_LINE_CHARS - 3] + "..."
|
|
275
|
+
matches.append(f"{entry.path}:{lineno}: {snippet}")
|
|
276
|
+
if len(matches) >= self.config.max_search_results:
|
|
277
|
+
truncated = True
|
|
278
|
+
break
|
|
279
|
+
if truncated:
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
if not matches:
|
|
283
|
+
return ToolResult.success(f"no matches for {pattern!r}")
|
|
284
|
+
text = "\n".join(matches)
|
|
285
|
+
if truncated:
|
|
286
|
+
text += f"\n... truncated at {self.config.max_search_results} results"
|
|
287
|
+
return ToolResult.success(text)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def default_tools(root: Path, config: Config | None = None) -> ToolRegistry:
|
|
291
|
+
"""The standard read-only set."""
|
|
292
|
+
config = config or Config()
|
|
293
|
+
registry = ToolRegistry()
|
|
294
|
+
for tool in (ListFiles(root, config), ReadFile(root, config), SearchCode(root, config)):
|
|
295
|
+
registry.register(tool)
|
|
296
|
+
return registry
|