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/__init__.py +7 -0
- chad/agent.py +1093 -0
- chad/base_engine.py +109 -0
- chad/bench.py +243 -0
- chad/cli.py +469 -0
- chad/compaction.py +108 -0
- chad/config.py +63 -0
- chad/diag.py +100 -0
- chad/engine.py +883 -0
- chad/guardrails.py +373 -0
- chad/ignore.py +18 -0
- chad/lsp.py +323 -0
- chad/mcp.py +719 -0
- chad/mcp_oauth.py +329 -0
- chad/openai_engine.py +252 -0
- chad/prompt.py +305 -0
- chad/render.py +462 -0
- chad/repomap.py +767 -0
- chad/session.py +281 -0
- chad/skills.py +407 -0
- chad/symbols.py +360 -0
- chad/syntaxgate.py +105 -0
- chad/toolcall_parse.py +115 -0
- chad/tools.py +962 -0
- chad/tui.py +921 -0
- chad/validate.py +361 -0
- chad_code-0.1.0.dist-info/METADATA +370 -0
- chad_code-0.1.0.dist-info/RECORD +32 -0
- chad_code-0.1.0.dist-info/WHEEL +5 -0
- chad_code-0.1.0.dist-info/entry_points.txt +4 -0
- chad_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- chad_code-0.1.0.dist-info/top_level.txt +1 -0
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)
|