Skip to content

fix(agent): preserve final_response on failure returns - #39345

Closed
OmarB97 wants to merge 2 commits into
NousResearch:mainfrom
OmarB97:fix/final-response-contract-upstream-main-20260604
Closed

fix(agent): preserve final_response on failure returns#39345
OmarB97 wants to merge 2 commits into
NousResearch:mainfrom
OmarB97:fix/final-response-contract-upstream-main-20260604

Conversation

@OmarB97

@OmarB97 OmarB97 commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Why

A MeshBoard Hermes worker streamed real activity, then exited through hermes -z with:

hermes -z: agent failed: 'final_response'

The linked stream tap showed the upstream path before that crash: successful streaming/tool-call activity, two ClientConnectionResetError stream drops, large fallback requests returning 400, small recovery/finalization requests returning 502 then 200, and finally the oneshot wrapper crashed because the agent result did not contain final_response.

Root cause: AIAgent.run_conversation() promises a dict with final_response, but several terminal failure branches returned dicts with only error. hermes_cli.oneshot._run_agent() indexes result['final_response'], so those branches turn a real provider/context failure into an opaque wrapper KeyError.

What changed

  • Adds final_response to the invalid-response retry-exhaustion return.
  • Adds final_response to payload/context compression-exhaustion returns.
  • Keeps error identical to final_response for these terminal failures so callers can both display and classify the same actionable message.
  • Adds a source-level regression test that fails if any run_conversation() dict return omits final_response.
  • Tightens the existing invalid-response regression to assert final_response == error.

Evidence

Live failing sequence from MeshBoard dispatch an automated hermes -z wrapper run:

  • stream request with prompt ~52k chars produced deltas and tool-call chunks
  • later stream requests reset with ClientConnectionResetError: Cannot write to closing transport
  • fallback non-streaming requests returned 400 for ~96k/~98k char prompts
  • smaller recovery/finalization calls returned 502 then 200
  • Hermes oneshot emitted agent failed: 'final_response'

That matches the contract hole: the real upstream/provider failure should be returned as a failed agent result, not converted into a missing-key crash.

Verification

  • python -m py_compile agent/conversation_loop.py hermes_cli/oneshot.py
  • python -m pytest tests/run_agent/test_run_agent.py::test_run_conversation_dict_returns_include_final_response tests/run_agent/test_run_agent.py::TestRetryExhaustion -q -> 4 passed
  • python -m pytest tests/run_agent/test_413_compression.py -q -> 20 passed
  • python -m pytest tests/hermes_cli/test_tui_resume_flow.py::test_oneshot_fails_closed_on_empty_final_response tests/hermes_cli/test_tui_resume_flow.py::test_oneshot_fails_closed_on_agent_exception tests/hermes_cli/test_tui_resume_flow.py::test_oneshot_prints_nonempty_final_response -q -> 3 passed
  • git diff --check HEAD^ HEAD

Risks / gaps

This does not hide the underlying provider/context failure; it preserves it in the promised final_response field so wrappers, cron, gateway, and external launchers can surface the actionable error instead of crashing on a missing key. The broader stream reset/400/502 behavior can still be investigated separately, but this removes the opaque final_response loss that made the worker task look like a Hermes wrapper failure.

@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint labels Jun 4, 2026

@Morad37 Morad37 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one. This covers all the failure exits. The error-to-final_response mapping means callers no longer get dicts with 'error' set but 'final_response' missing. That was a subtle hole in the contract.

@OmarB97

OmarB97 commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Review: fix(agent): preserve final_response on failure returns

Verdict: approve ✅ (no blocking issues)

This PR correctly addresses the task goal — adding final_response to all 6 failure return paths in _perform_api_call that were missing it. The AST-based contract test provides structural enforcement for future changes.

Findings (all non-blocking suggestions)

1. AST test could benefit from a docstring (suggestion)
test_run_conversation_dict_returns_include_final_response relies on AST parsing via inspect.getsource, which is a compile-time structural check rather than a runtime test. Adding a brief docstring explaining:

  • That it parses the source of run_conversation (including its nested _perform_api_call)
  • That it checks key presence but not key value (a final_response: None would pass)
  • That non-dict returns are intentionally excluded
    would help future maintainers understand what kind of regression this test guards against.

