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