Skip to content

fix(agent): the tool-output spill is wired only into AgentLoop, which is never instantiated in production #13997

Description

@mrveiss

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:

$ grep -rn "bind_task\|spill_results" autobot-backend/chat_workflow/
(0 rows)

spill_results_async has exactly one caller repo-wide:

autobot-backend/agent_loop/loop.py:592   AgentLoop._offload_oversized_results

And AgentLoop's own docstring (#11221) says:

not instantiated anywhere in production — the only references to AgentLoop are this module and the re-export/docstring in agent_loop/__init__.py. The live tool-execution seam is chat_workflow (_dispatch_tool_call), not this loop.

bind_task likewise has four call sites, all in agent_loop/loop.py. read_spilled_window returns early when nothing is bound, so every call from a real chat turn returns no_run_bound — always, not intermittently.

Consequences

  1. No spill ever happens on the live path, whatever AUTOBOT_TOOL_OUTPUT_SPILL is set to. Enabling the flag today changes nothing observable.
  2. The teaching channel does not exist either. The only place the model is told the tool exists is the excerpt note — emitted by the spill that never runs. grep -rn "spill" autobot-backend/resources/prompts/ returns nothing.
  3. ToolRegistry.get_available_tools() gates advertisement on the flag, but it feeds base_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

  1. Wire the live seam. _dispatch_tool_call spills its own oversized results and binds the run for the turn. Note _current_task_id is a ContextVar: asyncio copies context at Task creation, so the bind must happen in the same Task as the dispatch, not merely earlier in the request.
  2. Move the mechanism to wherever chat tool results are assembled, and drop the AgentLoop wiring.
  3. Close feat(agent): no context offload of tool output — spill and anchor oversized results #13692 as not-delivered and remove the mechanism rather than carry dead code behind a flag that cannot do anything.

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

  • a decision on the three options above, recorded on this issue
  • if wired: a test that spills and re-reads through _dispatch_tool_call with no mocking of read_spilled_window
  • if wired: the run binding is established in the same asyncio Task as the dispatch
  • AUTOBOT_TOOL_OUTPUT_SPILL is not enabled until one of the above lands

Related

#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)

Activity

  1. mrveiss commented on Aug 11, 2026

    @mrveiss
    OwnerAuthor

    Same root cause as #14031 (pre-action verifier + belief state run nowhere): AgentLoop has 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 into autobot_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.

  2. mrveiss commented on Aug 11, 2026

    @mrveiss
    OwnerAuthor

    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

    1. Spill oversized tool results at the chat seam. _dispatch_tool_call is where every real tool call funnels through, and execution_results is what carries output onward.
    2. Bind the run for the turn — in the same asyncio Task as the dispatch. _current_task_id is a ContextVar; asyncio copies the context at Task creation, so a set() 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_bound behaviour with more code.
    3. 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_call with no mocking of read_spilled_window — the mocked version of that test is what let the unreachability survive two "deliveries".
    • AUTOBOT_TOOL_OUTPUT_SPILL stays 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.

  3. mrveiss commented on Aug 11, 2026

    @mrveiss
    OwnerAuthor

    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_command output — 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 _truncate display/WebSocket safety, not context
    trajectory_context.py:162 500 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.

  4. mrveiss commented on Aug 15, 2026

    @mrveiss
    OwnerAuthor

    Landed in Dev_new_gui as 7785f8871 (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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendbugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions