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/validate.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Typed tool-call validation + self-repair (typia / autobe-inspired).
|
|
2
|
+
|
|
3
|
+
Weak local models fail tool calls in a small set of PREDICTABLE, RECOVERABLE
|
|
4
|
+
ways: a trailing comma, an integer sent as a string, a missing required field,
|
|
5
|
+
a wrong enum value, or a whole `arguments` object double-encoded as a JSON
|
|
6
|
+
string. The cloud fix (typia, https://typia.io) is a three-stage harness —
|
|
7
|
+
|
|
8
|
+
lenient PARSE -> type COERCE -> typed VALIDATE
|
|
9
|
+
|
|
10
|
+
— and on failure it feeds the model back its OWN arguments annotated with
|
|
11
|
+
exactly which fields are wrong and what was expected, so it repairs only the
|
|
12
|
+
marked fields instead of regenerating blindly. typia reports this took Qwen
|
|
13
|
+
6.75% -> 100% on a hard nested-union schema.
|
|
14
|
+
|
|
15
|
+
This is the Python port over chad's EXISTING JSON-Schema tool definitions
|
|
16
|
+
(`tools.SCHEMAS`) — no schema duplication. The same dict that is sent to the
|
|
17
|
+
model as the tool contract is the validation contract, so the two can never
|
|
18
|
+
drift. Three entry points:
|
|
19
|
+
|
|
20
|
+
repair_json(raw) -> dict | None (stage 1: lenient parse)
|
|
21
|
+
coerce_and_validate(name, a)-> (args, errs) (stages 2+3: coerce + validate)
|
|
22
|
+
render_repair(name, a, errs)-> str (the self-repair feedback)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
from typing import Any, List, Optional, Tuple
|
|
30
|
+
|
|
31
|
+
from . import config
|
|
32
|
+
from .tools import active_schemas
|
|
33
|
+
|
|
34
|
+
# A/B knob (mirrors CHAD_NO_SYMBOLS), the single source of truth for both the
|
|
35
|
+
# typed-validate path here and the lenient tool-call parse in toolcall_parse.py.
|
|
36
|
+
# With CHAD_NO_VALIDATE set, callers bypass the typia-style lenient-parse +
|
|
37
|
+
# typed-validate + self-repair loop and fall back to strict json.loads + the terse
|
|
38
|
+
# missing-required check in `legacy_validate` below. Lets the eval harness measure
|
|
39
|
+
# exactly what the validation harness buys, per model.
|
|
40
|
+
VALIDATE = not config.flag("CHAD_NO_VALIDATE")
|
|
41
|
+
|
|
42
|
+
def _param_schema(name):
|
|
43
|
+
"""The `parameters` JSON-Schema for a tool as it is exposed to the model RIGHT NOW —
|
|
44
|
+
chad's builtins plus every dynamically-appended tool (`activate_skill` when skills are
|
|
45
|
+
installed, `task`, and any connected MCP server's tool). None if the name is not
|
|
46
|
+
currently callable.
|
|
47
|
+
|
|
48
|
+
This reads the LIVE `active_schemas()` set rather than a frozen import-time snapshot.
|
|
49
|
+
The snapshot was the bug: dynamic tools like `activate_skill` are appended by
|
|
50
|
+
`active_schemas()` (so the model sees them) but were absent from the frozen table, so a
|
|
51
|
+
perfectly valid `activate_skill` call validated as an "unknown tool" — while the same
|
|
52
|
+
error listed it as available (`_known_tools` reads the dispatch table, which *does*
|
|
53
|
+
contain it). Sourcing both from `active_schemas()` guarantees the validation contract
|
|
54
|
+
can never drift from what the model is shown."""
|
|
55
|
+
for s in active_schemas():
|
|
56
|
+
if s["function"]["name"] == name:
|
|
57
|
+
return s["function"].get("parameters", {"type": "object"})
|
|
58
|
+
from . import mcp
|
|
59
|
+
return mcp.param_schema(name)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _known_tools():
|
|
63
|
+
"""All currently-callable tool names, for the 'available tools' hint in unknown-tool
|
|
64
|
+
repair messages. Sourced from the same live `active_schemas()` set as `_param_schema`
|
|
65
|
+
so the hint can never advertise a name the validator would then reject (the exact
|
|
66
|
+
inconsistency that produced 'unknown tool X ... Available: ...X...')."""
|
|
67
|
+
return [s["function"]["name"] for s in active_schemas()]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def legacy_validate(name, args):
|
|
71
|
+
"""The terse pre-`coerce_and_validate` check, kept ONLY as the CHAD_NO_VALIDATE
|
|
72
|
+
A/B baseline: unknown-tool / non-object-args / missing-required, no coercion or
|
|
73
|
+
self-repair. Returns an error string, or None when the args pass."""
|
|
74
|
+
if _param_schema(name) is None:
|
|
75
|
+
return f"[unknown tool '{name}'. Available: {', '.join(_known_tools())}]"
|
|
76
|
+
if not isinstance(args, dict):
|
|
77
|
+
return f"[arguments for '{name}' must be a JSON object]"
|
|
78
|
+
required = _param_schema(name).get("required", [])
|
|
79
|
+
missing = [p for p in required if p not in args]
|
|
80
|
+
if missing:
|
|
81
|
+
return f"[tool '{name}' missing required argument(s): {', '.join(missing)}. Retry with them.]"
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Stage 1 — lenient JSON repair.
|
|
87
|
+
# Recovers the malformed-but-obvious JSON weak models emit. Each transform is
|
|
88
|
+
# applied additively and we re-attempt json.loads after the cheap ones, so a
|
|
89
|
+
# call that only needs one fix isn't risked by a later, more aggressive one.
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
_FENCE = re.compile(r"^\s*```(?:json|tool_call)?\s*|\s*```\s*$", re.IGNORECASE)
|
|
93
|
+
_TRAILING_COMMA = re.compile(r",\s*([}\]])")
|
|
94
|
+
_BARE_KEY = re.compile(r"([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*):")
|
|
95
|
+
_PY_CONST = re.compile(r"\b(True|False|None)\b")
|
|
96
|
+
_PY_MAP = {"True": "true", "False": "false", "None": "null"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _balance(s: str) -> str:
|
|
100
|
+
"""Close unterminated strings/brackets at end-of-string (truncated output)."""
|
|
101
|
+
in_str = False
|
|
102
|
+
esc = False
|
|
103
|
+
stack: List[str] = []
|
|
104
|
+
for ch in s:
|
|
105
|
+
if in_str:
|
|
106
|
+
if esc:
|
|
107
|
+
esc = False
|
|
108
|
+
elif ch == "\\":
|
|
109
|
+
esc = True
|
|
110
|
+
elif ch == '"':
|
|
111
|
+
in_str = False
|
|
112
|
+
continue
|
|
113
|
+
if ch == '"':
|
|
114
|
+
in_str = True
|
|
115
|
+
elif ch in "{[":
|
|
116
|
+
stack.append("}" if ch == "{" else "]")
|
|
117
|
+
elif ch in "}]" and stack:
|
|
118
|
+
stack.pop()
|
|
119
|
+
if in_str:
|
|
120
|
+
s += '"'
|
|
121
|
+
return s + "".join(reversed(stack))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def repair_json(raw: Any) -> Optional[dict]:
|
|
125
|
+
"""Best-effort parse of a possibly-malformed JSON object. Returns the dict,
|
|
126
|
+
or None if even the repaired text won't parse."""
|
|
127
|
+
if isinstance(raw, dict):
|
|
128
|
+
return raw
|
|
129
|
+
if not isinstance(raw, str):
|
|
130
|
+
return None
|
|
131
|
+
s = raw.strip()
|
|
132
|
+
# Plain parse first — the overwhelmingly common case, never mutated.
|
|
133
|
+
try:
|
|
134
|
+
v = json.loads(s)
|
|
135
|
+
return v if isinstance(v, dict) else None
|
|
136
|
+
except json.JSONDecodeError:
|
|
137
|
+
pass
|
|
138
|
+
# Progressive repairs, cheapest first; re-try after each.
|
|
139
|
+
s = _FENCE.sub("", s).strip()
|
|
140
|
+
for transform in (
|
|
141
|
+
lambda x: x,
|
|
142
|
+
lambda x: _TRAILING_COMMA.sub(r"\1", x),
|
|
143
|
+
lambda x: _PY_CONST.sub(lambda m: _PY_MAP[m.group(1)], x),
|
|
144
|
+
lambda x: _BARE_KEY.sub(r'\1"\2"\3:', x),
|
|
145
|
+
_balance,
|
|
146
|
+
):
|
|
147
|
+
s = transform(s)
|
|
148
|
+
try:
|
|
149
|
+
v = json.loads(s)
|
|
150
|
+
if isinstance(v, dict):
|
|
151
|
+
return v
|
|
152
|
+
except json.JSONDecodeError:
|
|
153
|
+
continue
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Stages 2+3 — type coercion + typed validation, in one schema walk.
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Err:
|
|
163
|
+
"""One field-level validation failure: where, what was expected, what came."""
|
|
164
|
+
|
|
165
|
+
__slots__ = ("path", "expected", "got")
|
|
166
|
+
|
|
167
|
+
def __init__(self, path: str, expected: str, got: str):
|
|
168
|
+
self.path = path or "$"
|
|
169
|
+
self.expected = expected
|
|
170
|
+
self.got = got
|
|
171
|
+
|
|
172
|
+
def __str__(self) -> str:
|
|
173
|
+
return f"{self.path}: expected {self.expected}, got {self.got}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _tname(v: Any) -> str:
|
|
177
|
+
if isinstance(v, bool):
|
|
178
|
+
return "boolean"
|
|
179
|
+
if isinstance(v, int):
|
|
180
|
+
return "integer"
|
|
181
|
+
if isinstance(v, float):
|
|
182
|
+
return "number"
|
|
183
|
+
if isinstance(v, str):
|
|
184
|
+
return "string"
|
|
185
|
+
if isinstance(v, list):
|
|
186
|
+
return "array"
|
|
187
|
+
if isinstance(v, dict):
|
|
188
|
+
return "object"
|
|
189
|
+
if v is None:
|
|
190
|
+
return "null"
|
|
191
|
+
return type(v).__name__
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _expected(schema: dict) -> str:
|
|
195
|
+
enum = schema.get("enum")
|
|
196
|
+
if enum:
|
|
197
|
+
return " | ".join(json.dumps(e) for e in enum)
|
|
198
|
+
typ = schema.get("type", "value")
|
|
199
|
+
if typ == "array":
|
|
200
|
+
item = schema.get("items", {})
|
|
201
|
+
return f"array<{item.get('type', 'value')}>"
|
|
202
|
+
return typ
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_TRUE = {"true", "yes", "1"}
|
|
206
|
+
_FALSE = {"false", "no", "0"}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _coerce_scalar(value: Any, typ: Optional[str]) -> Tuple[Any, bool]:
|
|
210
|
+
"""Return (coerced_value, ok). ok=False means it cannot be made to fit."""
|
|
211
|
+
if typ == "integer":
|
|
212
|
+
if isinstance(value, bool):
|
|
213
|
+
return value, False
|
|
214
|
+
if isinstance(value, int):
|
|
215
|
+
return value, True
|
|
216
|
+
if isinstance(value, float) and value.is_integer():
|
|
217
|
+
return int(value), True
|
|
218
|
+
if isinstance(value, str) and re.fullmatch(r"-?\d+", value.strip()):
|
|
219
|
+
return int(value.strip()), True
|
|
220
|
+
return value, False
|
|
221
|
+
if typ == "number":
|
|
222
|
+
if isinstance(value, bool):
|
|
223
|
+
return value, False
|
|
224
|
+
if isinstance(value, (int, float)):
|
|
225
|
+
return value, True
|
|
226
|
+
if isinstance(value, str):
|
|
227
|
+
try:
|
|
228
|
+
return float(value.strip()), True
|
|
229
|
+
except ValueError:
|
|
230
|
+
return value, False
|
|
231
|
+
return value, False
|
|
232
|
+
if typ == "boolean":
|
|
233
|
+
if isinstance(value, bool):
|
|
234
|
+
return value, True
|
|
235
|
+
if isinstance(value, str):
|
|
236
|
+
low = value.strip().lower()
|
|
237
|
+
if low in _TRUE:
|
|
238
|
+
return True, True
|
|
239
|
+
if low in _FALSE:
|
|
240
|
+
return False, True
|
|
241
|
+
return value, False
|
|
242
|
+
if typ == "string":
|
|
243
|
+
if isinstance(value, str):
|
|
244
|
+
return value, True
|
|
245
|
+
# A scalar where a string was wanted (e.g. a path typed as a number) is
|
|
246
|
+
# safe to stringify; containers are not.
|
|
247
|
+
if isinstance(value, (int, float, bool)):
|
|
248
|
+
return json.dumps(value) if isinstance(value, bool) else str(value), True
|
|
249
|
+
return value, False
|
|
250
|
+
# Unknown/unconstrained type: accept as-is.
|
|
251
|
+
return value, True
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _walk(value: Any, schema: dict, path: str) -> Tuple[Any, List[Err]]:
|
|
255
|
+
typ = schema.get("type")
|
|
256
|
+
# Un-double-stringify: a container field whose value arrived as a JSON string
|
|
257
|
+
# (Qwen3/Ornith do this on nested fields — typia's signature failure mode).
|
|
258
|
+
if typ in ("object", "array") and isinstance(value, str):
|
|
259
|
+
un = repair_json(value) if typ == "object" else _load_json(value)
|
|
260
|
+
if un is not None:
|
|
261
|
+
value = un
|
|
262
|
+
|
|
263
|
+
if typ == "object":
|
|
264
|
+
if not isinstance(value, dict):
|
|
265
|
+
return value, [Err(path, "object", _tname(value))]
|
|
266
|
+
props = schema.get("properties", {})
|
|
267
|
+
required = schema.get("required", [])
|
|
268
|
+
out: dict = {}
|
|
269
|
+
errs: List[Err] = []
|
|
270
|
+
for k, v in value.items():
|
|
271
|
+
sub = props.get(k)
|
|
272
|
+
if sub is None: # unknown key — keep it, stay lenient
|
|
273
|
+
out[k] = v
|
|
274
|
+
continue
|
|
275
|
+
cv, e = _walk(v, sub, f"{path}.{k}" if path else f"${k}")
|
|
276
|
+
out[k] = cv
|
|
277
|
+
errs += e
|
|
278
|
+
for r in required:
|
|
279
|
+
if r not in out:
|
|
280
|
+
errs.append(Err(f"{path}.{r}" if path else f"${r}",
|
|
281
|
+
_expected(props.get(r, {})), "missing"))
|
|
282
|
+
return out, errs
|
|
283
|
+
|
|
284
|
+
if typ == "array":
|
|
285
|
+
# A lone scalar where an array was wanted (focus="agent.py" instead of
|
|
286
|
+
# ["agent.py"]) is the most common weak-model array mistake — wrap it into a
|
|
287
|
+
# one-element list and let the item walk below validate/coerce it, rather than
|
|
288
|
+
# burning a self-repair round-trip. A JSON-encoded array string was already
|
|
289
|
+
# un-stringified above; a dict/None stays an error (genuine shape mismatch).
|
|
290
|
+
if isinstance(value, (str, int, float, bool)):
|
|
291
|
+
value = [value]
|
|
292
|
+
if not isinstance(value, list):
|
|
293
|
+
return value, [Err(path, "array", _tname(value))]
|
|
294
|
+
item_schema = schema.get("items", {})
|
|
295
|
+
out_l: list = []
|
|
296
|
+
errs = []
|
|
297
|
+
for i, item in enumerate(value):
|
|
298
|
+
cv, e = _walk(item, item_schema, f"{path}[{i}]")
|
|
299
|
+
out_l.append(cv)
|
|
300
|
+
errs += e
|
|
301
|
+
return out_l, errs
|
|
302
|
+
|
|
303
|
+
# Scalar leaf.
|
|
304
|
+
coerced, ok = _coerce_scalar(value, typ)
|
|
305
|
+
if not ok:
|
|
306
|
+
return value, [Err(path, _expected(schema), _tname(value))]
|
|
307
|
+
enum = schema.get("enum")
|
|
308
|
+
if enum is not None and coerced not in enum:
|
|
309
|
+
return coerced, [Err(path, _expected(schema), json.dumps(coerced))]
|
|
310
|
+
return coerced, []
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _load_json(s: str) -> Optional[Any]:
|
|
314
|
+
try:
|
|
315
|
+
return json.loads(s)
|
|
316
|
+
except (json.JSONDecodeError, TypeError):
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def coerce_and_validate(name: str, args: Any) -> Tuple[Any, List[Err]]:
|
|
321
|
+
"""Coerce loosely-typed args toward the schema and return (coerced, errors).
|
|
322
|
+
An empty error list means `coerced` is safe to dispatch."""
|
|
323
|
+
schema = _param_schema(name)
|
|
324
|
+
if schema is None:
|
|
325
|
+
return args, [Err("$", "a known tool", f"unknown tool {name!r}")]
|
|
326
|
+
# Whole arguments value double-encoded as a string.
|
|
327
|
+
if isinstance(args, str):
|
|
328
|
+
un = repair_json(args)
|
|
329
|
+
if un is not None:
|
|
330
|
+
args = un
|
|
331
|
+
if not isinstance(args, dict):
|
|
332
|
+
return args, [Err("$", "object", _tname(args))]
|
|
333
|
+
return _walk(args, schema, "")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
# The self-repair feedback message.
|
|
338
|
+
# typia's lever: show the model its OWN arguments annotated with exactly which
|
|
339
|
+
# fields are wrong and what was expected, and tell it to fix ONLY those.
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def render_repair(name: str, args: Any, errors: List[Err]) -> str:
|
|
344
|
+
if _param_schema(name) is None:
|
|
345
|
+
return (f"[unknown tool {name!r}. Available tools: {', '.join(_known_tools())}. "
|
|
346
|
+
"Re-emit the call using one of these names.]")
|
|
347
|
+
lines = [f"[invalid arguments for `{name}` — fix ONLY the fields marked ✗ below, "
|
|
348
|
+
"keep everything else, and re-emit the tool call]"]
|
|
349
|
+
for e in errors:
|
|
350
|
+
if e.got == "missing":
|
|
351
|
+
lines.append(f" ✗ {e.path} → required, but missing (expected {e.expected})")
|
|
352
|
+
else:
|
|
353
|
+
lines.append(f" ✗ {e.path} = {e.got} → expected {e.expected}")
|
|
354
|
+
try:
|
|
355
|
+
echo = json.dumps({"name": name, "arguments": args}, ensure_ascii=False)
|
|
356
|
+
except (TypeError, ValueError):
|
|
357
|
+
echo = str({"name": name, "arguments": args})
|
|
358
|
+
if len(echo) <= 600:
|
|
359
|
+
lines.append("your call was:")
|
|
360
|
+
lines.append(f"<tool_call>{echo}</tool_call>")
|
|
361
|
+
return "\n".join(lines)
|