Files
hermes-agent/hermes_state_terminal.py
Teknium 5e327d2b26 fix(desktop): a retry refused because its id is already admitted settles as delivered
An attachment send that reached the owner but whose landed check missed comes back as text under its
original submission id. Enter on it was refused `admission_conflict` (same id, other payload): the
words stayed in the composer under a "Prompt failed" card, although the send had run once.

`admission_conflict` alone proves nothing (route, binding and lineage checks raise it too), so the
three checks that DO prove "this principal's id on this target is already admitted" now name it:
`field=request_id` (hermes_state_runtime.ADMITTED_REQUEST_CONFLICT) in admit_session_input,
retry_terminal_admission and the attachment-bytes check. Desktop settles exactly that refusal as the
dedupe it is (a `terminal` receipt: bubble dropped, journal retired, draft cleared, no new id).
Every other conflict, and a backend that names no field, stays a visible failure. An edited restored
draft already drops its id (matchingDraftRetry), so a deliberate edit is a new message.
2026-10-27 12:56:31 -07:00

171 lines
9.2 KiB
Python

"""Private terminal-only tombstone queries; never reconstruct executable history.
The authenticated edge supplies the original principal/target/request and frozen
payload. A retired target permits only that exact digest, never fresh admission.
Worker receipt reads additionally require the original private adoption proof.
"""
import hmac
import json
from hermes_state_runtime import ADMITTED_REQUEST_CONFLICT, RuntimeStoreError, _epoch, _json, _row, _secret_digest, _text, admission_fingerprint
RESULT_PREFIX = 'gateway.admission.result.v1.'
ADMISSION_PREFIX = 'gateway.terminal_admission.v1.'
IDENTITY_PREFIX = 'gateway.terminal_identity.v1.'
WORKER_PREFIX = 'gateway.terminal_worker.v1.'
def compact_result(result, *, user_message=None):
"""Retain replay/accounting and current-turn API output, never cumulative history."""
result = dict(result)
value = result.get('result')
if not isinstance(value, dict):
return result
value = dict(value)
# The tool schema list rides every turn result for in-process transcript logging; it is the
# same session-wide copy each turn (tens of KB, more with MCP) and no stored-result reader uses it.
value.pop('tools', None)
messages = value.get('messages')
if isinstance(messages, list):
# Agent results include all previous turns. APIs only project this turn's
# assistant/tool suffix; previous_response_id stores its own history. Compression
# is the exception: its replacement snapshot is the only immutable record of the
# rewritten prefix for this turn. A later live transcript may include a successor.
if not value.get('_compressed') and not value.get('_messages_are_turn_suffix'):
from agent.turn_context import reanchor_current_turn_user_idx
anchor = value.get('current_turn_user_idx')
if not (type(anchor) is int and 0 <= anchor < len(messages)
and isinstance(messages[anchor], dict) and messages[anchor].get('role') == 'user'):
anchor = reanchor_current_turn_user_idx(messages, user_message)
value['messages'] = messages[anchor + 1:]
if anchor >= 0 and isinstance(messages[anchor], dict) and messages[anchor].get('api_content'):
# The user row as the model saw it (memory prefetch, plugin context): a chained
# Responses turn replays it instead of a row rebuilt from the raw request text.
value['_turn_user_row'] = {k: messages[anchor][k] for k in ('role', 'content', 'api_content')}
# Recovery/verification nudges are later user-role rows within this same turn.
# Consumers must not infer a new boundary from them after the real anchor is gone.
value['_messages_are_turn_suffix'] = True
value.pop('current_turn_user_idx', None)
result['result'] = value
return result
# Top-level row key only ``encode_result`` writes. The row's top level is built by the gateway
# (``result``/``usage``), never by the agent or a message, so message fields can't collide with it.
_STORED = '_stored'
def encode_result(result):
"""The stored row keeps the reply once. When the turn's closing assistant message carries
exactly ``final_response``, its ``content`` is stored as null and the row gets
``_stored: {v: 1, reply_ref: <that message's index>}``. Retirement drops ``messages`` and
keeps ``final_response``, so a deleted session's exact retry still has its reply. A result
that already has ``_stored`` is stored whole with that value escaped as ``{v: 1, raw: ...}``."""
if not isinstance(result, dict):
return result
if _STORED in result:
return {**result, _STORED: {'v': 1, 'raw': result[_STORED]}}
value = result.get('result')
if not isinstance(value, dict) or not isinstance(value.get('messages'), list):
return result
reply, messages = value.get('final_response'), list(value['messages'])
last = next((i for i in range(len(messages) - 1, -1, -1) if isinstance(messages[i], dict)
and messages[i].get('role') == 'assistant'), None)
if not (isinstance(reply, str) and reply and last is not None and messages[last].get('content') == reply):
return result
messages[last] = {**messages[last], 'content': None}
return {**result, 'result': {**value, 'messages': messages}, _STORED: {'v': 1, 'reply_ref': last}}
def load_result(text):
"""Decode a stored result row. Only a row carrying ``encode_result``'s exact envelope is
decoded (to the dict it encoded, key order included); every other row, including all rows
written before it, is returned as stored."""
# Decodes in place: safe only because each call parses its own copy. A cache in front of this
# must hand out copies, never the decoded dict itself.
result = json.loads(text)
if not isinstance(result, dict) or not isinstance(envelope := result.get(_STORED), dict):
return result
if envelope.keys() == {'v', 'raw'} and type(envelope['v']) is int and envelope['v'] == 1:
result[_STORED] = envelope['raw']
return result
value, ref = result.get('result'), envelope.get('reply_ref')
messages = value.get('messages') if isinstance(value, dict) else None
if not (envelope.keys() == {'v', 'reply_ref'} and type(envelope['v']) is int and envelope['v'] == 1
and type(ref) is int and isinstance(messages, list) and 0 <= ref < len(messages)
and isinstance(value.get('final_response'), str) and isinstance(messages[ref], dict)
and messages[ref].get('role') == 'assistant' and messages[ref].get('content', 0) is None):
return result
messages[ref] = {k: value['final_response'] if k == 'content' else v for k, v in messages[ref].items()}
del result[_STORED]
return result
def identity_key(principal_id, session_id, request_id):
return IDENTITY_PREFIX + admission_fingerprint(canonical_target=session_id,
payload={'principal': principal_id, 'request': request_id})
def terminal_api_admissions(conn, request_id):
"""Retired API admissions that carried *request_id*: a deleted session's tombstones, kept for
the exact-retry horizon, still spend its Idempotency-Key for every other target."""
return [json.loads(row[0]) for row in conn.execute(
"SELECT value FROM state_meta WHERE key GLOB ? "
"AND json_extract(value, '$.principal_id')='api' AND json_extract(value, '$.request_id')=?",
(ADMISSION_PREFIX + '*', request_id))]
def terminal_admission(conn, admission_id):
saved = conn.execute('SELECT value FROM state_meta WHERE key=?',
(ADMISSION_PREFIX + admission_id,)).fetchone()
return json.loads(saved[0]) if saved else None
def retry_terminal_admission(db, *, epoch, principal_id, session_id, request_id, payload, intent='queue'):
"""None means a live target; a retired miss/conflict is a definitive refusal.
This is a read-only identity lookup, not authorization to attach, restore a
route, or write. HTTP authentication/grant checks must precede this query.
"""
for value in (principal_id, session_id, request_id):
_text(value)
digest = admission_fingerprint(canonical_target=session_id,
payload={'input': json.loads(_json(payload)), 'intent': intent})
with db._read_ctx() as conn:
_epoch(conn, epoch)
from hermes_state_mutation_retirement import RETIRED_PREFIX
if not conn.execute('SELECT 1 FROM state_meta WHERE key=?', (RETIRED_PREFIX + session_id,)).fetchone():
return None
saved = conn.execute('SELECT value FROM state_meta WHERE key=?',
(identity_key(principal_id, session_id, request_id),)).fetchone()
if saved is None:
raise RuntimeStoreError('not_found')
row = terminal_admission(conn, json.loads(saved[0]))
if row['payload_digest'] != digest:
raise RuntimeStoreError('admission_conflict', **ADMITTED_REQUEST_CONFLICT)
return _row(row)
def terminal_worker_receipt(db, *, execution_id, session_id, generation, sequence,
adoption_secret, payload_digest):
"""Exact retired producer evidence; possession never reactivates the worker."""
proof = _secret_digest(adoption_secret)
with db._read_ctx() as conn:
saved = conn.execute('SELECT value FROM state_meta WHERE key=?',
(WORKER_PREFIX + execution_id,)).fetchone()
if saved is None:
raise RuntimeStoreError('not_found')
worker = json.loads(saved[0])
if worker['session_id'] != session_id or not hmac.compare_digest(worker['adoption_digest'], proof):
raise RuntimeStoreError('permission_denied')
if type(generation) is not int or generation != worker['generation']:
raise RuntimeStoreError('stale_generation')
receipt = next((r for r in worker['receipts'] if r['sequence'] == sequence), None)
if receipt is None:
raise RuntimeStoreError('stale_generation')
if receipt['payload_digest'] != payload_digest:
raise RuntimeStoreError('admission_conflict')
if receipt['result_json'] is None:
raise RuntimeStoreError('stale_generation')
return json.loads(receipt['result_json'])