minimalcode 1.5.2__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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- minimalcode-1.5.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""Grep and find implementations exposed as ToolRuntime mixins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..errors import ToolError
|
|
14
|
+
from ..sandbox import SENSITIVE_SEARCH_GLOBS
|
|
15
|
+
from . import search as search_module
|
|
16
|
+
from .search import (
|
|
17
|
+
_MAX_GREP_LINE_CHARS,
|
|
18
|
+
IGNORED_DIRECTORIES,
|
|
19
|
+
IGNORED_GLOBS,
|
|
20
|
+
inline_guard_available,
|
|
21
|
+
inline_python_grep,
|
|
22
|
+
run_python_grep,
|
|
23
|
+
smart_case_ignore,
|
|
24
|
+
)
|
|
25
|
+
from .validation import positive_limit
|
|
26
|
+
|
|
27
|
+
_GREP_RESULT_RE = re.compile(r"^(?P<path>.+?):(?P<line>\d+):")
|
|
28
|
+
# Sensitive-path filtering happens after ripgrep exits, so a run that returns
|
|
29
|
+
# only filtered paths must be retried with a wider window. The cap keeps one
|
|
30
|
+
# pathological search from scanning an unbounded number of lines.
|
|
31
|
+
_MAX_GREP_SCAN_LINES = 20_000
|
|
32
|
+
# `{a,b}{c,d}...` expands to 2**n patterns. A cap keeps a pathological glob
|
|
33
|
+
# from turning `find`/`grep` into an hour-long loop.
|
|
34
|
+
_MAX_GLOB_VARIANTS = 256
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SearchToolsMixin:
|
|
38
|
+
"""Workspace-scoped text and filename search."""
|
|
39
|
+
|
|
40
|
+
def grep(
|
|
41
|
+
self,
|
|
42
|
+
pattern: str,
|
|
43
|
+
path: str = ".",
|
|
44
|
+
glob: str | None = None,
|
|
45
|
+
max_results: int | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Search file contents with ripgrep semantics and a Python fallback."""
|
|
48
|
+
if not isinstance(pattern, str) or not pattern:
|
|
49
|
+
raise ToolError("pattern must be a non-empty string")
|
|
50
|
+
if glob is not None and not isinstance(glob, str):
|
|
51
|
+
raise ToolError("glob must be a string or null")
|
|
52
|
+
target = self._path(path, operation="read")
|
|
53
|
+
if not target.exists():
|
|
54
|
+
raise ToolError(f"path not found: {target}")
|
|
55
|
+
if not target.is_file() and not target.is_dir():
|
|
56
|
+
raise ToolError(f"path is not a file or directory: {target}")
|
|
57
|
+
limit = positive_limit(max_results, self.max_results, 2000, "max_results")
|
|
58
|
+
if (
|
|
59
|
+
glob
|
|
60
|
+
and target.is_file()
|
|
61
|
+
and not self._matches_glob(target, target.parent, glob)
|
|
62
|
+
):
|
|
63
|
+
# ripgrep does not apply `--glob` to a file named on the command
|
|
64
|
+
# line, so `grep(path="a.txt", glob="*.py")` returned the file with
|
|
65
|
+
# rg installed and nothing without it. The restriction is enforced
|
|
66
|
+
# here so both engines agree.
|
|
67
|
+
return {"status": "success", "matches": [], "truncated": False}
|
|
68
|
+
|
|
69
|
+
# Prefer ripgrep when it is installed and the backend can spawn argv.
|
|
70
|
+
rg = shutil.which("rg")
|
|
71
|
+
if rg and self._can_spawn_argv():
|
|
72
|
+
command = [
|
|
73
|
+
rg,
|
|
74
|
+
"--no-config",
|
|
75
|
+
"--line-number",
|
|
76
|
+
"--no-heading",
|
|
77
|
+
# A single-file target otherwise prints `LINE:TEXT` with no
|
|
78
|
+
# path, which the result-path filter rejects as a stray line.
|
|
79
|
+
"--with-filename",
|
|
80
|
+
"--color",
|
|
81
|
+
"never",
|
|
82
|
+
"--hidden",
|
|
83
|
+
"--no-ignore",
|
|
84
|
+
"--no-follow",
|
|
85
|
+
"--smart-case",
|
|
86
|
+
"--max-columns",
|
|
87
|
+
str(_MAX_GREP_LINE_CHARS),
|
|
88
|
+
"--max-columns-preview",
|
|
89
|
+
]
|
|
90
|
+
if glob:
|
|
91
|
+
command.extend(["--glob", self._anchor_glob(glob, target)])
|
|
92
|
+
# Exclusions must follow the positive glob: ripgrep uses the last
|
|
93
|
+
# matching --glob, so this order stays exclusion-only.
|
|
94
|
+
for ignored_glob in self._ignore_globs(target):
|
|
95
|
+
command.extend(["--glob", ignored_glob])
|
|
96
|
+
for sandbox_glob in self._sandbox_globs():
|
|
97
|
+
command.extend(["--glob", sandbox_glob])
|
|
98
|
+
command.extend(["--", pattern, str(target)])
|
|
99
|
+
requested = limit + 1
|
|
100
|
+
while True:
|
|
101
|
+
returncode, lines, truncated, stderr = (
|
|
102
|
+
search_module.run_bounded_command(
|
|
103
|
+
command,
|
|
104
|
+
cwd=self.cwd,
|
|
105
|
+
# One line past the requested count proves truncation.
|
|
106
|
+
limit=requested,
|
|
107
|
+
sandbox=self.sandbox,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
# Exit 1 means "no matches" and is not an error.
|
|
111
|
+
if not truncated and returncode not in (0, 1):
|
|
112
|
+
raise ToolError(stderr or f"grep exited {returncode}")
|
|
113
|
+
matches = [line for line in lines if self._grep_line_allowed(line)]
|
|
114
|
+
if not truncated or len(matches) > limit or len(lines) < requested:
|
|
115
|
+
break
|
|
116
|
+
# Filtered paths consumed the whole window. Read a wider one
|
|
117
|
+
# so an allowed match later in the output is not lost.
|
|
118
|
+
grown = min(requested * 4, _MAX_GREP_SCAN_LINES)
|
|
119
|
+
if grown <= requested:
|
|
120
|
+
break
|
|
121
|
+
requested = grown
|
|
122
|
+
return {
|
|
123
|
+
"status": "success",
|
|
124
|
+
"matches": matches[:limit],
|
|
125
|
+
"truncated": truncated or len(matches) > limit,
|
|
126
|
+
}
|
|
127
|
+
return self._python_grep(pattern, target, glob, limit)
|
|
128
|
+
|
|
129
|
+
def _explicit_ignored_target(self, target: Path) -> bool:
|
|
130
|
+
"""Return True when the caller named a normally ignored directory."""
|
|
131
|
+
relative = None
|
|
132
|
+
for root in (self.cwd, *self.sandbox.policy.allowed_roots):
|
|
133
|
+
try:
|
|
134
|
+
relative = target.relative_to(root)
|
|
135
|
+
break
|
|
136
|
+
except ValueError:
|
|
137
|
+
continue
|
|
138
|
+
if relative is None:
|
|
139
|
+
relative = target
|
|
140
|
+
parts = list(relative.parts)
|
|
141
|
+
if target.is_file():
|
|
142
|
+
parts = parts[:-1]
|
|
143
|
+
return any(part in IGNORED_DIRECTORIES for part in parts)
|
|
144
|
+
|
|
145
|
+
def _ignore_globs(self, target: Path) -> tuple[str, ...]:
|
|
146
|
+
# An explicitly requested ignored path must still be searchable;
|
|
147
|
+
# otherwise `grep(..., path="build")` silently returns nothing.
|
|
148
|
+
return () if self._explicit_ignored_target(target) else IGNORED_GLOBS
|
|
149
|
+
|
|
150
|
+
def _sandbox_globs(self) -> tuple[str, ...]:
|
|
151
|
+
# The sensitive-file filter is a path policy, not an execution setting,
|
|
152
|
+
# so it also applies when `sandbox.enabled` is false.
|
|
153
|
+
if self.sandbox.policy.allow_sensitive_files:
|
|
154
|
+
return ()
|
|
155
|
+
return SENSITIVE_SEARCH_GLOBS
|
|
156
|
+
|
|
157
|
+
def _anchor_glob(self, glob_pattern: str, target: Path) -> str:
|
|
158
|
+
"""Anchor a path-bearing glob the way ripgrep matches ``--glob``.
|
|
159
|
+
|
|
160
|
+
ripgrep compares a glob against the candidate path relative to its
|
|
161
|
+
working directory (absolute only outside it), so a glob from the
|
|
162
|
+
caller — which is relative to the *search root* — never matched when
|
|
163
|
+
the two differed. `src/*.py` searched from `.` silently returned no
|
|
164
|
+
files while the Python fallback found them. Globs without a separator
|
|
165
|
+
keep matching the file name at any depth.
|
|
166
|
+
"""
|
|
167
|
+
if "/" not in glob_pattern:
|
|
168
|
+
return glob_pattern
|
|
169
|
+
negated = glob_pattern.startswith("!")
|
|
170
|
+
body = glob_pattern[1:] if negated else glob_pattern
|
|
171
|
+
if body.startswith("/") or (len(body) > 1 and body[1] == ":"):
|
|
172
|
+
return glob_pattern
|
|
173
|
+
try:
|
|
174
|
+
prefix = target.relative_to(self.cwd).as_posix()
|
|
175
|
+
except ValueError:
|
|
176
|
+
# A root outside the workspace has no cwd-relative form.
|
|
177
|
+
prefix = target.as_posix()
|
|
178
|
+
anchored = body if prefix in {"", "."} else f"{prefix.rstrip('/')}/{body}"
|
|
179
|
+
return f"!{anchored}" if negated else anchored
|
|
180
|
+
|
|
181
|
+
def _allowed_result_path(self, raw: str) -> bool:
|
|
182
|
+
"""Return True when one search result may be shown for this workspace."""
|
|
183
|
+
try:
|
|
184
|
+
candidate = Path(raw)
|
|
185
|
+
if not candidate.is_absolute():
|
|
186
|
+
candidate = self.cwd / candidate
|
|
187
|
+
candidate = candidate.resolve(strict=False)
|
|
188
|
+
except (OSError, RuntimeError, ValueError):
|
|
189
|
+
return False
|
|
190
|
+
return self.sandbox.is_allowed(
|
|
191
|
+
candidate, operation="read", allow_outside=self.allow_outside
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _grep_line_allowed(self, line: str) -> bool:
|
|
195
|
+
"""Keep one ripgrep match only when its path is allowed."""
|
|
196
|
+
path = self._grep_line_path(line)
|
|
197
|
+
if path is None:
|
|
198
|
+
# A head/tail truncation marker or a line fragment is not a match.
|
|
199
|
+
return False
|
|
200
|
+
return self._allowed_result_path(path)
|
|
201
|
+
|
|
202
|
+
def _grep_line_path(self, line: str) -> str | None:
|
|
203
|
+
"""Extract the file path from one `path:line:text` match line.
|
|
204
|
+
|
|
205
|
+
A file name may itself contain ``:N:``, so the shortest prefix is not
|
|
206
|
+
always the real path. The longest prefix that exists on disk wins; the
|
|
207
|
+
first candidate is the fallback for a path that vanished mid-search.
|
|
208
|
+
"""
|
|
209
|
+
match = _GREP_RESULT_RE.match(line)
|
|
210
|
+
if match is None:
|
|
211
|
+
return None
|
|
212
|
+
fallback = match.group("path")
|
|
213
|
+
chosen = fallback
|
|
214
|
+
for boundary in re.finditer(r":\d+:", line):
|
|
215
|
+
candidate = line[: boundary.start()]
|
|
216
|
+
try:
|
|
217
|
+
path = Path(candidate)
|
|
218
|
+
if not path.is_absolute():
|
|
219
|
+
path = self.cwd / path
|
|
220
|
+
if path.exists():
|
|
221
|
+
chosen = candidate
|
|
222
|
+
except (OSError, RuntimeError, ValueError):
|
|
223
|
+
continue
|
|
224
|
+
return chosen
|
|
225
|
+
|
|
226
|
+
def _find_result_allowed(self, raw: str) -> bool:
|
|
227
|
+
"""Keep one `rg --files` path only when it names an allowed file."""
|
|
228
|
+
try:
|
|
229
|
+
candidate = Path(raw)
|
|
230
|
+
if not candidate.is_absolute():
|
|
231
|
+
candidate = self.cwd / candidate
|
|
232
|
+
if not candidate.is_file():
|
|
233
|
+
return False
|
|
234
|
+
except (OSError, RuntimeError, ValueError):
|
|
235
|
+
return False
|
|
236
|
+
return self._allowed_result_path(str(candidate))
|
|
237
|
+
|
|
238
|
+
def _can_spawn_argv(self) -> bool:
|
|
239
|
+
"""Return True when the active backend can start a child process.
|
|
240
|
+
|
|
241
|
+
Capabilities describe what a backend *could* do; a selected backend
|
|
242
|
+
that is not installed must still let file tools fall back to the
|
|
243
|
+
in-process scanner instead of failing the whole search.
|
|
244
|
+
"""
|
|
245
|
+
if not self.sandbox.policy.enabled:
|
|
246
|
+
return True
|
|
247
|
+
return (
|
|
248
|
+
"argv" in self.sandbox.backend.capabilities
|
|
249
|
+
and self.sandbox.backend.available()
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _python_grep(
|
|
253
|
+
self,
|
|
254
|
+
pattern: str,
|
|
255
|
+
target: Path,
|
|
256
|
+
glob_pattern: str | None,
|
|
257
|
+
limit: int,
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
"""Run the Python fallback over the same file set ripgrep would see."""
|
|
260
|
+
flags = re.IGNORECASE if smart_case_ignore(pattern) else 0
|
|
261
|
+
try:
|
|
262
|
+
re.compile(pattern, flags)
|
|
263
|
+
except re.error as exc:
|
|
264
|
+
raise ToolError(f"invalid regular expression: {exc}") from exc
|
|
265
|
+
|
|
266
|
+
ignored = () if self._explicit_ignored_target(target) else IGNORED_DIRECTORIES
|
|
267
|
+
files = (
|
|
268
|
+
[target]
|
|
269
|
+
if target.is_file()
|
|
270
|
+
else list(self._walk_files(target, ignored=ignored))
|
|
271
|
+
)
|
|
272
|
+
if glob_pattern:
|
|
273
|
+
# A single-file target has no relative path of its own, so its
|
|
274
|
+
# parent is the root the glob is matched against; otherwise the
|
|
275
|
+
# search root anchors the walk.
|
|
276
|
+
glob_root = target.parent if target.is_file() else target
|
|
277
|
+
files = [
|
|
278
|
+
file_path
|
|
279
|
+
for file_path in files
|
|
280
|
+
if self._matches_glob(file_path, glob_root, glob_pattern)
|
|
281
|
+
]
|
|
282
|
+
if not files:
|
|
283
|
+
return {"status": "success", "matches": [], "truncated": False}
|
|
284
|
+
if not self._can_spawn_argv():
|
|
285
|
+
# A backend without argv support still gets line-accurate results.
|
|
286
|
+
# The in-process engine cannot interrupt a catastrophic regex from
|
|
287
|
+
# a worker thread or on Windows, so in that case the trusted child
|
|
288
|
+
# interpreter is used instead of risking a hung agent process.
|
|
289
|
+
if inline_guard_available():
|
|
290
|
+
return inline_python_grep(
|
|
291
|
+
pattern=pattern,
|
|
292
|
+
files=files,
|
|
293
|
+
limit=limit,
|
|
294
|
+
max_bytes=self.max_file_bytes,
|
|
295
|
+
)
|
|
296
|
+
return run_python_grep(
|
|
297
|
+
pattern=pattern,
|
|
298
|
+
files=files,
|
|
299
|
+
limit=limit,
|
|
300
|
+
max_bytes=self.max_file_bytes,
|
|
301
|
+
sandbox=None,
|
|
302
|
+
cwd=self.cwd,
|
|
303
|
+
)
|
|
304
|
+
return run_python_grep(
|
|
305
|
+
pattern=pattern,
|
|
306
|
+
files=files,
|
|
307
|
+
limit=limit,
|
|
308
|
+
max_bytes=self.max_file_bytes,
|
|
309
|
+
sandbox=self.sandbox,
|
|
310
|
+
cwd=self.cwd,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
@lru_cache(maxsize=256)
|
|
315
|
+
def _glob_tokens(glob_pattern: str) -> tuple[tuple[str, Any], ...]:
|
|
316
|
+
"""Translate a ripgrep-style glob into path-aware match tokens.
|
|
317
|
+
|
|
318
|
+
A regex translation of repeated ``**`` runs (``**a**a**a...``) is
|
|
319
|
+
exponential on a non-matching path, so matching is a small linear scan
|
|
320
|
+
instead. ``*`` never crosses ``/``, ``**`` does, and ``**/`` matches
|
|
321
|
+
zero or more complete directories.
|
|
322
|
+
"""
|
|
323
|
+
tokens: list[tuple[str, Any]] = []
|
|
324
|
+
index = 0
|
|
325
|
+
length = len(glob_pattern)
|
|
326
|
+
while index < length:
|
|
327
|
+
char = glob_pattern[index]
|
|
328
|
+
if char == "*":
|
|
329
|
+
if glob_pattern.startswith("**/", index):
|
|
330
|
+
tokens.append(("dirs", None))
|
|
331
|
+
index += 3
|
|
332
|
+
elif glob_pattern.startswith("**", index):
|
|
333
|
+
tokens.append(("any", None))
|
|
334
|
+
index += 2
|
|
335
|
+
else:
|
|
336
|
+
tokens.append(("star", None))
|
|
337
|
+
index += 1
|
|
338
|
+
continue
|
|
339
|
+
if char == "?":
|
|
340
|
+
tokens.append(("one", None))
|
|
341
|
+
index += 1
|
|
342
|
+
continue
|
|
343
|
+
if char == "[":
|
|
344
|
+
end = index + 1
|
|
345
|
+
if end < length and glob_pattern[end] in "!^":
|
|
346
|
+
end += 1
|
|
347
|
+
if end < length and glob_pattern[end] == "]":
|
|
348
|
+
end += 1
|
|
349
|
+
while end < length and glob_pattern[end] != "]":
|
|
350
|
+
end += 1
|
|
351
|
+
if end >= length:
|
|
352
|
+
tokens.append(("lit", "["))
|
|
353
|
+
index += 1
|
|
354
|
+
continue
|
|
355
|
+
content = glob_pattern[index + 1 : end]
|
|
356
|
+
if content.startswith("!"):
|
|
357
|
+
content = "^" + content[1:]
|
|
358
|
+
if content in {"", "^"}:
|
|
359
|
+
tokens.append(("lit", "[]"))
|
|
360
|
+
else:
|
|
361
|
+
try:
|
|
362
|
+
tokens.append(("class", re.compile(f"[{content}]")))
|
|
363
|
+
except re.error:
|
|
364
|
+
tokens.append(("lit", glob_pattern[index : end + 1]))
|
|
365
|
+
index = end + 1
|
|
366
|
+
continue
|
|
367
|
+
end = index
|
|
368
|
+
while end < length and glob_pattern[end] not in "*?[":
|
|
369
|
+
end += 1
|
|
370
|
+
tokens.append(("lit", glob_pattern[index:end]))
|
|
371
|
+
index = end
|
|
372
|
+
return tuple(tokens)
|
|
373
|
+
|
|
374
|
+
@classmethod
|
|
375
|
+
@lru_cache(maxsize=256)
|
|
376
|
+
def _expand_braces(cls, glob_pattern: str) -> tuple[str, ...]:
|
|
377
|
+
"""Expand the simple ``{py,txt}`` alternation ripgrep accepts."""
|
|
378
|
+
if cls._brace_variants(glob_pattern) > _MAX_GLOB_VARIANTS:
|
|
379
|
+
# Matching the pattern literally beats an exponential expansion.
|
|
380
|
+
return (glob_pattern,)
|
|
381
|
+
return cls._expand_braces_uncapped(glob_pattern)
|
|
382
|
+
|
|
383
|
+
@staticmethod
|
|
384
|
+
def _brace_variants(glob_pattern: str) -> int:
|
|
385
|
+
"""Count the alternatives the brace groups would expand to."""
|
|
386
|
+
count = 1
|
|
387
|
+
index = 0
|
|
388
|
+
while True:
|
|
389
|
+
start = glob_pattern.find("{", index)
|
|
390
|
+
if start < 0:
|
|
391
|
+
return count
|
|
392
|
+
end = glob_pattern.find("}", start + 1)
|
|
393
|
+
if end < 0:
|
|
394
|
+
return count
|
|
395
|
+
choices = len(glob_pattern[start + 1 : end].split(","))
|
|
396
|
+
if choices > 1:
|
|
397
|
+
count *= choices
|
|
398
|
+
if count > _MAX_GLOB_VARIANTS:
|
|
399
|
+
return count
|
|
400
|
+
index = end + 1
|
|
401
|
+
|
|
402
|
+
@classmethod
|
|
403
|
+
def _expand_braces_uncapped(cls, glob_pattern: str) -> tuple[str, ...]:
|
|
404
|
+
start = glob_pattern.find("{")
|
|
405
|
+
if start < 0:
|
|
406
|
+
return (glob_pattern,)
|
|
407
|
+
end = glob_pattern.find("}", start + 1)
|
|
408
|
+
if end < 0:
|
|
409
|
+
return (glob_pattern,)
|
|
410
|
+
choices = glob_pattern[start + 1 : end].split(",")
|
|
411
|
+
if len(choices) < 2 or any(not choice for choice in choices):
|
|
412
|
+
return (glob_pattern,)
|
|
413
|
+
prefix = glob_pattern[:start]
|
|
414
|
+
suffix = glob_pattern[end + 1 :]
|
|
415
|
+
# A pattern can hold more than one group (`{a,b}.{py,txt}`); leaving a
|
|
416
|
+
# later group in place matched nothing at all.
|
|
417
|
+
return tuple(
|
|
418
|
+
expanded
|
|
419
|
+
for choice in choices
|
|
420
|
+
for expanded in cls._expand_braces(prefix + choice + suffix)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
@classmethod
|
|
424
|
+
def _matches_glob(cls, file_path: Path, root: Path, glob_pattern: str) -> bool:
|
|
425
|
+
"""Match a ripgrep-style glob against the file name and relative path."""
|
|
426
|
+
if glob_pattern.startswith("!"):
|
|
427
|
+
# ripgrep treats a leading `!` as an exclusion.
|
|
428
|
+
return not cls._matches_glob(file_path, root, glob_pattern[1:])
|
|
429
|
+
return any(
|
|
430
|
+
cls._matches_single_glob(file_path, root, expanded)
|
|
431
|
+
for expanded in cls._expand_braces(glob_pattern)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
@classmethod
|
|
435
|
+
def _matches_single_glob(
|
|
436
|
+
cls, file_path: Path, root: Path, glob_pattern: str
|
|
437
|
+
) -> bool:
|
|
438
|
+
try:
|
|
439
|
+
relative = file_path.relative_to(root).as_posix()
|
|
440
|
+
except ValueError:
|
|
441
|
+
relative = file_path.as_posix()
|
|
442
|
+
# A glob containing a separator is matched against the relative path;
|
|
443
|
+
# `*.py` keeps matching any file name, but `*` no longer crosses `/`.
|
|
444
|
+
candidate = relative if "/" in glob_pattern else file_path.name
|
|
445
|
+
return cls._match_glob_tokens(cls._glob_tokens(glob_pattern), candidate)
|
|
446
|
+
|
|
447
|
+
@staticmethod
|
|
448
|
+
def _match_glob_tokens(tokens: tuple[tuple[str, Any], ...], text: str) -> bool:
|
|
449
|
+
"""Return True when ``text`` matches the token sequence."""
|
|
450
|
+
length = len(text)
|
|
451
|
+
positions: set[int] = {0}
|
|
452
|
+
for kind, value in tokens:
|
|
453
|
+
next_positions: set[int] = set()
|
|
454
|
+
if kind == "lit":
|
|
455
|
+
for position in positions:
|
|
456
|
+
if text.startswith(value, position):
|
|
457
|
+
next_positions.add(position + len(value))
|
|
458
|
+
elif kind == "one":
|
|
459
|
+
for position in positions:
|
|
460
|
+
if position < length and text[position] != "/":
|
|
461
|
+
next_positions.add(position + 1)
|
|
462
|
+
elif kind == "class":
|
|
463
|
+
for position in positions:
|
|
464
|
+
if (
|
|
465
|
+
position < length
|
|
466
|
+
and text[position] != "/"
|
|
467
|
+
and value.match(text[position])
|
|
468
|
+
):
|
|
469
|
+
next_positions.add(position + 1)
|
|
470
|
+
elif kind == "star":
|
|
471
|
+
for position in positions:
|
|
472
|
+
next_positions.add(position)
|
|
473
|
+
end = position
|
|
474
|
+
while end < length and text[end] != "/":
|
|
475
|
+
end += 1
|
|
476
|
+
next_positions.add(end)
|
|
477
|
+
elif kind == "any":
|
|
478
|
+
for position in positions:
|
|
479
|
+
next_positions.update(range(position, length + 1))
|
|
480
|
+
elif kind == "dirs":
|
|
481
|
+
for position in positions:
|
|
482
|
+
next_positions.add(position)
|
|
483
|
+
end = position
|
|
484
|
+
while True:
|
|
485
|
+
slash = text.find("/", end)
|
|
486
|
+
if slash < 0:
|
|
487
|
+
break
|
|
488
|
+
next_positions.add(slash + 1)
|
|
489
|
+
end = slash + 1
|
|
490
|
+
positions = next_positions
|
|
491
|
+
if not positions:
|
|
492
|
+
return False
|
|
493
|
+
return length in positions
|
|
494
|
+
|
|
495
|
+
def find(
|
|
496
|
+
self,
|
|
497
|
+
pattern: str,
|
|
498
|
+
path: str = ".",
|
|
499
|
+
max_results: int | None = None,
|
|
500
|
+
) -> dict[str, Any]:
|
|
501
|
+
"""Find files by glob without descending into ignored directories."""
|
|
502
|
+
if not isinstance(pattern, str) or not pattern:
|
|
503
|
+
raise ToolError("pattern must be a non-empty string")
|
|
504
|
+
target = self._path(path, operation="read")
|
|
505
|
+
if not target.is_dir():
|
|
506
|
+
raise ToolError(f"directory not found: {target}")
|
|
507
|
+
limit = positive_limit(max_results, self.max_results, 5000, "max_results")
|
|
508
|
+
|
|
509
|
+
rg = shutil.which("rg")
|
|
510
|
+
if rg and self._can_spawn_argv():
|
|
511
|
+
command = [
|
|
512
|
+
rg,
|
|
513
|
+
"--no-config",
|
|
514
|
+
"--files",
|
|
515
|
+
"--hidden",
|
|
516
|
+
"--no-ignore",
|
|
517
|
+
"--no-follow",
|
|
518
|
+
"--glob",
|
|
519
|
+
self._anchor_glob(pattern, target),
|
|
520
|
+
]
|
|
521
|
+
for ignored_glob in self._ignore_globs(target):
|
|
522
|
+
command.extend(["--glob", ignored_glob])
|
|
523
|
+
for sandbox_glob in self._sandbox_globs():
|
|
524
|
+
command.extend(["--glob", sandbox_glob])
|
|
525
|
+
command.append(str(target))
|
|
526
|
+
requested = limit + 1
|
|
527
|
+
while True:
|
|
528
|
+
returncode, paths, truncated, stderr = (
|
|
529
|
+
search_module.run_bounded_command(
|
|
530
|
+
command,
|
|
531
|
+
cwd=self.cwd,
|
|
532
|
+
limit=requested,
|
|
533
|
+
sandbox=self.sandbox,
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
if not truncated and returncode not in (0, 1):
|
|
537
|
+
raise ToolError(stderr or f"find exited {returncode}")
|
|
538
|
+
files = [path for path in paths if self._find_result_allowed(path)]
|
|
539
|
+
if not truncated or len(files) > limit or len(paths) < requested:
|
|
540
|
+
break
|
|
541
|
+
grown = min(requested * 4, _MAX_GREP_SCAN_LINES)
|
|
542
|
+
if grown <= requested:
|
|
543
|
+
break
|
|
544
|
+
requested = grown
|
|
545
|
+
return {
|
|
546
|
+
"status": "success",
|
|
547
|
+
"files": files[:limit],
|
|
548
|
+
"truncated": truncated or len(files) > limit,
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
paths: list[str] = []
|
|
552
|
+
ignored = () if self._explicit_ignored_target(target) else IGNORED_DIRECTORIES
|
|
553
|
+
for file_path in self._walk_files(target, ignored=ignored):
|
|
554
|
+
if self._matches_glob(file_path, target, pattern):
|
|
555
|
+
paths.append(str(file_path))
|
|
556
|
+
if len(paths) > limit:
|
|
557
|
+
return {
|
|
558
|
+
"status": "success",
|
|
559
|
+
"files": paths[:limit],
|
|
560
|
+
"truncated": True,
|
|
561
|
+
}
|
|
562
|
+
return {"status": "success", "files": paths[:limit], "truncated": False}
|
|
563
|
+
|
|
564
|
+
def _walk_files(
|
|
565
|
+
self,
|
|
566
|
+
root: Path,
|
|
567
|
+
*,
|
|
568
|
+
ignored: tuple[str, ...] = IGNORED_DIRECTORIES,
|
|
569
|
+
) -> Iterator[Path]:
|
|
570
|
+
"""Yield readable files under ``root`` while respecting the sandbox."""
|
|
571
|
+
ignored_names = set(ignored)
|
|
572
|
+
for directory, dirnames, filenames in os.walk(root):
|
|
573
|
+
dirnames[:] = [name for name in dirnames if name not in ignored_names]
|
|
574
|
+
for filename in filenames:
|
|
575
|
+
candidate = Path(directory) / filename
|
|
576
|
+
# os.walk does not descend into symlinked directories, but a
|
|
577
|
+
# symlinked file or a configured extra root can still leave the
|
|
578
|
+
# sandbox, so every candidate is re-checked.
|
|
579
|
+
if not self.sandbox.is_allowed(
|
|
580
|
+
candidate, operation="read", allow_outside=self.allow_outside
|
|
581
|
+
):
|
|
582
|
+
continue
|
|
583
|
+
yield candidate
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Argument validation shared by the tool dispatcher.
|
|
2
|
+
|
|
3
|
+
Model-supplied values are untrusted. Binding a signature only checks names and
|
|
4
|
+
arity, so these helpers also enforce the documented primitive types before a
|
|
5
|
+
handler touches the filesystem or a child process.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from types import UnionType
|
|
11
|
+
from typing import Any, Union, get_args, get_origin, get_type_hints
|
|
12
|
+
|
|
13
|
+
from ..errors import ToolError
|
|
14
|
+
|
|
15
|
+
_UNION_ORIGINS = {Union, UnionType}
|
|
16
|
+
_TYPE_LABELS: dict[Any, str] = {
|
|
17
|
+
str: "a string",
|
|
18
|
+
bool: "a boolean",
|
|
19
|
+
int: "an integer",
|
|
20
|
+
float: "a number",
|
|
21
|
+
type(None): "null",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def clip(value: str, limit: int = 180) -> str:
|
|
26
|
+
"""Collapse whitespace and bound a value used in one-line activity text."""
|
|
27
|
+
value = " ".join(value.split())
|
|
28
|
+
return value if len(value) <= limit else value[: limit - 3] + "..."
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def positive_limit(
|
|
32
|
+
requested: int | None,
|
|
33
|
+
default: int,
|
|
34
|
+
maximum: int,
|
|
35
|
+
label: str,
|
|
36
|
+
) -> int:
|
|
37
|
+
"""Validate a result limit and clamp it to the schema's hard cap."""
|
|
38
|
+
if requested is not None and (
|
|
39
|
+
isinstance(requested, bool) or not isinstance(requested, int)
|
|
40
|
+
):
|
|
41
|
+
raise ToolError(f"{label} must be an integer")
|
|
42
|
+
# A configured default above the hard cap must not silently exceed it.
|
|
43
|
+
limit = min(default if requested is None else requested, maximum)
|
|
44
|
+
if limit < 1:
|
|
45
|
+
raise ToolError(f"{label} must be at least 1")
|
|
46
|
+
return limit
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _describe_annotation(annotation: Any) -> str:
|
|
50
|
+
"""Render a supported annotation as a short human-readable phrase."""
|
|
51
|
+
if get_origin(annotation) in _UNION_ORIGINS:
|
|
52
|
+
return " or ".join(_describe_annotation(arg) for arg in get_args(annotation))
|
|
53
|
+
return _TYPE_LABELS.get(annotation, "the documented type")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _matches_annotation(value: Any, annotation: Any) -> bool:
|
|
57
|
+
"""Return True when ``value`` satisfies the primitive annotation.
|
|
58
|
+
|
|
59
|
+
Unknown annotations are accepted so custom handlers keep their own checks;
|
|
60
|
+
this guard exists to stop obvious mismatches, not to replace them.
|
|
61
|
+
"""
|
|
62
|
+
if get_origin(annotation) in _UNION_ORIGINS:
|
|
63
|
+
return any(_matches_annotation(value, arg) for arg in get_args(annotation))
|
|
64
|
+
if annotation is bool:
|
|
65
|
+
return isinstance(value, bool)
|
|
66
|
+
if annotation is str:
|
|
67
|
+
return isinstance(value, str)
|
|
68
|
+
if annotation is int:
|
|
69
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
70
|
+
if annotation is float:
|
|
71
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
72
|
+
if annotation is type(None):
|
|
73
|
+
return value is None
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_argument_types(
|
|
78
|
+
name: str,
|
|
79
|
+
handler: Any,
|
|
80
|
+
arguments: dict[str, Any],
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Reject model-supplied arguments that do not match the handler signature.
|
|
83
|
+
|
|
84
|
+
``bind`` only checks names and arity, so a call such as ``{"command": 5}``
|
|
85
|
+
would otherwise reach the handler and raise ``AttributeError``/``TypeError``
|
|
86
|
+
outside the tool-loop error handling.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
hints = get_type_hints(handler)
|
|
90
|
+
except (NameError, TypeError): # pragma: no cover - stay permissive
|
|
91
|
+
return
|
|
92
|
+
for key, value in arguments.items():
|
|
93
|
+
annotation = hints.get(key)
|
|
94
|
+
if annotation is None or _matches_annotation(value, annotation):
|
|
95
|
+
continue
|
|
96
|
+
raise ToolError(
|
|
97
|
+
f"invalid arguments for {name}: {key} must be "
|
|
98
|
+
f"{_describe_annotation(annotation)}"
|
|
99
|
+
)
|