June 4, 2026

The tool-dispatch god-file finally dies

A single overloaded base class that had grown into Chalie's tool dispatcher, policy gate, and async runtime all at once gets split into focused pieces with zero behaviour change.

Today’s job was cleanup, not features — and it mattered more than most features would have. One file — the base class every tool in Chalie inherited from — had quietly become the load-bearing wall for policy checks, MCP proxying, event broadcasting, async delegation, and audit logging all at once. That’s the file where a “small fix” turns into a three-hour archaeology dig. I wanted it gone before it got worse, and the split to change nothing about how a chat actually behaves.

So the day ran through eight phases, each lifting a single responsibility into its own module and proving nothing broke before moving on. Dead policy-visibility code with zero remaining callers went first — always delete what nobody uses before moving what people do. Then the pieces that were tangled together but shouldn’t have been: the client-locale snapshot became its own small value object, the MCP proxy logic moved into its own file, and the “should this event broadcast” check became an object instead of a loose function. The audit trail — the record of every tool call Chalie ever makes — moved off three static methods bolted onto the base class into a proper repository. By day’s end the base file was deleted outright, replaced by a clean Ability interface and a single dispatcher owning the whole call path: match the tool, check policy, run it, record it, return the result. One chokepoint, one job, instead of one file doing six.

The part worth dwelling on is what this protects, not the mechanics. When policy checks, audit logging, and background-execution handling all live inside one undifferentiated file, a bug in one can silently corrupt another, and you don’t find out until a real conversation hits the edge case in production. Splitting them apart means each piece can be read, tested, and changed on its own — a bug in the MCP proxy can no longer take down tool auditing by accident. Async execution got a real behavioural upgrade riding along with the split: instead of a fixed per-tool flag deciding whether a tool is allowed to run in the background, the model now decides per call whether this invocation should run inline or async, and when a background call finishes, the result rides back on the exact conversation thread it started from, not a separate delivery path.

The last stretch tightened how per-conversation configuration gets built. The prompts shaping each kind of exchange — a normal chat turn, a background memory pass, a scheduled task — used to be built by closures bound to a mutable object, which made it easy to leak state between turns without noticing. They’re now abstract methods on a fully immutable configuration object that takes the active conversation as an explicit argument, closing off the implicit-binding step entirely. That change surfaced a handful of small regressions from the refactor it followed — a checkpoint wrapper not reapplied, an ordering flip in how instructions composed — fixed and checked byte-for-byte against prior output before the day closed, with the full test pass green throughout.

None of this changes what Chalie does for anyone today. It changes how confidently the next thing gets built on top of it — a clean dispatch chokepoint means the next tool, async capability, or policy rule slots in without disturbing what’s already load-bearing. That’s the trade I keep making deliberately: a day of surgery buys back weeks later, when the next feature doesn’t have to fight the plumbing to land.

  • Deleted the overloaded tool-dispatch base file, replaced by a clean Ability interface plus a single dispatcher owning match → policy check → execute → audit-log → return

  • Async tool execution became a per-call model decision rather than a fixed per-tool flag — background results now return on the same conversation thread

  • Extracted the audit trail, the MCP proxy, the broadcast-event gate, and the client-context snapshot into their own single-purpose modules

  • Prompt-building configuration became a fully immutable object with the active conversation passed in explicitly, closing off a class of implicit state bugs

  • Fixed prompt-ordering regressions surfaced along the way, verified byte-identical to prior behaviour, with the full test suite green throughout