May 6, 2026

Closing subagent's silent-death paths

A day spent hunting down every way a background task could vanish without a trace, and rewiring routing so subagents only fire when the job actually needs one.

Today was about trust in the plumbing you don’t see. Chalie can hand a job off to a subagent — a background worker that goes and does something (research, a multi-step task) while the main conversation keeps going. That only works if the result always makes it back. Today I went looking for every place it wouldn’t, and fixed the routing decision that was sending jobs there in the first place.

I found three silent-death paths in async delivery. If the return channel came back empty, the result was dropped instead of falling back to a message. If sending the result threw an exception, that exception was swallowed instead of being delivered as an error. And if envelope delivery itself failed, it could take the entire background thread down with it — no log, no trace, just a task that quietly never finished. All three are now closed: empty returns get a fallback message, exceptions get surfaced as errors, and delivery failures are caught and logged instead of killing the thread. The invariant now holds: a subagent result reaches the user — success, failure, or error — never nothing.

The bigger fix was routing. subagent had been living in ALWAYS_AVAILABLE, meaning the model saw it as an option on every single turn. That biased it toward wrapping simple requests in a subagent instead of just calling the right tool — someone asks for the temperature and the model spins up a whole delegated task instead of hitting the weather tool directly. I moved subagent into DISCOVERABLE, the same tier as web search, news, and browser — tools the model reaches for through semantic search when a prompt actually warrants them, not by default. Real multi-step research is still one find_tools lookup away. Background processes (scheduled tasks, DMN) keep subagent out of their discoverable set entirely, so automated runs can’t spawn nested subagents on their own.

That routing change immediately exposed a second-order bug: asking for weather in two different cities looks like “multi-step” work on paper, and the multi-step gate was reading it that way and kicking it to a subagent instead of just calling the weather tool twice. One clause fixed it — multiple calls to the same dedicated tool no longer trip the gate. I also added SKIP_INPUT_ROW so a subagent’s raw envelope never leaks into the user-facing transcript; only the synthesized response the parent produces from it does. The isolation contract is now enforced, not just assumed.

Two smaller fixes round out the day. File and image uploads were letting the LLM answer before processing had actually finished — the poll deadline was 10 seconds, nowhere near enough for slower jobs, so I pushed it to 300 seconds and normalized every file tag to one consistent envelope format with real error messages instead of opaque codes. And /privacy/delete-all was silently failing on four tables that don’t exist anymore (dropped in earlier schema work) — those failures were quietly appending warnings and degrading the confidence of the response. Dropped the dead entries and added a test that parses schema.sql directly, so the next schema change can’t reintroduce the same rot without the test catching it.

None of this is glamorous work, but it’s the kind that determines whether people trust the product with real tasks. A subagent that occasionally disappears without a trace is worse than no subagent at all. Next: keep leaning on this — the routing tier now exists as a lever, and I want to watch how it holds up as more delegated tools get added behind it.

  • Closed three silent-death paths in async subagent delivery — every result now reaches the user, even on failure

  • Moved subagent from ALWAYS_AVAILABLE to DISCOVERABLE so routing favors dedicated tools over blanket delegation

  • Fixed the multi-step gate misreading repeated calls to the same tool as delegation-worthy

  • Isolated subagent envelopes from the user transcript via SKIP_INPUT_ROW

  • Extended the upload poll deadline to 300s and removed four dead tables from /privacy/delete-all