fix(agent): close non-interrupted tool-tail turns with a visible response

A turn that falls out of the loop after a tool result with no follow-up
assistant text left the durable transcript ending at a raw tool row and
returned a silent result: Desktop/TUI showed a ready composer (or kept
spinning) with no final message. The pending_tool_result explainer copy
already existed but nothing minted the exit reason.

finalize_turn now detects the non-interrupted tool tail, fails the turn
with turn_exit_reason=pending_tool_result, and synthesizes the visible
assistant close before persistence so the durable tail is alternation-
safe. Stream-recovered turns (#95514) and interrupted tails keep their
existing paths.

Fixes #55316
Fixes #54756

Co-authored-by: blakehermes9 <blakehermes9@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-09-25 11:36:20 -05:00
committed by brooklyn!
parent 35ad70c035
commit a3a85a3143
3 changed files with 101 additions and 0 deletions

View File

@@ -94,6 +94,9 @@ _EXIT_REASON_FAILURES: Tuple[Tuple[str, str, bool, bool], ...] = (
# Advisory: the reasoning-only text may literally be the answer, and cron stays silent.
("empty_response_exhausted", "empty_response", True, False),
("all_retries_exhausted_no_response", FailoverReason.server_error.value, True, True),
# #55316/#54756: the loop stopped on a tool tail with no follow-up text; the
# finalizer synthesizes the visible close and fails the turn.
("pending_tool_result", "loop_error", True, True),
("interpreter_shutdown", "interpreter_shutdown", False, True),
# Advisory: a deterministic local bug is not a task failure for the kanban breaker.
("local_processing_error", "loop_error", False, False),

View File

@@ -505,6 +505,40 @@ def finalize_turn(
logger=logger,
)
# A non-interrupted turn that fell out of the loop after a tool result, with no
# follow-up assistant text, is the Desktop/TUI "silent stop" (#55316, #54756): the
# composer returns to ready (or keeps spinning) while the durable transcript ends
# at a raw ``tool`` row — the user never learns the turn stopped, and the next user
# message lands as ``tool → user``. Interrupted tails keep
# ``close_interrupted_tool_sequence``; this is the non-interrupt sibling. Mint the
# exit reason, fail the turn, and synthesize the visible close so the tail close in
# ``_persist_step`` persists an assistant row. A turn that already streamed text is
# left alone: ``_recover_final_from_stream`` owns that recovery (#95514).
if (
not final_response
and not interrupted
and messages
and isinstance(messages[-1], dict)
and messages[-1].get("role") == "tool"
and not (getattr(agent, "_current_streamed_assistant_text", "") or "").strip()
):
_turn_exit_reason = "pending_tool_result"
failed = True
final_response = ""
try:
if agent._turn_completion_explainer_enabled():
final_response = (
agent._format_turn_completion_explanation("pending_tool_result", None) or ""
)
except Exception:
final_response = ""
if not final_response:
# The turn-completion explainer opt-out must not reintroduce the silent stop.
final_response = (
"No reply: the turn stopped while a tool result was still pending. "
"Send `continue` to let the model summarize."
)
# Loop exits that are failures in their own right (outer-loop error cap, shutdown, context
# that could not be shrunk) carry the verdict the UI descriptor needs; a bare
# ``turn_exit_reason`` collapsed to code="unknown", retryable=True on every surface.

View File

@@ -61,6 +61,8 @@ class _StubAgent:
self.session_cost_status = "ok"
self.session_cost_source = "stub"
self.persisted_messages = None
# #95514 stream-recovery state read by the finalizer; None on a clean stub.
self._current_streamed_assistant_text: str | None = None
# --- fallible cleanup surfaces (all succeed here) ------------------
def _save_trajectory(self, *a, **k):
@@ -194,3 +196,65 @@ def test_interrupted_turn_with_diagnostic_text_is_not_completed():
assert result["interrupted"] is True
assert result["completed"] is False
assert result["failed"] is False
def _pending_tool_result_tail():
"""A non-interrupted turn that fell out of the loop after a tool result, with no
follow-up assistant text — the #55316/#54756 "silent stop" shape."""
return [
{"role": "user", "content": "summarize the log"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "c1", "function": {"name": "terminal", "arguments": "{}"}}
],
},
{"role": "tool", "tool_call_id": "c1", "content": "log output"},
]
def test_non_interrupted_tool_tail_gets_visible_close():
"""A turn that stops on a tool tail WITHOUT an interrupt must not return a silent,
ready-looking result: the finalizer fails the turn, mints the ``pending_tool_result``
exit reason, and persists a visible assistant close so the durable transcript does
not end at a raw ``tool`` row (#55316, #54756)."""
agent = _StubAgent()
messages = _pending_tool_result_tail()
result = _finalize(agent, messages, interrupted=False, final_response=None)
assert result["turn_exit_reason"] == "pending_tool_result"
assert result["failed"] is True
assert result["completed"] is False
assert result["final_response"].strip()
# The durable tail is an assistant row, not the raw tool result.
assert messages[-1]["role"] == "assistant"
assert messages[-1]["content"].strip()
assert agent.persisted_messages is not None
assert agent.persisted_messages[-1]["role"] == "assistant"
follow_on = agent.persisted_messages + [{"role": "user", "content": "continue"}]
_assert_no_tool_then_user(follow_on)
def test_tool_tail_with_streamed_text_is_recovered_not_marked_pending():
"""A turn whose stream already delivered text is #95514's stream-recovery case; the
``pending_tool_result`` close must not fire over it."""
agent = _StubAgent()
agent._current_streamed_assistant_text = "Here is the summary you asked for."
messages = _pending_tool_result_tail()
result = _finalize(agent, messages, interrupted=False, final_response=None)
assert result["turn_exit_reason"] != "pending_tool_result"
assert result["final_response"] == "Here is the summary you asked for."
assert messages[-1]["role"] == "assistant"
def test_tool_tail_with_non_tool_last_role_is_untouched():
"""Only the tool-tail shape triggers the close; a plain user-tail turn keeps its
existing exit reason."""
agent = _StubAgent()
result = _finalize(
agent, [{"role": "user", "content": "hi"}], interrupted=False, final_response=None,
)
assert result["turn_exit_reason"] == "interrupted_by_user" # unchanged passthrough
assert result["failed"] is False