Skip to content

fix(conversation-loop): recover when a leaked "(empty)" sentinel ends a turn - #83506

Open
samfoy wants to merge 3 commits into
NousResearch:mainfrom
samfoy:fix/leaked-empty-sentinel-ends-turn
Open

fix(conversation-loop): recover when a leaked "(empty)" sentinel ends a turn#83506
samfoy wants to merge 3 commits into
NousResearch:mainfrom
samfoy:fix/leaked-empty-sentinel-ends-turn

Conversation

@samfoy

@samfoy samfoy commented Aug 10, 2026

Copy link
Copy Markdown

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_conversation treated 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:

finish_reason : stop
content       : (empty) again risk. Need progress + tool. Already can. Use execute.

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:

if not agent._has_content_after_think_block(final_response):

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, [TRANSCRIPT and <untrusted_tool_result appear 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:

Sibling issue #24933 (closed) fixed the commentary-phase form of the same scratchpad leak; this is the case where the note arrives stamped final_answer instead.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

agent/conversation_loop.py

  • EMPTY_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. Drops thinking / reasoning / redacted_thinking / reasoning_content parts. 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.
  • The partial-stream recovery branch inside that gate is fenced with not _leaked_empty_sentinel. Without this the fix is a no-op with extra steps: the leaked text was streamed, so _current_streamed_assistant_text holds 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:

  • No mirrored oracle. The predicate is borrowed off the real class (_MinimalAgent._has_content_after_think_block = AIAgent._has_content_after_think_block, pinned with assertIs) and the guard expression is extracted from source with ast.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.
  • Both directions asserted. The leak must recover; "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:

  • Recovery is not guaranteed. It depends on the post-tool nudge still being available; with the nudge latch already consumed this turn the leak reaches the empty terminal and the user sees a bare (empty). Still better than shipping shorthand as an answer, and the comment now says so instead of claiming "must recover".
  • No infinite-loop risk from the terminal's own "(empty)": it assigns and breaks 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:

import re
def has_content(c):   # mirrors _has_content_after_think_block
    return bool(re.sub(r"<(think|thinking|reasoning)>.*?</\1>", "", c or "",
                       flags=re.S | re.I).strip())

leak = "(empty) again risk. Need progress + tool. Already can. Use execute."
print(has_content(leak))   # True -> old gate skipped -> turn terminated

2. Run the new module:

python -m pytest tests/test_leaked_empty_sentinel_guard.py -q
# 17 passed, 23 subtests passed

3. Confirm the tests are revert-sensitive (this is the part worth checking):

# remove the guard from agent/conversation_loop.py, then:
python -m pytest tests/test_leaked_empty_sentinel_guard.py -q
# ERROR at collection: `_leaked_empty_sentinel = ...` not found — the fix is missing

# or keep the guard but make _visible_text_for_sentinel_check skip the
# reasoning-exclusion, then:
# 6 subtests fail, each naming the (part_type, key) shape that regressed

4. Suite:

python -m pytest tests/run_agent/ -q

Checklist

Code

Note on the test checkbox, stated plainly rather than ticked: tests/run_agent/ reports 7 failures on this branch — and the identical 7 on a pristine origin/main worktree. I captured failing test IDs both ways in the same container and diffed them: 0 introduced, 0 fixed. All 7 are ModuleNotFoundError: anthropic, an artifact of my minimal test image installing only the dev extra. I have not run the entire tests/ tree, so I can't honestly claim all-green.

Verification ran in Docker against a throwaway clone (--network none, repo mounted read-only, HERMES_HOME redirected) because this repo is also my live agent install and an in-place run can be rewritten mid-suite.

Documentation & Housekeeping

  • I've updated relevant documentation — N/A; rationale lives in code comments beside the guard
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A, no new config
  • I've updated CONTRIBUTING.md / AGENTS.md — N/A, no architecture change
  • I've considered cross-platform impact — pure Python string/dict handling, no OS-specific paths or APIs
  • I've updated tool descriptions/schemas — N/A, no tool behaviour change

Screenshots / Logs

The real session row that motivated this, from the local session DB:

finish_reason      : stop
content len        : 206        <- an earlier instance: a progress sentence
codex_message_items:
   phase='final_answer' status='completed' text='The queue is broad and includes...'

and the one that leaked the sentinel:

finish_reason : stop
content       : (empty) again risk. Need progress + tool. Already can. Use execute.

Both were mid-task, both had just completed successful tool calls.

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 10, 2026
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(conversation-loop): recover when a leaked "(empty)" sentinel ends a turn

  1. The regression test reads production source from disk and ast.parse/evals the guard expression (tests/test_leaked_empty_sentinel_guard.py). Beyond conflicting with the repo's "never read source code in tests" hardline, it fails at import/collection time if the guard is refactored (renamed, inlined, extracted into a helper) — the whole test module breaks even when the behavior is correct. Since the file already imports _visible_text_for_sentinel_check and EMPTY_RESPONSE_SENTINEL from production, extracting the guard into a pure helper that both sides call would achieve the anti-drift goal without source inspection.

  2. The documented false-positive edge: a legitimate answer that genuinely begins with "(empty)" (e.g. the user asked what a placeholder should be) is misrouted into recovery, and with the nudge latch already consumed this turn, the user sees a bare "(empty)". The 1/3804 measurement makes this acceptable, but a slightly stricter trigger — sentinel followed by whitespace and word characters, matching the observed scratchpad shape — would narrow it further.

  3. _visible_text_for_sentinel_check returns "" on any exception, which is the safe direction but also silently swallows genuine shape-regression bugs in the flattening logic. A rate-limited warning log would make silent failures visible without spamming.

Sam Painter added 3 commits September 2, 2026 18:41
… 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.
@samfoy
samfoy force-pushed the fix/leaked-empty-sentinel-ends-turn branch from d171a18 to cf74a7c Compare September 2, 2026 19:39
@samfoy

samfoy commented Sep 2, 2026

Copy link
Copy Markdown
Author

cf74a7c730 is the new head, and the PR reports MERGEABLE with unchanged scope: 2 files, 318 insertions, and 3 deletions.

  • The rebase from 5,682 commits behind main produced one additive conflict in agent/conversation_loop.py: upstream added _COMPRESSION_TIMEOUT_FINAL_RESPONSE, while this branch added EMPTY_RESPONSE_SENTINEL, _NON_VISIBLE_CONTENT_PART_TYPES, and _visible_text_for_sentinel_check.
  • I kept both additions, placed upstream first, dropped no code, and verified references at lines 3143, 6624, and 9324 plus the guard call at 8399–8401.
  • The patch remains byte-identical: both merge-base-to-tip stats report 2 files, 318 insertions, and 3 deletions, with only index hashes and @@ line numbers different.
  • The regression test reports 17 passed with 23 subtests, while scripts/run_tests.sh reports 794 passed and 0 failed across 31 relevant files.
  • Plain main reports 777 passed across 30 files, so this PR's test file accounts for the exact 17-test difference.
  • The branch is three commits behind main again, but they touch neither PR file, merge-tree reports no conflict, and I can rebase again on request.

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 P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: a leaked "(empty)" sentinel in a final answer ends the turn instead of recovering

3 participants