From 6bbdc8ad8072da5bd247cd353dde893ad7cf4a98 Mon Sep 17 00:00:00 2001 From: alrcatraz Date: Sun, 4 Oct 2026 20:36:36 +0800 Subject: [PATCH] fix(agent): hide the synthetic interrupt close from the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- agent/message_sanitization.py | 15 +++++++++++++-- .../test_turn_finalizer_interrupt_alternation.py | 12 ++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/agent/message_sanitization.py b/agent/message_sanitization.py index fbff5af965..7ea09a0ae2 100644 --- a/agent/message_sanitization.py +++ b/agent/message_sanitization.py @@ -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 diff --git a/tests/agent/test_turn_finalizer_interrupt_alternation.py b/tests/agent/test_turn_finalizer_interrupt_alternation.py index 081942c176..3ff097f7ea 100644 --- a/tests/agent/test_turn_finalizer_interrupt_alternation.py +++ b/tests/agent/test_turn_finalizer_interrupt_alternation.py @@ -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.