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/symbols.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Symbol EDITOR for chad — `replace_symbol` / `insert_symbol`, all languages.
|
|
2
|
+
|
|
3
|
+
This module edits code by symbol rather than by text: locate a function / class /
|
|
4
|
+
method by name and replace or insert around it, returning a unified diff. Two
|
|
5
|
+
backends share one splice engine (`_apply` + syntaxgate):
|
|
6
|
+
|
|
7
|
+
* **Python** goes through jedi (already a dependency, in-process, scope-aware,
|
|
8
|
+
understands qualified `Class/method` paths).
|
|
9
|
+
* **Every other language** goes through the tree-sitter repo map's definition tags
|
|
10
|
+
(`repomap._find_defs`), whose `line`/`end_line` spans are the same ones
|
|
11
|
+
`view_symbol` displays — so what the model just viewed is exactly what an edit
|
|
12
|
+
replaces. No language-server subprocess on either path.
|
|
13
|
+
|
|
14
|
+
Symbolic **reads / search / rename** (`overview`, `view_symbol`, `find_symbol`,
|
|
15
|
+
`find_refs`, `repo_map`, `rename_symbol`) live in `repomap.py`, the tree-sitter
|
|
16
|
+
backend — it's language-agnostic and adds LSP "used by" annotations. `tools.py`
|
|
17
|
+
routes reads there and only edits here. A jedi read backend used to live in this
|
|
18
|
+
module too; it was dead (nothing routed to it) and has been removed.
|
|
19
|
+
|
|
20
|
+
Name paths: a bare name ("generate") or a qualified path ("Engine/generate" or
|
|
21
|
+
"Engine.generate") to disambiguate a method from a free function of the same name
|
|
22
|
+
(qualified paths are jedi-only; for other languages pass path=).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import difflib
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import time
|
|
29
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
30
|
+
|
|
31
|
+
import jedi
|
|
32
|
+
|
|
33
|
+
from . import syntaxgate
|
|
34
|
+
from .ignore import IGNORE_DIRS
|
|
35
|
+
|
|
36
|
+
# How long a walked .py file list stays fresh. Agent edits come in bursts (locate,
|
|
37
|
+
# replace, re-replace after a failed test); re-walking an 11k-file tree for each one
|
|
38
|
+
# measured ~200ms of pure repetition. 30s is short enough that a file created by a
|
|
39
|
+
# `write`/`bash` step is visible by the time the model turns back to symbol edits.
|
|
40
|
+
_PY_FILES_TTL_S = 30.0
|
|
41
|
+
_NAMES_CACHE_MAX = 16 # (path, mtime)-keyed jedi parses; re-edits of one file are common
|
|
42
|
+
_MAX_DEF_FILES = 16 # more definers than this -> disambiguate by file, skip jedi
|
|
43
|
+
_DISAMBIG_MAX_LINES = 20 # cap disambiguation listings (205 lines of 'main' is prefill)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _norm_path(parts):
|
|
47
|
+
return [p for p in parts.replace(".", "/").split("/") if p]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SymbolService:
|
|
51
|
+
"""Symbol editor rooted at a project directory: jedi for Python, tree-sitter
|
|
52
|
+
definition spans for every other language the repo map parses."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, root: str = "."):
|
|
55
|
+
self.root = os.path.abspath(root)
|
|
56
|
+
self._project = jedi.Project(self.root)
|
|
57
|
+
self._py_files_cache: list = []
|
|
58
|
+
self._py_files_at = 0.0
|
|
59
|
+
self._names_cache: dict = {} # path -> (mtime, names, code)
|
|
60
|
+
self._rm = None # private RepoMap, only if roots diverge (tests)
|
|
61
|
+
|
|
62
|
+
# -- helpers ----------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def _py_files(self):
|
|
65
|
+
now = time.monotonic()
|
|
66
|
+
if self._py_files_cache and now - self._py_files_at < _PY_FILES_TTL_S:
|
|
67
|
+
return self._py_files_cache
|
|
68
|
+
out = []
|
|
69
|
+
for dirpath, dirnames, filenames in os.walk(self.root):
|
|
70
|
+
# prune ignored + hidden dirs during the walk (never descend into a
|
|
71
|
+
# node_modules/ or .venv/ at all) instead of filtering the full list after
|
|
72
|
+
dirnames[:] = [d for d in dirnames
|
|
73
|
+
if not d.startswith(".") and d not in IGNORE_DIRS]
|
|
74
|
+
out.extend(os.path.join(dirpath, fn) for fn in filenames
|
|
75
|
+
if fn.endswith(".py") and not fn.startswith("."))
|
|
76
|
+
self._py_files_cache = sorted(out)[:20000]
|
|
77
|
+
self._py_files_at = now
|
|
78
|
+
return self._py_files_cache
|
|
79
|
+
|
|
80
|
+
def _candidate_files(self, needle, path=None, should_stop=None):
|
|
81
|
+
"""Files that could DEFINE `needle` — a cheap prefilter so we only run
|
|
82
|
+
(expensive) jedi analysis on the handful of files that could possibly match,
|
|
83
|
+
instead of every .py in the project. A hit must put the name on a def/class
|
|
84
|
+
line (`_is_def`'s contract), so the probe is that textual shape, not a bare
|
|
85
|
+
substring — `"main" in text` matched thousands of files ("domain", comments,
|
|
86
|
+
`__main__` guards) and each false candidate cost a full jedi parse: measured
|
|
87
|
+
130s for _find_defs("main") on an 11k-file repo. Reads are farmed to a thread
|
|
88
|
+
pool (I/O releases the GIL); order stays deterministic."""
|
|
89
|
+
probe = re.compile(rf"\b(?:def|class)\s+{re.escape(needle)}\b")
|
|
90
|
+
files = [path] if path else self._py_files()
|
|
91
|
+
files = [fp for fp in files if os.path.isfile(fp) and fp.endswith(".py")]
|
|
92
|
+
|
|
93
|
+
def scan(chunk):
|
|
94
|
+
hits = []
|
|
95
|
+
for fp in chunk:
|
|
96
|
+
if should_stop and should_stop():
|
|
97
|
+
break
|
|
98
|
+
try:
|
|
99
|
+
with open(fp, errors="ignore") as f:
|
|
100
|
+
text = f.read()
|
|
101
|
+
# substring first: C-speed rejection of the ~all files that don't
|
|
102
|
+
# even contain the name; only those pay the regex probe
|
|
103
|
+
if needle in text and probe.search(text):
|
|
104
|
+
hits.append(fp)
|
|
105
|
+
except OSError:
|
|
106
|
+
continue
|
|
107
|
+
return hits
|
|
108
|
+
|
|
109
|
+
# 8 contiguous chunks, one future each: per-file futures measured ~140ms of
|
|
110
|
+
# pure executor overhead on 4.5k files. Concatenating in order keeps the
|
|
111
|
+
# deterministic sorted result.
|
|
112
|
+
step = max(1, -(-len(files) // 8))
|
|
113
|
+
chunks = [files[i:i + step] for i in range(0, len(files), step)]
|
|
114
|
+
out = []
|
|
115
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
116
|
+
for hits in pool.map(scan, chunks):
|
|
117
|
+
out.extend(hits)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
def _script(self, path):
|
|
121
|
+
with open(path, errors="replace") as f:
|
|
122
|
+
code = f.read()
|
|
123
|
+
return jedi.Script(code=code, path=path, project=self._project), code
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _is_def(code_lines, name_obj):
|
|
127
|
+
"""True only for functions/classes DEFINED in this file. Keyed off the name's
|
|
128
|
+
own line (cheap, in-file) — NOT get_definition_start_position(), which resolves
|
|
129
|
+
imports and can make jedi follow a name into site-packages and crash there.
|
|
130
|
+
Takes pre-split lines: jedi returns tens of thousands of names for a big
|
|
131
|
+
module, and splitting the source once per name measured ~850ms/file."""
|
|
132
|
+
if name_obj.type not in ("function", "class"):
|
|
133
|
+
return False
|
|
134
|
+
try:
|
|
135
|
+
line = code_lines[name_obj.line - 1]
|
|
136
|
+
except (IndexError, TypeError):
|
|
137
|
+
return False
|
|
138
|
+
return line.lstrip().startswith(("def ", "class ", "async def "))
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _matches(name_obj, parts):
|
|
142
|
+
"""True if a jedi Name matches the (possibly qualified) name-path parts."""
|
|
143
|
+
if name_obj.name != parts[-1]:
|
|
144
|
+
return False
|
|
145
|
+
if len(parts) == 1:
|
|
146
|
+
return True
|
|
147
|
+
try:
|
|
148
|
+
parent = name_obj.parent()
|
|
149
|
+
except Exception:
|
|
150
|
+
return False
|
|
151
|
+
return parent is not None and parent.name == parts[-2]
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _span(name_obj):
|
|
155
|
+
"""(start_line, end_line), 1-based, of a LOCAL definition; falls back to the
|
|
156
|
+
name's own line if jedi can't produce a range."""
|
|
157
|
+
try:
|
|
158
|
+
a = name_obj.get_definition_start_position()[0]
|
|
159
|
+
b = name_obj.get_definition_end_position()[0]
|
|
160
|
+
if a and b and b >= a:
|
|
161
|
+
return a, b
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
return name_obj.line, name_obj.line
|
|
165
|
+
|
|
166
|
+
def _names(self, fp):
|
|
167
|
+
"""jedi names + source for one file, cached by (path, mtime). An agent edits
|
|
168
|
+
the same file repeatedly (replace, test, re-replace); each jedi parse of a big
|
|
169
|
+
module costs 100ms+, so a re-locate in an unchanged file must not re-pay it.
|
|
170
|
+
Our own _apply rewrites the file, which changes its mtime and evicts."""
|
|
171
|
+
try:
|
|
172
|
+
mt = os.path.getmtime(fp)
|
|
173
|
+
except OSError:
|
|
174
|
+
mt = None
|
|
175
|
+
hit = self._names_cache.get(fp)
|
|
176
|
+
if hit is not None and hit[0] == mt:
|
|
177
|
+
return hit[1], hit[2]
|
|
178
|
+
s, code = self._script(fp)
|
|
179
|
+
names = s.get_names(all_scopes=True, definitions=True, references=False)
|
|
180
|
+
if len(self._names_cache) >= _NAMES_CACHE_MAX:
|
|
181
|
+
self._names_cache.pop(next(iter(self._names_cache)))
|
|
182
|
+
self._names_cache[fp] = (mt, names, code)
|
|
183
|
+
return names, code
|
|
184
|
+
|
|
185
|
+
def _find_defs(self, parts, path=None, should_stop=None, files=None):
|
|
186
|
+
"""Definitions (function/class) matching the name path. Only files that
|
|
187
|
+
could define the name are jedi-parsed (fast prefilter); errors on any
|
|
188
|
+
single file or symbol are swallowed so one bad file can't break the search.
|
|
189
|
+
`files` short-circuits the prefilter when the caller already ran it."""
|
|
190
|
+
hits = []
|
|
191
|
+
if files is None:
|
|
192
|
+
files = self._candidate_files(parts[-1], path, should_stop)
|
|
193
|
+
for fp in files:
|
|
194
|
+
if should_stop and should_stop():
|
|
195
|
+
break
|
|
196
|
+
try:
|
|
197
|
+
names, code = self._names(fp)
|
|
198
|
+
except Exception: # jedi can raise KeyError/RecursionError/etc.
|
|
199
|
+
continue
|
|
200
|
+
code_lines = code.splitlines()
|
|
201
|
+
for n in names:
|
|
202
|
+
try:
|
|
203
|
+
# name equality first: it filters ~all of a big module's names
|
|
204
|
+
# before the (comparatively pricey) definition-line check runs
|
|
205
|
+
if self._matches(n, parts) and self._is_def(code_lines, n):
|
|
206
|
+
hits.append((n, fp, code))
|
|
207
|
+
except Exception:
|
|
208
|
+
continue
|
|
209
|
+
return hits
|
|
210
|
+
|
|
211
|
+
def _rel(self, p):
|
|
212
|
+
try:
|
|
213
|
+
return os.path.relpath(p, self.root)
|
|
214
|
+
except ValueError:
|
|
215
|
+
return p
|
|
216
|
+
|
|
217
|
+
def _disambig(self, name, hits):
|
|
218
|
+
opts = "\n".join(f" {self._rel(fp)}:{n.line} {n.full_name}"
|
|
219
|
+
for n, fp, _ in hits[:_DISAMBIG_MAX_LINES])
|
|
220
|
+
if len(hits) > _DISAMBIG_MAX_LINES:
|
|
221
|
+
opts += f"\n … (+{len(hits) - _DISAMBIG_MAX_LINES} more)"
|
|
222
|
+
return f"[{len(hits)} symbols named '{name}'; pass path= to disambiguate:]\n{opts}"
|
|
223
|
+
|
|
224
|
+
# -- edit -------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def _apply(self, fp, code, a, b, new_lines, label):
|
|
227
|
+
lines = code.splitlines()
|
|
228
|
+
old_block = lines[a - 1:b]
|
|
229
|
+
updated = lines[:a - 1] + new_lines + lines[b:]
|
|
230
|
+
text = "\n".join(updated)
|
|
231
|
+
if code.endswith("\n"):
|
|
232
|
+
text += "\n"
|
|
233
|
+
with open(fp, "w") as f:
|
|
234
|
+
f.write(text)
|
|
235
|
+
self._names_cache.pop(fp, None) # mtime would evict anyway; don't rely on it
|
|
236
|
+
diff = [d for d in difflib.unified_diff(old_block, new_lines, lineterm="", n=1)
|
|
237
|
+
if not d.startswith(("---", "+++", "@@"))]
|
|
238
|
+
adds = sum(d.startswith("+") for d in diff)
|
|
239
|
+
dels = sum(d.startswith("-") for d in diff)
|
|
240
|
+
result = f"[{label} in {self._rel(fp)}: +{adds} -{dels}]\n" + "\n".join(diff)
|
|
241
|
+
warn = syntaxgate.check_syntax(fp, code) # `code` is the pre-edit content
|
|
242
|
+
return result + warn if warn else result
|
|
243
|
+
|
|
244
|
+
# -- locate: jedi for .py, tree-sitter tags for everything else --------
|
|
245
|
+
|
|
246
|
+
def _repomap(self):
|
|
247
|
+
"""The repo map rooted at OUR root — normally the cwd-keyed singleton (shared
|
|
248
|
+
warm cache); a private instance only when the roots diverge (tests)."""
|
|
249
|
+
from . import repomap
|
|
250
|
+
svc = repomap.service()
|
|
251
|
+
if svc.root == self.root:
|
|
252
|
+
return svc
|
|
253
|
+
if self._rm is None:
|
|
254
|
+
self._rm = repomap.RepoMap(self.root)
|
|
255
|
+
return self._rm
|
|
256
|
+
|
|
257
|
+
def _ts_locate(self, name, path, should_stop=None):
|
|
258
|
+
"""Non-Python definitions of `name` from the tree-sitter repo map, as
|
|
259
|
+
(start, end, path, code, label) splice specs. The map's `line`/`end_line`
|
|
260
|
+
spans are what view_symbol shows, so an edit replaces what was viewed."""
|
|
261
|
+
try:
|
|
262
|
+
tags = self._repomap()._find_defs(name, path, should_stop)
|
|
263
|
+
except Exception: # a broken map must degrade to "not found", not a crash
|
|
264
|
+
return []
|
|
265
|
+
out = []
|
|
266
|
+
for t in tags:
|
|
267
|
+
if t.path.endswith(".py"):
|
|
268
|
+
continue # jedi owns Python — scope-aware and qualified-path capable
|
|
269
|
+
try:
|
|
270
|
+
with open(t.path, errors="replace") as f:
|
|
271
|
+
code = f.read()
|
|
272
|
+
except OSError:
|
|
273
|
+
continue
|
|
274
|
+
out.append((t.line, t.end_line, t.path, code, f"{t.kind} {t.name}"))
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
def _locate_one(self, name, path, should_stop=None):
|
|
278
|
+
"""Resolve `name` (optionally within `path`) to one (start, end, file, code,
|
|
279
|
+
label) splice spec, or (None, error-message). Python resolves via jedi;
|
|
280
|
+
misses fall through to the tree-sitter map so every language the map parses
|
|
281
|
+
is editable. A name found in BOTH resolves to Python (the pre-existing
|
|
282
|
+
behavior when other languages were invisible)."""
|
|
283
|
+
parts = _norm_path(name)
|
|
284
|
+
py_target = path is None or path.endswith(".py")
|
|
285
|
+
hits: list = []
|
|
286
|
+
if py_target:
|
|
287
|
+
for _attempt in (0, 1):
|
|
288
|
+
cands = self._candidate_files(parts[-1], path, should_stop)
|
|
289
|
+
if path is None and len(cands) > _MAX_DEF_FILES:
|
|
290
|
+
# A name defined in this many files can't be located without
|
|
291
|
+
# path= anyway; jedi-parsing every definer just to say
|
|
292
|
+
# "ambiguous" measured 60s for 'forward' on the pytorch clone.
|
|
293
|
+
listing = "\n".join(f" {self._rel(fp)}"
|
|
294
|
+
for fp in cands[:_DISAMBIG_MAX_LINES])
|
|
295
|
+
if len(cands) > _DISAMBIG_MAX_LINES:
|
|
296
|
+
listing += f"\n … (+{len(cands) - _DISAMBIG_MAX_LINES} more)"
|
|
297
|
+
return None, (f"['{parts[-1]}' is defined in {len(cands)} files; "
|
|
298
|
+
f"pass path= to pick one:]\n{listing}")
|
|
299
|
+
hits = self._find_defs(parts, path, should_stop, files=cands)
|
|
300
|
+
if hits or path is not None or time.monotonic() - self._py_files_at <= 1.0:
|
|
301
|
+
break
|
|
302
|
+
# The symbol may live in a file created after the cached walk (the
|
|
303
|
+
# TTL trades that staleness for burst speed). Before reporting
|
|
304
|
+
# not-found, re-walk once — only genuine misses pay this.
|
|
305
|
+
self._py_files_at = 0.0
|
|
306
|
+
if hits:
|
|
307
|
+
if len(hits) > 1 and path is None:
|
|
308
|
+
return None, self._disambig(name, hits)
|
|
309
|
+
n, fp, code = hits[0]
|
|
310
|
+
a, b = self._span(n)
|
|
311
|
+
return (a, b, fp, code, n.full_name or name), None
|
|
312
|
+
if path is None or not py_target:
|
|
313
|
+
ts = self._ts_locate(parts[-1], path, should_stop)
|
|
314
|
+
if len(ts) > 1 and path is None:
|
|
315
|
+
opts = "\n".join(f" {self._rel(fp)}:{a} {label}"
|
|
316
|
+
for a, _b, fp, _c, label in ts[:_DISAMBIG_MAX_LINES])
|
|
317
|
+
if len(ts) > _DISAMBIG_MAX_LINES:
|
|
318
|
+
opts += f"\n … (+{len(ts) - _DISAMBIG_MAX_LINES} more)"
|
|
319
|
+
return None, (f"[{len(ts)} symbols named '{parts[-1]}'; pass path= "
|
|
320
|
+
f"to disambiguate:]\n{opts}")
|
|
321
|
+
if ts:
|
|
322
|
+
return ts[0], None
|
|
323
|
+
return None, f"[symbol not found: {name}]"
|
|
324
|
+
|
|
325
|
+
def replace_symbol(self, name: str, new: str, path=None, should_stop=None) -> str:
|
|
326
|
+
hit, err = self._locate_one(name, path, should_stop)
|
|
327
|
+
if err:
|
|
328
|
+
return err
|
|
329
|
+
a, b, fp, code, label = hit
|
|
330
|
+
if not new.strip():
|
|
331
|
+
return "[refusing to replace a symbol with empty content]"
|
|
332
|
+
return self._apply(fp, code, a, b, new.rstrip("\n").split("\n"),
|
|
333
|
+
f"replaced {label}")
|
|
334
|
+
|
|
335
|
+
def insert_symbol(self, name: str, code_text: str, where: str = "after",
|
|
336
|
+
path=None, should_stop=None) -> str:
|
|
337
|
+
hit, err = self._locate_one(name, path, should_stop)
|
|
338
|
+
if err:
|
|
339
|
+
return err
|
|
340
|
+
a, b, fp, code, label = hit
|
|
341
|
+
if not code_text.strip():
|
|
342
|
+
return "[nothing to insert]"
|
|
343
|
+
new_lines = code_text.rstrip("\n").split("\n")
|
|
344
|
+
if where == "before": # insert just above the symbol's first line
|
|
345
|
+
return self._apply(fp, code, a, a - 1, new_lines + [""],
|
|
346
|
+
f"inserted before {label}")
|
|
347
|
+
# after (default): splice in just past the symbol's last line
|
|
348
|
+
return self._apply(fp, code, b + 1, b, [""] + new_lines,
|
|
349
|
+
f"inserted after {label}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
_SERVICE = None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def service() -> SymbolService:
|
|
356
|
+
"""Lazily-bound, cwd-rooted service (the project the agent is operating in)."""
|
|
357
|
+
global _SERVICE
|
|
358
|
+
if _SERVICE is None or _SERVICE.root != os.path.abspath(os.getcwd()):
|
|
359
|
+
_SERVICE = SymbolService(os.getcwd())
|
|
360
|
+
return _SERVICE
|
chad/syntaxgate.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Deterministic post-edit syntax gate (plan 038).
|
|
2
|
+
|
|
3
|
+
After a successful write/edit/replace_symbol/insert_symbol, re-check the mutated
|
|
4
|
+
file; if the edit *introduced* a syntax error, ride a warning along in the SAME tool
|
|
5
|
+
result. It never blocks or rolls the edit back — the edit stands and the warning
|
|
6
|
+
costs no extra round-trip, so the model reacts with the context still hot instead of
|
|
7
|
+
paying a run-tests → parse-failure → re-locate cycle to notice a broken file.
|
|
8
|
+
|
|
9
|
+
Python is checked exactly with `ast.parse` (line-accurate). Other languages use a
|
|
10
|
+
tree-sitter ERROR/MISSING-node delta: warn only when the edit ADDED nodes, since many
|
|
11
|
+
real files carry baseline parse errors tree-sitter can't fully recover (and we must
|
|
12
|
+
never warn on a pre-existing one). Gated by CHAD_NO_SYNTAX_GATE for run_evals --ab.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from . import config, repomap
|
|
19
|
+
|
|
20
|
+
_MAX_BYTES = 1_000_000 # skip pathologically large files — the parse cost isn't worth it
|
|
21
|
+
_PARSERS: dict = {} # lang -> tree_sitter.Parser | None (grammar download cached by tlp)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parser(lang):
|
|
25
|
+
"""A raw tree-sitter Parser for `lang`, or None if the grammar won't build. Reuses
|
|
26
|
+
repomap's language name; kept separate from repomap._lang_tools because that one is
|
|
27
|
+
keyed on the *tags* query (absent for many grammars we can still parse)."""
|
|
28
|
+
if lang not in _PARSERS:
|
|
29
|
+
try:
|
|
30
|
+
import tree_sitter_language_pack as tlp
|
|
31
|
+
from tree_sitter import Parser
|
|
32
|
+
_PARSERS[lang] = Parser(tlp.get_language(lang))
|
|
33
|
+
except Exception:
|
|
34
|
+
_PARSERS[lang] = None
|
|
35
|
+
return _PARSERS[lang]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _error_nodes(root) -> int:
|
|
39
|
+
"""Count ERROR and MISSING nodes in the tree — tree-sitter's two ways of flagging a
|
|
40
|
+
fragment it couldn't parse."""
|
|
41
|
+
n, stack = 0, [root]
|
|
42
|
+
while stack:
|
|
43
|
+
node = stack.pop()
|
|
44
|
+
if node.type == "ERROR" or node.is_missing:
|
|
45
|
+
n += 1
|
|
46
|
+
stack.extend(node.children)
|
|
47
|
+
return n
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ts_error_count(lang, text: str):
|
|
51
|
+
"""ERROR/MISSING count for `text` parsed as `lang`, or None if we can't tell (no
|
|
52
|
+
grammar / parse blew up) — a None means 'don't warn', never 'clean'."""
|
|
53
|
+
parser = _parser(lang)
|
|
54
|
+
if parser is None:
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
tree = parser.parse(text.encode("utf-8", "replace"))
|
|
58
|
+
except Exception:
|
|
59
|
+
return None
|
|
60
|
+
return _error_nodes(tree.root_node)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_syntax(path: str, before: str | None) -> str | None:
|
|
64
|
+
"""A warning string when the current on-disk content of `path` has a *newly
|
|
65
|
+
introduced* syntax error, else None.
|
|
66
|
+
|
|
67
|
+
`before` is the pre-edit text (None for a freshly created file). The post-edit
|
|
68
|
+
content is read here, so a tool that left the file unchanged (a failed or no-op
|
|
69
|
+
edit → before == after) never warns, and callers don't have to detect success.
|
|
70
|
+
"""
|
|
71
|
+
if config.flag("CHAD_NO_SYNTAX_GATE"):
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
if os.path.getsize(path) > _MAX_BYTES:
|
|
75
|
+
return None
|
|
76
|
+
with open(path, errors="replace") as f:
|
|
77
|
+
after = f.read()
|
|
78
|
+
except OSError:
|
|
79
|
+
return None
|
|
80
|
+
if after == before: # the tool didn't actually change the file — nothing to flag
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
lang = repomap.service().lang_for(path)
|
|
84
|
+
|
|
85
|
+
if lang == "python":
|
|
86
|
+
try:
|
|
87
|
+
ast.parse(after)
|
|
88
|
+
except SyntaxError as e:
|
|
89
|
+
lines = after.splitlines()
|
|
90
|
+
line = lines[e.lineno - 1] if e.lineno and e.lineno <= len(lines) else ""
|
|
91
|
+
return (f"\n[warning: the file no longer parses — {e.msg} at line "
|
|
92
|
+
f"{e.lineno}: {line.strip()!r}. Fix this before moving on.]")
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
if lang:
|
|
96
|
+
after_errs = _ts_error_count(lang, after)
|
|
97
|
+
if not after_errs: # None (can't tell) or 0 (clean) -> no warning
|
|
98
|
+
return None
|
|
99
|
+
# Only warn if the edit ADDED errors. A new file has a baseline of 0.
|
|
100
|
+
before_errs = _ts_error_count(lang, before) if before is not None else 0
|
|
101
|
+
if before_errs is None or after_errs <= before_errs:
|
|
102
|
+
return None
|
|
103
|
+
return ("\n[warning: this edit introduced a syntax error — the file no longer "
|
|
104
|
+
"parses cleanly. Re-check the change before moving on.]")
|
|
105
|
+
return None
|
chad/toolcall_parse.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Pure tool-call parser for chad (extracted from agent.py).
|
|
2
|
+
|
|
3
|
+
`parse_tool_calls` is the boundary between raw model text and tool dispatch — the
|
|
4
|
+
single most format-fragile surface in the project. Local models are inconsistent
|
|
5
|
+
about tool-call formatting (lesson from prior art like opencode): some emit JSON
|
|
6
|
+
inside <tool_call> tags, some emit ```json fences, some emit a bare JSON object,
|
|
7
|
+
and some (Qwen3 / Ornith / GLM thinking models) emit an XML function-call dialect:
|
|
8
|
+
<function=name><parameter=key>value</parameter></function>. Parse them all,
|
|
9
|
+
de-duplicated.
|
|
10
|
+
|
|
11
|
+
This module is pure (no model, no I/O) so it can be unit-tested directly — see
|
|
12
|
+
test_toolcall_parse.py. `agent` re-exports these names so existing importers keep
|
|
13
|
+
working unchanged.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from .validate import VALIDATE, repair_json # VALIDATE: single source of truth in validate.py
|
|
20
|
+
|
|
21
|
+
_TAG_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
|
|
22
|
+
_FENCE_RE = re.compile(r"```(?:json|tool_call)?\s*(\{.*?\})\s*```", re.DOTALL)
|
|
23
|
+
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
|
24
|
+
_XML_FUNC_RE = re.compile(r"<function=([^>\s]+)\s*>(.*?)</function>", re.DOTALL)
|
|
25
|
+
_XML_PARAM_RE = re.compile(r"<parameter=([^>\s]+)\s*>(.*?)</parameter>", re.DOTALL)
|
|
26
|
+
_INT_PARAMS = {"offset", "limit", "timeout"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def strip_think(text: str) -> str:
|
|
30
|
+
"""Remove reasoning. Handles both an explicit <think>...</think> block and the
|
|
31
|
+
template-opened case, where generation starts inside <think> so the text has a
|
|
32
|
+
leading </think> with no opening tag."""
|
|
33
|
+
o = text.find("<think>")
|
|
34
|
+
c = text.find("</think>")
|
|
35
|
+
if c != -1 and (o == -1 or c < o): # leading close => everything up to it is reasoning
|
|
36
|
+
text = text[c + len("</think>"):]
|
|
37
|
+
return _THINK_RE.sub("", text)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_xml_calls(text: str):
|
|
41
|
+
calls = []
|
|
42
|
+
for fm in _XML_FUNC_RE.finditer(text):
|
|
43
|
+
name = fm.group(1).strip()
|
|
44
|
+
args = {}
|
|
45
|
+
for pm in _XML_PARAM_RE.finditer(fm.group(2)):
|
|
46
|
+
key = pm.group(1).strip()
|
|
47
|
+
val = pm.group(2).strip()
|
|
48
|
+
if key in _INT_PARAMS and val.lstrip("-").isdigit():
|
|
49
|
+
val = int(val)
|
|
50
|
+
args[key] = val
|
|
51
|
+
if name:
|
|
52
|
+
calls.append((name, args))
|
|
53
|
+
return calls
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _iter_json_objects(text: str):
|
|
57
|
+
"""Yield top-level {...} JSON spans via brace matching (handles strings)."""
|
|
58
|
+
depth = 0; start = None; in_str = False; esc = False
|
|
59
|
+
for i, ch in enumerate(text):
|
|
60
|
+
if in_str:
|
|
61
|
+
if esc: esc = False
|
|
62
|
+
elif ch == "\\": esc = True
|
|
63
|
+
elif ch == '"': in_str = False
|
|
64
|
+
continue
|
|
65
|
+
if ch == '"': in_str = True
|
|
66
|
+
elif ch == "{":
|
|
67
|
+
if depth == 0: start = i
|
|
68
|
+
depth += 1
|
|
69
|
+
elif ch == "}":
|
|
70
|
+
if depth > 0:
|
|
71
|
+
depth -= 1
|
|
72
|
+
if depth == 0 and start is not None:
|
|
73
|
+
yield text[start : i + 1]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_tool_calls(text: str):
|
|
77
|
+
text = strip_think(text)
|
|
78
|
+
# XML function-call dialect takes priority when present (thinking models).
|
|
79
|
+
xml_calls = _parse_xml_calls(text)
|
|
80
|
+
if xml_calls:
|
|
81
|
+
return xml_calls
|
|
82
|
+
calls = []
|
|
83
|
+
seen = set()
|
|
84
|
+
candidates = [m.group(1) for m in _TAG_RE.finditer(text)]
|
|
85
|
+
candidates += [m.group(1) for m in _FENCE_RE.finditer(text)]
|
|
86
|
+
if not candidates: # last resort: any bare top-level JSON object
|
|
87
|
+
candidates = list(_iter_json_objects(text))
|
|
88
|
+
for raw in candidates:
|
|
89
|
+
raw = raw.strip()
|
|
90
|
+
if raw in seen:
|
|
91
|
+
continue
|
|
92
|
+
seen.add(raw)
|
|
93
|
+
# Lenient repair (typia stage 1): recover trailing commas, bare keys,
|
|
94
|
+
# Python literals, truncated/unbalanced output — so a malformed call is
|
|
95
|
+
# repaired and surfaced for validation, not silently dropped (which left
|
|
96
|
+
# the model with no tool result and no idea what went wrong).
|
|
97
|
+
if VALIDATE:
|
|
98
|
+
obj = repair_json(raw)
|
|
99
|
+
else:
|
|
100
|
+
try:
|
|
101
|
+
obj = json.loads(raw)
|
|
102
|
+
except json.JSONDecodeError:
|
|
103
|
+
obj = None
|
|
104
|
+
if not isinstance(obj, dict):
|
|
105
|
+
continue
|
|
106
|
+
name = obj.get("name")
|
|
107
|
+
if not name:
|
|
108
|
+
continue
|
|
109
|
+
args = obj.get("arguments", obj.get("parameters", {}))
|
|
110
|
+
if isinstance(args, str): # whole arguments value double-stringified
|
|
111
|
+
args = repair_json(args) or args
|
|
112
|
+
if not isinstance(args, dict):
|
|
113
|
+
args = {}
|
|
114
|
+
calls.append((name, args))
|
|
115
|
+
return calls
|