Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from typing import Any, Dict, List, Optional, Tuple
from hermes_cli.timeouts import get_provider_request_timeout
from agent.message_sanitization import (
_FULL_ARGS_LOG_BOUND, coalesce_tool_call_id, coerce_tool_name, tool_call_id_variants, tool_result_id_variants
_FULL_ARGS_LOG_BOUND, INTERRUPTED_TOOL_TAIL_KEY, coalesce_tool_call_id, coerce_tool_name, tool_call_id_variants, tool_result_id_variants
)
from agent.prompt_builder import STEER_DISPLAY_KIND, steer_user_row
from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message
Expand Down Expand Up @@ -3012,7 +3012,45 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
messages = _drop_results_without_ids(messages)
messages = _pair_tool_calls_positionally(messages)
messages = _dedupe_tool_call_ids(messages)
return _realign_tool_result_names(messages)
messages = _realign_tool_result_names(messages)
# A user redirect after an explicitly interrupted tool-result tail needs
# an API-only assistant closure. The same role shape is also a valid normal
# redirect, so interruption provenance is mandatory; role adjacency alone
# must never synthesize cancellation context (#48879, #63292).
closed_tool_tails: List[Dict[str, Any]] = []
inserted_closures = 0
stripped_markers = 0
previous_was_interrupted_tool = False
for msg in messages:
if msg.get("role") == "user" and previous_was_interrupted_tool:
closed_tool_tails.append({
"role": "assistant",
"content": "Operation interrupted.",
})
inserted_closures += 1

api_msg = msg
if INTERRUPTED_TOOL_TAIL_KEY in msg:
api_msg = {
key: value
for key, value in msg.items()
if key != INTERRUPTED_TOOL_TAIL_KEY
}
stripped_markers += 1
closed_tool_tails.append(api_msg)
previous_was_interrupted_tool = (
msg.get("role") == "tool"
and msg.get(INTERRUPTED_TOOL_TAIL_KEY) is True
)

if inserted_closures or stripped_markers:
messages = closed_tool_tails
if inserted_closures:
logger.debug(
"Pre-call sanitizer: closed %d interrupted tool-result tail(s)",
inserted_closures,
)
return messages


_ACK_FUTURE_RE = re.compile(r"\b(i['’]ll|i will|let me|i can do that|i can help with that)\b")
Expand Down
20 changes: 20 additions & 0 deletions agent/message_sanitization.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,25 @@ def normalize_finish_reason(raw: Any) -> Any:
return _FINISH_REASON_ALIASES.get(lowered, lowered)


INTERRUPTED_TOOL_TAIL_KEY = "_interrupted_tool_tail"


def mark_interrupted_tool_tail(messages: list) -> bool:
"""Mark a tool-result tail as ended by an explicit turn interruption.

The marker is durable internal metadata. API-copy sanitization consumes it
when a later user redirect needs a synthetic assistant closure; provider
transports strip the underscore-prefixed key from the wire payload.
"""
if not messages:
return False
tail = messages[-1]
if not isinstance(tail, dict) or tail.get("role") != "tool":
return False
tail[INTERRUPTED_TOOL_TAIL_KEY] = True
return True


