Lab 01 — Tool-Calling Engine & Repair Loop
Phase 02 · Lab 01 · Phase README · Warmup
The problem
Phase 01 parsed the model's free text — Action: / Action Input: — across the trust
boundary. Production stacks moved on: modern models emit a native tool call, structured
JSON in a dedicated field ({"name": ..., "arguments": {...}}), and you validate it against a
JSON Schema contract before you run anything. The wire format changed; the discipline did
not. The model still only proposes; your code still executes, after validating.
Build that engine from scratch — stdlib only, no jsonschema package — because when a tool
call is silently rejected at 2 a.m., the person who wrote the validator is the one who fixes it:
- A JSON-Schema (subset) validator — the contract the model's output must satisfy.
- Typed dispatch — validate arguments, then run; a schema failure is a structured result, never a crash.
- A schema-guided repair loop — feed the validation errors back to the model (here, an injected fixer) and let it correct its own output, bounded so it terminates.
- Structured-output coercion — parse and validate what the model returns, tolerating the
```jsonfenced-block habit.
The lesson the tests nail down: constrained ≠ correct. Validation guarantees the call is schema-valid, not that it is semantically right — a valid-but-wrong call still dispatches.
What you build
| Piece | What it does |
|---|---|
validate(instance, schema) | from-scratch JSON-Schema subset validator → list of error strings (empty = valid); recurses into nested objects/arrays; rejects bool-as-int |
Tool / ToolRegistry | typed tools carrying a parameters schema; to_openai_schema() emits the {"type":"function","function":{…}} list a model receives |
parse_tool_call(raw) | native tool call → ToolCall; handles the double-encoded arguments gotcha (a JSON string needing a second parse); malformed → ValueError |
dispatch(registry, call) | validate before run; schema failure → ToolResult(ok=False, errors=…), never raises |
run_with_repair(fixer, …) | the bounded repair loop; returns (ToolResult, repairs_used) |
coerce_output(text, schema) | structured output: parse (fenced-JSON tolerant) + validate an OUTPUT schema |
File map
| File | Role |
|---|---|
lab.py | your implementation (fill the # TODOs; dataclasses + regexes are done for you) |
solution.py | reference + a worked example: valid call, invalid call, one-round repair, fenced-JSON coercion |
test_lab.py | 30 tests: validator happy/enum/range/nested/additionalProperties/bool-not-int, native parse incl. double-encoding + malformed, validate-before-run, structured errors, repair rounds, output coercion, determinism |
requirements.txt | pytest only |
Run it
pytest test_lab.py -v # your lab.py (red until you implement)
LAB_MODULE=solution pytest test_lab.py -v # the reference (green)
python solution.py # the four-part worked example
Success criteria
-
Your
validatereturns[]for a conforming instance and a pathed error string ($.days: …) for each violation, recursing into nested objects and arrays. -
Your validator rejects
Truefor atype: integerfield —boolis a subclass ofintin Python, and letting it through is a real schema-bypass. -
parse_tool_calldecodes a double-encodedargumentsstring (a secondjson.loads) and raisesValueErroron malformed input. -
dispatchvalidates before running and turns every failure — unknown tool, schema violation, tool exception — into a structuredToolResult, never an exception. -
run_with_repairfixes a wrong-type argument in one round (repairs == 1) and gives up atmax_repairswith a structured failure. -
coerce_outputparses fenced JSON and raises on invalid JSON or a schema violation. -
All 30 tests pass under both
labandsolution.
How this maps to the real stack
validateis thejsonschemapackage (Draft 2020-12) that sits behind every serious tool layer — and thestrictschema OpenAI/Anthropic enforce for tool arguments and structured outputs. Real validators add$ref,oneOf,pattern, formats; the mechanism — walk the schema, collect pathed errors, recurse — is exactly this.Tool+to_openai_schemais what@openai_function/ Anthropic'stools=[…]/ Pydantic-AI's@agent.tool/ theinstructorlibrary generate for you: a JSON Schema derived from a typed function signature, shipped to the model in the request.parse_tool_callis the client-side decode of the model'stool_callsarray. The double-encodedargumentsis not a contrived edge case — OpenAI returnsargumentsas a JSON string every time, so the second parse is the normal path, not the exception.dispatchis the framework's tool executor (OpenAI Agents SDK, LangGraphToolNode). Validate-before-run is the trust boundary from Phase 00 made mechanical.run_with_repairis the auto-repair loop ininstructor, Pydantic-AI's output retries, and Outlines/Guidance's constrained decoding — the reliability mechanism that turns a flaky generator into a typed API. Constrained decoding enforces syntax at generation time; a repair loop enforces the schema after the fact. Both leave semantics to you.coerce_outputis "JSON mode" / "structured outputs" —response_format={"type": "json_schema", …}. The fenced-block tolerance is the workaround for models (and JSON mode's weaker cousins) that wrap the payload in Markdown.
Limits. A real validator implements the full Draft spec ($ref, allOf/anyOf,
pattern, format, dependentRequired); constrained decoding can guarantee schema-valid
JSON at generation time (a GBNF grammar, Outlines' FSM) so the repair loop rarely fires; and the
model is a live, sampling oracle. The control flow — schema in, call out, validate, repair — is
exactly this.
Extensions (your own machine)
- Add
pattern(regex) andformat(email,date-time) keywords, and aoneOf/anyOfcombinator, and watch the error-message design get hard — that is why good validators are worth their weight. - Wire a real model behind the
fixer_policyinterface (one function swap): on a schema error, re-prompt with the error strings and take the model's corrected JSON. Measure how often it fixes in one round vs two. - Add parallel tool calls:
parse_tool_calls(raw) -> list[ToolCall]and adispatch_allthat validates and runs a batch, collecting per-call results (the shape modern APIs return). - Add an idempotency key to
ToolCalland makedispatcha no-op replay for a repeated key — the bridge to durable execution (Phase 08).
Interview / resume signal
"Built a native tool-calling engine from scratch — a JSON-Schema validator, typed validate-before-dispatch, and a bounded schema-guided repair loop — that turns an LLM's stochastic JSON into a typed, self-correcting tool API. Handles the double-encoded-arguments wire gotcha and structured-output coercion, and proves the 'constrained ≠ correct' boundary: validity is enforced in code, semantics are verified separately."