Files
hermes-agent/agent/message_metadata.py
Teknium 4061818385 fix(hooks): /retry and /undo after a block use the typed text, not the placeholder
A vetoed user row stores the withheld placeholder as its content, so /retry
(CLI, TUI, gateway, ACP, Desktop) re-sent the placeholder as a new prompt and
/undo pre-filled it into the composer. A rewind's live view and its receipt now
swap the placeholder back for display_metadata.withheld_prompt: /retry
re-submits what the user typed, so the hook decides on it again, and /undo
restores it. Stored rows, compaction and search are unchanged.
2026-10-31 22:59:20 -07:00

381 lines
19 KiB
Python

"""Internal metadata attached to durable conversation messages."""
from __future__ import annotations
from collections import Counter
from time import time as wall_time
from uuid import uuid4
from typing import Any, List, Mapping, MutableMapping, Optional, TypeVar
from agent.conversation_compression_archive import MERGED_DURABLE_ROWS, RETIRED_DURABLE_ROWS, UNNAMED_DURABLE_ROWS
# These fields describe Hermes' durable record and timeline display, not
# provider-visible message content. The request builder strips them from every
# outgoing copy and the token estimator ignores them: one set, so an estimate
# never prices bytes the provider never receives (an edit's inline_diff in
# display_metadata is ~9KB and would trigger premature compaction).
# Transcript-repair bookkeeping riding on batch rows / live dicts (agent/transcript_repair.py): the
# stored-row CAS digest and the durable row adopted onto the live dict. Never transcript payload.
DB_ROW_SNAPSHOT = "_db_row_snapshot"
CANONICAL_ROW = "_canonical_row"
# Set by the agent flush on a batch row (never a live dict) whose ``message_uid`` the agent itself saw durable in
# this session (written by an earlier flush, or loaded from the store with its marker). Only such a row, with no
# row address or snapshot, may be skipped as a re-insert of an ACTIVE uid; a bare copied uid proves nothing.
RESTORED_FROM_STORE = "_restored_from_store"
REPAIR_BOOKKEEPING_FIELDS = frozenset({DB_ROW_SNAPSHOT, CANONICAL_ROW, RESTORED_FROM_STORE})
# Unanswered text a merged user row held before the current turn was absorbed
# (agent_runtime_helpers._merge_consecutive_users); the persist override keeps it.
# It repeats the row's own content, so pricing it would double the estimate.
MERGED_TURN_PREFIX = "_merged_turn_prefix"
# The durable per-message id (``messages.message_uid``): minted once at the row's first insert and kept by
# every host copy of that logical message (in-place compaction generation, rotation child, concurrent-tail
# clone, replace re-issue, rewrite in place). Unlike ``_row_id`` (a physical id re-issued per copy, opt-in on
# restore) it is restored unconditionally, so context engines can key on it across restarts and boundaries.
MESSAGE_UID = "message_uid"
# The merge witness on a consecutive-user merge survivor: the ``message_uid`` of each absorbed row, in
# absorption order (the uid sibling of ``_absorbed_row_ids``; persisted as ``messages.absorbed_message_uids``).
# The survivor keeps the FIRST constituent's uid.
ABSORBED_MESSAGE_UIDS = "_absorbed_message_uids"
# Per-occurrence tool-call identity. Provider tool-call ids repeat (Hermes mints deterministic ``call_<12hex>``
# ids for identical calls, and models reuse ids), so an assistant row carries ``{provider id: uid}`` for its
# ``tool_calls`` (``messages.tool_call_uids``) and its tool-result rows carry the matching uid
# (``messages.tool_call_uid``). The provider-facing ``id`` is untouched; these never reach the wire.
TOOL_CALL_UIDS = "_tool_call_uids"
TOOL_CALL_UID = "_tool_call_uid"
PERSISTENCE_ONLY_MESSAGE_FIELDS = frozenset(
# Membership is the real contract, NOT the leading underscore: the chat-completions transport happens
# to sweep underscore keys, but turn_context.py pops this set from every outgoing copy and a strict
# backend 400s on any key it does not know.
{"timestamp", "display_kind", "display_metadata", "_row_id", "_submit_row_session_id",
MERGED_TURN_PREFIX, MESSAGE_UID, ABSORBED_MESSAGE_UIDS, TOOL_CALL_UIDS, TOOL_CALL_UID,
# The alternation repair's row counts: an in-place compaction reads them off the live dict.
MERGED_DURABLE_ROWS, UNNAMED_DURABLE_ROWS, RETIRED_DURABLE_ROWS}
) | REPAIR_BOOKKEEPING_FIELDS
# Connector data-use requirement on a tool message; persisted inside ``display_metadata``, never sent to a model.
DATA_USE = "data_use"
def data_use_requirement(messages: Any) -> frozenset[str]:
"""The union of ``data_use`` over *messages*: what the model receiving them must guarantee."""
return frozenset(r for m in messages or () if isinstance(m, Mapping) for r in m.get(DATA_USE) or ())
def stamp_data_use(message: MutableMapping[str, Any], requires: list[str]) -> None:
"""Mark a tool message with its requirement. The ``display_metadata`` copy is the durable one: that
column already rides every writer, branch and compaction copy, and replay lifts it back (SessionDB)."""
if not requires:
return
message[DATA_USE] = list(requires)
metadata = message.get("display_metadata")
message["display_metadata"] = {**(metadata if isinstance(metadata, dict) else {}), DATA_USE: list(requires)}
# What the user typed on a turn a ``pre_llm_call`` hook vetoed, kept inside ``display_metadata``. The row's
# ``content`` is the withheld placeholder, so every reader that hands transcript text to a model (wire
# replay, compaction, session_search, memory providers, background review) only ever sees that; transcript
# views render this key instead (agent/turn_context.py::_withhold_blocked_prompt).
WITHHELD_PROMPT = "withheld_prompt"
PRE_LLM_BLOCK_WITHHELD_PROMPT = "[Message withheld: a pre_llm_call policy hook blocked it.]"
def withheld_prompt_text(message: Any) -> Optional[str]:
"""The typed text a transcript view shows for a vetoed user row, else None."""
metadata = message.get("display_metadata") if isinstance(message, Mapping) else None
text = metadata.get(WITHHELD_PROMPT) if isinstance(metadata, Mapping) else None
return text if isinstance(text, str) else None
def typed_prompt_content(content: Any, message: Any) -> Any:
"""*content* (a rewind's live view of *message*) with the withheld placeholder swapped back for what
the user typed: ``/retry`` re-submits the real ask so the hook decides again, and ``/undo`` restores it
to the composer. Never the placeholder as a new prompt."""
typed = withheld_prompt_text(message)
if typed is None:
return content
if isinstance(content, str):
return content.replace(PRE_LLM_BLOCK_WITHHELD_PROMPT, typed)
if isinstance(content, list):
return [{**part, "text": part["text"].replace(PRE_LLM_BLOCK_WITHHELD_PROMPT, typed)}
if isinstance(part, dict) and isinstance(part.get("text"), str) else part for part in content]
return content
def without_withheld_prompts(messages: Any) -> Any:
"""*messages* with the vetoed text dropped from each row's ``display_metadata`` (copies only where
one is present), for hand-offs that leave the host with whole row dicts (memory providers)."""
if not isinstance(messages, list) or not any(withheld_prompt_text(m) is not None for m in messages):
return messages
return [
{**m, "display_metadata": {k: v for k, v in m["display_metadata"].items() if k != WITHHELD_PROMPT}}
if withheld_prompt_text(m) is not None else m
for m in messages
]
def lift_data_use(message: MutableMapping[str, Any]) -> None:
"""Restore a replayed row's ``data_use`` key from its durable ``display_metadata`` copy."""
metadata = message.get("display_metadata")
if isinstance(metadata, dict) and metadata.get(DATA_USE):
message[DATA_USE] = list(metadata[DATA_USE])
# Replay carriers the preflight estimate prices at 0, exactly as the compaction tail walk does (opaque signatures,
# duplicate reasoning copies, ordered sidecars); a mismatch between the two estimates loops compaction.
_UNPRICED_CORE_FIELDS = frozenset({"_anthropic_content_blocks", "reasoning_details", "extra_content",
"bedrock_content_blocks", DATA_USE})
def unpriced_replay_fields() -> frozenset[str]:
"""The core unpriced carriers plus every message carrier key a registered profile declares
(``ProviderProfile.message_carrier_*``). Resolve once per message, never per key."""
from agent.reasoning_carriers import declared_carrier_keys
opaque, text = declared_carrier_keys()
return _UNPRICED_CORE_FIELDS.union(opaque, text)
def without_persistence_fields(msg: Mapping[str, Any]) -> Mapping[str, Any]:
"""*msg* itself when it carries no persistence-only field, else a shallow copy without them."""
if PERSISTENCE_ONLY_MESSAGE_FIELDS.isdisjoint(msg):
return msg
return {k: v for k, v in msg.items() if k not in PERSISTENCE_ONLY_MESSAGE_FIELDS}
def mint_uid() -> str:
"""A fresh durable id (message or tool-call occurrence): random, never derived from content."""
return uuid4().hex
def uid_list(value: Any) -> list[str]:
"""The unique non-empty string uids of a live list, in order; anything else is ``[]``."""
return list(dict.fromkeys(u for u in (value if isinstance(value, list) else ()) if isinstance(u, str) and u))
def message_uid_or_none(msg: Mapping[str, Any]) -> Optional[str]:
"""The dict's ``message_uid`` when it is a non-empty string, else ``None`` (never coerced: an int or a
blank would be a bug upstream of the write, not an identity)."""
uid = msg.get(MESSAGE_UID)
return uid if isinstance(uid, str) and uid else None
def stamp_message_uid(msg: MutableMapping[str, Any]) -> str:
"""The dict's ``message_uid``, minting one (``mint_uid``) when it carries none.
Minted ONCE per logical message, at its first insert, and stamped on the caller's dict so every later
insert of that dict (compaction generation, rotation handoff, replace) writes the same uid. Never
derived from content, timestamp or tool-call ids: two distinct messages may share all three.
"""
uid = message_uid_or_none(msg)
if uid is None:
uid = msg[MESSAGE_UID] = mint_uid()
return uid
# Producer-owned provenance for the compressor's standalone in-flight-task restatement. It rides in
# display_metadata (persisted by SessionDB, popped from every wire copy) so gateway replay can send the
# row unstamped, as the compaction turn did; a human pasting the header text carries no flag.
INFLIGHT_TASK_RESTATEMENT = "compaction_inflight_task_restatement"
def mark_inflight_restatement(msg: MutableMapping[str, Any]) -> None:
metadata = msg.get("display_metadata")
msg["display_metadata"] = {**(metadata if isinstance(metadata, dict) else {}), INFLIGHT_TASK_RESTATEMENT: True}
def scaffolding_replay_fields(msg: Mapping[str, Any]) -> dict:
"""The compaction-provenance fields replay keeps (summary flag, restatement flag), else ``{}``. A proactive
prune re-persists the replayed view, so a dropped flag would get the row stamped on the next replay."""
metadata = msg.get("display_metadata")
fields: dict = {"_compressed_summary": True} if msg.get("_compressed_summary") else {}
if isinstance(metadata, dict) and metadata.get(INFLIGHT_TASK_RESTATEMENT):
fields["display_metadata"] = {INFLIGHT_TASK_RESTATEMENT: True}
return fields
def is_compaction_scaffolding(msg: Mapping[str, Any]) -> bool:
"""Compaction sent its summary and restatement rows unstamped; a replay stamp would break the cached prefix."""
return bool(scaffolding_replay_fields(msg))
_IDENTITY_FIELD_TYPES = ((MESSAGE_UID, str), (ABSORBED_MESSAGE_UIDS, list), (TOOL_CALL_UIDS, dict), (TOOL_CALL_UID, str))
def copy_identity_fields(src: Mapping[str, Any], dst: MutableMapping[str, Any]) -> None:
"""Copy the non-empty identity fields (uid, merge witness, tool-call uids) from *src* onto *dst*: the
live dict to its flush row, and the committed row back onto the live dict."""
for key, kind in _IDENTITY_FIELD_TYPES:
value = src.get(key)
if isinstance(value, kind) and value:
dst[key] = kind(value)
def message_identity(msg: MutableMapping[str, Any], *, with_tool_uids: bool = False) -> dict:
"""The identity fields a new row copied from *msg* must carry, minting *msg*'s uid first when it has none:
a branch/seed copy writes fresh rows from the live dicts the new session keeps using, and a row without
them would restore with a different uid than the live dict carries. ``with_tool_uids`` only for a copy
that also carries ``tool_calls`` / ``tool_call_id``; a uid map on a row without its calls pairs nothing."""
stamp_message_uid(msg)
identity: dict = {}
copy_identity_fields(msg, identity)
if not with_tool_uids:
identity.pop(TOOL_CALL_UIDS, None)
identity.pop(TOOL_CALL_UID, None)
return identity
def record_absorbed_message(
survivor: MutableMapping[str, Any], dropped: Mapping[str, Any], *, dropped_leads: bool = False,
) -> None:
"""Merge-witness bookkeeping for every host fold of *dropped* into *survivor*.
The composite keeps the uid of the constituent whose text comes first and records every other
constituent's uid in ``_absorbed_message_uids`` (text order, no repeats). By default the survivor's
text leads; with *dropped_leads* the dropped dict's text was put first (the real user anchor folded
into a scaffolding turn), so its uid becomes the survivor's and the survivor's former uid is recorded.
A dict without a uid (unflushed, scaffolding, engine-authored) contributes nothing; an empty result
leaves the survivor untouched.
"""
survivor_uid = message_uid_or_none(survivor)
dropped_uid = message_uid_or_none(dropped)
own = uid_list(survivor.get(ABSORBED_MESSAGE_UIDS))
theirs = uid_list(dropped.get(ABSORBED_MESSAGE_UIDS))
if dropped_leads and dropped_uid:
survivor[MESSAGE_UID] = dropped_uid
ordered = theirs + ([survivor_uid] if survivor_uid else []) + own
else:
ordered = own + ([dropped_uid] if dropped_uid else []) + theirs
if absorbed := [uid for uid in dict.fromkeys(ordered) if uid != survivor.get(MESSAGE_UID)]:
survivor[ABSORBED_MESSAGE_UIDS] = absorbed
def merge_tool_call_uids(into: Mapping[str, Any], extra: Mapping[str, Any]) -> dict:
"""Union two ``_tool_call_uids`` maps without losing an occurrence. Folding two assistant turns that
reuse a provider id (llama.cpp-style constant ids) leaves both calls in ``tool_calls``; the id then maps
to every occurrence's uid, in call order, as a list. Provider ids themselves are never rewritten."""
merged = dict(into)
for call_id, uid in extra.items():
merged[call_id] = _uid_occurrences(merged[call_id]) + _uid_occurrences(uid) if call_id in merged else uid
return merged
def _uid_occurrences(value: Any) -> list:
return list(value) if isinstance(value, list) else [value]
def per_occurrence_tool_call_uids(uids: Mapping[str, Any], tool_calls: list[Mapping[str, Any]]) -> dict:
"""*uids* with every provider id this row names more than once spelled out as one uid per occurrence. A
single response that repeats an id shares ONE uid (its results carry it, so every call stays paired);
before a fold appends another turn's occurrences, the shared uid must fill each of this row's slots or
the appended uids would align with the wrong calls."""
from agent.message_sanitization import coalesce_tool_call_id
counts = Counter(call_id for tc in tool_calls if (call_id := coalesce_tool_call_id(tc)))
expanded = dict(uids)
for call_id, count in counts.items():
if count > 1 and isinstance(uid := uids.get(call_id), str):
expanded[call_id] = [uid] * count
return expanded
def index_tool_call_uids(index: MutableMapping[str, str], assistant: Mapping[str, Any]) -> frozenset:
"""Register an assistant dict's ``_tool_call_uids`` under every pairing-id variant of its tool calls, so a
later tool-result row can be resolved by any spelling of its ``tool_call_id``. Provider ids repeat, so
every id this assistant names first shadows an earlier occurrence's entry: a result pairs with the
NEAREST preceding call, and a call without a uid (a legacy row) pairs its result with nothing. A
provider id repeated inside one row (two folded turns) maps each call to its own occurrence's uid.
Returns every pairing-id variant the assistant names."""
from agent.message_sanitization import coalesce_tool_call_id, tool_call_id_variants
calls = [(tc, tool_call_id_variants(tc)) for tc in assistant.get("tool_calls") or ()]
named = frozenset(variant for _, variants in calls for variant in variants)
for variant in named:
index.pop(variant, None)
uids = assistant.get(TOOL_CALL_UIDS)
if not isinstance(uids, dict) or not uids:
return named
occurrence: dict = {} # provider id -> calls seen so far: a list value holds one uid per occurrence
for tc, variants in calls:
call_id = coalesce_tool_call_id(tc)
uid = uids.get(call_id)
if isinstance(uid, list):
nth = occurrence[call_id] = occurrence.get(call_id, -1) + 1
uid = uid[nth] if nth < len(uid) else None
if isinstance(uid, str) and uid:
for variant in variants:
index[variant] = uid
return named
def resolve_tool_call_uid(index: MutableMapping[str, str], tool_call_id: Any) -> Optional[str]:
"""The uid an indexed assistant dict minted for ``tool_call_id`` (any variant), else ``None``."""
from agent.message_sanitization import tool_result_id_variants
if not index or not isinstance(tool_call_id, str) or not tool_call_id:
return None
for variant in tool_result_id_variants(tool_call_id):
uid = index.get(variant)
if uid:
return uid
return None
def tool_call_uid_from_history(messages: list[dict], tool_index: int, owners: dict) -> Optional[str]:
"""Resolve a tool-result dict's uid from the nearest preceding assistant dict in ``messages`` that
named its ``tool_call_id`` (the cross-flush case: the assistant row landed in an earlier batch).
``owners`` memoizes each assistant's (named variants, uid index) across one flush's results, so K
parallel results of one assistant cost O(K) variant work instead of O(K^2)."""
tool_call_id = messages[tool_index].get("tool_call_id")
if not isinstance(tool_call_id, str) or not tool_call_id:
return None
from agent.message_sanitization import tool_result_id_variants
result_variants = set(tool_result_id_variants(tool_call_id))
for prior_index in range(tool_index - 1, -1, -1): # no messages[:i] copy: this runs per flushed result
prior = messages[prior_index]
if not isinstance(prior, dict):
continue
if prior.get("role") == "user":
return None # a tool result never pairs across a user turn
if prior.get("role") != "assistant":
continue
if (entry := owners.get(id(prior))) is None:
index: dict = {}
entry = owners[id(prior)] = (index_tool_call_uids(index, prior), index)
named, index = entry
if result_variants.isdisjoint(named):
continue
# The nearest assistant naming this id owns the result: its uid, or none if it has no map (legacy).
return resolve_tool_call_uid(index, tool_call_id)
return None
_Message = TypeVar("_Message", bound=MutableMapping[str, Any])
def stamp_message_timestamp(
message: _Message,
*,
timestamp: Optional[float] = None,
) -> _Message:
"""Attach a creation timestamp without replacing source-provided time.
Gateway adapters can supply the platform event time; all other callers use
the local wall clock. Returns the same mapping for use at append sites.
"""
if message.get("timestamp") is None:
message["timestamp"] = wall_time() if timestamp is None else timestamp
return message
def append_message(
messages: list[Any],
message: _Message,
*,
timestamp: Optional[float] = None,
) -> _Message:
"""Stamp and append one live transcript message."""
messages.append(stamp_message_timestamp(message, timestamp=timestamp))
return message