feat(plugins): turn_failed hook — observe non-clean turn exits - #56720
brandonedley wants to merge 4 commits into
Conversation
teknium1
left a comment
There was a problem hiding this comment.
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:300only runs whenfinalize_turn()is reached. Current main has non-clean terminal exits that return directly fromconversation_loop, including invalid-response exhaustion atagent/conversation_loop.py:1600-1607and disabled-compaction context overflow at:3135-3144. Those failures would not emitturn_failed, despite the hook's stated all-non-clean-exit contract. tests/run_agent/test_turn_failed_hook.pycovers 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.
| # 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): |
There was a problem hiding this comment.
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.
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>
7243640 to
7946984
Compare
|
Both problems addressed in Problem 1 — emission only from
|
…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.
What
Adds a
turn_failedplugin hook (registered inVALID_HOOKS, emitted fromfinalize_turnat the same point the turn-exit diagnostic is logged) so observability plugins can capture non-clean turn exits: pending-tool premature stops, non-text_responseexit 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_responsewas delivered) lands before the turn-exit diagnostic, somessages[-1]can no longer be"tool"there — which silently degraded the existingTurn ended with pending tool resultWARNING 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