fix(agent): hide the synthetic interrupt close from the transcript

close_interrupted_tool_sequence() appends a synthetic assistant turn when an
interrupted tail is a raw tool result, so the next user message does not land as
tool -> user. The row carried a user-visible "Operation interrupted." string and
was persisted as an ordinary assistant message, so background preemption —
session reaping, watchdogs, batch guards — surfaced in the transcript as an
interruption the user was expected to act on.

Store it in the same hidden shape turn_api_call.py already uses for interrupt
placeholders: content="" plus display_kind="hidden" keeps it out of rendered
transcripts, while the api_content sidecar carries the LLM-visible text and is
substituted at API-build time. The alternation guarantee is unchanged.

(cherry picked from commit 22502b1c40c26a08eefc3d35cee067bd1d889bfc)
This commit is contained in:
alrcatraz
2026-10-04 20:36:36 +08:00
committed by kshitij
parent 3336c96b87
commit 6bbdc8ad80
2 changed files with 21 additions and 6 deletions

View File

@@ -288,14 +288,25 @@ def close_interrupted_tool_sequence(messages: list, final_response: Any = None)
"""Append a synthetic assistant turn when an interrupted tail is a tool result: a transcript
ending on a raw ``tool`` message makes the next user message land as ``tool → user``, an
alternation violation strict providers (Gemini, Claude) answer by hallucinating a
continuation. Mutates in place; True if a closing turn was appended."""
continuation. Mutates in place; True if a closing turn was appended.
The synthetic row is hidden from the user: ``api_content`` sidecar carries the LLM-visible
text (substituted at API-build time by ``substitute_api_content``), while ``content=""`` +
``display_kind="hidden"`` keep it out of rendered transcripts. Matches the
``_INTERRUPTED_PLACEHOLDER`` shape in ``turn_api_call.py``."""
last = messages[-1] if messages else None
if not isinstance(last, dict) or last.get("role") != "tool":
return False
text = final_response if isinstance(final_response, str) else ""
from agent.message_metadata import append_message
from agent.agent_runtime_helpers import _INTERRUPTED_PLACEHOLDER
append_message(messages, {"role": "assistant", "content": text.strip() or "Operation interrupted."})
append_message(messages, {
"role": "assistant",
"content": "",
"display_kind": "hidden",
"api_content": text.strip() or _INTERRUPTED_PLACEHOLDER,
})
return True

View File

@@ -10,7 +10,9 @@ user's message before transitioning into the assistant persona.
``finalize_turn`` closes the tool-call sequence on interrupt by appending a
synthetic ``assistant`` message before persistence. ``final_response`` is
typically empty on an interrupt, so the placeholder text is used rather than
an empty-content assistant turn.
an empty-content assistant turn. The synthetic row is hidden from the user
(``content=""`` + ``display_kind="hidden"``); the placeholder lives in the
``api_content`` sidecar, substituted at API-build time for the LLM.
"""
@@ -153,9 +155,11 @@ def test_interrupt_after_tool_closes_sequence_with_placeholder():
# Tail must now be an assistant message, not a raw tool result.
assert messages[-1]["role"] == "assistant"
# Empty final_response falls back to the explicit placeholder rather
# than persisting an empty-content assistant turn.
assert messages[-1]["content"].strip()
# Hidden from user: content intentionally empty, display_kind marks it
# hidden; placeholder lives in api_content (LLM sees it via sidecar).
assert messages[-1]["content"] == ""
assert messages[-1]["display_kind"] == "hidden"
assert messages[-1]["api_content"].strip()
# The persisted snapshot is alternation-safe: appending a new user
# message would follow an assistant, not an orphan tool.