turnloop 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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
turnloop/tools/grep.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Grep — content search.
|
|
2
|
+
|
|
3
|
+
Prefers ripgrep when it is on PATH (an order of magnitude faster on real repos,
|
|
4
|
+
and it already honors .gitignore), with a pure-Python fallback so the tool is
|
|
5
|
+
never simply unavailable. Both paths produce identical output formats, because a
|
|
6
|
+
tool whose output shape depends on what happens to be installed is a tool the
|
|
7
|
+
model cannot learn.
|
|
8
|
+
|
|
9
|
+
The default output mode is `files_with_matches` on purpose: "which files mention
|
|
10
|
+
this" is the question that actually starts most searches, and it costs a fraction
|
|
11
|
+
of the context that matching lines do.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, Field
|
|
24
|
+
|
|
25
|
+
from turnloop.tools.base import Tool, ToolContext, ToolOutput
|
|
26
|
+
from turnloop.tools.glob import ALWAYS_SKIP, _is_ignored
|
|
27
|
+
|
|
28
|
+
OutputMode = Literal["files_with_matches", "content", "count"]
|
|
29
|
+
DEFAULT_HEAD_LIMIT = 250
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GrepArgs(BaseModel):
|
|
33
|
+
pattern: str = Field(description="Regular expression to search for.")
|
|
34
|
+
path: str | None = Field(default=None, description="File or directory to search. Default cwd.")
|
|
35
|
+
glob: str | None = Field(default=None, description="Restrict to files matching this glob.")
|
|
36
|
+
output_mode: OutputMode = Field(
|
|
37
|
+
default="files_with_matches",
|
|
38
|
+
description="'files_with_matches' (default), 'content', or 'count'.",
|
|
39
|
+
)
|
|
40
|
+
case_insensitive: bool = Field(default=False, description="Ignore case.")
|
|
41
|
+
show_line_numbers: bool = Field(default=True, description="Line numbers in content mode.")
|
|
42
|
+
context_lines: int = Field(default=0, description="Lines of context around each match.")
|
|
43
|
+
head_limit: int | None = Field(default=None, description=f"Cap results (default {DEFAULT_HEAD_LIMIT}).")
|
|
44
|
+
multiline: bool = Field(default=False, description="Let the pattern span line breaks.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GrepTool(Tool):
|
|
48
|
+
name = "Grep"
|
|
49
|
+
Args = GrepArgs
|
|
50
|
+
read_only = True
|
|
51
|
+
parallel_safe = True
|
|
52
|
+
bulky = True
|
|
53
|
+
timeout_s = 60.0
|
|
54
|
+
|
|
55
|
+
descriptions = {
|
|
56
|
+
"terse": """
|
|
57
|
+
Regex search across files. Modes: files_with_matches (default), content, count.
|
|
58
|
+
Filter with `glob`. Prefer this over shelling out to grep.
|
|
59
|
+
""",
|
|
60
|
+
"normal": """
|
|
61
|
+
Search file contents with a regular expression.
|
|
62
|
+
|
|
63
|
+
- `output_mode`: `files_with_matches` (default, just paths), `content` (matching
|
|
64
|
+
lines), or `count` (matches per file).
|
|
65
|
+
- Filter the search with `glob`, e.g. `**/*.py`.
|
|
66
|
+
- `context_lines` adds surrounding lines in content mode.
|
|
67
|
+
- Ignored and vendored directories are skipped.
|
|
68
|
+
- Start with the default mode to find where something lives, then Read those
|
|
69
|
+
files or re-run in content mode. Dumping every matching line first wastes
|
|
70
|
+
context you will want later.
|
|
71
|
+
""",
|
|
72
|
+
"verbose": """
|
|
73
|
+
Search file contents with a regular expression.
|
|
74
|
+
|
|
75
|
+
Arguments:
|
|
76
|
+
- `pattern`: a regular expression. Escape regex metacharacters when you mean them
|
|
77
|
+
literally — searching for `foo(bar)` needs `foo\\(bar\\)`.
|
|
78
|
+
- `path`: a file or directory. Defaults to the working directory.
|
|
79
|
+
- `glob`: restrict to matching filenames, e.g. `**/*.ts`, `*.md`.
|
|
80
|
+
- `output_mode`: `files_with_matches` (default) lists paths only; `content` lists
|
|
81
|
+
matching lines; `count` lists match counts per file.
|
|
82
|
+
- `case_insensitive`, `show_line_numbers`, `context_lines`, `multiline`,
|
|
83
|
+
`head_limit`.
|
|
84
|
+
|
|
85
|
+
Behavior:
|
|
86
|
+
- Uses ripgrep when available and a pure-Python search otherwise; output format is
|
|
87
|
+
identical either way.
|
|
88
|
+
- Ignored files are excluded (.gitignore when in a repository, plus a skip list
|
|
89
|
+
for node_modules, __pycache__, .venv, dist, build, target, vendor).
|
|
90
|
+
- Binary files are skipped.
|
|
91
|
+
- Results are capped at 250 entries by default with an explicit truncation note.
|
|
92
|
+
- Strategy that keeps context small: search in the default mode first to learn
|
|
93
|
+
which files are involved, then Read the interesting ones. Content mode on a
|
|
94
|
+
common term can return thousands of lines you will pay for all session.
|
|
95
|
+
""",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async def run(self, args: GrepArgs, ctx: ToolContext) -> ToolOutput:
|
|
99
|
+
target = ctx.resolve(args.path) if args.path else ctx.cwd
|
|
100
|
+
if not target.exists():
|
|
101
|
+
return ToolOutput.error(f"{target} does not exist")
|
|
102
|
+
if not ctx.within_allowed(target):
|
|
103
|
+
return ToolOutput.error(f"{target} is outside the project root ({ctx.root}).")
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
re.compile(args.pattern)
|
|
107
|
+
except re.error as exc:
|
|
108
|
+
return ToolOutput.error(f"invalid regular expression: {exc}")
|
|
109
|
+
|
|
110
|
+
limit = args.head_limit or DEFAULT_HEAD_LIMIT
|
|
111
|
+
|
|
112
|
+
if shutil.which("rg"):
|
|
113
|
+
result = _run_ripgrep(args, target, limit)
|
|
114
|
+
if result is not None:
|
|
115
|
+
return _format(result, args, target, ctx, engine="ripgrep", limit=limit)
|
|
116
|
+
|
|
117
|
+
result = _run_python(args, target, limit)
|
|
118
|
+
return _format(result, args, target, ctx, engine="python", limit=limit)
|
|
119
|
+
|
|
120
|
+
def summary(self, args: GrepArgs) -> str: # type: ignore[override]
|
|
121
|
+
scope = f" in {args.glob}" if args.glob else ""
|
|
122
|
+
return f"Grep {args.pattern!r}{scope}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _Result:
|
|
126
|
+
def __init__(self):
|
|
127
|
+
self.lines: list[str] = []
|
|
128
|
+
self.files: set[str] = set()
|
|
129
|
+
self.total: int = 0
|
|
130
|
+
self.truncated: bool = False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _run_ripgrep(args: GrepArgs, target: Path, limit: int) -> _Result | None:
|
|
134
|
+
cmd = ["rg", "--no-config"]
|
|
135
|
+
if target.is_dir() and _is_ignored(target):
|
|
136
|
+
# rg honors .gitignore by default, same as `git ls-files`, and has the
|
|
137
|
+
# same blind spot: if `target` itself sits inside an ignored tree, rg
|
|
138
|
+
# reports zero matches instead of searching it. --no-ignore-vcs is
|
|
139
|
+
# scoped to exactly this case -- passing it unconditionally would make
|
|
140
|
+
# Grep search genuinely ignored files inside a normal, non-ignored repo.
|
|
141
|
+
cmd.append("--no-ignore-vcs")
|
|
142
|
+
if args.output_mode == "files_with_matches":
|
|
143
|
+
cmd.append("--files-with-matches")
|
|
144
|
+
elif args.output_mode == "count":
|
|
145
|
+
cmd.append("--count-matches")
|
|
146
|
+
else:
|
|
147
|
+
if args.show_line_numbers:
|
|
148
|
+
cmd.append("--line-number")
|
|
149
|
+
if args.context_lines:
|
|
150
|
+
cmd += ["--context", str(args.context_lines)]
|
|
151
|
+
if args.case_insensitive:
|
|
152
|
+
cmd.append("--ignore-case")
|
|
153
|
+
if args.multiline:
|
|
154
|
+
cmd += ["--multiline", "--multiline-dotall"]
|
|
155
|
+
if args.glob:
|
|
156
|
+
cmd += ["--glob", args.glob]
|
|
157
|
+
cmd += ["--", args.pattern, str(target)]
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=45)
|
|
161
|
+
except (OSError, subprocess.SubprocessError):
|
|
162
|
+
return None
|
|
163
|
+
# rg exits 1 for "no matches", which is not an error here.
|
|
164
|
+
if proc.returncode not in (0, 1):
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
result = _Result()
|
|
168
|
+
for line in proc.stdout.splitlines():
|
|
169
|
+
if not line:
|
|
170
|
+
continue
|
|
171
|
+
result.total += 1
|
|
172
|
+
if len(result.lines) < limit:
|
|
173
|
+
result.lines.append(line)
|
|
174
|
+
else:
|
|
175
|
+
result.truncated = True
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _iter_candidate_files(target: Path, glob: str | None) -> list[Path]:
|
|
180
|
+
if target.is_file():
|
|
181
|
+
return [target]
|
|
182
|
+
|
|
183
|
+
from turnloop.permissions.rules import _glob_to_regex
|
|
184
|
+
from turnloop.tools.glob import _normalize_pattern
|
|
185
|
+
|
|
186
|
+
matcher = re.compile(_glob_to_regex(_normalize_pattern(glob))) if glob else None
|
|
187
|
+
out: list[Path] = []
|
|
188
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
189
|
+
dirnames[:] = [d for d in dirnames if d not in ALWAYS_SKIP]
|
|
190
|
+
for name in filenames:
|
|
191
|
+
full = Path(dirpath) / name
|
|
192
|
+
if matcher is not None:
|
|
193
|
+
try:
|
|
194
|
+
rel = full.relative_to(target).as_posix()
|
|
195
|
+
except ValueError:
|
|
196
|
+
continue
|
|
197
|
+
if not matcher.fullmatch(rel):
|
|
198
|
+
continue
|
|
199
|
+
out.append(full)
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _run_python(args: GrepArgs, target: Path, limit: int) -> _Result:
|
|
204
|
+
flags = re.IGNORECASE if args.case_insensitive else 0
|
|
205
|
+
if args.multiline:
|
|
206
|
+
flags |= re.DOTALL | re.MULTILINE
|
|
207
|
+
regex = re.compile(args.pattern, flags)
|
|
208
|
+
result = _Result()
|
|
209
|
+
|
|
210
|
+
for path in _iter_candidate_files(target, args.glob):
|
|
211
|
+
try:
|
|
212
|
+
raw = path.read_bytes()
|
|
213
|
+
except OSError:
|
|
214
|
+
continue
|
|
215
|
+
if b"\x00" in raw[:4_000]:
|
|
216
|
+
continue
|
|
217
|
+
text = raw.decode("utf-8", errors="replace")
|
|
218
|
+
|
|
219
|
+
if args.multiline:
|
|
220
|
+
hits = list(regex.finditer(text))
|
|
221
|
+
if not hits:
|
|
222
|
+
continue
|
|
223
|
+
result.files.add(str(path))
|
|
224
|
+
result.total += len(hits)
|
|
225
|
+
if args.output_mode == "content" and len(result.lines) < limit:
|
|
226
|
+
for hit in hits:
|
|
227
|
+
line_no = text.count("\n", 0, hit.start()) + 1
|
|
228
|
+
snippet = hit.group(0).replace("\n", "\\n")[:300]
|
|
229
|
+
result.lines.append(f"{path}:{line_no}:{snippet}")
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
lines = text.splitlines()
|
|
233
|
+
matched_indices = [i for i, line in enumerate(lines) if regex.search(line)]
|
|
234
|
+
if not matched_indices:
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
result.files.add(str(path))
|
|
238
|
+
result.total += len(matched_indices)
|
|
239
|
+
|
|
240
|
+
if args.output_mode != "content":
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
emitted: set[int] = set()
|
|
244
|
+
for i in matched_indices:
|
|
245
|
+
lo = max(0, i - args.context_lines)
|
|
246
|
+
hi = min(len(lines), i + args.context_lines + 1)
|
|
247
|
+
for j in range(lo, hi):
|
|
248
|
+
if j in emitted:
|
|
249
|
+
continue
|
|
250
|
+
emitted.add(j)
|
|
251
|
+
if len(result.lines) >= limit:
|
|
252
|
+
result.truncated = True
|
|
253
|
+
break
|
|
254
|
+
prefix = f"{path}:{j + 1}:" if args.show_line_numbers else f"{path}:"
|
|
255
|
+
result.lines.append(prefix + lines[j])
|
|
256
|
+
if result.truncated:
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
if args.output_mode == "files_with_matches":
|
|
260
|
+
ordered = sorted(result.files)
|
|
261
|
+
result.truncated = len(ordered) > limit
|
|
262
|
+
result.lines = ordered[:limit]
|
|
263
|
+
elif args.output_mode == "count":
|
|
264
|
+
# Per-file counts need a second read: the first pass tracked which files
|
|
265
|
+
# matched, not how often. Only count mode pays for it.
|
|
266
|
+
result.lines = []
|
|
267
|
+
for name in sorted(result.files):
|
|
268
|
+
try:
|
|
269
|
+
text = Path(name).read_bytes().decode("utf-8", errors="replace")
|
|
270
|
+
except OSError:
|
|
271
|
+
continue
|
|
272
|
+
n = len(regex.findall(text))
|
|
273
|
+
if n:
|
|
274
|
+
result.lines.append(f"{name}:{n}")
|
|
275
|
+
result.truncated = len(result.lines) > limit
|
|
276
|
+
result.lines = result.lines[:limit]
|
|
277
|
+
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _format(result: _Result, args: GrepArgs, target: Path, ctx: ToolContext,
|
|
282
|
+
engine: str, limit: int) -> ToolOutput:
|
|
283
|
+
if not result.lines:
|
|
284
|
+
return ToolOutput(
|
|
285
|
+
content=f"No matches for {args.pattern!r} in {target}.",
|
|
286
|
+
display=f"Grep {args.pattern!r} → no matches",
|
|
287
|
+
metrics={"matches": 0, "engine": engine},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
root = ctx.cwd.resolve()
|
|
291
|
+
rendered = []
|
|
292
|
+
for line in result.lines:
|
|
293
|
+
rendered.append(line.replace(str(root) + os.sep, "").replace(str(root) + "/", ""))
|
|
294
|
+
|
|
295
|
+
body = "\n".join(rendered)
|
|
296
|
+
if result.truncated:
|
|
297
|
+
body += f"\n\n[truncated at {limit} results; narrow the pattern or add a glob filter]"
|
|
298
|
+
|
|
299
|
+
label = {
|
|
300
|
+
"files_with_matches": "file(s)",
|
|
301
|
+
"content": "line(s)",
|
|
302
|
+
"count": "file(s)",
|
|
303
|
+
}[args.output_mode]
|
|
304
|
+
|
|
305
|
+
return ToolOutput(
|
|
306
|
+
content=body,
|
|
307
|
+
display=f"Grep {args.pattern!r} → {len(result.lines)} {label}",
|
|
308
|
+
metrics={
|
|
309
|
+
"matches": result.total,
|
|
310
|
+
"shown": len(result.lines),
|
|
311
|
+
"engine": engine,
|
|
312
|
+
"truncated": result.truncated,
|
|
313
|
+
},
|
|
314
|
+
)
|
turnloop/tools/read.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Read — the tool models use most, so its output format matters most."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from turnloop.tools.base import Tool, ToolContext, ToolOutput
|
|
10
|
+
|
|
11
|
+
MAX_LINE_CHARS = 2_000
|
|
12
|
+
BINARY_SNIFF_BYTES = 8_000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ReadArgs(BaseModel):
|
|
16
|
+
file_path: str = Field(description="Absolute or project-relative path to read.")
|
|
17
|
+
offset: int | None = Field(default=None, description="1-indexed first line to read.")
|
|
18
|
+
limit: int | None = Field(default=None, description="How many lines to read (default 2000).")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReadTool(Tool):
|
|
22
|
+
name = "Read"
|
|
23
|
+
Args = ReadArgs
|
|
24
|
+
read_only = True
|
|
25
|
+
parallel_safe = True
|
|
26
|
+
bulky = True
|
|
27
|
+
|
|
28
|
+
descriptions = {
|
|
29
|
+
"terse": """
|
|
30
|
+
Read a file. Returns lines numbered from 1, `offset`/`limit` for large files.
|
|
31
|
+
Always Read before Edit or Write.
|
|
32
|
+
""",
|
|
33
|
+
"normal": """
|
|
34
|
+
Read a file from the filesystem.
|
|
35
|
+
|
|
36
|
+
- `file_path` may be absolute or relative to the working directory.
|
|
37
|
+
- Output is line-numbered starting at 1, so you can cite exact lines and build
|
|
38
|
+
Edit calls against them.
|
|
39
|
+
- Reads up to 2000 lines by default. For a larger file, use `offset` and `limit`
|
|
40
|
+
to page through it rather than reading it whole — a big file consumes context
|
|
41
|
+
you will need later.
|
|
42
|
+
- You must Read a file before you Edit or Write it.
|
|
43
|
+
- Binary files are refused rather than dumped.
|
|
44
|
+
""",
|
|
45
|
+
"verbose": """
|
|
46
|
+
Read a file from the filesystem.
|
|
47
|
+
|
|
48
|
+
Arguments:
|
|
49
|
+
- `file_path`: absolute, or relative to the working directory.
|
|
50
|
+
- `offset`: 1-indexed line to start from. Use with `limit` to page a large file.
|
|
51
|
+
- `limit`: how many lines to return. Defaults to 2000.
|
|
52
|
+
|
|
53
|
+
Behavior and constraints:
|
|
54
|
+
- Output is `cat -n` style: right-aligned line numbers, a tab, then the line.
|
|
55
|
+
Cite these numbers when discussing code, and use them to target Edit calls.
|
|
56
|
+
- Lines longer than 2000 characters are truncated with a marker; the file is not
|
|
57
|
+
modified.
|
|
58
|
+
- Reading a file records it as seen. Write and Edit both refuse to touch a file
|
|
59
|
+
that has not been read in this session, and refuse again if it changed on disk
|
|
60
|
+
afterwards — re-read it in that case.
|
|
61
|
+
- Binary files (detected by NUL bytes) are refused with their size and type
|
|
62
|
+
rather than being dumped into the conversation.
|
|
63
|
+
- Directories are refused; use Glob or Grep to explore instead.
|
|
64
|
+
- Paths outside the project root are refused unless the user configured
|
|
65
|
+
additional directories.
|
|
66
|
+
""",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async def run(self, args: ReadArgs, ctx: ToolContext) -> ToolOutput:
|
|
70
|
+
path = ctx.resolve(args.file_path)
|
|
71
|
+
|
|
72
|
+
if not ctx.within_allowed(path):
|
|
73
|
+
return ToolOutput.error(
|
|
74
|
+
f"{path} is outside the project root ({ctx.root}). "
|
|
75
|
+
"Ask the user to add it to permissions.additional_directories if this is intended."
|
|
76
|
+
)
|
|
77
|
+
if path.is_dir():
|
|
78
|
+
return ToolOutput.error(f"{path} is a directory. Use Glob or Grep to explore it.")
|
|
79
|
+
if not path.exists():
|
|
80
|
+
hint = _nearby_suggestion(path)
|
|
81
|
+
return ToolOutput.error(f"{path} does not exist." + (f" {hint}" if hint else ""))
|
|
82
|
+
|
|
83
|
+
raw = path.read_bytes()
|
|
84
|
+
if b"\x00" in raw[:BINARY_SNIFF_BYTES]:
|
|
85
|
+
return ToolOutput.error(
|
|
86
|
+
f"{path.name} looks binary ({len(raw):,} bytes). Not reading it as text."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# utf-8-sig: a BOM must not appear as an invisible U+FEFF on line 1, or the
|
|
90
|
+
# model copies it into an Edit's old_string and the match fails invisibly.
|
|
91
|
+
text = raw.decode("utf-8-sig", errors="replace")
|
|
92
|
+
lines = text.splitlines()
|
|
93
|
+
total = len(lines)
|
|
94
|
+
|
|
95
|
+
start = max(1, args.offset or 1)
|
|
96
|
+
limit = args.limit or 2_000
|
|
97
|
+
window = lines[start - 1 : start - 1 + limit]
|
|
98
|
+
|
|
99
|
+
if not window:
|
|
100
|
+
return ToolOutput(
|
|
101
|
+
content=f"(no lines: file has {total} lines, offset {start} is past the end)"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
numbered = []
|
|
105
|
+
for i, line in enumerate(window, start=start):
|
|
106
|
+
if len(line) > MAX_LINE_CHARS:
|
|
107
|
+
line = line[:MAX_LINE_CHARS] + f"… [{len(line) - MAX_LINE_CHARS} chars truncated]"
|
|
108
|
+
numbered.append(f"{i:6d}\t{line}")
|
|
109
|
+
|
|
110
|
+
body = "\n".join(numbered)
|
|
111
|
+
shown_end = start + len(window) - 1
|
|
112
|
+
if shown_end < total:
|
|
113
|
+
body += (
|
|
114
|
+
f"\n\n[showing lines {start}-{shown_end} of {total}. "
|
|
115
|
+
f"Use offset={shown_end + 1} to continue.]"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Only a complete read establishes read-before-write: recording a partial
|
|
119
|
+
# read would let an Edit be built against content we never showed.
|
|
120
|
+
if start == 1 and shown_end >= total:
|
|
121
|
+
ctx.files.record_read(path, text)
|
|
122
|
+
|
|
123
|
+
return ToolOutput(
|
|
124
|
+
content=body,
|
|
125
|
+
display=f"Read {_rel(path, ctx.root)} ({len(window)} of {total} lines)",
|
|
126
|
+
metrics={"lines": len(window), "total_lines": total, "bytes": len(raw)},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def summary(self, args: ReadArgs) -> str: # type: ignore[override]
|
|
130
|
+
return f"Read {args.file_path}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _rel(path: Path, root: Path) -> str:
|
|
134
|
+
try:
|
|
135
|
+
return str(path.resolve().relative_to(root.resolve()))
|
|
136
|
+
except ValueError:
|
|
137
|
+
return str(path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _nearby_suggestion(path: Path, limit: int = 4) -> str:
|
|
141
|
+
"""Name the closest existing siblings — a typo'd path is the common case."""
|
|
142
|
+
parent = path.parent
|
|
143
|
+
if not parent.is_dir():
|
|
144
|
+
return f"Its parent directory {parent} does not exist either."
|
|
145
|
+
import difflib
|
|
146
|
+
|
|
147
|
+
names = [p.name for p in parent.iterdir()]
|
|
148
|
+
close = difflib.get_close_matches(path.name, names, n=limit, cutoff=0.6)
|
|
149
|
+
if close:
|
|
150
|
+
return "Did you mean: " + ", ".join(close) + "?"
|
|
151
|
+
return ""
|