2. AST test dependency on source file availability (suggestion)
inspect.getsource requires the .py source file at test time. If the package is ever installed as a wheel (compiled only), this test will raise OSError. Consider wrapping in a try/except with a skip or noting the dependency explicitly.

3. final_responseerror identity is an explicit design choice (info/nit)
All 6 paths set final_response and error to the exact same string. This is correct for the current task (ensuring final_response is never missing after failure), but it means callers cannot distinguish between a user-facing message and a technical error detail. If this distinction becomes needed later, the _final_response variable pattern makes it straightforward to diverge.

4. Potential merge conflict signal (info)
The pre-PR code for the last changed path uses post_compress_request_tokens in the current working copy, whereas the PR diff references approx_tokens. If the base commit differs from HEAD on the target branch, a rebase may be needed. Not a correctness issue in the diff itself.

Test coverage

  • ✅ AST test verifies key presence in ALL dict returns from run_conversation — covers all 6 paths
  • ✅ Runtime assertion in existing test validates final_response == error for the invalid-response path
  • ✅ No change-detector anti-pattern (the AST test asserts a contract, not a snapshot count)

@OmarB97

OmarB97 commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

MeshBoard verification update on 2026-06-05 after follow-up commit 93326ec:

  • Verified PR head 93326ec from a clean side worktree.
  • python -m py_compile agent/conversation_loop.py hermes_cli/oneshot.py passed.
  • uv run --extra dev python -m pytest tests/run_agent/test_run_agent.py::test_run_conversation_dict_returns_include_final_response tests/run_agent/test_run_agent.py::TestRetryExhaustion -q passed: 4 tests.
  • uv run --extra dev python -m pytest tests/run_agent/test_413_compression.py -q passed: 20 tests.
  • uv run --extra dev python -m pytest tests/hermes_cli/test_tui_resume_flow.py::test_oneshot_fails_closed_on_empty_final_response tests/hermes_cli/test_tui_resume_flow.py::test_oneshot_fails_closed_on_agent_exception tests/hermes_cli/test_tui_resume_flow.py::test_oneshot_prints_nonempty_final_response -q passed: 3 tests.
  • Canonical repo runner passed all three relevant files: scripts/run_tests.sh tests/run_agent/test_run_agent.py tests/run_agent/test_413_compression.py tests/hermes_cli/test_tui_resume_flow.py -- -q, with 433 tests passed and 0 failed.
  • Extra AST sanity now reports missing_final_response=[] and literal_none_final_response=[] across run_conversation() dict returns.

I attempted auto-merge before the follow-up patch and GitHub blocked it because OmarB97 does not have permission to enable auto-merge on NousResearch/hermes-agent. The current PR remains maintainer-gated rather than locally verification-gated.

@OmarB97

OmarB97 commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Superseded note: this correction was malformed by shell quoting and is replaced by the updated MeshBoard verification comment for PR head 93326ec.

@OmarB97

OmarB97 commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Superseded note: after inspecting the literal final_response None branches, I pushed follow-up commit 93326ec to strengthen the contract and the AST regression. See the updated MeshBoard verification comment for current evidence.

@Morad37 Morad37 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The follow-up patch converting final_response: None to a meaningful string in every path closes the last gap. The six-path contract is now fully consistent — all dict returns from _perform_api_call carry both error and final_response with matching values. Still good from my side.

@teknium1

teknium1 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Merged via #56176 (rebase-merge, your authorship preserved in git history).

Your fix approach and AST regression test were reapplied to current main — the line numbers had drifted ~4 months, so it was salvaged as a fresh cherry rather than a direct rebase, but the change is yours: all 16 run_conversation() terminal-failure branches now populate final_response with the actionable error text (8 that omitted the key, 8 that returned None), and your AST test guards the contract going forward.

Verified the premise still held on main: run_agent.py's chat() and the __main__ printer still index result["final_response"] directly, so the missing/None branches were still a live KeyError path. E2E-confirmed a forced retry-exhaustion branch now returns a non-None final_response == error. 412 tests in test_run_agent.py + 25 in test_413_compression.py pass.

Thanks for the fix and the durable regression test!

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

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P1 High — major feature broken, no workaround sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants