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:
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user