July 23, 2026
Three Waves to Kill the Raw Dict
Every ability handler used to take a bare, unvalidated dict off the dispatch layer and hope for the best — three waves of a typed-contract migration closed that gap for the whole fleet in a single evening.
Every ability handler used to take whatever the dispatch layer handed it — a bare dict[str, object], no shape, no guarantees — and validate it however that particular author felt like that day. Bash’s timeout parameter is the example I’d pick: hand it something that isn’t a number and it used to quietly fall back to the default. No error, no warning — your bad input just vanished into a 30-second timeout you never asked for. Exactly the sort of thing a raw dict interface lets slide.
Morning started smaller and more mechanical: a handful of functions that took a whole dict but only ever read one or two fields — client-context seeding, the folder-watcher scan, provider probes, the MCP client, ONNX inference, the search transformers — all switched to taking those fields directly as typed parameters. A per-lane recall-score dict in memory became a real RecallSignals named tuple instead. Small stuff, but it’s the same instinct that drove everything that came after it: if a function only needs two things, don’t make it dig them out of a bag that could contain anything.
By evening that instinct turned into an actual contract. ParamBag: a frozen dataclass built once, at the dispatch seam, via a from_params factory, with typed validators that either return a clean value or throw a loud, specific error — require_str, clamp_int with real bounds, a new require_str_list for abilities that take a query list. Multi-action abilities like memory don’t get one flat bag; they get a router bag that fans out by action to per-action leaf dataclasses, and run() narrows to the exact leaf type with an isinstance check instead of guessing at keys. The handler gets exactly the type it needs. Nothing else.
Then three waves, back to back, same night. The pilot took read and memory — worth proving the pattern on before committing to it everywhere. Wave one knocked out bash, weather, find_tools, and find_skills — and finally fixed that timeout bug: a non-numeric value is now a loud invalid-param, not a silent default. Wave two finished the rest of the single-shot abilities — docs search, the chat history compactor, the review pair — and caught a smaller inconsistency along the way: a blank query used to report invalid-param, now it reports missing-params, matching what the rest of the fleet already says. Wave three closed it out with contacts, mcp_manager, manage_files, and search_files; contacts lost a dead DEFAULT_ACTION fallback nobody was hitting, and search_files now clamps out-of-range pagination instead of erroring, while still rejecting garbage loudly.
Every wave ran the same check: tests build the bag exactly the way the dispatch seam does, and the full suite held at the same baseline throughout — same five pre-existing failures, same 1151 passing, every time. A refactor that touches this many entry points is exactly the kind that quietly breaks one obscure ability nobody tests by hand — checking after every wave, not just at the end, is the only way to know it didn’t.
This is the same story as the Endpoint/Action contract work on the API side, just one layer down — same complaint, different seam: stop letting a bare dict be the interface between two parts of the system that already agree on the shape.
-
Trimmed a handful of runtime functions from taking a whole dict to taking the one or two fields they actually read, and replaced an untyped per-lane recall-score dict with a real
RecallSignalsnamed tuple -
Introduced
ParamBag— frozen-dataclass input contracts built at the dispatch seam viafrom_params, with typed validators (require_str,clamp_int,require_str_list) that fail loud instead of silently defaulting -
Ran three migration waves in one evening covering fourteen abilities, from single-shots like bash and weather to multi-action ones like memory, which fans out to per-action leaf types via
isinstancenarrowing -
Fixed a real bug along the way: bash’s timeout used to silently fall back to 30 seconds on bad input; now a non-numeric timeout is a loud
invalid-param -
Verified every wave against the same baseline — bags built exactly as the seam builds them, full unit suite steady at 1151 passing throughout