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/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