Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions agent/turn_finalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,25 @@ def finalize_turn(
from agent.message_sanitization import close_interrupted_tool_sequence
close_interrupted_tool_sequence(messages, final_response)

# Some recovery/fallback paths return a real final_response without
# adding a closing assistant message to the transcript (e.g. the
# partial-stream and prior-turn-content recovery ``break`` sites in
# ``conversation_loop``). If persisted as-is, the durable session can
# end at a tool/user message even though the caller — and the gateway
# platform — already saw a completed assistant response. The next turn
# then replays a user-only backlog and the model re-answers every
# "unanswered" message. Close the durable turn at the source, at the
# single chokepoint every recovery ``break`` flows through, so the
# invariant "delivered final_response ⇒ assistant row in transcript"
# holds regardless of which path produced it. (#43849 / #44100)
if final_response and not interrupted:
try:
_tail_role = messages[-1].get("role") if messages else None
except Exception:
_tail_role = None
if _tail_role != "assistant":
messages.append({"role": "assistant", "content": final_response})
Comment on lines +199 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 New tail-closure code defeats '(empty)' sentinel stripping, causing '(empty)' to persist in session (bug)

During the empty_response_exhausted recovery path in conversation_loop.py (lines 4827-4866), the model returns no content after exhausting retries. The loop appends an assistant message with content="(empty)" and the _empty_terminal_sentinel=True flag (line 4838), then sets final_response = "(empty)" (line 4865) and breaks. In finalize_turn, _drop_trailing_empty_response_scaffolding at line 168 correctly strips this flagged message (and pass 2 may rewind trailing tool result/assistant pairs). However, the new code at lines 199-205 then detects the tail is no longer "assistant" (the sentinel was just popped), and re-appends {"role": "assistant", "content": "(empty)"} without the _empty_terminal_sentinel flag. This unflagged message is then persisted by _persist_session at line 207 (_flush_messages_to_session_db only filters messages by _is_ephemeral_scaffolding which checks the flag). The sentinel mechanism — explicitly designed to prevent (empty) from landing in durable storage (per docstring at lines 4832-4836, also per the comment at line 163-166) — is completely bypassed. A subsequent "continue" turn will replay assistant("(empty)") as if it were a real model response, which can keep long tool-heavy sessions stuck in empty-response loops.

💡 Suggestion: Guard the new tail-closure block against the sentinel value so that recovery paths producing "(empty)" do not re-add the sentinel content. The cleanest fix is to exclude final_response == "(empty)" from the guard condition, since this is the explicit sentinel value that _drop_trailing_empty_response_scaffolding is designed to strip.

Suggested change
if final_response and not interrupted:
try:
_tail_role = messages[-1].get("role") if messages else None
except Exception:
_tail_role = None
if _tail_role != "assistant":
messages.append({"role": "assistant", "content": final_response})
if final_response and not interrupted and final_response != "(empty)":
try:
_tail_role = messages[-1].get("role") if messages else None
except Exception:
_tail_role = None
if _tail_role != "assistant":
messages.append({"role": "assistant", "content": final_response})
📋 Prompt for AI Agents

In agent/turn_finalizer.py line 199, change the guard condition from if final_response and not interrupted: to if final_response and not interrupted and final_response != "(empty)":. This prevents the new tail-closure logic from re-adding the "(empty)" sentinel content that _drop_trailing_empty_response_scaffolding correctly stripped at line 168. The sentinel path is already handled by the conversation loop's own scaffolding mechanism and should not be re-closed here. Add a test in tests/agent/test_turn_finalizer_final_response_persistence.py that passes final_response="(empty)" with _turn_exit_reason="empty_response_exhausted" and asserts the persisted messages list does NOT contain an "(empty)" content message.


agent._persist_session(messages, conversation_history)
except Exception as _persist_err:
_cleanup_errors.append(f"persist_session: {_persist_err}")
Expand Down
4 changes: 3 additions & 1 deletion scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@

# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"30854794+YLChen-007@users.noreply.github.com": "YLChen-007", # PR #27289 salvage (case-insensitive streaming reasoning-tag filter in cli.py _stream_delta + gateway stream_consumer so mixed-case variants like <Think>/<ThInK> are suppressed, not just the hardcoded case literals)
"259353979+testingbuddies24@users.noreply.github.com": "testingbuddies24", # PR #43192 salvage (strip orphan think-tag close tags in progressive gateway stream so a bare </think> whose open was dropped upstream can't leak to the user)
"shx_929@163.com": "Lazymonter", # PR #42914 salvage (retry launchd bootstrap after bootout on EIO for install/start instead of degrading to detached)
"96322396+WXBR@users.noreply.github.com": "WXBR", # PR #46183 salvage (persist recovered final_response at the finalize_turn chokepoint so recovery-path breaks don't drop the delivered assistant row)
"5848605+itenev@users.noreply.github.com": "itenev", # PR #22753 salvage (asyncify model-context resolution in gateway message path so blocking requests.get can't starve Discord heartbeats)
"arthur.zhang@ingenico.com": "arthurzhang", # PR #34718 salvage (redact Slack App-Level xapp- tokens in agent/redact.py + gateway/run.py)
"290873280+rrevenanttt@users.noreply.github.com": "rrevenanttt", # PR #40773 salvage (close hardline rm bypass via quoted paths and ${HOME} brace form)
Expand Down Expand Up @@ -221,6 +222,7 @@
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
"dirtyren@users.noreply.github.com": "dirtyren",
"tgmerritt@gmail.com": "tgmerritt", # PR #43553 salvage (parse vLLM's token-based output-cap error format so over-cap max_tokens 400s reduce the output cap instead of death-looping into compression)
"13277570+justin-cyhuang@users.noreply.github.com": "justin-cyhuang",
"agent@tranquil-flow.dev": "Tranquil-Flow",
"jason@hermes-jc": "jcjc81",
Expand Down
112 changes: 112 additions & 0 deletions tests/agent/test_turn_finalizer_final_response_persistence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from types import SimpleNamespace

from agent.turn_finalizer import finalize_turn


class FakeAgent:
def __init__(self):
self.max_iterations = 90
self.iteration_budget = SimpleNamespace(remaining=10, used=1, max_total=90)
self.quiet_mode = True
self.model = "test-model"
self.provider = "test-provider"
self.base_url = ""
self.session_id = "sess-test"
self.context_compressor = SimpleNamespace(last_prompt_tokens=0)
self.session_input_tokens = 0
self.session_output_tokens = 0
self.session_cache_read_tokens = 0
self.session_cache_write_tokens = 0
self.session_reasoning_tokens = 0
self.session_prompt_tokens = 0
self.session_completion_tokens = 0
self.session_total_tokens = 0
self.session_estimated_cost_usd = 0
self.session_cost_status = "unknown"
self.session_cost_source = "test"
self._tool_guardrail_halt_decision = None
self._interrupt_message = None
self._response_was_previewed = True
self._skill_nudge_interval = 0
self._iters_since_skill = 0
self.valid_tool_names = []
self.persisted_messages = None

def _handle_max_iterations(self, messages, api_call_count):
raise AssertionError("not expected")

def _emit_status(self, *_args, **_kwargs):
pass

def _safe_print(self, *_args, **_kwargs):
pass

def _save_trajectory(self, *_args, **_kwargs):
pass

def _cleanup_task_resources(self, *_args, **_kwargs):
pass

def _drop_trailing_empty_response_scaffolding(self, messages):
pass

def _persist_session(self, messages, conversation_history):
self.persisted_messages = list(messages)

def _file_mutation_verifier_enabled(self):
return False

def _turn_completion_explainer_enabled(self):
return False

def _drain_pending_steer(self):
return None

def clear_interrupt(self):
pass

def _sync_external_memory_for_turn(self, **_kwargs):
pass


def test_final_response_closes_tool_tail_before_persistence(monkeypatch):
"""A recovered/previewed final response must be durable in session history.

Regression for turns where the caller receives a non-empty final_response,
but the message transcript still ends at a tool result. If persisted that
way, the next turn reloads a stale/malformed history and can appear to loop
because the assistant's visible final answer is missing from durable state.
"""
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
agent = FakeAgent()
messages = [
{"role": "user", "content": "do it"},
{
"role": "assistant",
"content": "I'll check.",
"tool_calls": [
{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}
],
},
{"role": "tool", "tool_call_id": "call-1", "name": "terminal", "content": "ok"},
]

result = finalize_turn(
agent,
final_response="Done.",
api_call_count=2,
interrupted=False,
failed=False,
messages=messages,
conversation_history=[],
effective_task_id="task",
turn_id="turn",
user_message="do it",
original_user_message="do it",
_should_review_memory=False,
_turn_exit_reason="fallback_prior_turn_content",
)

assert result["messages"][-1] == {"role": "assistant", "content": "Done."}
assert agent.persisted_messages is not None
assert agent.persisted_messages[-1] == {"role": "assistant", "content": "Done."}
Loading