Skip to content
Merged
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: 42 additions & 0 deletions src/xagent/web/api/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
TerminalTaskEventDraft,
TerminalTaskEventMessageCode,
bind_terminal_event_draft,
first_party_message_terminal_text,
is_external_cancel_command,
terminal_event_draft_for_error,
)
Expand Down Expand Up @@ -462,11 +463,24 @@ def client_safe_task_command_failure(
picked by what the terminal exception proves -- non-application is
asserted only when it is established, uncertainty otherwise -- and
needs no task status, so the caller does not read the task for it.

A first-party MESSAGE drops the prefix for the same proof rule: its
sender is deciding whether to resend a durably accepted reply, so the
sentence comes from the bound terminal-event draft instead of the
exception text (#1500).
"""
if is_external_cancel_command(kind=kind.value, scope=scope):
return external_cancel_exhausted_message(task_status)
if scope == EXTERNAL_COMMAND_SCOPE and kind == TaskCommandKind.MESSAGE:
return external_input_terminal_message(error)
if kind == TaskCommandKind.MESSAGE:
# A first-party MESSAGE follows the external rule above rather than
# the generic fallback: restating the deferral's last wait condition
# under a "failed" prefix tells the sender nothing about whether the
# accepted reply was applied (#1500). The sentence is derived from
# the bound terminal-event draft, so it asserts non-application only
# when the persisted outcome proves it.
return first_party_message_terminal_text(terminal_event_draft_for_error(error))
Comment thread
codeacme17 marked this conversation as resolved.
# kind.value in the text is safe only while every external-scope kind is
# handled above; a new external-scope kind needs its own branch first.
return f"Task command {kind.value} failed: {client_safe_error_message(error)}"
Expand Down Expand Up @@ -9759,6 +9773,27 @@ async def _broadcast_terminal_command_error(
command.task_id,
)
return
# ``outcome``/``resend_safe``/``message_code`` expose the persisted
# terminal disposition structurally (#1500), so the sender can decide
# whether resending the command is safe without parsing ``message``.
# The field names match the durable terminal-event projection (#1904),
# including its two disambiguators: ``task_run_id`` (the acceptance
# snapshot's run) and ``outcome_version`` (the attempt count, which the
# terminal CAS write pins to this same value), because an operator retry
# can send one ``command_id`` through a terminal broadcast twice.
# Values come from the draft the dispatcher binds before broadcasting;
# a missing draft degrades to the unsafe/unknown reading. Only this
Comment thread
codeacme17 marked this conversation as resolved.
# identity-bearing frame carries them: the two external frames above
# deliberately expose nothing the anonymous audience cannot act on,
# and a retry decision needs the ``command_id`` they withhold.
#
# ``resend_safe`` is a proof of non-application, not a retryability
# rating: the only producer of ``True`` is the MESSAGE contention
# deferral. PAUSE/RESUME/CANCEL terminals therefore always carry
# ``False`` even though those commands are idempotent by design -- a
# consumer deciding whether to offer a retry for them must reason from
# ``command_kind``, never from this flag.
draft = terminal_event_draft_for_error(error)
Comment thread
codeacme17 marked this conversation as resolved.
await manager.broadcast_to_task(
{
"type": "agent_error",
Expand All @@ -9770,9 +9805,16 @@ async def _broadcast_terminal_command_error(
error,
scope=scope,
),
"outcome": "failed",
Comment thread
codeacme17 marked this conversation as resolved.
"resend_safe": bool(draft and draft.resend_safe),
"message_code": (
draft.message_code.value if draft and draft.message_code else None
),
"command_kind": command.kind.value,
"task_id": command.task_id,
"command_id": command.command_id,
"task_run_id": command.target_run_id,
"outcome_version": int(command.attempt_count or 0),
"timestamp": datetime.now(timezone.utc).timestamp(),
},
command.task_id,
Expand Down
38 changes: 38 additions & 0 deletions src/xagent/web/services/task_command_terminal_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,44 @@ def terminal_event_draft_for_error(
return draft if isinstance(draft, TerminalTaskEventDraft) else None


# Wording for a first-party MESSAGE command that reached a terminal
# disposition after the sender's reply was durably accepted. The sentence
# pair matches ``external_task_input.external_input_terminal_message``, but
# the proof rule is narrower, not a mirror: the external helper also treats
# ``TaskCommandRejected`` as proven non-application, while this one reads
# only ``draft.resend_safe`` -- which stays ``False`` for rejections --
# because first-party MESSAGE rejections keep their handler-owned
# notification and never reach this broadcast. Whoever wires that broadcast
# up later must widen the proof rule here, or a provably-unapplied
# rejection would be reported as unconfirmed. The categorical "not applied"
# sentence is reserved for outcomes whose draft proves non-application, and
# every other terminal gets the sentence that asserts only uncertainty,
# because a worker may have injected the message before crashing and the
# reclaiming attempt cannot know.
FIRST_PARTY_MESSAGE_NOT_APPLIED_MESSAGE = (
"This message was not applied to the task. It is safe to send it again."
)
FIRST_PARTY_MESSAGE_UNCONFIRMED_MESSAGE = (
"We could not confirm whether this message was applied to the task. "
"Review the conversation before sending it again."
)


def first_party_message_terminal_text(draft: TerminalTaskEventDraft | None) -> str:
"""Wording for a terminal first-party MESSAGE outcome, by what is provable.

Deriving from the draft rather than the exception keeps the sentence
aligned with the persisted terminal event: ``resend_safe`` is set only
when the failed handoff proved the command never reached the downstream
operation. A missing draft yields the uncertain sentence, the safe
direction for a duplicate-send decision.
"""

if draft is not None and draft.resend_safe:
Comment thread
codeacme17 marked this conversation as resolved.
return FIRST_PARTY_MESSAGE_NOT_APPLIED_MESSAGE
return FIRST_PARTY_MESSAGE_UNCONFIRMED_MESSAGE


def stage_terminal_event(
db: Session,
*,
Expand Down
Loading