Files
hermes-agent/tui_gateway/session_history.py
T
Siddharth Balyan c22a8d8e3f Seeded sessions survive a gateway restart and store their seed once (tui_gateway) (#107549)
* fix(tui-gateway): a seeded session is durable at create, and its seed is written once

session.create accepts opening messages. Three defects sat in that path:

- A seeded session without a parent was never persisted at create, so a
  restart before the first prompt lost it and session.resume answered
  4007. Only branch children (#93959) were persisted up front. The
  same rationale applies to any seeded create: seeded content is
  intent, not an abandoned draft. Parentless seeds now persist their
  row, transcript and client title at create; empty drafts stay lazy.
- _coerce_seed_history dropped display_kind, so a seeded row tagged
  "hidden" (model-facing scaffolding) rendered as a user bubble. The
  coercion keeps "hidden" and only "hidden"; every other kind is
  stamped by the gateway at turn time and is not accepted from the wire.
- A branch child's seed was written twice: _seed_branch_row copied it at
  create but never marked it persisted, so the first prompt's
  _persist_branch_seed appended the copy again. The create path now
  sets _branch_seed_persisted, and the gate is a create-time `seeded`
  stamp instead of parent_session_id, so a resumed session (whose
  history comes from the DB) can never re-append its transcript.

Two invariant tests, both red on main: a parentless seed survives a
gateway restart with the hidden row kept out of the wire transcript and
not re-written by the first-submit path; a branch child's seed is stored
exactly once. The reasoning-fields fixture stamps `seeded`, the flag
session.create sets.

* fix(tui-gateway): a hidden seed row stays out of the list preview and the create count

Live-testing the seeded create on every surface showed two places where
the newly durable hidden row (display_kind="hidden") still surfaced:

- session.list built a session's preview from its first user row with no
  display_kind filter, so a hidden opening row (model-facing scaffolding
  the gateway never paints) became the sidebar preview. The preview
  predicate now skips hidden rows, in every listing query that shares it.
- session.create reported message_count as the raw seed length while its
  messages array already filtered the hidden row (2 vs 1). It now counts
  what is on the wire, the same rule session.resume applies.

Both are covered by the existing seeded-create test: the create count
equals the wire transcript, and the preview of a session whose first
user row is hidden is its first visible user row.

* fix(tui-gateway): a live unpersisted resume counts the wire transcript

session.resume on a live session that has no row yet reported message_count as
the raw history length while its messages array was already filtered, the same
mismatch the previous commit fixed on session.create. Count the wire, as the
cold, deferred and reuse-live resume paths already do.

* chore: retrigger CI (zero-job dispatch failure, auto-heal)
2026-09-10 17:55:24 +00:00

400 lines
20 KiB
Python

"""Session history/message shaping: image-ref messages, content coercion, history->wire messages, in-flight
turn tracking and turn-failure detail. Bodies are rebound onto server.py's globals (method_ctx.bind_module)."""
from __future__ import annotations
from .method_ctx import bind_module
from agent.prompt_builder import STEER_DISPLAY_KIND
def _active_image_routing_identity(agent: Any) -> tuple[str, str]:
"""Return the live provider/model, falling back before agent startup."""
from agent.auxiliary_client import _read_main_model, _read_main_provider
return (getattr(agent, "provider", "") or _read_main_provider(), getattr(agent, "model", "") or _read_main_model())
def _build_image_ref_message(user_text: str, image_paths: list[str]) -> str:
"""Reference attached images by path so the agent analyzes them in-loop with ``vision_analyze``: pre-
analyzing with the auxiliary vision model blocked submit 60-90s/photo and poisoned auto-titles.
This used to pre-analyze every image with the auxiliary vision model *before* the turn was dispatched
(``_enrich_with_attached_images``): serial blocking calls on the submit path — 60-90s per large photo —
with failures silently swallowed and an interrupt during the window killing the turn with zero API calls
(#83291). It also prepended the vision description to the first user message, poisoning session
auto-titles (#82339). The CLI never gates turn dispatch on vision like this, which is why the same
message was seconds there and minutes on desktop.
"""
prefix = "\n\n".join(
f"[The user attached an image: {p.name}]\n[Examine it with the vision_analyze tool using image_url: {p}]"
for p in map(Path, image_paths) if p.exists()
)
text = user_text or ""
if prefix:
return f"{prefix}\n\n{text}" if text else prefix
return text or "What do you see in this image?"
def _build_persist_message_with_image_refs(user_text: str, image_paths: list[str]) -> str:
"""Persisted form of the user's message: ``@image:<path>`` directives (the desktop renders them as
images); ``_build_image_ref_message``'s ``image_url:`` hint is model-only, never persisted. Caption
first, directives last: session previews are the first 60 chars of the first user message."""
from agent.context_references import format_reference_value
text = user_text or ""
refs = "\n".join(f"@image:{format_reference_value(p)}" for p in image_paths if Path(p).exists())
if not refs:
return text
return f"{text}\n{refs}" if text else refs
def _build_persist_user_message(user_text: str, image_paths: list[str], run_message: Any) -> Any:
"""Shape the persisted user turn like the model payload: ``_flush_messages_to_session_db`` ignores a
plain-string override for a list (native-vision) payload, so swap only the text part for the
``@image:`` form, keep image parts, drop API-only text parts (barge-in note)."""
persist_text = _build_persist_message_with_image_refs(user_text, image_paths)
if not isinstance(run_message, list):
return persist_text
image_parts = [p for p in run_message if not (isinstance(p, dict) and p.get("type") == "text")]
return [{"type": "text", "text": persist_text}, *image_parts]
_HISTORY_TEXT_KINDS = frozenset({"text", "input_text", "output_text"})
_HISTORY_IMAGE_KINDS = frozenset({"image_url", "input_image", "image"})
_HISTORY_AUDIO_KINDS = frozenset({"input_audio", "audio"})
def _history_part_image_url(part: dict) -> str:
"""The URL carried by an image part (``image_url`` dict or str), else ""."""
image_url = part.get("image_url")
if isinstance(image_url, dict):
image_url = image_url.get("url")
return image_url if isinstance(image_url, str) else ""
def _history_dict_text(content: dict, *, image_urls: bool) -> str:
"""Placeholder/text rendering of one structured content dict."""
kind = content.get("type")
if kind in _HISTORY_TEXT_KINDS:
return str(content.get("text") or content.get("content") or "")
if kind in _HISTORY_IMAGE_KINDS:
return (_history_part_image_url(content) if image_urls else "") or "[image]"
if kind in _HISTORY_AUDIO_KINDS:
return "[audio]"
if kind:
return f"[{kind}]"
if "text" in content:
return str(content.get("text") or "")
return "[structured content]"
def _content_display_text(content: Any) -> str:
if isinstance(content, list):
return "\n".join(t for t in (_content_display_text(part).strip() for part in content) if t)
if isinstance(content, dict):
return _history_dict_text(content, image_urls=False)
return "" if content is None else str(content)
def _coerce_message_text(content: Any) -> str:
"""Render ``message['content']`` (str, parts list, or one structured dict) as a plain string. Image parts
keep their URL inline so the desktop's ``extractEmbeddedImages`` and the resume payload agree with the
cached message (else the inline image flashed, then vanished); other shapes become a placeholder."""
if isinstance(content, list):
chunks: list[str] = []
for part in content:
if isinstance(part, str) or (isinstance(part, dict) and isinstance(part.get("text"), str)):
chunks.append(part if isinstance(part, str) else part["text"])
elif isinstance(part, dict) and part.get("type"):
rendered = _history_dict_text(part, image_urls=True)
chunks.append(rendered if part["type"] in _HISTORY_TEXT_KINDS else f"\n{rendered}")
return "".join(chunks)
if isinstance(content, dict):
return _history_dict_text(content, image_urls=True)
return "" if content is None else str(content)
def _history_text_only_part(part: dict) -> bool:
kind = part.get("type")
return kind in _HISTORY_TEXT_KINDS or (kind is None and isinstance(part.get("text"), str))
def _is_text_only_busy_payload(content: Any) -> bool:
"""True when a busy submit carries only plain text, not attachments/media."""
if isinstance(content, list):
return bool(content) and all(
isinstance(part, str) or (isinstance(part, dict) and _history_text_only_part(part)) for part in content
)
return isinstance(content, (str, int, float)) or (isinstance(content, dict) and _history_text_only_part(content))
def _is_display_hidden_marker(role: str | None, text: str) -> bool:
"""Gateway notices (model-switch, personality) persist as role=user ``[System: …]`` rows so strict providers
accept them mid-history; they must never render as a user bubble. Filtering in this one projection hides
them everywhere (raw marker stays in ``session["history"]``) and keeps the desktop's user ordinals stable.
It also removes the stored marker from the payload the desktop reconciles against, so it can no longer
shift user-message ordinals and duplicate the optimistic prompt (#67603).
"""
return role == "user" and text.lstrip().startswith("[System:")
def _skill_scaffold_projection(content_text: str) -> str:
"""The invocation a slash-skill-expanded turn came from, else "" — UIs render ``/work fix the leak``."""
return describe_skill_invocation(content_text, separator=" ") or ""
def _expand_skill_invocation_for_replay(text: str, task_id: str) -> str:
"""Inverse of :func:`_skill_scaffold_projection`: rewind/regenerate hands back the projected invocation,
and re-running it verbatim would drop the skill. Unchanged when not resolvable."""
head, _, arg = (text or "").strip().partition(" ")
if not head.startswith("/"):
return text
try:
from agent.skill_commands import build_skill_invocation_message, resolve_skill_command_key
cmd_key = resolve_skill_command_key(head.lstrip("/"))
return text if cmd_key is None else (build_skill_invocation_message(cmd_key, arg.strip(), task_id=task_id) or text)
except Exception: # a skill that no longer resolves must not break the rewind
logger.debug("skill re-expansion failed for replay", exc_info=True)
return text
# Opening of the crash-recovery note synthesized by _auto_continue_note; matched (not just built) for
# rows persisted before display typing existed and for the messaging gateway's twin note.
_AUTO_CONTINUE_NOTE_PREFIX = "[System note: Your previous turn was interrupted mid-run"
def _legacy_display_kind(role: str, text: str) -> str | None:
"""Display type of a synthetic row persisted untyped: new rows are typed at turn start (``persist_user_display_kind``);
this prefix sniff migrates rows already on disk (a turn killed mid-run never reached the stamp)."""
return "auto_continue" if role == "user" and text.lstrip().startswith(_AUTO_CONTINUE_NOTE_PREFIX) else None
_HISTORY_ASSISTANT_DETAIL_KEYS = (
"reasoning",
"reasoning_content",
"reasoning_details",
"codex_reasoning_items",
"codex_message_items",
)
_HISTORY_ROLES = frozenset({"user", "assistant", "tool", "system"})
def _history_to_messages(history: list[dict]) -> list[dict]:
messages = []
tool_call_args = {}
for m in history:
if not isinstance(m, dict):
continue
m = project_compaction_message_for_display(m)
if m is None:
continue
role = m.get("role")
# display_kind="hidden": model-facing scaffolding the "[System:" sniff does not catch.
if role not in _HISTORY_ROLES or m.get("display_kind") == "hidden":
continue
content_text = _coerce_message_text(m.get("content"))
if _is_display_hidden_marker(role, content_text):
continue
if role == "assistant" and m.get("tool_calls"):
for tc in m["tool_calls"]:
fn, tc_id = tc.get("function", {}), tc.get("id", "")
if tc_id and fn.get("name"):
try:
args = json.loads(fn.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
args = {}
tool_call_args[tc_id] = (fn["name"], args)
if role == "user" and m.get("display_kind") == STEER_DISPLAY_KIND:
# Mid-turn /steer: show the user's own words, not the model-facing marker wrapper.
from agent.conversation_compression import _extract_steer_text_from_message
content_text = _extract_steer_text_from_message(m) or content_text
if role == "tool":
tc_name, tc_args = tool_call_args.get(m.get("tool_call_id") or "", (None, None))
name = tc_name or m.get("tool_name") or "tool"
args = tc_args or {}
# `context` is an 80-char preview; ship args so a full-call renderer isn't truncated.
messages.append({"role": "tool", "name": name, "context": _tool_ctx(name, args), **({"args": args} if args else {})})
continue
# Assistant detail sidecars can carry the only visible reply or reasoning after resume/reload.
has_assistant_detail = role == "assistant" and any(m.get(key) for key in _HISTORY_ASSISTANT_DETAIL_KEYS)
if not content_text.strip() and not has_assistant_detail:
continue
msg = {"role": role, "text": content_text}
# Authoring time (Unix seconds) for display.timestamps; display-only.
# Display-only: never fed back into model context. See #41531.
ts = m.get("timestamp")
if isinstance(ts, (int, float)) and ts > 0:
msg["timestamp"] = float(ts)
# Durable row identity (_rows_to_conversation); reactions etc. address persisted messages by it.
if m.get("_row_id") is not None:
msg["row_id"] = m["_row_id"]
# A user turn shows its skill invocation, never the expanded body (rewind re-sends by ordinal).
invocation = _skill_scaffold_projection(content_text) if role == "user" else ""
if invocation:
msg.update(text=invocation, display_kind="skill_invocation")
if role == "assistant":
msg.update((key, m[key]) for key in _HISTORY_ASSISTANT_DETAIL_KEYS if m.get(key) is not None)
# Display-only timeline metadata (model switches, delegation events).
display_kind = m.get("display_kind") or _legacy_display_kind(role, content_text)
if display_kind:
msg["display_kind"] = display_kind
if m.get("display_metadata"):
msg["display_metadata"] = m["display_metadata"]
messages.append(msg)
return messages
def _coerce_seed_history(value: Any) -> list[dict]:
history = []
for item in value if isinstance(value, list) else ():
if not isinstance(item, dict) or item.get("role") not in ("user", "assistant", "system"):
continue
content = item.get("text") if item.get("content") is None else item.get("content")
if isinstance(content, str) and content.strip():
row = {"role": item["role"], "content": content}
# "hidden" is the one display_kind a seeding client may author: model-facing scaffolding the
# renderer must not paint (a guided-chat runbook). Every other kind is stamped by the gateway
# at turn time, so it is not accepted from the wire.
if item.get("display_kind") == "hidden":
row["display_kind"] = "hidden"
history.append(row)
return history
def _inflight_text(value: Any) -> str:
return _content_display_text(value).strip()
def _start_inflight_turn(session: dict, text: Any) -> None:
now = time.time()
session["inflight_turn"] = {"assistant": "", "started_at": now, "streaming": True, "updated_at": now, "user": _inflight_text(text)}
def _append_inflight_delta(session: dict, delta: Any) -> None:
text = "" if delta is None else str(delta)
if not text:
return
turn = session.get("inflight_turn")
if not isinstance(turn, dict):
turn = {"assistant": "", "streaming": True, "user": ""}
turn.update(assistant=f"{turn.get('assistant') or ''}{text}", streaming=True, updated_at=time.time())
session["inflight_turn"] = turn
def _record_inflight_correction(session: dict, text: Any) -> None:
"""Record an accepted mid-turn correction on the live turn — appended, never written over ``user``,
so a resuming client can rebuild BOTH bubbles."""
correction = _inflight_text(text)
turn = session.get("inflight_turn")
if not correction or not isinstance(turn, dict):
return
# correction_offsets: arrival-order boundary (assistant chars already streamed) so resuming clients
# place the bubble between the output seen and the output redirected.
turn = dict(turn)
turn["corrections"] = [*(turn.get("corrections") or []), correction]
turn["correction_offsets"] = [*(turn.get("correction_offsets") or []), len(str(turn.get("assistant") or ""))]
turn["updated_at"] = time.time()
session["inflight_turn"] = turn
def _clear_inflight_turn(session: dict) -> None:
session["inflight_turn"] = None
def _fail_inflight_turn(session: dict, error: Any, error_surface: Optional[dict] = None) -> None:
"""Mark the in-flight turn terminal-error but keep it replayable: a failure's terminal frame can be lost on
WS disconnect and the turn may never have been committed, so the snapshot lets ``session.resume`` replay
prompt, partial text and error. Lives until the next turn starts or the session closes. Caller holds history_lock."""
message = str(error) if not isinstance(error, BaseException) else (str(error) or type(error).__name__)
now = time.time()
turn = session.get("inflight_turn")
if not isinstance(turn, dict):
turn = {"assistant": "", "user": "", "started_at": now}
turn.update(
assistant=str(turn.get("assistant") or ""), user=str(turn.get("user") or ""),
error=message or "turn failed", status="error", recoverable=True,
)
if error_surface: # {layer, code, retryable} so a reconnect renders the same layered error card
turn["error_surface"] = dict(error_surface)
else:
turn.pop("error_surface", None)
turn.update(streaming=False, updated_at=now)
session["inflight_turn"] = turn
_TURN_FAILURE_DETAIL_LIMIT = 240
# Shortest prompt run counting as a quote-back: above shared boilerplate, below a quoted sentence.
_TURN_PROMPT_ECHO_WINDOW = 24
# Ceiling on the prompt we shingle (an @-expanded prompt can carry a whole file).
_TURN_PROMPT_ECHO_MAX_PROMPT = 65536
def _strip_prompt_echo(message: str, prompt: Any) -> str:
"""Blank runs of the submitted prompt that ``message`` quotes back: secret redaction is pattern-based and a
provider 4xx echoing the request carries private prose matching no pattern. Any ``_TURN_PROMPT_ECHO_WINDOW``+
char run shared with the prompt (or its JSON-escaped form) becomes ``<prompt>``; shingles keep it linear."""
if not message or not prompt:
return message
needle = " ".join(str(prompt).split())[:_TURN_PROMPT_ECHO_MAX_PROMPT]
window = _TURN_PROMPT_ECHO_WINDOW
if len(needle) < window or len(message) < window:
return message
shingles = {needle[i:i + window] for i in range(len(needle) - window + 1)}
escaped = json.dumps(needle)[1:-1]
if escaped != needle:
shingles.update(escaped[i:i + window] for i in range(len(escaped) - window + 1))
out: list[str] = []
i, n = 0, len(message)
while i <= n - window:
if message[i:i + window] in shingles:
j = i + window
while j < n and message[j - window + 1:j + 1] in shingles:
j += 1
out.append("<prompt>")
i = j
else:
out.append(message[i])
i += 1
out.append(message[i:])
return "".join(out)
def _turn_failure_detail(error: Any, reason: Any = None, prompt: Any = None) -> str:
"""Why a turn failed, for the ``tui turn finished`` bookend: ``""`` when nothing to say, else a fragment with
its own leading space. ``redact_sensitive_text`` removes credentials; ``_strip_prompt_echo`` removes a 4xx
body quoting ``prompt`` back. This record may gain failure detail, never the user's own content.
86865 added the bookend to trace compression rotations, so it logs identities and a coarse ``status``
and deliberately logs no content. 89117 is what the missing cause costs: a report consisting of two
lines reading ``status=error error_retained=True duration=0.9s`` with no way to tell a provider 4xx from
a budget wall from a crashed finalizer. The returned-error path -- the one a 0.9 s failure almost always
takes -- emits no other log line at all; only the exception path prints to stderr, which is why the
quiet failures are the ones that get filed. See #86865, #89117.
Content discipline follows #86865's, and it takes two separate steps because it is two separate
contracts. It does nothing about a 4xx body that quotes the request back, because ordinary private prose
is not pattern-shaped -- so ``_strip_prompt_echo`` removes that separately, using the submitted
``prompt`` itself as the thing to look for.
"""
reason_text = str(reason or "").strip()
message = str(error or "").strip()
if isinstance(error, BaseException):
message = message or type(error).__name__
if not message and not reason_text:
return ""
try:
from agent.redact import redact_sensitive_text
message = redact_sensitive_text(message, force=True)
except Exception:
message = "<unredactable>" # never fail open
message = " ".join(message.split())
# After the collapse (same shape both sides), before truncation (a quote must not survive the cut).
message = _strip_prompt_echo(message, prompt)
if len(message) > _TURN_FAILURE_DETAIL_LIMIT:
message = message[:_TURN_FAILURE_DETAIL_LIMIT] + "\u2026"
out = " failure_reason=%s" % " ".join(reason_text.split()) if reason_text else ""
return out + (" cause=%r" % message if message else "")
def register(server) -> None:
"""Publish this module's helpers + handlers onto ``server``, rebound to its globals."""
bind_module(globals(), server, skip=("_",))