def serialized_messages_bytes(messages: list) -> int:
"""Exact serialized byte size of ``messages`` (HTTP 413 is a BYTE-size error the token
estimator, pricing images flat, cannot score). Non-serializable values fall back to
Expand Down Expand Up @@ -438,6 +457,7 @@ def _looks_like_corrupt_image_rejection(error_body: str) -> bool:


__all__ = [
"INTERRUPTED_TOOL_TAIL_KEY", "mark_interrupted_tool_tail",
"_SURROGATE_RE", "close_interrupted_tool_sequence",
"_sanitize_surrogates", "_sanitize_structure_surrogates", "_sanitize_messages_surrogates",
"coerce_tool_name",
Expand Down
22 changes: 21 additions & 1 deletion agent/session_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ def _db_flush_row(agent, msg: Dict, is_current_turn_user: bool) -> Dict[str, Any
"timestamp": timestamp, "api_content": api_content,
"display_kind": _summary_display_kind(msg), "display_metadata": msg.get("display_metadata"),
"platform_message_id": msg.get("platform_message_id"), # load-bearing for restart drain-window recovery dedup
"_interrupted_tool_tail": bool(msg.get("_interrupted_tool_tail")),
}
if isinstance(msg.get("_row_id"), int):
row["_row_id"] = msg["_row_id"]
Expand All @@ -237,7 +238,26 @@ def _db_flush_collect(agent, messages: List[Dict], conversation_history: Optiona
msg = messages[msg_idx]
# Append-only flush: a mid-turn persist of scaffolding would commit a synthetic turn the end-of-turn
# drop cannot un-write. Skip regardless of position.
if not isinstance(msg, dict) or _is_ephemeral_scaffolding(msg) or msg.get(_DB_PERSISTED_MARKER):
if not isinstance(msg, dict) or _is_ephemeral_scaffolding(msg):
continue
if msg.get(_DB_PERSISTED_MARKER):
# Already-durable tool rows marked interrupted at finalize time still need
# the durable provenance column stamped — they were flushed before the
# marker existed (#63292).
if (
msg.get("role") == "tool"
and msg.get("_interrupted_tool_tail") is True
and not msg.get("_db_interrupted_tail_stamped")
):
# Only latch the one-shot guard when the durable UPDATE actually
# matched a row — a 0-row match (row not yet flushed, raced
# rewind) must stay eligible for a later flush's back-stamp
# (quad review P2: silent provenance loss otherwise).
if agent._session_db.mark_tool_tail_interrupted(
agent.session_id,
msg.get("tool_call_id"),
):
msg["_db_interrupted_tail_stamped"] = True
continue
# Already durable (history copy or caller-seeded): stamp so future flushes skip it.
if (
Expand Down
13 changes: 9 additions & 4 deletions agent/turn_finalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,16 @@ def _recover_final_from_stream(agent, final_response, interrupted, failed) -> Tu
def _close_transcript_tail(agent, messages, final_response, interrupted, _recovered_from_stream) -> None:
"""Shape the transcript tail before the durable snapshot (scaffolding already dropped
and ``final_response`` already stream-recovered by the caller)."""
# An interrupt can leave a tool result as the tail; close the sequence so strict
# providers don't see ``tool → user`` (placeholder: final_response is usually empty).
# When the turn was interrupted and the last message is a tool result, persist
# interruption PROVENANCE on the tool tail instead of a synthetic assistant
# closure (#48879, #63292). A persisted ``tool → user`` alternation makes strict
# providers (Gemini, Claude) hallucinate a continuation of the user's message; the
# durable ``interrupted_tool_tail`` marker lets the API-copy sanitizer close the
# sequence provider-side at the next call while the stored transcript keeps real
# rows only.
if interrupted:
from agent.message_sanitization import close_interrupted_tool_sequence
close_interrupted_tool_sequence(messages, final_response)
from agent.message_sanitization import mark_interrupted_tool_tail
mark_interrupted_tool_tail(messages)

# Recovery ``break`` sites can return a final_response with no closing assistant
# row; enforce "delivered final_response ⇒ assistant row" here. Compare content,
Expand Down
6 changes: 3 additions & 3 deletions agent/turn_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
_looks_like_corrupt_image_rejection, _looks_like_image_content_rejection, _sanitize_messages_non_ascii,
_sanitize_messages_surrogates, _sanitize_structure_non_ascii, _sanitize_structure_surrogates,
_strip_images_from_messages, _strip_non_ascii,
close_interrupted_tool_sequence,
mark_interrupted_tool_tail,
)
from agent.thinking_timeout_guidance import build_thinking_timeout_guidance, is_thinking_timeout
from agent.vision_message_prep import _provider_model_key
Expand Down Expand Up @@ -1255,10 +1255,10 @@ def abort_turn_on_interrupt(
agent: Any, messages: List[Dict[str, Any]], conversation_history: Any, api_call_count: int, *,
abort_message: str, interrupt_text: str,
) -> Dict[str, Any]:
"""Announce ``abort_message``, close any open tool sequence with ``interrupt_text``,
"""Announce ``abort_message``, mark an open tool sequence with interruption provenance,
persist, clear the interrupt and return the ``interrupted`` result dict."""
_vlines(agent, f"⚡ {abort_message}")
close_interrupted_tool_sequence(messages, interrupt_text)
mark_interrupted_tool_tail(messages)
agent._persist_session(messages, conversation_history)
# The turn was stopped, not rebuilt: a pending steer was aimed at this turn's next
# tool iteration, which will no longer happen — drop it (hard-cancel semantics).
Expand Down
7 changes: 4 additions & 3 deletions agent/turn_tool_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from typing import Any, Dict, List, Optional

