← TinyGPT · docs · devlog · roadmap · speedup
source: docs/prds/B26-deferred-tools.md · view on GitHub ↗

PRD — tinygpt serve --tool-mode deferred

Goal

Today, tinygpt serve --tools <catalog.json> injects every tool’s full JSON schema into the system prompt of every request via ServeToolsSpec.systemPrompt() (DynamicGrammar.swift). For a catalog of 20–100 tools that is hundreds of always-resident tokens that the model pays for on tasks where it never uses any tool.

Ship a deferred-catalog mode in which the system prompt carries only a one-line-per-tool index and a built-in get_tool_info(name) meta-tool. The model fetches schemas on demand; serve answers the meta-tool itself and re-prompts so the round-trip is transparent to OpenAI-compatible clients.

Why now

Scope — in

Scope — out (deliberately deferred)

Why this design

The essay’s argument is that good context discipline tiers capabilities by frequency: hot in L1, warm in L2, cold in L3. Deferred mode is the L2 layer for tool schemas. The compact index keeps “what tools exist” in L1 (cheap, always needed) and pushes “what each tool’s arguments look like” to L2 (expensive, sometimes needed).

The decision to keep serve-side interception (rather than expose a /v1/tool-info endpoint that the client calls) was load-bearing:

BFCL parity ship gate

Deferred mode is OFF by default until BFCL says it doesn’t regress. As of 2026-06-19, tinygpt eval-bfcl can pass --tools <json> and --tool-mode {full,deferred} through to the server it starts, so the gate is runnable from the Swift harness. The full acceptance gate still needs a loaded specialist model and the 10-category BFCL run:

evals/b26-deferred-parity-run.sh \
  --model /path/to/specialist-model \
  --tools /path/to/tools.json \
  --out-dir /tmp/tinygpt-b26 \
  --confirm-heavy-run

Use --dry-run first to print the exact commands without starting BFCL or loading a model.

Equivalent manual form:

TINYGPT=./native-mac/.build/arm64-apple-macosx/release/tinygpt
MODEL=/path/to/specialist-model
TOOLS=/path/to/tools.json
OUT=/tmp/tinygpt-b26
mkdir -p "$OUT"

"$TINYGPT" eval-bfcl "$MODEL" \
  --tools "$TOOLS" \
  --tool-mode full \
  --model-name b26-full \
  --out "$OUT/bfcl-full.jsonl"

"$TINYGPT" eval-bfcl "$MODEL" \
  --tools "$TOOLS" \
  --tool-mode deferred \
  --model-name b26-deferred \
  --out "$OUT/bfcl-deferred.jsonl"

python3 scripts/b26_deferred_parity_report.py \
  --full "$OUT/bfcl-full.jsonl" \
  --deferred "$OUT/bfcl-deferred.jsonl" \
  --require-hop-stats \
  --out "$OUT/b26-parity.json"

Bounded wiring smoke run 2026-06-19: browser/public/demo.tinygpt, one BFCL simple item, same toy tools file, full vs deferred. Both modes completed and scored 0/1 (random demo model parse errors), proving the server mode switch and harness pass-through work; this is not an acceptance-quality parity result.

Bounded real-model probe 2026-06-19: Qwen3-0.6B, BFCL pace12, normalized 12-tool catalog. Full schema mode scored 5/12 (41.7%, 1 parse error, p50 7354ms). Deferred mode on the same first four prompts scored 0/4 (0 parse errors, p50 10137ms), while the full-schema prefix was 2/4; deferred delta on that tiny prefix was -50pp. This is not the full B26 acceptance gate, but it is enough to keep deferred mode OFF for the planner lock.

Hop accounting is wired for the real gate: serve --tool-metrics-out <jsonl> records get_tool_info_hops per non-streaming chat request, and tinygpt eval-bfcl --tool-mode deferred emits the average as a bfcl/deferred_tools/get_tool_info_hops row for scripts/b26_deferred_parity_report.py.

Accept if the deferred BFCL average is within ±2pp of the full average across the 10 BFCL categories, AND the average number of get_tool_info round-trips per BFCL sample is ≤2.

Reject if either condition fails. The likely failure mode is the model under-using get_tool_info because the index entry was misread as sufficient; mitigations to try in order:

  1. Tighten the index format (e.g. include argument names, not just purpose).
  2. Lower the temperature on the first generation pass.
  3. Inject a “you may need to call get_tool_info first” reminder when the model emits a verb=<tool> without having fetched its schema in the current session.

Update docs/PLAN.md B26 status with the BFCL delta + decision once the gate runs.

Files

FileChange
native-mac/Sources/TinyGPTServe/DynamicGrammar.swiftServeToolMode enum + compactSystemPrompt() + compactGrammarSpec() + compactOutputSchemaJSON() + toolInfo(name:)
native-mac/Sources/TinyGPTServe/Serve.swift--tool-mode parsing, --tool-metrics-out, plumb through Server.boot, select compact prompt/grammar in deferred mode, post-generate() interception loop in handleChatCompletions non-streaming branch, parseGetToolInfoCall(_:) static helper
native-mac/Sources/TinyGPT/EvalBFCL.swiftpasses --tool-metrics-out in deferred mode and emits the average hop-count metric row
native-mac/Tests/TinyGPTServeTests/DeferredToolsTests.swift5 unit tests (no model needed): compactSystemPrompt strips schemas, compactGrammarSpec adds get_tool_info to the verb enum, toolInfo resolves known/unknown names, parseGetToolInfoCall recognizes the canonical shape and rejects garbage
docs/PLAN.mdB26 entry already filed; status update after BFCL gate runs
scripts/b26_deferred_parity_report.pyscore-parity + optional hop-count acceptance report for full-vs-deferred BFCL JSONLs
evals/b26-deferred-parity-smoke.shno-model smoke for pass/fail parity-report behavior
docs/learn/agent-context-hierarchy.mdAlready linked as the parent learn doc (Steal #3)

Out of scope but worth noting later