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