chad-code 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.
chad/tools.py ADDED
@@ -0,0 +1,962 @@
1
+ """Claude-Code-style tools for the agent loop.
2
+
3
+ Each tool has an OpenAI/Qwen-compatible JSON schema (exposed to the model via the
4
+ chat template's `tools` argument) and a Python implementation. Implementations are
5
+ deliberately conservative: reads are unrestricted, writes/bash are real but the CLI
6
+ gates them behind a confirmation unless --yolo is set.
7
+ """
8
+
9
+ import fnmatch
10
+ import glob as _glob
11
+ import io
12
+ import os
13
+ import re
14
+ import signal
15
+ import subprocess
16
+ import threading
17
+ import time
18
+ from typing import Any
19
+
20
+ from . import config, repomap, symbols, syntaxgate
21
+ from .ignore import IGNORE_DIRS, slash_wrapped
22
+
23
+ # Directories never worth walking: huge, generated, or VCS internals. The canonical set
24
+ # of bare names lives in `ignore.py` (the single source of truth); `IGNORE_DIRS` is
25
+ # re-exported here because agent.expand_mentions imports it to filter @dir listings.
26
+ # `_SKIP_DIRS` is the slash-wrapped form for path-substring tests.
27
+ _SKIP_DIRS = slash_wrapped(IGNORE_DIRS)
28
+
29
+
30
+ def _skip(path: str) -> bool:
31
+ p = "/" + path.replace(os.sep, "/")
32
+ return any(d in p for d in _SKIP_DIRS)
33
+
34
+
35
+ def _rel(path: str) -> str:
36
+ try:
37
+ return os.path.relpath(path)
38
+ except ValueError:
39
+ return path
40
+
41
+
42
+ # The only writable area in plan mode: plan files land here, nothing else may be
43
+ # touched. See the plan-mode gate in agent.run_turn.
44
+ PLANS_DIR = "plans"
45
+
46
+
47
+ def _under_plans(path: str) -> bool:
48
+ """True if `path` resolves inside ./plans/ (the only writable area in plan mode)."""
49
+ root = os.path.abspath(PLANS_DIR)
50
+ p = os.path.abspath(path)
51
+ return p == root or p.startswith(root + os.sep)
52
+
53
+
54
+ def _kill_group(p):
55
+ """Kill the whole process group, not just the /bin/sh parent. `shell=True`
56
+ spawns `/bin/sh -c <command>`; p.kill() SIGKILLs only that shell, leaving
57
+ backgrounded/piped children (`cmd &`, `a | b`, a spawned server) alive — the
58
+ exact long-running processes a timeout/interrupt exists to stop. start_new_session
59
+ puts the shell in its own group so we can signal the whole tree."""
60
+ try:
61
+ os.killpg(os.getpgid(p.pid), signal.SIGKILL)
62
+ except (ProcessLookupError, PermissionError):
63
+ p.kill() # group already gone, or no permission — fall back to the parent
64
+
65
+
66
+ def tool_bash(command: str, timeout: int = 120, should_stop=None) -> str:
67
+ try:
68
+ p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE,
69
+ stderr=subprocess.STDOUT, text=True, start_new_session=True)
70
+ except OSError as e:
71
+ return f"[failed to launch: {e}]"
72
+ # Drain output on a helper thread (so large output can't deadlock the pipe)
73
+ # while we poll for an interrupt or timeout and kill the process if needed.
74
+ box = {}
75
+ t = threading.Thread(target=lambda: box.__setitem__("out", p.communicate()[0]),
76
+ daemon=True)
77
+ t.start()
78
+ deadline = time.time() + timeout
79
+ while t.is_alive():
80
+ if should_stop and should_stop():
81
+ _kill_group(p); t.join(2)
82
+ return "[interrupted by user]"
83
+ if time.time() > deadline:
84
+ _kill_group(p); t.join(2)
85
+ return f"[timed out after {timeout}s]"
86
+ t.join(0.1)
87
+ out = (box.get("out") or "").strip()
88
+ if p.returncode not in (0, None):
89
+ out = f"[exit {p.returncode}]\n{out}"
90
+ return _bash_headtail(out) if out else "[no output]"
91
+
92
+
93
+ # Bash output budget. A plain head-slice is exactly wrong for the thing bash is used
94
+ # for most — running tests/builds — because pytest/compilers put the actionable
95
+ # summary (`=== N failed ===`, the traceback tail) at the BOTTOM. On a noisy run a
96
+ # head-only cap shows 20k chars of passing dots and hides the failure, undermining the
97
+ # verify loop guardrails.py exists to enforce. So keep HEAD and TAIL (Claude Code does
98
+ # the same), biased toward the tail where the summary lives.
99
+ BASH_MAX_CHARS = 20000
100
+ BASH_HEAD_CHARS = 8000
101
+ BASH_TAIL_CHARS = 12000
102
+
103
+
104
+ def _bash_headtail(s: str) -> str:
105
+ if len(s) <= BASH_MAX_CHARS:
106
+ return s
107
+ omitted = len(s) - BASH_HEAD_CHARS - BASH_TAIL_CHARS
108
+ return (s[:BASH_HEAD_CHARS]
109
+ + f"\n[… {omitted} chars omitted — output truncated; the TAIL below is "
110
+ f"usually the failure summary …]\n"
111
+ + s[-BASH_TAIL_CHARS:])
112
+
113
+
114
+ # Local-model read budget. Every token a read returns must be PREFILLED into the
115
+ # KV cache, and on Ornith (~50 tok/s, ~350 tok/s prefill) a whole-file dump of a
116
+ # 1377-line test file = ~21k tokens = a ~60s stall AND a multi-GB transient-memory
117
+ # spike during the prefill. Cloud Claude Code can afford 2000-line reads because of
118
+ # prompt caching; locally we default small and page, nudging toward the symbolic
119
+ # tools (find_symbol/view_symbol/grep) for big files. A hard char cap also clips
120
+ # pathological long lines (minified/data files).
121
+ READ_DEFAULT_LIMIT = 400
122
+ READ_MAX_LIMIT = 800
123
+ READ_MAX_CHARS = 24000
124
+ # Above this many lines, a default (un-paged) read of a parseable code file returns
125
+ # its SKELETON (signatures) instead of the body. The eval data showed the model
126
+ # defaults to `read` even when view_symbol is cheaper, so the harness — not the
127
+ # model — caps the cost: a skeleton is ~10x smaller and the bodies are one
128
+ # view_symbol / offset-read away. Set above the size of normal source files so
129
+ # small reads are untouched; big files can't blow up prefill.
130
+ READ_SKELETON_LINES = 250
131
+
132
+
133
+ def tool_read(path: str, offset: int = 0, limit: int = READ_DEFAULT_LIMIT) -> str:
134
+ # Small models often emit a workspace-relative path with a stray leading slash
135
+ # ("/construct.py"). If the absolute path doesn't exist but the relative one does,
136
+ # fall back to it rather than a misleading "[no such file]".
137
+ if not os.path.exists(path) and path.startswith("/") and os.path.exists(path.lstrip("/")):
138
+ path = path.lstrip("/")
139
+ if not os.path.exists(path):
140
+ return f"[no such file: {path}]"
141
+ try:
142
+ with open(path, "r", errors="replace") as f:
143
+ lines = f.readlines()
144
+ except (IsADirectoryError, OSError) as e:
145
+ return f"[cannot read {path}: {e}]"
146
+ total = len(lines)
147
+
148
+ # Skeleton mode: a plain "read this big file" returns structure, not the whole
149
+ # body — keeps prefill bounded without relying on the model to pick view_symbol.
150
+ # Only for default reads (no explicit window) of large, parseable code files.
151
+ if offset == 0 and limit == READ_DEFAULT_LIMIT and total > READ_SKELETON_LINES:
152
+ skel = repomap.service().overview(path)
153
+ if skel and not skel.startswith("["): # had real functions/classes
154
+ return (f"[{_rel(path)} is {total} lines — showing structure only to keep "
155
+ f"context small. Use view_symbol(name) for one symbol's body, or "
156
+ f"read(path, offset=N) to page raw lines.]\n{skel}")
157
+
158
+ # Paging a big code file with offset= defeats the skeleton guard above and racks up
159
+ # prefill page by page. Nudge toward symbol-targeted reads (the bigfile tasks show
160
+ # this is the expensive losing move on a non-trimmable cache).
161
+ lead = ""
162
+ if offset > 0 and total > READ_SKELETON_LINES and repomap.service().lang_for(path):
163
+ lead = (f"[paging a {total}-line code file — view_symbol(name) returns just the one "
164
+ f"function (~10x cheaper than reading pages); overview({_rel(path)}) lists "
165
+ f"the symbols.]\n")
166
+
167
+ limit = max(1, min(limit, READ_MAX_LIMIT))
168
+ chunk = lines[offset : offset + limit]
169
+ width = len(str(offset + len(chunk)))
170
+ body = "".join(f"{i+offset+1:>{width}} {ln}" for i, ln in enumerate(chunk))
171
+ note = ""
172
+ if len(body) > READ_MAX_CHARS: # clip long-line blobs before they bloat context
173
+ body = body[:READ_MAX_CHARS]
174
+ note = (f"\n[…clipped at {READ_MAX_CHARS} chars. This file is dense — use grep "
175
+ f"or find_symbol/view_symbol to target what you need.]")
176
+ shown_end = offset + len(chunk)
177
+ if shown_end < total: # more file remains past the window we returned
178
+ note += (f"\n[showed lines {offset+1}-{shown_end} of {total}. To continue, read "
179
+ f"with offset={shown_end}; or use grep/find_symbol to jump to what you "
180
+ f"need instead of reading the whole file.]")
181
+ return (lead + body + note) if body else "[empty]"
182
+
183
+
184
+ def tool_write(path: str, content: str) -> str:
185
+ before = None
186
+ if os.path.exists(path):
187
+ try:
188
+ with open(path, errors="replace") as f:
189
+ before = f.read()
190
+ except OSError:
191
+ pass
192
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
193
+ with open(path, "w") as f:
194
+ f.write(content)
195
+ result = f"[wrote {len(content)} bytes to {_rel(path)}]"
196
+ warn = syntaxgate.check_syntax(path, before)
197
+ return result + warn if warn else result
198
+
199
+
200
+ # Edit robustness. Dogfooding logs showed ~1 in 6 `edit` calls failed to apply —
201
+ # dominated by two MECHANICAL near-misses, not bad intent: (1) the model emits literal
202
+ # "\n"/"\t" in `old` instead of real control chars (so a multiline `old` never matches),
203
+ # and (2) indentation / trailing-whitespace drift between what it quoted and the file.
204
+ # Both are recoverable WITHOUT risking a wrong edit, because each recovery still requires
205
+ # a UNIQUE target — we never replace on an ambiguous or fuzzy-multiple match. A miss that
206
+ # can't be resolved now returns the closest line in the file so the model can self-correct
207
+ # instead of looping on the identical bad call.
208
+
209
+ def _unescape_ws(s: str) -> str:
210
+ """Interpret the literal backslash escapes a weak model emits (\\n \\t \\r) as the
211
+ real control chars. Targeted, not a blanket unicode_escape (which would mangle real
212
+ backslashes and unicode in code)."""
213
+ return s.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\t", "\t")
214
+
215
+
216
+ def _line_offsets(data: str):
217
+ offs, pos = [], 0
218
+ for ln in data.split("\n"):
219
+ offs.append(pos)
220
+ pos += len(ln) + 1 # +1 for the stripped '\n'
221
+ return offs
222
+
223
+
224
+ def _ws_flexible_spans(data: str, old: str):
225
+ """Char spans (start, end) where `old` matches a run of lines in `data` ignoring
226
+ each line's leading/trailing whitespace. Skips all-blank patterns (too ambiguous)."""
227
+ norm = [l.strip() for l in old.strip("\n").split("\n")]
228
+ if not any(norm):
229
+ return []
230
+ dlines = data.split("\n")
231
+ offs = _line_offsets(data)
232
+ n = len(norm)
233
+ spans = []
234
+ for i in range(len(dlines) - n + 1):
235
+ if [dlines[i + j].strip() for j in range(n)] == norm:
236
+ spans.append((offs[i], offs[i + n - 1] + len(dlines[i + n - 1])))
237
+ return spans
238
+
239
+
240
+ def _reindent(new: str, target_indent: str) -> str:
241
+ """Shift `new` so its first non-blank line carries target_indent, preserving the
242
+ relative indentation of the rest (so a recovered block lands at the file's indent)."""
243
+ lines = new.split("\n")
244
+ first = next((l for l in lines if l.strip()), "")
245
+ src = first[: len(first) - len(first.lstrip())]
246
+ out = []
247
+ for ln in lines:
248
+ if not ln.strip():
249
+ out.append("")
250
+ elif ln.startswith(src):
251
+ out.append(target_indent + ln[len(src):])
252
+ else:
253
+ out.append(target_indent + ln.lstrip())
254
+ return "\n".join(out)
255
+
256
+
257
+ def _closest_hint(data: str, old: str) -> str:
258
+ import difflib
259
+ target = old.strip("\n").split("\n")[0].strip()
260
+ if not target:
261
+ return ""
262
+ best = difflib.get_close_matches(
263
+ target, [l.strip() for l in data.split("\n") if l.strip()], n=1, cutoff=0.6)
264
+ return (f" Closest line in the file is {best[0]!r} — copy it exactly (mind "
265
+ f"indentation), or use replace_symbol to rewrite the whole function.") if best else ""
266
+
267
+
268
+ def _apply_edit(path: str, before: str, after: str, note: str) -> str:
269
+ if after == before:
270
+ return "[no-op edit: the replacement leaves the file unchanged]"
271
+ with open(path, "w") as f:
272
+ f.write(after)
273
+ result = f"[edited {_rel(path)}{note}]"
274
+ warn = syntaxgate.check_syntax(path, before)
275
+ return result + warn if warn else result
276
+
277
+
278
+ def tool_edit(path: str, old: str, new: str) -> str:
279
+ if not os.path.exists(path):
280
+ return f"[no such file: {path}]"
281
+ if old == new:
282
+ return "[no-op edit: old and new are identical; change the content or stop]"
283
+ with open(path) as f:
284
+ data = f.read()
285
+
286
+ # (1) exact match — the common, fast path.
287
+ n = data.count(old)
288
+ if n == 1:
289
+ return _apply_edit(path, data, data.replace(old, new, 1), "")
290
+ if n > 1:
291
+ return f"[old string appears {n} times; make it unique by including more surrounding lines]"
292
+
293
+ # (2) escape-normalized: literal \n/\t in `old` (and `new` when it has no real newline).
294
+ uold = _unescape_ws(old)
295
+ unew = _unescape_ws(new) if ("\n" not in new and "\\n" in new) else new
296
+ # STOP condition (plan 044 item 4): whether a literal `\n` in `new` is an escape the
297
+ # model meant as a newline, or a genuine backslash-n it wants written verbatim, is
298
+ # ambiguous — and `unew` is only ever used on a recovery path where `old` itself
299
+ # needed the same unescape, so we keep the historical transform but DISCLOSE it in the
300
+ # result so the model can correct a mis-transformed literal instead of it happening
301
+ # silently. `note_new` is the disclosure fragment, empty when `new` was left as-is.
302
+ note_new = (" [note: \\n in replacement interpreted as newline; re-edit with a real "
303
+ "newline if you meant a literal backslash-n]") if unew != new else ""
304
+ if uold != old:
305
+ c = data.count(uold)
306
+ if c == 1:
307
+ return _apply_edit(path, data, data.replace(uold, unew, 1),
308
+ " (recovered: interpreted \\n/\\t escapes in `old`)" + note_new)
309
+ if c > 1:
310
+ return f"[old string appears {c} times; make it unique by including more surrounding lines]"
311
+
312
+ # (3) whitespace-flexible: indentation / trailing-space drift, still requiring uniqueness.
313
+ probe = uold if uold != old else old
314
+ spans = _ws_flexible_spans(data, probe)
315
+ if len(spans) == 1:
316
+ s, e = spans[0]
317
+ head = data[s:e].split("\n")[0]
318
+ indent = head[: len(head) - len(head.lstrip())]
319
+ used_unew = uold != old # this path only unescapes `new` when `old` was unescaped
320
+ repl = _reindent((unew if used_unew else new).strip("\n"), indent)
321
+ return _apply_edit(path, data, data[:s] + repl + data[e:],
322
+ " (recovered: matched ignoring indentation/whitespace)"
323
+ + (note_new if used_unew else ""))
324
+ if len(spans) > 1:
325
+ return (f"[old string matches {len(spans)} places ignoring whitespace; include "
326
+ f"more surrounding lines to make it unique]")
327
+
328
+ return f"[old string not found; no change made.{_closest_hint(data, old)}]"
329
+
330
+
331
+ # Planning tool (deepagents' write_todos): a scaffold that keeps the model on track
332
+ # across multi-step tasks. Stateless-ish — the model re-sends the whole list each call.
333
+ _TODOS = []
334
+
335
+
336
+ def tool_write_todos(todos) -> str:
337
+ global _TODOS
338
+ if not isinstance(todos, list):
339
+ return "[todos must be a list of {content, status} objects]"
340
+ _TODOS = todos
341
+ marks = {"completed": "[x]", "in_progress": "[~]", "pending": "[ ]"}
342
+ lines = [f" {marks.get(t.get('status', 'pending'), '[ ]')} {t.get('content', '')}"
343
+ for t in todos]
344
+ return "Plan updated:\n" + "\n".join(lines)
345
+
346
+
347
+ def _walk_glob(path: str, pattern: str):
348
+ """Pruned-walk equivalent of `glob(os.path.join(path, pattern), recursive=True)`
349
+ for basename-only patterns — "**/*" or "**/<name-glob>" — which is what the model
350
+ actually sends. glob materializes the ENTIRE tree (weights dirs, node_modules, VCS
351
+ caches included) and only then lets `_skip` filter; the walk prunes those dirs
352
+ before descending, keeps glob's hidden-file rule, and doesn't follow dir symlinks.
353
+ Returns None for structured patterns (a "/" or "**" past the leading "**/") so
354
+ callers fall back to glob. Yields dirs too, matching glob("**/*")."""
355
+ if pattern in ("**", "**/*"):
356
+ base = "*"
357
+ elif pattern.startswith("**/") and "/" not in pattern[3:] and "**" not in pattern[3:]:
358
+ base = pattern[3:]
359
+ else:
360
+ return None
361
+
362
+ def walk():
363
+ for dirpath, dirnames, filenames in os.walk(path):
364
+ dirnames[:] = [d for d in dirnames
365
+ if not d.startswith(".") and d not in IGNORE_DIRS]
366
+ for d in dirnames:
367
+ if fnmatch.fnmatch(d, base):
368
+ yield os.path.join(dirpath, d)
369
+ for fn in filenames:
370
+ if not fn.startswith(".") and fnmatch.fnmatch(fn, base):
371
+ yield os.path.join(dirpath, fn)
372
+
373
+ return walk()
374
+
375
+
376
+ def tool_glob(pattern: str, should_stop=None) -> str:
377
+ it = _walk_glob(".", pattern)
378
+ if it is None:
379
+ hits = [h for h in sorted(_glob.glob(pattern, recursive=True)) if not _skip(h)]
380
+ else:
381
+ # walk yields "./x/y" but glob(pattern) yields "x/y" — keep the output stable
382
+ hits = sorted(h[2:] if h.startswith("./") else h for h in it)
383
+ return "\n".join(hits[:200]) or "[no matches]"
384
+
385
+
386
+ # grep output budgets. A single match in a minified JS/JSON file dumps a multi-MB
387
+ # line straight into the transcript — the same blowup READ_MAX_CHARS guards on the
388
+ # read path, and every char is prefill on a ~350 tok/s model. So cap each emitted
389
+ # line, the total number of lines, and the number of files walked — and, unlike the
390
+ # old code, ANNOUNCE when a cap binds so the model narrows the query instead of
391
+ # concluding "no matches".
392
+ GREP_MAX_LINES = 200
393
+ GREP_MAX_FILES = 5000
394
+ GREP_LINE_CHARS = 500
395
+
396
+
397
+ def _grep_clip(s: str) -> str:
398
+ return s if len(s) <= GREP_LINE_CHARS else s[:GREP_LINE_CHARS] + "…[line clipped]"
399
+
400
+
401
+ # Files up to this size are read whole and prescreened with one C-speed regex pass;
402
+ # only files that contain a match pay the (slow) per-line Python loop. Bigger files
403
+ # keep the old streaming scan.
404
+ GREP_FULLREAD_MAX = 4 * 1024 * 1024
405
+
406
+
407
+ def _grep_prescreen_rx(pattern: str, flags: int):
408
+ """A whole-file version of the per-line search: re.MULTILINE makes ^/$ anchor per
409
+ line, so any line the per-line loop would match, this finds somewhere in the full
410
+ text (a superset — false positives just run the line loop and emit nothing). The
411
+ exceptions where a full-text search could MISS a per-line match: \\A/\\Z (different
412
+ meaning across the two scans) and negative lookarounds (can see past the line's
413
+ \\n and reject). Those patterns return None: no prescreen, stream as before."""
414
+ if any(tok in pattern for tok in (r"\A", r"\Z", "(?!", "(?<!")):
415
+ return None
416
+ try:
417
+ return re.compile(pattern, flags | re.MULTILINE)
418
+ except re.error:
419
+ return None
420
+
421
+
422
+ def tool_grep(pattern: str, path: str = ".", glob: str = "**/*", ignore_case: bool = False,
423
+ context: int = 0, should_stop=None) -> str:
424
+ flags = re.IGNORECASE if ignore_case else 0
425
+ try:
426
+ rx = re.compile(pattern, flags)
427
+ except re.error as rx_err:
428
+ return f"[bad regex: {rx_err}]"
429
+ rx_pre = _grep_prescreen_rx(pattern, flags)
430
+ ctx = max(0, min(int(context or 0), 5))
431
+ out: list[str] = []
432
+ matches = 0 # total match lines seen (may exceed what we emit once capped)
433
+ capped = False # hit the GREP_MAX_LINES output cap
434
+
435
+ def emit(s: str):
436
+ nonlocal capped
437
+ if len(out) < GREP_MAX_LINES:
438
+ out.append(s)
439
+ else:
440
+ capped = True
441
+
442
+ fast = _walk_glob(path, glob)
443
+ if fast is None:
444
+ files = _glob.glob(os.path.join(path, glob), recursive=True)
445
+ files_truncated = len(files) > GREP_MAX_FILES
446
+ files = files[:GREP_MAX_FILES]
447
+ else:
448
+ files, files_truncated = [], False
449
+ for fp in fast:
450
+ if len(files) >= GREP_MAX_FILES:
451
+ files_truncated = True
452
+ break
453
+ files.append(fp)
454
+ for fp in files:
455
+ if should_stop and should_stop():
456
+ return "[interrupted by user]"
457
+ if _skip(fp) or not os.path.isfile(fp):
458
+ continue
459
+ try:
460
+ # Prescreen: one full-text regex pass (C speed) decides whether the file
461
+ # is worth the per-line Python loop at all. io.StringIO(text) then feeds
462
+ # that loop the exact same lines an open file would, so match/emit
463
+ # semantics are unchanged.
464
+ text = None
465
+ if rx_pre is not None and os.path.getsize(fp) <= GREP_FULLREAD_MAX:
466
+ with open(fp, errors="ignore") as f:
467
+ text = f.read()
468
+ if not rx_pre.search(text):
469
+ continue
470
+ src: io.TextIOBase = (io.StringIO(text) if text is not None
471
+ else open(fp, errors="ignore"))
472
+ if ctx:
473
+ # context mode needs surrounding lines, so read the whole file and
474
+ # emit `--`-separated groups (merging overlapping windows), like grep -C.
475
+ with src as fh:
476
+ flines = [ln.rstrip("\n") for ln in fh]
477
+ idxs = [i for i, ln in enumerate(flines) if rx.search(ln)]
478
+ if not idxs:
479
+ continue
480
+ matches += len(idxs)
481
+ groups: list[list[int]] = [] # merged [start, end] inclusive windows
482
+ for i in idxs:
483
+ s, e = max(0, i - ctx), min(len(flines) - 1, i + ctx)
484
+ if groups and s <= groups[-1][1] + 1:
485
+ groups[-1][1] = max(groups[-1][1], e)
486
+ else:
487
+ groups.append([s, e])
488
+ for gi, (s, e) in enumerate(groups):
489
+ if gi:
490
+ emit("--")
491
+ for j in range(s, e + 1):
492
+ sep = ":" if rx.search(flines[j]) else "-"
493
+ emit(f"{fp}:{j+1}{sep} {_grep_clip(flines[j])}")
494
+ else:
495
+ with src as fh:
496
+ for i, ln in enumerate(fh, 1):
497
+ if rx.search(ln):
498
+ matches += 1
499
+ emit(f"{fp}:{i}: {_grep_clip(ln.rstrip())}")
500
+ except OSError:
501
+ continue
502
+
503
+ if not out:
504
+ return "[no matches]"
505
+ notices = []
506
+ if capped:
507
+ notices.append(f"[results truncated: {len(out)}/{matches} lines — narrow the "
508
+ f"pattern or add a path]")
509
+ if files_truncated:
510
+ notices.append(f"[searched first {GREP_MAX_FILES} files]")
511
+ return "\n".join(out + notices)
512
+
513
+
514
+ # Each entry takes (args, should_stop); long-running tools honor should_stop so a
515
+ # ctrl-c interrupt can abort them mid-flight.
516
+ DISPATCH = {
517
+ "write_todos": lambda a, ss=None: tool_write_todos(a["todos"]),
518
+ "bash": lambda a, ss=None: tool_bash(a["command"], a.get("timeout", 120), should_stop=ss),
519
+ "read": lambda a, ss=None: tool_read(a["path"], a.get("offset", 0),
520
+ a.get("limit", READ_DEFAULT_LIMIT)),
521
+ "write": lambda a, ss=None: tool_write(a["path"], a["content"]),
522
+ "edit": lambda a, ss=None: tool_edit(a["path"], a["old"], a["new"]),
523
+ "glob": lambda a, ss=None: tool_glob(a["pattern"], should_stop=ss),
524
+ "grep": lambda a, ss=None: tool_grep(a["pattern"], a.get("path", "."),
525
+ a.get("glob", "**/*"),
526
+ a.get("ignore_case", False),
527
+ a.get("context", 0), should_stop=ss),
528
+ # Symbolic code tools. READS go through the tree-sitter backend (repomap) — it's
529
+ # language-agnostic and the repo_map gives a ranked skeleton for cheap navigation.
530
+ # EDITS stay on the jedi backend (symbols), the proven Python symbol editor.
531
+ "repo_map": lambda a, ss=None: repomap.service().repo_map(
532
+ a.get("budget", 1500), a.get("focus"), should_stop=ss),
533
+ "overview": lambda a, ss=None: repomap.service().overview(a["path"], should_stop=ss),
534
+ "view_symbol": lambda a, ss=None: repomap.service().view_symbol(
535
+ a["name"], a.get("path"), should_stop=ss),
536
+ "find_symbol": lambda a, ss=None: repomap.service().find_symbol(a["name"], should_stop=ss),
537
+ "find_refs": lambda a, ss=None: repomap.service().find_refs(
538
+ a["name"], a.get("path"), should_stop=ss),
539
+ "replace_symbol": lambda a, ss=None: symbols.service().replace_symbol(
540
+ a["name"], a["new"], a.get("path"), should_stop=ss),
541
+ "insert_symbol": lambda a, ss=None: symbols.service().insert_symbol(
542
+ a["name"], a["code"], a.get("where", "after"), a.get("path"), should_stop=ss),
543
+ "rename_symbol": lambda a, ss=None: repomap.service().rename_symbol(
544
+ a["name"], a["new_name"], a.get("path"), should_stop=ss),
545
+ # Agent Skills (https://agentskills.io): load one skill's full instructions on
546
+ # demand (tier-2 progressive disclosure). Registered in active_schemas() only when
547
+ # skills are installed; the dispatch is harmless (a clear message) otherwise.
548
+ "activate_skill": lambda a, ss=None: _skills().activate(a["name"]),
549
+ }
550
+
551
+
552
+ def _skills():
553
+ """Lazy import of the skills module (avoids a circular import at module load)."""
554
+ from . import skills
555
+ return skills
556
+
557
+ # Tools that mutate state -> CLI asks for confirmation unless --yolo.
558
+ MUTATING = {"bash", "write", "edit", "replace_symbol", "insert_symbol", "rename_symbol"}
559
+
560
+ # Terminal tools end the turn cleanly (forge's terminal_tool idea). Small models
561
+ # instinctively try to "stop"/"finish"; giving them a real tool avoids hallucinated
562
+ # unknown-tool churn at the end of a task.
563
+ TERMINAL = {"done", "finish", "stop"}
564
+
565
+ SCHEMAS: list[dict[str, Any]] = [
566
+ {
567
+ "type": "function",
568
+ "function": {
569
+ "name": "write_todos",
570
+ "description": "Record or update your step-by-step plan for a multi-step task. "
571
+ "Call this first for any task with 2+ steps, and again to update "
572
+ "statuses as you progress.",
573
+ "parameters": {
574
+ "type": "object",
575
+ "properties": {
576
+ "todos": {
577
+ "type": "array",
578
+ "items": {
579
+ "type": "object",
580
+ "properties": {
581
+ "content": {"type": "string"},
582
+ "status": {"type": "string",
583
+ "enum": ["pending", "in_progress", "completed"]},
584
+ },
585
+ "required": ["content", "status"],
586
+ },
587
+ },
588
+ },
589
+ "required": ["todos"],
590
+ },
591
+ },
592
+ },
593
+ {
594
+ "type": "function",
595
+ "function": {
596
+ "name": "done",
597
+ "description": "Call this when the task is fully complete and verified, to end "
598
+ "your turn. Provide a one-line summary of what you did.",
599
+ "parameters": {
600
+ "type": "object",
601
+ "properties": {"summary": {"type": "string"}},
602
+ "required": ["summary"],
603
+ },
604
+ },
605
+ },
606
+ {
607
+ "type": "function",
608
+ "function": {
609
+ "name": "task",
610
+ "description": "Delegate an open-ended exploration or sub-task to a fresh "
611
+ "sub-agent that works in its OWN small, isolated context and "
612
+ "returns only a condensed answer. Use this for spelunking — "
613
+ "'find where X is handled', 'which files touch Y', 'trace how "
614
+ "Z flows' — so your MAIN context stays small and cheap (the "
615
+ "sub-agent's grep/read churn never enters this conversation; "
616
+ "only its final findings do). Read-only by default. The "
617
+ "sub-agent does NOT see this conversation, so put everything it "
618
+ "needs in `prompt`. It cannot spawn further sub-agents.",
619
+ "parameters": {
620
+ "type": "object",
621
+ "properties": {
622
+ "description": {"type": "string",
623
+ "description": "A short (3-6 word) label for the sub-task."},
624
+ "prompt": {"type": "string",
625
+ "description": "The full, self-contained instruction: what to "
626
+ "find or do, and exactly what to report back."},
627
+ "tools": {"type": "string", "enum": ["read-only", "all"],
628
+ "description": "Tool access: 'read-only' (default; search/read "
629
+ "only) or 'all' (also edit/run — honored only in "
630
+ "--yolo/auto mode; otherwise clamped to read-only)."},
631
+ },
632
+ "required": ["description", "prompt"],
633
+ },
634
+ },
635
+ },
636
+ {
637
+ "type": "function",
638
+ "function": {
639
+ "name": "bash",
640
+ "description": "Run a shell command in the working directory and return combined stdout/stderr.",
641
+ "parameters": {
642
+ "type": "object",
643
+ "properties": {
644
+ "command": {"type": "string", "description": "The shell command to run."},
645
+ "timeout": {"type": "integer", "description": "Timeout in seconds (default 120)."},
646
+ },
647
+ "required": ["command"],
648
+ },
649
+ },
650
+ },
651
+ {
652
+ "type": "function",
653
+ "function": {
654
+ "name": "read",
655
+ "description": ("Read a text file with line numbers. Returns up to "
656
+ f"{READ_DEFAULT_LIMIT} lines by default ({READ_MAX_LIMIT} max) "
657
+ "to keep context small. A default read of a large code file "
658
+ f"(>{READ_SKELETON_LINES} lines) returns its STRUCTURE "
659
+ "(signatures) instead of the body — then use view_symbol(name) "
660
+ "for a function's body, or read(path, offset=N) to page raw lines."),
661
+ "parameters": {
662
+ "type": "object",
663
+ "properties": {
664
+ "path": {"type": "string"},
665
+ "offset": {"type": "integer", "description": "Start line (0-based)."},
666
+ "limit": {"type": "integer",
667
+ "description": f"Max lines (capped at {READ_MAX_LIMIT})."},
668
+ },
669
+ "required": ["path"],
670
+ },
671
+ },
672
+ },
673
+ {
674
+ "type": "function",
675
+ "function": {
676
+ "name": "write",
677
+ "description": "Write (create or overwrite) a file with the given content.",
678
+ "parameters": {
679
+ "type": "object",
680
+ "properties": {
681
+ "path": {"type": "string"},
682
+ "content": {"type": "string"},
683
+ },
684
+ "required": ["path", "content"],
685
+ },
686
+ },
687
+ },
688
+ {
689
+ "type": "function",
690
+ "function": {
691
+ "name": "edit",
692
+ "description": "Replace a unique substring in a file with new text.",
693
+ "parameters": {
694
+ "type": "object",
695
+ "properties": {
696
+ "path": {"type": "string"},
697
+ "old": {"type": "string", "description": "Exact text to replace (must be unique)."},
698
+ "new": {"type": "string"},
699
+ },
700
+ "required": ["path", "old", "new"],
701
+ },
702
+ },
703
+ },
704
+ {
705
+ "type": "function",
706
+ "function": {
707
+ "name": "glob",
708
+ "description": "Find files matching a glob pattern (supports ** recursion).",
709
+ "parameters": {
710
+ "type": "object",
711
+ "properties": {"pattern": {"type": "string"}},
712
+ "required": ["pattern"],
713
+ },
714
+ },
715
+ },
716
+ {
717
+ "type": "function",
718
+ "function": {
719
+ "name": "grep",
720
+ "description": "Search file contents with a regex; returns path:line: match.",
721
+ "parameters": {
722
+ "type": "object",
723
+ "properties": {
724
+ "pattern": {"type": "string"},
725
+ "path": {"type": "string", "description": "Directory to search (default '.')."},
726
+ "glob": {"type": "string", "description": "File glob (default '**/*')."},
727
+ "ignore_case": {"type": "boolean",
728
+ "description": "Case-insensitive match (default false)."},
729
+ "context": {"type": "integer",
730
+ "description": "Lines of context to show before/after each "
731
+ "match, 0-5 (default 0)."},
732
+ },
733
+ "required": ["pattern"],
734
+ },
735
+ },
736
+ },
737
+ {
738
+ "type": "function",
739
+ "function": {
740
+ "name": "repo_map",
741
+ "description": "Get a ranked, signatures-only map of the whole codebase (most "
742
+ "referenced files first; functions/classes with line numbers, no "
743
+ "bodies). Call this FIRST on an unfamiliar project to orient yourself "
744
+ "cheaply instead of reading files — it costs a few hundred tokens for "
745
+ "the entire repo. Then use find_symbol/view_symbol to drill in.",
746
+ "parameters": {
747
+ "type": "object",
748
+ "properties": {
749
+ "budget": {"type": "integer",
750
+ "description": "Approx token budget for the map (default 1500)."},
751
+ "focus": {"type": "array", "items": {"type": "string"},
752
+ "description": "Optional path substrings to rank toward."},
753
+ },
754
+ "required": [],
755
+ },
756
+ },
757
+ },
758
+ {
759
+ "type": "function",
760
+ "function": {
761
+ "name": "overview",
762
+ "description": "List the functions and classes defined in ONE file (names, "
763
+ "signatures, line numbers) WITHOUT their bodies — any language. Use "
764
+ "this to understand a file cheaply instead of reading the whole thing.",
765
+ "parameters": {
766
+ "type": "object",
767
+ "properties": {"path": {"type": "string"}},
768
+ "required": ["path"],
769
+ },
770
+ },
771
+ },
772
+ {
773
+ "type": "function",
774
+ "function": {
775
+ "name": "view_symbol",
776
+ "description": "Show the full source of ONE function/class/method by name, instead "
777
+ "of reading an entire file. Name may be qualified ('Class/method') to "
778
+ "disambiguate. Prefer this over `read` for inspecting code.",
779
+ "parameters": {
780
+ "type": "object",
781
+ "properties": {
782
+ "name": {"type": "string", "description": "Symbol or 'Class/method'."},
783
+ "path": {"type": "string", "description": "Optional file to disambiguate."},
784
+ },
785
+ "required": ["name"],
786
+ },
787
+ },
788
+ },
789
+ {
790
+ "type": "function",
791
+ "function": {
792
+ "name": "find_symbol",
793
+ "description": "Find where a function/class/method is DEFINED across the project "
794
+ "(returns path:line + signature). Use this instead of grep to locate "
795
+ "a definition.",
796
+ "parameters": {
797
+ "type": "object",
798
+ "properties": {"name": {"type": "string"}},
799
+ "required": ["name"],
800
+ },
801
+ },
802
+ },
803
+ {
804
+ "type": "function",
805
+ "function": {
806
+ "name": "find_refs",
807
+ "description": "Find every place a symbol is USED across the project — precise "
808
+ "(a real language server follows imports and scope, so it won't "
809
+ "confuse a method with an unrelated function of the same name; far "
810
+ "better than grep before a rename/refactor). Name may be "
811
+ "'Class/method'; pass path to disambiguate.",
812
+ "parameters": {
813
+ "type": "object",
814
+ "properties": {
815
+ "name": {"type": "string"},
816
+ "path": {"type": "string", "description": "Optional file to disambiguate."},
817
+ },
818
+ "required": ["name"],
819
+ },
820
+ },
821
+ },
822
+ {
823
+ "type": "function",
824
+ "function": {
825
+ "name": "replace_symbol",
826
+ "description": "Replace the ENTIRE source of one function/class/method with new code "
827
+ "(found by name, not text matching — robust to whitespace). `new` is "
828
+ "the complete new definition including its signature and indentation.",
829
+ "parameters": {
830
+ "type": "object",
831
+ "properties": {
832
+ "name": {"type": "string", "description": "Symbol or 'Class/method'."},
833
+ "new": {"type": "string", "description": "Complete replacement source."},
834
+ "path": {"type": "string", "description": "Optional file to disambiguate."},
835
+ },
836
+ "required": ["name", "new"],
837
+ },
838
+ },
839
+ },
840
+ {
841
+ "type": "function",
842
+ "function": {
843
+ "name": "insert_symbol",
844
+ "description": "Insert new code immediately before or after an existing symbol "
845
+ "(e.g. add a new function/method next to a related one).",
846
+ "parameters": {
847
+ "type": "object",
848
+ "properties": {
849
+ "name": {"type": "string", "description": "Anchor symbol or 'Class/method'."},
850
+ "code": {"type": "string", "description": "New code to insert."},
851
+ "where": {"type": "string", "enum": ["after", "before"],
852
+ "description": "Default 'after'."},
853
+ "path": {"type": "string", "description": "Optional file to disambiguate."},
854
+ },
855
+ "required": ["name", "code"],
856
+ },
857
+ },
858
+ },
859
+ {
860
+ "type": "function",
861
+ "function": {
862
+ "name": "rename_symbol",
863
+ "description": "Rename a function/class/method AND every precise reference to it "
864
+ "across the project in one step — the safe way to do a multi-file "
865
+ "rename. Uses a real language server, so it follows imports and "
866
+ "scope and will NOT touch an unrelated symbol of the same name; "
867
+ "each identifier is rewritten by position, never by text match. "
868
+ "Pass path to disambiguate when several symbols share the name.",
869
+ "parameters": {
870
+ "type": "object",
871
+ "properties": {
872
+ "name": {"type": "string", "description": "Current symbol or 'Class/method'."},
873
+ "new_name": {"type": "string", "description": "New identifier."},
874
+ "path": {"type": "string", "description": "Optional file to disambiguate."},
875
+ },
876
+ "required": ["name", "new_name"],
877
+ },
878
+ },
879
+ },
880
+ ]
881
+
882
+ # A/B knob (used by the eval harness): with CHAD_NO_SYMBOLS set, hide the
883
+ # tree-sitter symbolic tools so the agent must navigate with read/grep/glob only.
884
+ # Lets us measure the prefill savings the symbolic/repo-map layer actually buys.
885
+ #
886
+ # Read the env PER RENDER (not once at import) so a single loaded engine can serve
887
+ # both arms of `run_evals.py --ab` in-process: the harness flips CHAD_NO_SYMBOLS
888
+ # between arms and the agent's render path calls active_schemas() each turn. SCHEMAS
889
+ # itself stays the full list (name lookups / required-arg validation need every tool).
890
+ _SYMBOLIC = {"repo_map", "overview", "view_symbol", "find_symbol", "find_refs",
891
+ "replace_symbol", "insert_symbol", "rename_symbol"}
892
+
893
+
894
+ def _activate_skill_schema(names):
895
+ """The activate_skill tool schema, with `name` constrained to the set of installed
896
+ skill names so the model can't hallucinate one that doesn't exist (spec guidance)."""
897
+ return {
898
+ "type": "function",
899
+ "function": {
900
+ "name": "activate_skill",
901
+ "description": "Load the full instructions for one of the available skills "
902
+ "(listed under '# Skills' in the system prompt) before doing a "
903
+ "task that matches its description. Returns the skill's "
904
+ "step-by-step instructions and the files it bundles.",
905
+ "parameters": {
906
+ "type": "object",
907
+ "properties": {
908
+ "name": {"type": "string", "enum": names,
909
+ "description": "Exact name of the skill to activate."},
910
+ },
911
+ "required": ["name"],
912
+ },
913
+ },
914
+ }
915
+
916
+
917
+ def active_schemas():
918
+ """The tool schemas to expose to the model right now. Starts from SCHEMAS (minus the
919
+ symbolic tools when CHAD_NO_SYMBOLS is set) and appends the activate_skill tool when
920
+ any Agent Skills are installed for the current project/user (omitted otherwise, so a
921
+ skill-less project never sees a dead tool)."""
922
+ schemas = SCHEMAS
923
+ # Subagent/Task tool (plan 041) ships opt-out: CHAD_NO_TASK hides it (the A/B arm and
924
+ # the escape hatch if the model misuses it). The subagent's OWN render drops it again
925
+ # via Agent._active_schemas — reentrancy guard, subagents can't spawn subagents.
926
+ if config.flag("CHAD_NO_TASK"):
927
+ schemas = [s for s in schemas if s["function"]["name"] != "task"]
928
+ if config.flag("CHAD_NO_SYMBOLS"):
929
+ schemas = [s for s in schemas if s["function"]["name"] not in _SYMBOLIC]
930
+ names = _skills().skill_names()
931
+ if names:
932
+ schemas = schemas + [_activate_skill_schema(names)]
933
+ # Tools from connected MCP servers (mcp__<server>__<tool>), if any are configured.
934
+ # Empty list when none, so a server-less project never sees an extra tool.
935
+ mcp_schemas = _mcp().schemas()
936
+ if mcp_schemas:
937
+ schemas = schemas + mcp_schemas
938
+ return schemas
939
+
940
+
941
+ def _mcp():
942
+ """Lazy import of the mcp module (avoids a circular import at module load)."""
943
+ from . import mcp
944
+ return mcp
945
+
946
+
947
+ def dispatch_for(name):
948
+ """Return the callable (args, should_stop)->str that handles a tool call, checking
949
+ chad's builtin DISPATCH first and then connected MCP servers. None if the name is
950
+ not a known tool (the agent then runs the unknown-tool repair path)."""
951
+ fn = DISPATCH.get(name)
952
+ if fn is not None:
953
+ return fn
954
+ if _mcp().is_mcp_tool(name) and _mcp().has_tool(name):
955
+ return lambda a, ss=None: _mcp().call(name, a)
956
+ return None
957
+
958
+
959
+ def is_mutating(name) -> bool:
960
+ """Whether a tool call needs the confirmation gate: a builtin mutator, or an MCP
961
+ tool the server didn't mark read-only (see mcp._is_mutating)."""
962
+ return name in MUTATING or _mcp().is_mutating(name)