Skip to content

feat(plugins): turn_failed hook — observe non-clean turn exits - #56720

Open
brandonedley wants to merge 4 commits into
NousResearch:mainfrom
brandonedley:feat/turn-failed-hook
Open

brandonedley wants to merge 4 commits into
NousResearch:mainfrom
brandonedley:feat/turn-failed-hook

Conversation

@brandonedley

Copy link
Copy Markdown

What

Adds a turn_failed plugin hook (registered in VALID_HOOKS, emitted from finalize_turn at the same point the turn-exit diagnostic is logged) so observability plugins can capture non-clean turn exits: pending-tool premature stops, non-text_response exit reasons, guardrail/exhaustion exits. Observers only — a failing handler can never break finalization, and deliberate user interrupts (/stop) never fire it.

Why

Failure-capture pipelines (e.g. feeding an agent-failure sink or a kanban triage queue) currently have no supported way to observe "the turn ended badly" — the closest hooks (api_request_error, on_session_end) fire at the wrong granularity. This has been running in production on a fork for a week feeding an automated failure-ingest pipeline.

Bonus fix included

While rebasing onto current main we found the #43849/#44100 persist-invariant (append an assistant row whenever a final_response was delivered) lands before the turn-exit diagnostic, so messages[-1] can no longer be "tool" there — which silently degraded the existing Turn ended with pending tool result WARNING for any premature stop that delivered partial text. The third commit captures the tail role before persist-time mutations and uses it for both the diagnostic and the new hook.

Testing

tests/run_agent/test_turn_failed_hook.py — 9 tests covering: fires on pending-tool stop, fires on non-text exit reasons, never fires on healthy completion, never fires on user interrupt, kwargs payload shape, handler-exception isolation, and the pre-persist tail classification. pytest tests/run_agent/ -k 'final or persist or turn' — 221 passed.

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/feature New feature or request comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have labels Jul 2, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for identifying the post-persistence tail-classification regression; current main does append a recovery assistant row before the diagnostic reads messages[-1] (agent/turn_finalizer.py:223-229, :249-285).

Problems

  • The new emission at agent/turn_finalizer.py:300 only runs when finalize_turn() is reached. Current main has non-clean terminal exits that return directly from conversation_loop, including invalid-response exhaustion at agent/conversation_loop.py:1600-1607 and disabled-compaction context overflow at :3135-3144. Those failures would not emit turn_failed, despite the hook's stated all-non-clean-exit contract.
  • tests/run_agent/test_turn_failed_hook.py covers the finalizer path, but not a failed direct-return path that bypasses it.

Suggested changes

  • Centralize terminal-result emission/finalization, or cover every failed direct return with the same observer-only emission contract.
  • Add an integration test for one bypassing failure path, such as the invalid-response terminal return at agent/conversation_loop.py:1600.

Automated hermes-sweeper review.

Comment thread agent/turn_finalizer.py
# only — for Phase-0 agent-failure observability capture. Gated to
# non-clean exits via the shared classification so healthy turns stay
# quiet, and wrapped so a failing handler never breaks finalization.
if _should_emit_turn_failed(_turn_exit_reason, _last_msg_role, interrupted):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This only covers the post-loop fallthrough. Current failed returns such as agent/conversation_loop.py:1600-1607 (invalid-response exhaustion) and :3135-3144 (compaction-disabled overflow) bypass finalize_turn() entirely, so they cannot emit this hook. Please centralize terminal failure emission or cover those direct-return paths before claiming all non-clean exits.

@teknium1 teknium1 added the sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit label Jul 15, 2026
brandonedley and others added 4 commits July 29, 2026 15:52
Phase-0 agent-failure observability (T1). Register "turn_failed" in
VALID_HOOKS and emit it from finalize_turn at the turn-exit diagnostic
point, reusing the already-computed diag fields (no new computation).

