Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,12 @@ def init_agent(
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
# Gateway-only receipt proof tags copied onto the next user message. The
# underscore-prefixed field never leaves the process or persists to SQLite;
# it binds the in-memory `_db_persisted` proof to an exact mobile mutation.
agent._pending_mobile_mutation_receipt_tags: list[str] = []
agent._durable_mobile_mutation_receipt_tags: set[str] = set()
agent._mobile_mutation_receipt_condition = None

# Cache anthropic image-to-text fallbacks per image payload/URL so a
# single tool loop does not repeatedly re-run auxiliary vision on the
Expand Down
26 changes: 26 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,32 @@ def _is_codex_interim(m: Dict) -> bool:
if prev_content and new_content
else (prev_content or new_content)
)
# The gateway binds a mobile receipt to the exact new user
# dict that SessionDB successfully persisted. Repair discards
# that dict after merging its text into ``prev``. Preserve an
# explicit proof-only tag without marking the merged content as
# wholly DB-persisted (``prev`` itself may still need a flush).
persisted_receipt_tags = list(
msg.get("_mobile_mutation_persisted_receipt_tags") or ()
)
if msg.get("_db_persisted"):
for tag in msg.get(
"_mobile_mutation_receipt_tags",
(),
) or ():
if tag not in persisted_receipt_tags:
persisted_receipt_tags.append(tag)
if persisted_receipt_tags:
existing_receipt_tags = list(
prev.get("_mobile_mutation_persisted_receipt_tags")
or ()
)
for tag in persisted_receipt_tags:
if tag not in existing_receipt_tags:
existing_receipt_tags.append(tag)
prev["_mobile_mutation_persisted_receipt_tags"] = (
existing_receipt_tags
)
repairs += 1
continue
merged.append(msg)
Expand Down
43 changes: 42 additions & 1 deletion agent/turn_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,8 +313,19 @@ def build_turn_context(
should_review_memory = True
agent._turns_since_memory = 0

# Add user message.
# Add user message. Gateway mobile receipt tags are process-private proof
# tags: transport sanitizers strip underscore-prefixed fields and SessionDB
# stores only the canonical message columns, while `_db_persisted` remains
# on this exact dict after a successful append for receipt finalization.
user_msg = {"role": "user", "content": user_message}
mobile_mutation_receipt_tags = list(
getattr(agent, "_pending_mobile_mutation_receipt_tags", ()) or ()
)
agent._pending_mobile_mutation_receipt_tags = []
if mobile_mutation_receipt_tags:
user_msg["_mobile_mutation_receipt_tags"] = (
mobile_mutation_receipt_tags
)
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
Expand Down Expand Up @@ -360,6 +371,36 @@ def build_turn_context(
agent.session_id or "none",
exc_info=True,
)
if user_msg.get("_db_persisted") and mobile_mutation_receipt_tags:
receipt_condition = getattr(
agent,
"_mobile_mutation_receipt_condition",
None,
)
if receipt_condition is not None:
with receipt_condition:
durable_tags = set(
getattr(
agent,
"_durable_mobile_mutation_receipt_tags",
set(),
)
or set()
)
durable_tags.update(mobile_mutation_receipt_tags)
agent._durable_mobile_mutation_receipt_tags = durable_tags
receipt_condition.notify_all()
else:
durable_tags = set(
getattr(
agent,
"_durable_mobile_mutation_receipt_tags",
set(),
)
or set()
)
durable_tags.update(mobile_mutation_receipt_tags)
agent._durable_mobile_mutation_receipt_tags = durable_tags

# ── Preflight context compression ──
# Gate the (expensive) full token estimate behind a cheap pre-check.
Expand Down
104 changes: 104 additions & 0 deletions docs/mobile-client-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Mobile Client Contract implementation notes

The normative client-facing Mobile Client Contract is documented in
[Programmatic Integration](../website/docs/developer-guide/programmatic-integration.md#nativemobile-websocket-contract).
That guide owns the authentication flow, wire schemas, compatibility rules,
scope allowlist, reconciliation algorithm, mutation failure semantics, approval
lifecycle, and end-to-end reconnect sequence. Keep wire-level changes there
rather than duplicating the contract in this implementation note.

Hermes advertises the additive contract, schema, and capability fields in the
`gateway.ready` event for every accepted WebSocket. Mobile dispatch authority,
synchronized event shapes, and durable mutation requirements apply only when
the effective `gateway.ready.authorization.audience` is `hermes.mobile`.

## Capability boundary

The contract is implemented on the existing authenticated `/api/ws` JSON-RPC
transport. It does not introduce another runtime or source of conversation
state. `tui_gateway/mobile_contract.py` owns:

- protocol, contract, and client-facing schema majors;
- independently advertised capability descriptors;
- the supported mobile scopes;
- the fail-closed method, parameter, and scope policy; and
- the stable effective authorization shape copied from the consumed ticket.

An advertised contract major or Hermes release version never implies an
individual capability. Legacy dashboard and stdio grants keep their prior
authority and wire behavior; only the `hermes.mobile` audience enters the
mobile allowlist.

## Synchronization boundary

`tui_gateway/mobile_sync.py` owns one `SessionEventStream` for each reconstructed
live session. The server process identity is stable for one process, while each
stream gets a new stream identity. A restart or reconstructed stream therefore
produces an explicit reset instead of pretending replay is complete.

Snapshot capture and event publication use this lock order:

1. acquire the conversation history lock;
2. acquire the per-stream reentrant lock;
3. mutate snapshot-visible state, allocate a sequence, retain the replay frame,
and enqueue transport delivery under that stream boundary.

The snapshot watermark consequently covers every state transition published
before capture. Clients can either replay a complete interval from their exact
cursor or install the returned snapshot. Replay eviction reports a gap; a
missing, invalid, foreign-process, or foreign-stream cursor reports a reset.
Neither outcome may masquerade as complete recovery.

Replay is process-local and bounded by both event count and encoded byte size.
Retention begins when a mobile-audience transport first attaches. If a legacy
transport later owns the session, Hermes continues retaining sequenced copies
for a future mobile reconnect while sending the legacy transport its unchanged
event shape.

## Durable mutation receipts

`tui_gateway/mobile_mutations.py` owns SQLite-backed at-most-once receipts that
are independent of a live TUI session. Receipt identity is scoped to the
effective authenticated provider and subject. Its fingerprint binds the method,
Hermes resource identity, and method-specific semantic parameters.

Only methods listed by `mutation.idempotency.methods` enter this path. A
same-principal retry with the same fingerprint replays the stored outcome;
changed semantics conflict. A reservation that might have executed but cannot
be completed safely becomes `outcome_unknown` and is never released for
automatic duplicate execution. On process startup, another process's unfinished
reservation is terminalized the same way.

Prompt receipts have a stronger completion boundary than handler return. The
gateway binds an opaque proof tag to the exact user turn and marks the receipt
complete only after that turn is proven in durable history. Queued, streaming,
and reconnect paths share one condition-driven receipt coordinator per live
session; a lost proof becomes `outcome_unknown`, not a successful receipt.

## Approval lifecycle

`tools/approval.py` owns approval identities and lifecycle state. Callers cannot
choose reserved lifecycle fields. Public descriptors are recursively and
forcibly redacted at the approval-core boundary before any gateway event or
snapshot sees them.

Each pending approval has one Hermes-issued identity. Mobile resolution targets
that exact identity, while ID-less FIFO and resolve-all behavior remain legacy
only. The terminal callback runs after the core state transition and before the
blocked waiter is released, so the sequenced terminal event cannot be overtaken
by downstream tool or turn events.

Pending approvals and terminal tombstones are process-local. A transport
reconnect can recover them while the same process and live stream retain the
session, but a server/stream reset invalidates that live approval state.
Completed `approval.respond` mutation receipts remain durable independently of
the in-memory tombstone.

## Conformance ownership

The public conformance path lives with the authenticated FastAPI/WebSocket tests
in `tests/hermes_cli/test_dashboard_auth_ws_auth.py`. It must exercise the real
ticket, WebSocket, dispatcher, synchronization, receipt, and approval seams in
one generic flow. Unit tests under `tests/tui_gateway/` continue to cover race,
overflow, redaction, persistence, and compatibility edge cases at their owning
modules.
39 changes: 31 additions & 8 deletions gateway/platforms/qqbot/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,9 @@ def __init__(self, config: PlatformConfig):
# box; callers can override with set_interaction_callback(None) or
# register a custom handler.
self._interaction_callback = self._default_interaction_dispatch
# Hermes approval_id → session key. Button payloads carry only the
# stable identity so a delayed click cannot consume another FIFO item.
self._approval_state: Dict[str, str] = {}

# ------------------------------------------------------------------
# Properties
Expand Down Expand Up @@ -1118,7 +1121,7 @@ async def _default_interaction_dispatch(
) -> None:
"""Route ``INTERACTION_CREATE`` button clicks to the right subsystem.

- ``approve:<session_key>:<decision>`` →
- ``approve:<approval_id>:<decision>`` →
:func:`tools.approval.resolve_gateway_approval`
(unblocks the agent thread waiting on a dangerous-command approval).
- ``update_prompt:<answer>`` →
Expand All @@ -1137,7 +1140,14 @@ async def _default_interaction_dispatch(

approval = parse_approval_button_data(button_data)
if approval is not None:
session_key, decision = approval
approval_id, decision = approval
session_key = self._approval_state.get(approval_id)
if not session_key:
logger.info(
"[%s] Approval %s is already resolved, expired, or unknown",
self._log_tag, approval_id,
)
return
choice = self._APPROVAL_BUTTON_TO_CHOICE.get(decision)
if choice is None:
logger.warning(
Expand All @@ -1156,11 +1166,16 @@ async def _default_interaction_dispatch(
# Import lazily to keep the adapter importable in tests that
# don't exercise the approval subsystem.
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(session_key, choice)
count = resolve_gateway_approval(
session_key,
choice,
approval_id=approval_id,
)
self._approval_state.pop(approval_id, None)
logger.info(
"[%s] Button resolved %d approval(s) for session %s "
"(choice=%s, operator=%s)",
self._log_tag, count, session_key, choice,
"(approval_id=%s, choice=%s, operator=%s)",
self._log_tag, count, session_key, approval_id, choice,
event.operator_openid,
)
except Exception as exc:
Expand Down Expand Up @@ -2648,7 +2663,7 @@ async def send_approval_request(
return await self.send_with_keyboard(
chat_id,
build_approval_text(req),
build_approval_keyboard(req.session_key),
build_approval_keyboard(req.approval_id or req.session_key),
reply_to=reply_to,
)

Expand Down Expand Up @@ -2676,7 +2691,11 @@ async def send_exec_approval(
:func:`tools.approval.resolve_gateway_approval` — dispatched by the
adapter's interaction callback (:meth:`_default_interaction_dispatch`).
"""
del metadata # QQ doesn't have thread_id / DM targeting overrides.
from agent.redact import redact_sensitive_text

approval_id = str((metadata or {}).get("approval_id") or uuid.uuid4().hex)
command = redact_sensitive_text(command, force=True)
description = redact_sensitive_text(description, force=True)

# Use the reply-to message for passive-message context when we have one.
# QQ requires a msg_id on outbound messages to a user we've never
Expand All @@ -2686,13 +2705,17 @@ async def send_exec_approval(
req = ApprovalRequest(
session_key=session_key,
title="Execute this command?",
approval_id=approval_id,
description=description,
command_preview=command,
timeout_sec=self._APPROVAL_TIMEOUT_SECONDS,
)
return await self.send_approval_request(
result = await self.send_approval_request(
chat_id, req, reply_to=msg_id,
)
if result.success:
self._approval_state[approval_id] = session_key
return result

_APPROVAL_TIMEOUT_SECONDS = 300 # matches gateway's default gateway_timeout

Expand Down
Loading