Repository navigation
fix(agent): the tool-output spill is wired only into AgentLoop, which is never instantiated in production #13997
Description
Activity
Same root cause as #14031 (pre-action verifier + belief state run nowhere):
AgentLoophas no production caller —grep -rn "from agent_loop.loop import\|AgentLoop("returns only test files.Full inventory of
agent_loop/capability that never reaches production is #13919 + #13997 + #14031. One extraction pass over the package is cheaper than three separate wirings — the pattern is #13590 (CLOSED): extract the decision logic intoautobot_shared/as a dependency-free pure function, call it from the live seam, and add a wiring assertion so the next refactor cannot silently un-wire it.Premise correction posted on umbrella #13587, which listed the verifier as "already covered" citing
agent_loop/loop.py.Decision (owner, 2026-08-11): wire the live chat seam
Option 1. The mechanism moves to where the problem actually is — one large tool result can consume the chat window today, and that is the case #13692 was built for.
Not chosen, and why:
- Park it — leaves working machinery attached to a dormant class, which is the same "built and never ran" state this whole audit exists to clear (umbrella: the tiered L0–L4 context stack was built and never ran — 2 of 5 layers cannot render #13685).
- Remove it — conflicts with the standing "never delete code, wire it in" rule. Recorded so it is not re-proposed as a fresh idea.
- Re-measure first — the 95.5% figure was measured against
AgentLoop, so it does not describe the chat path. Measurement is folded into the work below rather than gating it: the wiring emits the reduction it achieves, so the real number arrives from production instead of from another harness.
Scope
- Spill oversized tool results at the chat seam.
_dispatch_tool_callis where every real tool call funnels through, andexecution_resultsis what carries output onward. - Bind the run for the turn — in the same asyncio Task as the dispatch.
_current_task_idis aContextVar; asyncio copies the context at Task creation, so aset()in a parent or sibling Task is invisible here. Binding "earlier in the request" is not sufficient and would reproduce the current always-no_run_boundbehaviour with more code. - Log the achieved reduction, so the chat-path equivalent of the 95.5% figure comes from real traffic.
Gates
- Depends on fix(agent): wire read_spilled_output into the production dispatch seam (#13919) #13991 (the read tool at the production seam), which is open and green.
- A test must spill and re-read through
_dispatch_tool_callwith no mocking ofread_spilled_window— the mocked version of that test is what let the unreachability survive two "deliveries". AUTOBOT_TOOL_OUTPUT_SPILLstays off until the above lands and the reduction is measured on the live path.
Closes when
#13919's last unticked box — "the run binding is present at that seam" — can be ticked with evidence from a real dispatch, not from a grep of the layer that changed.
Pre-implementation audit — what the chat path does with oversized output today
Checked before building, to be sure this wires a missing mechanism rather than forking an existing one (the consolidate-never-fork rule).
There is no general cap.
execute_commandoutput — the most likely source of a window-consuming result — passes through uncapped. What exists is ad-hoc and per-tool:Site Cap Nature tool_handler.py:2975(web_search)3000 chars lossy — the remainder is discarded with ... [results truncated]tool_handler.py:2459(subagent result)1000 chars lossy tool_handler.py:2193(subagent completion)200 chars lossy cot_events.py:284,351_truncatedisplay/WebSocket safety, not context trajectory_context.py:162500 chars lossy So the chat path currently either loses the overflow or does not bound it at all.
That makes the spill additive rather than duplicative, and materially better than what is there: it is non-lossy — the excerpt carries an anchor and the full output stays retrievable, where today's truncation throws the remainder away permanently.
There is also a precedent for bounded context on this path:
_prune_stale_screenshots/VISION_TOOL_LOOKBACK_MESSAGES(manager.py:170-198, #11538) already keeps large content from persisting for a task's lifetime. The spill is the same idea for text, so it slots alongside rather than competing.Follow-on worth considering once wired (not in scope now): the five lossy truncations above are candidates to route through the spill instead, so an agent could recover a truncated web-search result rather than losing it. Recording the observation, not proposing it as work.
Landed in
Dev_new_guias7785f8871(PR #14237).Verified in base:
7785f887183af7d10d346143d737440556be3693 fix(agent): offload oversized tool output at the chat seam, where the model actually sees it (#13997) (#14237)The re-review verdict on the PR was no blockers, with all three findings verified on the property rather than on the reported instance. Closing.
- added a commit that references this issue
on Aug 16, 2026
The tool-output spill (#13692) is wired exclusively into
AgentLoop, which is never instantiated in production. On the live chat path it cannot fire, and its re-read tool cannot return anything.Found while wiring the read tool into the production dispatch seam (#13919, PR #13991). That PR closes the routing gap and is worth landing; it does not make the feature reachable, and #13919 should not close on it.
Evidence
The chat seam neither spills nor binds a run:
spill_results_asynchas exactly one caller repo-wide:And
AgentLoop's own docstring (#11221) says:bind_tasklikewise has four call sites, all inagent_loop/loop.py.read_spilled_windowreturns early when nothing is bound, so every call from a real chat turn returnsno_run_bound— always, not intermittently.Consequences
AUTOBOT_TOOL_OUTPUT_SPILLis set to. Enabling the flag today changes nothing observable.grep -rn "spill" autobot-backend/resources/prompts/returns nothing.ToolRegistry.get_available_tools()gates advertisement on the flag, but it feedsbase_modality_agent, which never parses or dispatches a tool call. Advertisement and dispatch live on disjoint agents — the same two-namespaces shape as the original defect.Why this matters beyond one feature
This is the third mechanism in this cluster found built-and-not-running: the tiered L0–L4 context stack (#13685), L2's schema mismatch (#13686), and now the spill. In each case the tests passed because they exercised the layer that was written, not the seam production uses.
A guard would catch the class: a check that every tool named in a model-facing string is dispatchable at
_dispatch_tool_call, and that any module whose only caller is a documented-dormant class is flagged. PR #13991 adds the first half for this one tool (TestTheNoteAndTheDispatchKeyCannotDrift).Options
_dispatch_tool_callspills its own oversized results and binds the run for the turn. Note_current_task_idis aContextVar: asyncio copies context at Task creation, so the bind must happen in the same Task as the dispatch, not merely earlier in the request.AgentLoopwiring.Option 3 is a real candidate: the measured 95.5% reduction that justified step 1 was taken against
AgentLoop, which no user reaches.Acceptance criteria
_dispatch_tool_callwith no mocking ofread_spilled_windowAUTOBOT_TOOL_OUTPUT_SPILLis not enabled until one of the above landsRelated
#13692 (the mechanism) · #13865 (made it safe to enable) · #13754 / #13919 (the read tool) · PR #13991 · #11221 (AgentLoop dormancy) · #13685 (same built-and-never-ran pattern)