Fires ONLY for non-clean exits via the shared _should_emit_turn_failed
guard: any error/exhaustion/interrupt/guardrail _turn_exit_reason, OR
last_msg_role == "tool" (the agent stopped mid-work — the
protocol_violation / breads-pc premature-stop class). A healthy
text_response(finish_reason=stop) with last_msg_role != "tool" stays
quiet. The emit is wrapped so a failing handler never breaks turn
finalization, matching the existing post_llm_call/transform_llm_output
pattern in this file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-review caught a false-positive: `_should_emit_turn_failed` fired on
clean `/stop` interrupts. The interrupt reason (`interrupted_by_user`) is
not a `text_response(...)`, so the reason-arm tripped — surfacing a
deliberate stop as a failure signal, the exact noise Phase-0 capture must
avoid.

- thread `interrupted` into the guard; short-circuit to False when set
  (mirrors the existing `and not interrupted` gate on the pending-tool
  diagnostic warning)
- emit `interrupted` in the hook payload so downstream observers can
  disambiguate even if the guard is later relaxed
- document the interrupt exclusion in the VALID_HOOKS contract
- add guard + finalize integration coverage for the interrupt path
  (9 tests, was 7)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t tail

The NousResearch#43849/NousResearch#44100 persist-invariant appends an assistant row whenever a
final_response was delivered, so messages[-1] can no longer be 'tool' at
the turn-exit diagnostic. That silently degraded BOTH the upstream
'Turn ended with pending tool result' WARNING and the turn_failed hook's
premature-stop classification. Capture the tail role before persist-time
mutations and use it for both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up: the hook only fired from `finalize_turn`, so it could not
see the terminal paths that build a result dict and `return` straight out of
`run_conversation` — which are exactly the failures worth observing.

Inventoried the gap rather than patching the two paths named in review: there
are **24** such returns in `run_conversation`, and every one sets
`completed: False`. Notably only 13 of them also set `failed`, so keying on
`failed` would miss 11 — including the context-overflow / compaction-disabled
exits. Three set `interrupted: True` and must stay silent, consistent with the
existing user-`/stop` gate.

Rather than instrument 24 sites (and require every future site to remember),
this reads the terminal contract they already satisfy:

* `emit_turn_failed(agent, **fields)` — the single emitter, idempotent per turn
  via an `agent._turn_failed_emitted` latch reset in turn setup. `finalize_turn`
  now routes through it, and marks the latch even when it deliberately stays
  silent, so the sweep cannot second-guess an intentional non-emit.
* `emit_turn_failed_for_unfinalized_exit(agent, result)` — runs once in the
  `run_conversation` forwarder, which every caller passes through. Emits only
  when `completed is False`, the turn has not already emitted, and the exit was
  not a user interrupt. A future 25th terminal path is covered the moment it
  honours the same contract.

`_should_emit_turn_failed` is unchanged and remains the sole classifier.

Tests (+11, `tests/run_agent/test_turn_failed_hook.py`):
- invalid-response exhaustion emits — the path named in review
- context overflow emits despite having no `failed` key
- silent on user interrupt, on healthy completion, and on shapes that cannot be
  positively identified as failed (None / non-dict / `{}` / `completed: None`)
- no double-fire when `finalize_turn` already emitted
- a raising observer cannot break turn teardown
- an AST invariant test asserting every terminal return carrying
  `final_response` also carries `completed`, so the sweep's premise is pinned
  at the source rather than assumed

The raising-observer test caught a real bug in the first draft: `logger` is
imported lazily in this module to avoid an `agent.conversation_loop` import
cycle, so referencing it at module scope turned a failing observer into a
`NameError`. The lazy import is now inside its own guard.

Full suite: 2455 passed (was 2444), 0 regressions.

Refs NousResearch#56720

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brandonedley
brandonedley force-pushed the feat/turn-failed-hook branch from 7243640 to 7946984 Compare July 29, 2026 21:03
@brandonedley

Copy link
Copy Markdown
Author

Both problems addressed in 794698445, and the branch is rebased onto current main. Taking your points in order.

Problem 1 — emission only from finalize_turn

