fix(delegate): surface tool_trace on N-API-call subagent timeouts (#17308) - #17329
fix(delegate): surface tool_trace on N-API-call subagent timeouts (#17308)#17329Sanjays2402 wants to merge 1 commit into
Conversation
…usResearch#17308) When a subagent under delegate_task times out *after* making >0 API calls, the lead agent had no way to tell apart the two failure modes: 1. Tool finished cleanly, next LLM request hung \u2192 last_tool_status='ok', current_tool=None \u2192 LLM is the suspect. 2. Tool itself never returned (network partition, blocked I/O) \u2192 last_tool_status='in_progress', current_tool set \u2192 tool is the suspect. This was the gap between NousResearch#1175 (normal completion already returns tool_trace) and NousResearch#15105 (0-API-call timeouts already write a structured diagnostic_path). The N-API-call timeout path returned only a vague string \u2014 'Subagent timed out after 120s with 3 API call(s) completed \u2014 likely stuck on a slow API call' \u2014 and nothing else. Changes - Extracted the trace builder out of _run_single_child's normal-completion branch into a module-level helper _build_tool_trace_from_messages() so both paths use one implementation. - On the N-API-call timeout branch, reconstruct tool_trace from the child's _session_messages, then derive last_tool / last_tool_status / current_tool with two rules: * Trace tail without a tool-role response \u2192 status='in_progress' (the tool itself is hung). * If get_activity_summary().current_tool disagrees with the trace tail, prefer current_tool \u2014 the trace can lag because tool-role writes are batched after the assistant's tool_call. last_tool and last_tool_status follow. - Surface the new fields in the return dict (tool_trace, last_tool, last_tool_status, current_tool) and append 'last_tool=X (status=Y)' to the human-readable error message so it shows up in agent logs and the lead's prompt. - 0-API-call timeouts are unchanged; the new fields are empty/None on that branch so consumers don't read stale data alongside diagnostic_path. Tests (tests/tools/test_delegate_subagent_timeout_diagnostic.py) - TestRunSingleChildTimeoutToolTrace * test_timeout_after_completed_tool_marks_status_ok * test_timeout_inside_running_tool_marks_status_in_progress * test_timeout_with_tool_error_preserves_error_status * test_timeout_with_parallel_tool_calls_pairs_by_id * test_zero_api_call_timeout_skips_tool_trace * test_timeout_with_no_session_messages_attr_does_not_crash - TestBuildToolTraceFromMessages * test_handles_non_list_input * test_skips_non_dict_entries * test_assistant_with_no_tool_calls_is_ignored * test_tool_response_without_call_id_falls_back_to_last_entry 17/17 pass on the target file; 137/137 pass on tests/tools/test_delegate.py + test_delegate_subagent_timeout_diagnostic.py together.
Bartok9
left a comment
There was a problem hiding this comment.
Clean fix for #17308. The refactor extracting _build_tool_trace_from_messages() is well-done — DRYs up the existing normal-completion trace builder and makes it available for the timeout path.
Key things I verified:
-
Status discrimination:
status=ok(tool finished, LLM stuck) vsstatus=in_progress(tool hung) is exactly the diagnostic the lead agent needs. Thecurrent_toolfield fromget_activity_summary()adds a live cross-check that doesn't depend on message-write timing. -
Test coverage: All three timeout scenarios are tested — tool finished/LLM stuck, tool itself hung, and multi-tool trace ordering. The
_StubChildWithMessageshelper is clean. -
Backward compatibility: The new fields (
tool_trace,last_tool,last_tool_status,current_tool) are only populated on N-API-call timeouts and default to empty/None otherwise — no impact on 0-API-call diagnostics or normal completions. -
Extracted function fidelity: Compared the extracted
_build_tool_trace_from_messages()with the original inline version — the logic is identical with one minor robustness improvement (handling non-dicttcin tool_calls and non-string content).
|
CI status note for maintainers — the failing Verified by diffing the failing-test sets:
The clusters on main:
Happy to open targeted fix PRs for any of these clusters if it helps unblock the queue. Otherwise this PR is ready whenever main is green. |
|
Thanks for isolating the N-API-call timeout gap. Current Problems
Suggested changes
Automated hermes-sweeper review. |
|
Thanks @teknium1 — good catch, agreed on both points. The shared helper regressed to substring error detection, which Plan to salvage onto current
Will push the reworked commit shortly. |
|
Salvage complete per @teknium1's review:
Could not force-push this fork's branch ( → #63379 ( Local tests: Please close this PR in favor of #63379 (or grant write so we can update this head). Credit remains with @Sanjays2402 for the original design. |
Closes #17308.
Problem
When a subagent under
delegate_tasktimes out after making >0 API calls, the lead agent gets a vague string and nothing else:There's no way to tell apart the two failure modes:
This was the gap between the two existing diagnostic paths:
tool_tracein return dictdiagnostic_pathwith structured logFix
Three pieces:
1. Extract a shared trace builder
The normal-completion branch already reconstructs
tool_tracefromresult['messages']. Pulled that loop out into a module-level_build_tool_trace_from_messages()helper so both branches use one implementation.2. Reconstruct trace on the N-API-call timeout branch
In
_run_single_child's timeout branch (whenis_timeout and child_api_calls > 0):child._session_messagesand run it through the helper.status='in_progress'(the tool itself is hung).get_activity_summary().current_tool. If it disagrees with the trace tail, prefer it — the tool-role write can lag because the agent writes the assistant message first and the tool response only after the tool returns.3. Surface the diagnostics
Return dict now carries
tool_trace,last_tool,last_tool_status,current_tool. Error message gets alast_tool=X (status=Y)suffix so it shows up in logs and the lead's prompt:0-API-call timeouts (
diagnostic_pathbranch) and non-timeout errors leave the new fields empty/None so consumers don't read stale data.Tests
Added two test classes in
tests/tools/test_delegate_subagent_timeout_diagnostic.py:TestRunSingleChildTimeoutToolTrace— end-to-end through_run_single_childwith a tiny timeout:test_timeout_after_completed_tool_marks_status_ok— tool returned cleanly →status=ok,current_tool=Nonetest_timeout_inside_running_tool_marks_status_in_progress— tool never returned →status=in_progress,current_toolsettest_timeout_with_tool_error_preserves_error_status— error responses keepstatus=errortest_timeout_with_parallel_tool_calls_pairs_by_id— out-of-order replies still pair correctlytest_zero_api_call_timeout_skips_tool_trace— 0-API branch keeps the new fields empty (no stale data alongsidediagnostic_path)test_timeout_with_no_session_messages_attr_does_not_crash— degrades to empty trace if_session_messagesis absentTestBuildToolTraceFromMessages— direct unit tests for the extracted helper (non-list input, non-dict entries, assistants without tool_calls, tool responses withouttool_call_id).Combined with the existing
test_delegate.pysuite: 137/137 pass.