Files
hermes-agent/agent/history_commentary.py
Teknium ad249d9cac fix(hold-stream): a held reply kept as the budget fallback, and its codex commentary, in history
Two more ways a held turn (hold_stream=True transform_llm_output) left history
out of step with what the user got:

- When verify-on-stop or pre_verify sent the answer back on the last allowed
  call, _append_interim_answer had already flushed it as a hidden row. The
  finalizer reused it as the budget fallback and transformed it, but the row
  was settled: _store_rewrite_on_tail skipped it and _close_transcript_tail
  added nothing (the tail already held assistant text), so reopening the
  session lost the reply. A held turn's sent-back answer now flushes with the
  turn instead of at once (the user never saw it), and when the turn ends on
  it _store_rewrite_on_tail publishes that row: visible, content = the
  rewrite, api_content = the bytes the model wrote and already saw, so the
  wire and the cached prefix stay unchanged. One row, so role alternation
  holds.
- A codex_responses reply can carry commentary and the final answer in
  codex_message_items. Publishing the held final unhid the whole row, and
  project_history_commentary rebuilt display_commentary from those items on
  reload. publish_held_final_row now stamps display_metadata
  commentary_withheld on such a row; the projection then shows no
  commentary for it, while the items stay for model replay.

Also: the plugin-reload hold test waited 0.5 s for a negative check; it now
signals that the read started and waits 2 s (tests/AGENTS.md).

Tests (red on 72fd611fef): a pre_verify fallback ending a held turn (history
shows the rewrite, the resumed request replays the original bytes), and a
held codex_responses final with a commentary item (FakeResponsesServer
Message gains an optional phase).

Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4370905634
Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4370905635
Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4370905636
2026-10-25 06:18:48 -07:00

182 lines
6.9 KiB
Python

"""Display-only Codex commentary projection shared by REST and gateway history.
Provider items and the stored reasoning string remain untouched for model replay.
Only the projected fields may be used for public transcript text.
"""
import json
from contextlib import contextmanager
from typing import Any
from agent.redact import redact_sensitive_text
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from utils import is_truthy_value
@contextmanager
def _owning_home(home):
token = set_hermes_home_override(str(home)) if home is not None else None
try:
yield
finally:
if token is not None:
reset_hermes_home_override(token)
# display_metadata flag on a ``hold_stream`` turn's published reply: its commentary items stay for
# model replay, but the transform never saw them, so no history projection shows them.
COMMENTARY_WITHHELD = "commentary_withheld"
def visible_commentary(text: str, *, strip_thinking=None) -> str:
"""Use the same think stripping and secret redaction as live delivery."""
if strip_thinking is None:
from agent.agent_runtime_helpers import strip_think_blocks
strip_thinking = lambda value: strip_think_blocks(None, value)
visible = strip_thinking(text).strip()
return redact_sensitive_text(visible) if visible else visible
def _phase_message_items(message: dict, phases: frozenset[str | None]) -> list[str]:
items = message.get("codex_message_items")
if isinstance(items, str):
try:
items = json.loads(items)
except (TypeError, ValueError):
return []
if not isinstance(items, list):
return []
result = []
for item in items:
if (
not isinstance(item, dict)
or item.get("type") != "message"
or item.get("role") != "assistant"
):
continue
phase, content = item.get("phase"), item.get("content")
if phase is not None and not isinstance(phase, str):
continue
normalized_phase = phase.strip().lower() if isinstance(phase, str) else None
if normalized_phase not in phases or not isinstance(content, list):
continue
text = "".join(
part["text"]
for part in content
if isinstance(part, dict)
and part.get("type") == "output_text"
and isinstance(part.get("text"), str)
and part["text"].strip()
).strip()
if text:
result.append(text)
return result
def commentary_items(message: dict) -> list[str]:
return _phase_message_items(message, frozenset({"commentary"}))
def _final_items(message: dict) -> list[str]:
# Unphased assistant messages are normalized as final content too.
return _phase_message_items(message, frozenset({"final", "final_answer", None}))
def _without_flattened_commentary(reasoning: str, commentary: list[str]) -> str:
"""Omit exact whole-line public segments from a display copy, not source data.
Current normalization separates parts with two newlines; older rows may
have one. If a private segment happens to be identical, origin is ambiguous:
omit both occurrences rather than leaking the public text or choosing the
wrong one. Surrounding reasoning is retained in its original line style.
"""
remaining = reasoning
for text in sorted(set(commentary), key=len, reverse=True):
search_from = 0
while (at := remaining.find(text, search_from)) >= 0:
end = at + len(text)
if (at == 0 or remaining[at - 1] == "\n") and (
end == len(remaining) or remaining[end] == "\n"
):
before, after = remaining[:at], remaining[end:]
prefix, suffix = before.rstrip("\n"), after.lstrip("\n")
separator = (
"\n\n"
if before.endswith("\n\n") or after.startswith("\n\n")
else "\n"
)
remaining = prefix + (separator if prefix and suffix else "") + suffix
search_from = 0
else:
search_from = end
return remaining
def _project_one(message: dict, *, enabled: bool) -> dict:
if message.get("role") != "assistant" or message.get("display_kind") == "hidden":
return message
raw = commentary_items(message)
if not raw:
return message
projected = dict(message)
# A disabled profile must not recover raw sidecar text on the frontend.
projected["display_commentary"] = []
if enabled and not (message.get("display_metadata") or {}).get(COMMENTARY_WITHHELD):
projected["display_commentary"] = [
part for text in raw if (part := visible_commentary(text))
]
reasoning = (
message.get("reasoning")
or message.get("reasoning_content")
or message.get("reasoning_details")
or ""
)
if isinstance(reasoning, str):
projected["display_reasoning"] = _without_flattened_commentary(reasoning, raw)
# A nonempty canonical answer can also be filled by stream recovery after
# the provider sidecar was captured. Without a matching final item there
# is no way to distinguish "commentary + final" from a real final that
# merely starts with the same words. Never delete canonical answer bytes.
# Sanitize the display copy when it contains raw commentary and avoid
# duplicating a leading commentary item in a separate Desktop bubble.
content = message.get(
"display_content", message.get("content", message.get("text"))
)
if isinstance(content, str) and any(text in content for text in raw):
finals = _final_items(message)
if not (finals and content.strip() == "\n".join(finals)):
projected["display_content"] = visible_commentary(content)
if any(content.startswith(text) for text in raw):
projected["display_commentary"] = []
return projected
def project_history_commentary(messages: list[dict], *, home: Any = None) -> list[dict]:
"""Project a batch inside the owning profile's config and redaction scope."""
if not any(
isinstance(message, dict) and message.get("codex_message_items")
for message in messages
):
return messages
with _owning_home(home):
from hermes_cli.config import load_config
try:
display = load_config().get("display") or {}
enabled = is_truthy_value(
display.get("show_commentary"), default=True
) and is_truthy_value(
display.get("interim_assistant_messages"), default=True
)
except Exception:
enabled = False # unreadable policy must not publish raw provider items
return [
_project_one(message, enabled=enabled)
if isinstance(message, dict)
else message
for message in messages
]