Repository navigation
fix(chat): close two tool-output-offload coverage holes — LLC dict payload and approval-gate resume (#14284, #14242) - #14382
Conversation
… envelope (#14284) spill_execution_results type-guards on str before it will look at a key, so _handle_llc_tool storing the raw dict dispatch_llc_tool returns under 'output' silently defeated the offload no matter the adapter's key list. Serialise at the producer (option 1 from the issue) so the envelope stays uniform with every other tool handler. Updates _as_output_text's stale docstring, which still described the dict shape this removes. Test fixture is built by driving the real dispatch_llc_tool/_handle_llc_tool path (DB session + service mocked, same pattern as llc/tests/test_agent_tools.py), not a hand-written dict.
…path (#14242) graph.py::execute_tools dispatches via manager._process_tool_calls directly and builds its own exec_history, bypassing _process_tool_results / _handle_execution_summary -- the seam #13997 hooks the spill into. Two things were needed to actually route it through: - the spill run was never bound on this path (_run_continuation_loop_ iteration binds it for the non-gated path, but this node never calls that method), so the offload would decline to write with no run to re-read it under; - the 'track execution results' check read isinstance(item, dict) on the yielded WorkflowMessage itself, which is never a dict -- only its to_dict() form (already computed into msg_dict) is -- so it never matched and exec_history silently stayed whatever state already held. New tool results never reached it at all, offloaded or not. Both fixed together: the second bug is what made the first untestable. Test drives graph.execute_tools directly (not _process_tool_results), with _process_tool_calls faked but routed through the real ToolHandlerMixin._build_execution_summary so the fixture matches the shape production actually yields.
Review — APPROVEI verified the third finding independently, because it is the one that changes the story. The latent bug is real. In base, msg_dict = item.to_dict() if hasattr(item, "to_dict") else item
...
if isinstance(item, dict) and item.get("type") == "execution_summary": # line 1122
The fix is also correct in the case the old code was reaching for: if Binding the spill run on this path is necessary and easy to miss — For #14284, serialising at the producer is the right end. Fixing One caveat on the evidence, stated plainlyThe mutation table is a static trace, not executed mutations. Repo policy forbids running the suite locally, so that is the correct ceiling here — but a traced mutation is weaker than an observed one, and the PR body should not be read as if the mutations were run. The two new tests build fixtures from the real path rather than hand-written dicts, which is the right shape and the reason these holes survived earlier review. CI executing them is what converts the trace into evidence — worth confirming both new files actually run and pass rather than being skipped. No blocking findings. |
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
Closes #14284, #14242
Thinking Path
Both issues are holes in the #13997 tool-output offload's coverage, found in
review of PR #14237. I read
agent_loop/tool_output_spill.py(the adapter)and
chat_workflow/tool_handler.py/manager.py/graph.py(the two producerseams) before touching anything, to find the actual root cause rather than
patching the reported symptom.
#14284.
spill_execution_resultsonly offloads an envelope field whenisinstance(entry.get(k), str)— a type guard, not a name guard._handle_llc_toolstored the raw dict
dispatch_llc_toolreturns underoutput, so no amount ofgrowing the key list could ever reach it. Per the issue's preferred fix shape
(option 1), I serialise at the producer instead of teaching the adapter a
second, dict-shaped contract — every other tool handler already writes a str
there.
#14242. Tracing
graph.py::execute_tools(the approval-gate resume node)to see how the offload could be wired in, I found it calls
manager._process_tool_callsdirectly and builds its ownexec_history,never routing through
_process_tool_results/_handle_execution_summary(where #13997 hooks the spill) — matching the issue exactly. Wiring the
offload call in exposed two more problems in the same lines, both necessary
for the wiring to do anything at all:
_bind_spill_runruns inside_run_continuation_loop_iteration, and this node never calls that method(that's the whole point of GH#11202's plan-only split) — so
_spill_run_id()would read
Noneand the offload declines to write ("an artifact thatcould never be re-read is pure cost").
isinstance(item, dict)on theyielded
WorkflowMessageitself, which is a dataclass and is never adict — only its already-computed
.to_dict()form (msg_dict) is. So thecheck never matched, and
exec_historysilently stayed whateverstate["execution_history"]already held on entry. New tool results neverreached it, offloaded or not — on the approval-gate path, tool output never
reached the model's next continuation prompt at all, a strictly worse bug
than "the offload doesn't fire on it."
All three had to be fixed together: (1)+(2) are what make the offload call
meaningful, and nothing here had a test, which is exactly how a dead
isinstancecheck and an unbound ContextVar both survived.What Changed
autobot-backend/chat_workflow/tool_handler.py—_handle_llc_toolnowJSON-serialises the LLC dispatch result before storing it under
output,instead of storing the raw dict. (fix(chat): an LLC tool result stores a dict under output, so the offload silently skips it whatever the key list says #14284)
autobot-backend/chat_workflow/manager.py— updated_as_output_text'sdocstring, which specifically named
_handle_llc_toolas the dict-shapeexample this change removes; left the dict-handling branch itself alone
(still needed for the MCP bridge's
resultfield). (fix(chat): an LLC tool result stores a dict under output, so the offload silently skips it whatever the key list says #14284, doc accuracy)autobot-backend/chat_workflow/graph.py—execute_tools:manager._bind_spill_run(session_id)) beforedispatch, since this path never calls the method that does it elsewhere;
isinstance(item, dict)check so newly dispatched resultsactually reach
exec_history(reading the flatexecution_resultslistout of the summary event's
metadata, the same shape the non-gated pathreads);
manager._offload_oversized_outputbeforeextending
exec_history, so oversized output is offloaded on a resumeexactly as it already is on the non-gated path. (fix(chat): tool-output offload does not fire on the approval-gate resume path #14242)
chat_workflow/llc_tool_output_offload_test.py,chat_workflow/graph_execute_tools_offload_test.py) — see Verification.No other producer was switched to a dict payload, and the MCP bridge's dict
under
resultis untouched, so the adapter's existing dict-handling paths(
_as_output_text) still have a live caller.Verification
Both test files build their fixtures from the real path the issues name,
not a hand-written dict/exec_history:
llc_tool_output_offload_test.pydrivesdispatch_llc_tool→_handle_llc_toolfor real (only the DB session +WorkItemServicearemocked, the same pattern as
llc/tests/test_agent_tools.py), then runs theresulting envelope through the real
spill_execution_results.graph_execute_tools_offload_test.pycallschat_workflow.graph.execute_toolsdirectly with a realistic
ChatState, faking onlymanager._process_tool_calls— and that fake yields a summary built by the real
ToolHandlerMixin._build_execution_summary, not a hand-writtenWorkflowMessage.Static verification only in this environment per project policy (no local
test execution) — CI runs both files. I hand-traced each assertion against
the code paths above rather than executing them; the mutation table below is
therefore a static trace of what each assertion depends on, not an
executed run.
tool_handler.py: storeresult(dict) instead ofjson.dumps(result)underoutputllc_tool_output_offload_test.py::TestTheEnvelopeStaysAString::test_a_successful_llc_result_stores_a_str_not_a_dictisinstance(..., str)directly on the reverted dictTestAnOversizedLLCResultOffloads::test_the_dict_payload_is_offloaded_with_excerpt_and_anchorisinstance(original_output, str)fails immediately; even if bypassed,spill_execution_resultswould skip the dict key andcount == 1would failgraph.py: removemanager._bind_spill_run(session_id)graph_execute_tools_offload_test.py::TestTheApprovalGateResumeOffloads::test_oversized_output_is_offloaded_with_excerpt_and_anchor_spill_run_id()readsNone,_offload_oversized_outputreturns results untouched —len(entry["output"]) < len(_BIG)failsgraph.py: revert theisinstance(item, dict)check (drop themsg_dict.get("type")fix)graph_execute_tools_offload_test.py::TestExecutionHistoryActuallyAccumulates::test_a_dispatched_result_reaches_execution_historyout["execution_history"]stays[](fromstate), never equalsoriginalTestTheApprovalGateResumeOffloads::test_oversized_output_is_offloaded_with_excerpt_and_anchorout["execution_history"]is empty —IndexErroron[0]graph.py: drop theawait manager._offload_oversized_output(new_results)call (keep theisinstance/bind fixes)test_oversized_output_is_offloaded_with_excerpt_and_anchorentry["output"]is the full_BIGstring,len(entry["output"]) < len(_BIG)fails, noanchorskeyAlso covered:
_as_output_text/_format_execution_stepstill render the(now-string) LLC output correctly; small/under-threshold results pass through
byte-identical on both paths; the flag-off case leaves the approval-gate turn
untouched; the full spilled output is retrievable through its anchor on both
paths.
Model Used
Claude Opus 5 (1M context)