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.
Files changed (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. 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 ""