from agent.message_metadata import append_message
from agent.message_sanitization import close_interrupted_tool_sequence, coalesce_tool_call_id
from agent.message_sanitization import coalesce_tool_call_id, mark_interrupted_tool_tail
from agent.turn_failure_copy import site_copy, stamp_failure

logger = logging.getLogger("agent.conversation_loop")
Expand Down Expand Up @@ -53,9 +53,10 @@ def _append_tool_error_results(messages, tool_calls, content_for) -> None:

def _partial_exit(agent, messages, conversation_history, api_call_count, final_response: str) -> Dict[str, Any]:
"""Terminal partial result. Prior retries or an earlier tool batch leave a tool-result
tail; close it as interrupt aborts do so the next turn is not tool→user (#48879).
tail; stamp interruption provenance as interrupt aborts do so the API-copy sanitizer
closes the next turn's tool→user provider-side (#48879, #63292).
This path never reaches finalize_turn, so persist here."""
close_interrupted_tool_sequence(messages, final_response)
mark_interrupted_tool_tail(messages)
agent._persist_session(messages, conversation_history)
return stamp_failure({
"final_response": final_response,
Expand Down
6 changes: 4 additions & 2 deletions agent/turn_truncation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from agent.error_classifier import FailoverReason
from agent.message_metadata import append_message
from agent.message_sanitization import close_interrupted_tool_sequence
from agent.message_sanitization import close_interrupted_tool_sequence, mark_interrupted_tool_tail
from agent.repetition_guard import is_repetition_dominated
from agent.turn_api_call import stop_thinking_spinner
from agent.turn_failure_copy import content_policy_copy, provider_label_for, site_copy, stamp_failure
Expand Down Expand Up @@ -349,7 +349,9 @@ def _retry_truncated_tool_call(st: _Trunc, api_kwargs: Any) -> TruncationVerdict
_final_response = _TRUNCATED_FINAL
agent._cleanup_task_resources(st.effective_task_id)
# Prior tool batches can leave a tool-result tail; this path never reaches finalize_turn.
close_interrupted_tool_sequence(st.messages, _final_response)
# Stamp interruption provenance — the API-copy sanitizer closes ``tool → user``
# provider-side so the durable transcript keeps real rows only (#63292).
mark_interrupted_tool_tail(st.messages)
return st.end_turn(
_final_response, cleanup=False,
failure=(FailoverReason.timeout.value if st.is_stub else "truncated", True),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ function firstBillingLine(text: string): string {
return (text || '').split('\n')[0]?.trim() ?? ''
}

// Legacy backends close interrupted turns with a synthetic status string as
// the final text. Anchored EXACT patterns (not prefixes): a genuine answer
// that merely opens with "Operation interrupted…" must survive; only the
// whole-message sentinel is demoted to metadata (#63292).
const LEGACY_INTERRUPT_STATUS_PATTERNS = [
/^Operation interrupted\.$/,
/^Operation interrupted: waiting for model response \(\d+\.\d+s elapsed\)\.$/,
/^Operation interrupted during retry \(.+, attempt \d+\/\d+\)\.$/,
/^Operation interrupted: handling API error \([^:\r\n]+: .*\)\.$/,
/^Operation interrupted: retrying API call after error \(retry \d+\/\d+\)\.$/,
// Empty-response retry backoff producer (agent/turn_empty_response.py) — same
// synthetic-interrupt family; the whole-message sentinel demotes to metadata.
/^Operation interrupted: retrying empty response from model \(retry \d+\/\d+\)\.$/
] as const

/**
* A turn failed on a billing wall (out of credits / payment required). The
* gateway forwards the structured descriptor built by `agent/billing_links.py`;
Expand Down Expand Up @@ -78,6 +93,7 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
finalizeInterimAssistantMessage,
flushQueuedDeltas,
nativeSubagentSessionsRef,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState
} = deps
Expand Down Expand Up @@ -332,10 +348,21 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {

flushQueuedDeltas(sessionId)

// Keyed by session so only one window beeps when several are open.
playCompletionSound(sessionId)
const completionInterrupted = payload?.status === 'interrupted' || sessionInterrupted(sessionId)
const completionText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)

// An interrupted completion whose text is just the legacy synthetic
// sentinel is metadata, not content — keep any real partial text.
const finalText =
completionInterrupted && LEGACY_INTERRUPT_STATUS_PATTERNS.some(pattern => pattern.test(completionText.trim()))
? ''
: completionText

const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
// Keyed by session so only one window beeps when several are open.
// Interruptions are cancellations, not completions — no fanfare.
if (!completionInterrupted) {
playCompletionSound(sessionId)
}

// Terminal error frames (status "error") carry the failure in
// structured fields: `error` is the message, `partial` marks
Expand All @@ -356,7 +383,8 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
payload?.response_previewed,
failure,
occurredAt,
payload?.persisted_turn
payload?.persisted_turn,
completionInterrupted
)

// Onboarding's first build: between turns is the only moment Setup may
Expand All @@ -377,18 +405,24 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
if (isActiveEvent) {
setTurnStartedAt(null)

// Pet beat: a finished turn always celebrates — go straight to the
// jump, never linger on the run/reason pose. One atom update (clears
// toolRunning/reasoning AND sets celebrate together) so no stray "run"
// frame leaks to the sprite — including the popped-out overlay, which
// mirrors each activity change. The jump runs ~2 loops, then settles.
flashPetActivity({ celebrate: true, reasoning: false, toolRunning: false }, 2200)

// Light up the pet's mail icon if the user wasn't looking when the turn
// finished — a glanceable "new message" hint on the popped-out overlay.
// Cleared when they open the app via the mail icon or refocus the window.
if (typeof document !== 'undefined' && !document.hasFocus()) {
markPetUnread()
if (completionInterrupted) {
// Clear stale working poses without turning a cancellation into a
// completion celebration — and never flag the turn as unread.
setPetActivity({ reasoning: false, toolRunning: false })
} else {
// Pet beat: a finished turn always celebrates — go straight to the
// jump, never linger on the run/reason pose. One atom update (clears
// toolRunning/reasoning AND sets celebrate together) so no stray "run"
// frame leaks to the sprite — including the popped-out overlay, which
// mirrors each activity change. The jump runs ~2 loops, then settles.
flashPetActivity({ celebrate: true, reasoning: false, toolRunning: false }, 2200)

// Light up the pet's mail icon if the user wasn't looking when the turn
// finished — a glanceable "new message" hint on the popped-out overlay.
// Cleared when they open the app via the mail icon or refocus the window.
if (typeof document !== 'undefined' && !document.hasFocus()) {
markPetUnread()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export interface GatewayEventDeps {
responsePreviewed?: boolean,
failure?: { error: string; partial: boolean },
occurredAt?: number,
persistedTurn?: PersistedTurn | null
persistedTurn?: PersistedTurn | null,
interrupted?: boolean
) => void
failAssistantMessage: (
sessionId: string,
Expand Down
19 changes: 12 additions & 7 deletions apps/desktop/src/app/session/hooks/use-message-stream/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,8 @@ export function useMessageStream({
responsePreviewed?: boolean,
failure?: { error: string; partial: boolean; surface?: ErrorSurface | null },
occurredAt = Date.now() / 1000,
persistedTurn?: PersistedTurn | null
persistedTurn?: PersistedTurn | null,
interrupted = false
) => {
let shouldHydrate = false

Expand Down Expand Up @@ -915,12 +916,16 @@ export function useMessageStream({
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
}

dispatchNativeNotification({
body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'),
kind: 'turnDone',
sessionId,
title: translateNow('notifications.native.turnDoneTitle')
})
if (!interrupted) {
// A cancelled turn is not a "turn done" — suppress the fanfare
// notification so the user is not told work finished when it stopped.
dispatchNativeNotification({
body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'),
kind: 'turnDone',
sessionId,
title: translateNow('notifications.native.turnDoneTitle')
})
}
},
[hydrateFromStoredSession, scheduleSessionsRefresh, updateSessionState]
)
Expand Down
Loading