fix(conversation-loop): recover when a leaked "(empty)" sentinel ends a turn - #83506
fix(conversation-loop): recover when a leaked "(empty)" sentinel ends a turn#83506samfoy wants to merge 3 commits into
Conversation
fix(conversation-loop): recover when a leaked "(empty)" sentinel ends a turn
|
… a turn
A turn could terminate on a non-answer. When the model emitted a final
response that began with Hermes' own "(empty)" sentinel followed by
scratchpad shorthand, the turn ended with finish_reason=stop and the
shorthand was delivered to the user as the final answer.
Observed live on a Responses-API provider, mid-task after a successful
tool round:
(empty) again risk. Need progress + tool. Already can. Use execute.
The post-tool empty-response nudge below already recovers this class of
failure, but its whole block is gated on the response being contentless
via _has_content_after_think_block(). The leak carried 67 characters, so
it read as content, skipped recovery, and killed a 27-minute task.
A garbage-but-present final answer is strictly worse than an empty one,
because empty is recoverable and garbage is not.
Treat a response starting with the sentinel as contentless. The sentinel
is injected by the nudge itself, so a model echoing it back has leaked a
scratchpad note rather than answered. This is a prefix match, not a
substring match, so an answer merely discussing "(empty)" is unaffected.
Measured against 3,804 real non-trivial stop-messages in a live session
DB: exactly one matches, the leak itself. The rule cannot swallow a
legitimate short answer such as "Done." or "PASS".
Partial-stream recovery needed fencing too. It runs first inside the
block and re-delivers _current_streamed_assistant_text, which holds the
leaked text verbatim — without the guard the fix would hand the same
shorthand back with extra steps. The sibling housekeeping fallback needs
no change: it is gated on all_housekeeping and the leak followed
substantive tools.
Tests assert both directions — the leak recovers, while "Done.", "PASS",
"OK", "42", markdown answers, and substring mentions of the sentinel all
still end the turn. Two further tests pin the guard to the source, so the
mirrored condition cannot silently drift.
tests/run_agent/: 1392 passed, 4 skipped, 0 failed.
… tests
Adversarial self-review of 386101a1f1 found two real defects in that
commit. Both are fixed here.
BLOCKER — the guard crashed on multimodal content.
(final_response or "").lstrip().startswith("(empty)")
`final_response` comes from `assistant_message.content`, which is not
always a str: Anthropic-via-OpenRouter returns a list of blocks
([{"type":"text"},{"type":"thinking"}]). That is precisely why
strip_think_blocks() coerces its own input. A non-empty list is truthy,
so it survived `or ""` and reached .lstrip(), raising AttributeError.
Worse, the guard is the `if` condition, so it ran BEFORE the tolerant
helper it sits next to. The original condition handled a list fine; the
new one turned any multimodal or vision reply into an unhandled
exception that killed the whole turn. Verified: strip_think_blocks(list)
returns 'hello' while (list or "").lstrip() raises.
Fixed by routing through flatten_message_text(), the codebase's existing
type-tolerant flattener. It is imported locally, matching the pattern
already used at conversation_loop.py:1717 — a module-level name would
have been a NameError, since this module never imported it.
MAJOR — 7 of the 9 new tests passed with the fix reverted.
The test file defined its own copies of _has_content_after_think_block
and of the guard expression, so it exercised the copies and never the
production code. Only the two source-grep tests failed on revert. This
is the mirrored-oracle antipattern.
Rewritten to import the real helpers (strip_think_blocks,
flatten_message_text) and to EXTRACT the guard expression from
conversation_loop.py source, evaluating it against those helpers. A
revert now fails the module at collection with an explicit message
rather than passing silently.
Added: two multimodal cases pinning the BLOCKER, and a meta-test
asserting no local copy of the stripper exists so the class cannot
recur. The flattener assertion strips comments before matching —
the explanatory comment quotes the old broken expression verbatim,
which made a naive substring assertion match its own documentation.
Also recorded, deliberately NOT changed: when the leak enters the
recovery block but the post-tool nudge does not fire (latch already
consumed, or no tool message in the last 5), no branch handles it —
prefill, empty-retry, and fallback are all gated on `_truly_empty`,
which is False for a non-empty leak. Control reaches the terminal and
the user sees a bare "(empty)". That is strictly better than shipping
scratchpad shorthand as an answer, and widening those gates is a larger
behavioural change than this fix warrants.
No infinite-loop risk from the terminal's own "(empty)": it assigns and
breaks immediately (L6916-6917), so the guard is never re-evaluated.
tests/run_agent/ + the new module: 1404 passed, 4 skipped, 0 failed.
Second adversarial review round. One real false positive, one contract
overstatement, plus test hardening.
FALSE POSITIVE — the guard could condemn a good answer by its scratchpad.
Round 2 routed the check through flatten_message_text(). That helper
deliberately KEEPS reasoning parts (callers use it to recover text from
any shape) and its key list includes the generic ``content`` key. So
[{"type": "thinking", "content": "(empty) scratch note"},
{"type": "text", "text": "Done."}]
flattened to "(empty) scratch note\nDone." and fired the guard on a turn
whose visible answer was a perfectly good "Done." — recovering a turn
that should have ended. Reproduced before fixing.
Fixed with _visible_text_for_sentinel_check(), which drops
thinking/reasoning/redacted_thinking/reasoning_content parts before
flattening. It is module-level (not nested in the 3,900-line
run_conversation) so tests import it instead of copying it, and it
returns "" on any hostile shape — failing toward the old terminate
behaviour rather than crashing the turn.
The sentinel literal is now EMPTY_RESPONSE_SENTINEL, shared with the
injection sites so the guard cannot drift from them.
CONTRACT — the comment overstated what the code guarantees.
It read "Such a turn must recover, not terminate." Recovery of a
NON-EMPTY leak depends entirely on the post-tool nudge, which needs both
_prior_was_tool and an unconsumed latch; every other branch in the ladder
is gated on _truly_empty, which is False for a leak. With the latch
already spent the leak reaches the terminal and the user sees a bare
"(empty)". Comment now states that limit instead of implying a guarantee.
TESTS — round 2 still mirrored production.
It imported strip_think_blocks but inlined the production BODY of
_has_content_after_think_block. Now the predicate is borrowed off the
real class (_MinimalAgent.pred = AIAgent.pred, asserted with assertIs)
and the guard expression is pulled from source via ast.unparse, so a
revert fails at COLLECTION rather than passing.
Two meta-tests were self-tripping on their own assertion strings; they
now strip assertion lines before scanning, the same hazard as the
comment-quoting case fixed last round.
Verified by mutation, one sub-fix at a time:
* whole guard reverted -> collection error
* only reasoning-exclusion reverted -> 6 named subtests fail, each
identifying the (part_type, key) shape that regressed
Scope: the guard's only enclosing condition is `if
assistant_message.tool_calls` (verified by AST), so tool-calling turns
never evaluate it — no hot-path blast radius.
Deliberately NOT widened: a reviewer noted the single-sentinel prefix is
an incomplete signal. Measured against 3,828 real turn-ending messages —
"(empty)" has 1 prefix occurrence (this bug); "[Tool loop warning",
"[System:", "[IMPORTANT:", the interruption scaffolding, "[TRANSCRIPT"
and "<untrusted_tool_result" have 0. Widening adds false-positive
surface for no measured benefit.
tests/test_leaked_empty_sentinel_guard.py: 17 passed, 23 subtests.
tests/run_agent/: 1392 passed. tests/agent/ is 146-red BEFORE this change
too — failing test IDs captured with and without the diff are identical
(comm reports 0 introduced, 0 fixed), so that suite is unrelated.
d171a18 to
cf74a7c
Compare
|
|
What does this PR do?
A turn could end on a non-answer. When a model emitted a final response that began with Hermes' own
"(empty)"sentinel followed by scratchpad shorthand,run_conversationtreated it as a completed text turn —finish_reason=stop, shorthand persisted as the assistant message, task abandoned mid-flight.Observed live on a Responses-API provider, one tool round after a
PASS:That ended a ~27-minute multi-step task, and the visible "answer" was the model's own planning note.
The recovery for this class already existed — the post-tool empty-response nudge. It was unreachable because the whole ladder is gated on the response being contentless:
The leak carried 67 characters, so it read as content and skipped every branch. A garbage-but-present final answer is strictly worse than an empty one: empty is recoverable, garbage is not.
This PR routes a leaked sentinel into that existing ladder. It adds no new recovery machinery.
Why the sentinel prefix is a safe signal.
"(empty)"is not model text — Hermes injects it (_nudge_msg["content"], and the empty terminal). A model echoing it back has leaked a planning note. Measured against a real session DB of 3,828 non-trivial turn-ending assistant messages:(empty)appears as a prefix exactly once — this bug.[Tool loop warning,[System:,[IMPORTANT:, the interrupt scaffolding,[TRANSCRIPTand<untrusted_tool_resultappear zero times, which is why the guard is deliberately narrow rather than a general shorthand heuristic. (Single-user data — indicative, not a population estimate.)Related Issue
Fixes #83505
Adjacent, not duplicated — all three touch the same recovery ladder in
conversation_loop.py, so whoever merges first will make the others rebase:_has_visible_contentguard on the thinking-prefill retry gateSibling issue #24933 (closed) fixed the commentary-phase form of the same scratchpad leak; this is the case where the note arrives stamped
final_answerinstead.Type of Change
Changes Made
agent/conversation_loop.pyEMPTY_RESPONSE_SENTINEL = "(empty)"— module-level, shared with the injection sites so the guard cannot drift from them._visible_text_for_sentinel_check(content)— returns only visible assistant prose from a str, a list of blocks, or a mapping. Dropsthinking/reasoning/redacted_thinking/reasoning_contentparts. Never raises: a bad shape yields""so the guard simply does not fire (fails toward the pre-existing terminate behaviour rather than crashing a turn)._leaked_empty_sentinel— true when the visible text starts with the sentinel and the response is otherwise non-empty; OR-ed into the existing empty-recovery gate.not _leaked_empty_sentinel. Without this the fix is a no-op with extra steps: the leaked text was streamed, so_current_streamed_assistant_textholds it and would be re-delivered verbatim.tests/test_leaked_empty_sentinel_guard.py(new) — 17 tests / 23 subtests.Two deliberate design choices, both learned the hard way in review:
_MinimalAgent._has_content_after_think_block = AIAgent._has_content_after_think_block, pinned withassertIs) and the guard expression is extracted from source withast.unparse, then evaluated against the production helpers. An earlier revision defined its own copies and 7 of 9 tests passed with the fix reverted. A revert now fails at collection."Done.","PASS","OK","42", markdown answers, and substring mentions (`(empty)` is the sentinel) must still end the turn.Two behaviours documented in-code rather than changed, to keep this to one concern:
(empty). Still better than shipping shorthand as an answer, and the comment now says so instead of claiming "must recover"."(empty)": it assigns andbreaks immediately, so the guard is never re-evaluated.How to Test
1. Reproduce the gate (no provider needed). The condition is a pure expression over
final_response:2. Run the new module:
python -m pytest tests/test_leaked_empty_sentinel_guard.py -q # 17 passed, 23 subtests passed3. Confirm the tests are revert-sensitive (this is the part worth checking):
4. Suite:
Checklist
Code
pytest tests/ -qand all tests pass — see note belowNote on the test checkbox, stated plainly rather than ticked:
tests/run_agent/reports 7 failures on this branch — and the identical 7 on a pristineorigin/mainworktree. I captured failing test IDs both ways in the same container and diffed them:0 introduced, 0 fixed. All 7 areModuleNotFoundError: anthropic, an artifact of my minimal test image installing only thedevextra. I have not run the entiretests/tree, so I can't honestly claim all-green.Verification ran in Docker against a throwaway clone (
--network none, repo mounted read-only,HERMES_HOMEredirected) because this repo is also my live agent install and an in-place run can be rewritten mid-suite.Documentation & Housekeeping
cli-config.yaml.exampleif I added/changed config keys — N/A, no new configCONTRIBUTING.md/AGENTS.md— N/A, no architecture changeScreenshots / Logs
The real session row that motivated this, from the local session DB:
and the one that leaked the sentinel:
Both were mid-task, both had just completed successful tool calls.