Confirmed, and worse than the two paths you named. I inventoried run_conversation with an AST walk rather than trusting a grep: there are 24 terminal returns that build a result dict and return directly, and every one sets completed: False.

Two things that inventory changed about the fix:

  • failed is the wrong predicate. Only 13 of the 24 set failed; the other 11 have no such key — including the disabled-compaction context-overflow exits you named. Keying on failed would have missed almost half, so the sweep keys on completed is False.
  • 3 of the 24 set interrupted: True and must stay silent, consistent with the user-/stop gate already in _should_emit_turn_failed.

Your line references point at an older tree, incidentally — on current main the invalid-response exhaustion is agent/conversation_loop.py:2530 and the context-overflow exit is :4166. Same paths, and both are covered.

Suggested change 1 — "centralize, or cover every failed direct return"

Took the centralize option rather than instrumenting 24 sites, since the per-site approach requires every future site to remember:

  • emit_turn_failed(agent, **fields) — one emitter, idempotent per turn via an agent._turn_failed_emitted latch reset in turn setup. finalize_turn now routes through it, and sets the latch even when it deliberately stays silent, so the sweep can never override an intentional non-emit.
  • emit_turn_failed_for_unfinalized_exit(agent, result) — runs once in the run_conversation forwarder in run_agent.py, which every caller (cli.py, batch_runner.py, the agent's own entry points) passes through. Emits only when completed is False, the turn has not already emitted, and the exit was not an interrupt.

_should_emit_turn_failed is untouched and remains the sole classifier — the guard you already reviewed keeps its contract.

The reason I'd argue for the contract-based sweep over per-site instrumentation: a 25th terminal path is covered the moment it honours completed: False, with no new emission call. To keep that from being an unchecked assumption, there's an AST test asserting every terminal return carrying final_response also carries completed — so if someone adds a path that breaks the premise, that test fails rather than the failure silently going unobserved.

Problem 2 / Suggested change 2 — a test for a bypassing path

test_bypass_sweep_fires_on_invalid_response_exhaustion covers the invalid-response terminal return you named specifically. Ten more alongside it:

  • context overflow emits despite having no failed key (the 11-path class above)
  • silent on user interrupt; silent on healthy completion
  • silent on shapes that cannot be positively identified as failed — None, non-dict, {}, completed: None
  • no double-fire when finalize_turn already emitted
  • a raising observer cannot break turn teardown
  • the AST source invariant

+11 tests. Full suite 2455 passed (was 2444), no regressions.

That raising-observer test earned its place immediately — it caught a real bug in my first draft. This module imports logger lazily to avoid an agent.conversation_loop import cycle, so referencing it at module scope turned a failing observer into a NameError, i.e. the handler meant to swallow a plugin error would itself have raised. The lazy import now sits inside its own guard.

One adjacent bug I found but deliberately did not bundle

The forwarder already classifies the relay outcome right after run_conversation returns:

elif terminal.get("failed") is True:
    relay_outcome = "failed"
else:
    relay_outcome = "success"

11 of the 24 terminal returns never set failed. Three of those do set interrupted and so land correctly on "cancelled" — but the remaining 8 non-clean exits are recorded as relay "success" (conversation_loop.py lines 2835, 2963, 3033, 3050, 5564, 5693, 5782, 5866).

That looks like a genuine observability defect independent of this hook, but fixing it changes relay-metrics semantics, so it does not belong in an observer-only PR. Happy to file it separately, or fold it in here if you'd rather see them together — your call.

AnalogHubris pushed a commit to AnalogHubris/hermes-agent that referenced this pull request Aug 13, 2026
…t failed

TUI/desktop error frames often showed a generic "request failed" while the
classified detail (provider, model, base_url, HTTP status, failure_reason,
fallback) only reached agent.log. Add _classify_turn_error_message and use
it in _fail_inflight_turn, message.complete error payloads, and compute_host
turn.error frames.

Composes with NousResearch#56720 turn_failed and NousResearch#58524 classify_api_error (NousResearch#64182 item 3).
Observer-side framing only — no delivery-path mutation.
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 comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants