refactor(agent/E_session): compact docstrings/comments by hand, keep every WHY
This commit is contained in:
@@ -51,14 +51,9 @@ def _orphan_recovery(name: str, unknown_text: str, none_text: str) -> tuple:
|
||||
|
||||
|
||||
def strip_interrupted_tool_tails(agent_history: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Strip interrupted assistant→tool sequences from replay history.
|
||||
|
||||
The interrupted block is not necessarily the final tail (a queued real user message may
|
||||
follow it), so every contiguous assistant(tool_calls)+tool-result block containing an
|
||||
interrupted result is handled; successful sequences stay intact. Read-only blocks are
|
||||
dropped; blocks with a side-effecting call are KEPT with the interrupted results rewritten
|
||||
as orphan-recovery notices, since the effect may already have happened.
|
||||
"""
|
||||
"""Strip interrupted assistant→tool blocks anywhere in replay history (a queued user message may
|
||||
follow one). Read-only blocks are dropped; blocks with a side-effecting call are KEPT with the
|
||||
interrupted results rewritten as orphan-recovery notices, since the effect may have happened."""
|
||||
if not agent_history:
|
||||
return agent_history
|
||||
|
||||
@@ -110,14 +105,10 @@ def strip_interrupted_tool_tails(agent_history: List[Dict[str, Any]]) -> List[Di
|
||||
|
||||
|
||||
def strip_dangling_tool_call_tail(agent_history: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Strip a trailing ``assistant(tool_calls)`` block left with NO answers.
|
||||
|
||||
A tool call that kills the gateway process itself (``docker restart``, ``hermes gateway
|
||||
restart``) is SIGKILLed mid-call, before any tool result or the orderly shutdown rewind; the
|
||||
persisted tail has zero matching ``tool`` rows, which ``strip_interrupted_tool_tails`` cannot
|
||||
detect. Only acts when the tail has NO tool answers — a partially answered block still
|
||||
resumes. Read-only tails are dropped; side-effecting ones get synthetic UNKNOWN-effect results.
|
||||
"""
|
||||
"""Strip a trailing ``assistant(tool_calls)`` with NO answers — a call that killed the gateway
|
||||
itself (``docker restart``) left zero ``tool`` rows, which ``strip_interrupted_tool_tails`` cannot
|
||||
detect. A partially answered block still resumes. Read-only tails are dropped; side-effecting
|
||||
ones get synthetic UNKNOWN-effect results."""
|
||||
if not agent_history:
|
||||
return agent_history
|
||||
|
||||
@@ -197,14 +188,9 @@ def strip_stale_dangerous_confirmations(
|
||||
now: float,
|
||||
expiry_seconds: float = _DANGEROUS_CONFIRMATION_EXPIRY_SECONDS,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Expire stale dangerous-confirmation text in user messages.
|
||||
|
||||
If a host restart killed the gateway before the tool result was written, the user's
|
||||
confirmation phrase survives in the transcript; a casual "are you there?" minutes later can
|
||||
read to the model as a fresh re-confirmation. Expired confirmations are REDACTED IN PLACE.
|
||||
Messages without a timestamp (legacy transcripts, test scaffolding) and confirmations still
|
||||
inside the expiry window are left untouched.
|
||||
"""
|
||||
"""Redact IN PLACE dangerous-confirmation text older than ``expiry_seconds`` in user messages: a
|
||||
confirmation surviving a restart can read as a fresh re-confirmation minutes later. Messages
|
||||
without a timestamp (legacy transcripts, test scaffolding) are left untouched."""
|
||||
if not agent_history:
|
||||
return agent_history
|
||||
|
||||
|
||||
@@ -59,11 +59,8 @@ def _is_ephemeral_scaffolding(msg: Any) -> bool:
|
||||
|
||||
|
||||
def _safe_session_filename_component(session_id: str) -> str:
|
||||
"""Path-safe filename component for a (possibly untrusted ``X-Hermes-Session-Id``) session ID.
|
||||
|
||||
Collapses non ``[A-Za-z0-9_-]`` chars to ``_``, caps length, and appends a short content hash
|
||||
whenever sanitization changed the string so distinct IDs cannot collide.
|
||||
"""
|
||||
"""Path-safe filename component for a (possibly untrusted ``X-Hermes-Session-Id``) session ID:
|
||||
non ``[A-Za-z0-9_-]`` → ``_``, capped, plus a content hash when changed so distinct IDs cannot collide."""
|
||||
raw = str(session_id or "").strip()
|
||||
sanitized = re.sub(r"[^\w-]", "_", raw).strip("._")
|
||||
sanitized = sanitized[:96] or "session"
|
||||
@@ -74,12 +71,9 @@ def _safe_session_filename_component(session_id: str) -> str:
|
||||
|
||||
|
||||
def _override_replaces_content(msg: Dict, content: Any, override: Any) -> bool:
|
||||
"""Whether the persist user-message override may replace ``content``.
|
||||
|
||||
A plain-text override must not replace native image/audio blocks (a list override is the clean
|
||||
multimodal payload and does). Preflight compaction may re-anchor the index at a message MERGED
|
||||
with the compaction summary — overwriting it would drop the summary from the durable transcript.
|
||||
"""
|
||||
"""Whether the persist user-message override may replace ``content``: a plain-text override must
|
||||
not replace native image/audio blocks (a list override is the clean multimodal payload and does),
|
||||
and never a message MERGED with a compaction summary (overwriting would drop the summary)."""
|
||||
return (
|
||||
override is not None
|
||||
and not msg.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
@@ -88,8 +82,8 @@ def _override_replaces_content(msg: Dict, content: Any, override: Any) -> bool:
|
||||
|
||||
|
||||
def _summary_display_kind(msg: Dict) -> Any:
|
||||
"""Standalone reference handoffs are always hidden so they never occupy the active user slot in
|
||||
retry/undo dispatch; merge-into-tail carriers keep their prior visibility."""
|
||||
"""Standalone handoffs are hidden so they never occupy the active user slot in retry/undo
|
||||
dispatch; merge-into-tail carriers keep their prior visibility."""
|
||||
if (
|
||||
msg.get(COMPRESSED_SUMMARY_METADATA_KEY)
|
||||
and user_originated_turn_view(msg) is None
|
||||
@@ -103,8 +97,7 @@ def _summary_display_kind(msg: Dict) -> Any:
|
||||
|
||||
|
||||
def _durable_content(content: Any) -> Any:
|
||||
"""Text-only projection for the DB: multimodal envelopes become their summary, OpenAI-style part
|
||||
lists keep text and replace images with ``[screenshot]`` (base64 bloats the DB)."""
|
||||
"""Text-only DB projection: multimodal envelopes → summary; part lists keep text, images → ``[screenshot]``."""
|
||||
if _is_multimodal_tool_result(content):
|
||||
return _multimodal_text_summary(content)
|
||||
if isinstance(content, list):
|
||||
@@ -132,8 +125,8 @@ def _tool_calls_data(msg: Dict) -> Any:
|
||||
|
||||
|
||||
def _db_flush_seed_ids(agent) -> set:
|
||||
"""One-shot ``_flushed_db_message_ids`` seed, honoured only for the same session after a
|
||||
non-empty flush; translated to markers by the scan and cleared afterwards."""
|
||||
"""One-shot ``_flushed_db_message_ids`` seed (same session, after a non-empty flush); the scan
|
||||
translates it to markers and the flush clears it."""
|
||||
current_session_id = getattr(agent, "session_id", None)
|
||||
seed_ids = None
|
||||
if getattr(agent, "_flushed_db_message_session_id", None) == current_session_id and agent._last_flushed_db_idx != 0:
|
||||
@@ -143,8 +136,7 @@ def _db_flush_seed_ids(agent) -> set:
|
||||
|
||||
|
||||
def _db_flush_scan_start(agent, messages: List[Dict]) -> int:
|
||||
"""Bounded scan: skip the identity-matched, still-marked prefix of the previous flush's
|
||||
snapshot — every message in it already got its final disposition."""
|
||||
"""Skip the identity-matched, still-marked prefix of the previous flush's snapshot."""
|
||||
scan_start = 0
|
||||
prev_prefix = getattr(agent, "_db_flush_scan_prefix", None)
|
||||
if isinstance(prev_prefix, list):
|
||||
@@ -231,8 +223,7 @@ def _db_flush_collect(agent, messages: List[Dict], conversation_history: Optiona
|
||||
|
||||
|
||||
def _db_flush_write(agent, batch_rows: List[Dict[str, Any]], batch_msgs: List[Dict]) -> None:
|
||||
"""One transaction for the turn's new rows; on failure no rows land and no markers are
|
||||
stamped, so the next flush re-writes the tail."""
|
||||
"""One transaction for the turn's new rows: on failure nothing lands and no markers are stamped."""
|
||||
if not batch_rows:
|
||||
return
|
||||
agent._session_db.append_messages_batch(
|
||||
@@ -246,11 +237,8 @@ def _db_flush_write(agent, batch_rows: List[Dict[str, Any]], batch_msgs: List[Di
|
||||
|
||||
|
||||
def _db_flush_adopt_compression_tip(agent) -> bool:
|
||||
"""Adopt the live continuation of a session closed by compression, if there is one.
|
||||
|
||||
``get_compression_tip`` returning the same id means no continuation exists; a tip whose row
|
||||
is missing or already ended is not adopted either.
|
||||
"""
|
||||
"""Adopt the live continuation of a session closed by compression. Same-id tip = no continuation;
|
||||
a tip whose row is missing or already ended is not adopted either."""
|
||||
old_id = agent.session_id
|
||||
try:
|
||||
tip = agent._session_db.get_compression_tip(old_id)
|
||||
@@ -313,11 +301,8 @@ class SessionPersistenceMixin:
|
||||
"""Session DB flush, session log and trajectory persistence (see module docstring)."""
|
||||
|
||||
def _apply_persist_user_message_override(self, messages: List[Dict]) -> None:
|
||||
"""Rewrite the current-turn user message in place before persistence/return.
|
||||
|
||||
Some paths send an API-only user-message variant that must not leak into transcripts or
|
||||
resumed history; mutating the live list keeps persistence and returned history clean.
|
||||
"""
|
||||
"""Rewrite the current-turn user message in place: some paths send an API-only variant that
|
||||
must not leak into transcripts or resumed history."""
|
||||
idx = getattr(self, "_persist_user_message_idx", None)
|
||||
override = getattr(self, "_persist_user_message_override", None)
|
||||
timestamp = getattr(self, "_persist_user_message_timestamp", None)
|
||||
@@ -336,13 +321,9 @@ class SessionPersistenceMixin:
|
||||
msg["platform_message_id"] = platform_id
|
||||
|
||||
def _persist_session(self, messages: List[Dict], conversation_history: List[Dict] = None):
|
||||
"""Save session state to both JSON log and SQLite on any exit path.
|
||||
|
||||
Trailing empty-response scaffolding is dropped from the live list. The persist user-message
|
||||
override is NOT applied here — ``_flush_messages_to_session_db`` writes it to the DB row only.
|
||||
"""
|
||||
# Close and turn-start persistence can run on separate CLI threads, so scaffolding removal
|
||||
# plus the marker test-and-append must be one critical section.
|
||||
"""Save session state to both JSON log and SQLite on any exit path. Trailing empty-response
|
||||
scaffolding is dropped from the live list; the persist override is applied to the DB row only."""
|
||||
# Close and turn-start persistence can run on separate CLI threads: one critical section.
|
||||
from agent.agent_runtime_helpers import note_turn_persisted
|
||||
|
||||
with getattr(self, "_session_persist_lock", None) or nullcontext():
|
||||
@@ -356,12 +337,9 @@ class SessionPersistenceMixin:
|
||||
note_turn_persisted(self)
|
||||
|
||||
def _drop_trailing_empty_response_scaffolding(self, messages: List[Dict]) -> None:
|
||||
"""Remove private empty-response retry/failure scaffolding from transcript tails.
|
||||
|
||||
Also rewinds a trailing tool-result / assistant(tool_calls) pair the failed iteration left
|
||||
hanging; otherwise the next user turn lands as ``...tool, user`` and providers return empty
|
||||
content forever. The rewind only runs when scaffolding was actually present.
|
||||
"""
|
||||
"""Remove empty-response retry scaffolding from the tail, then (only if any was present) rewind
|
||||
the tool-result / assistant(tool_calls) pair the failed iteration left hanging — otherwise the
|
||||
next user turn lands as ``...tool, user`` and providers return empty content forever."""
|
||||
def _tail(*keys: str) -> bool:
|
||||
return bool(messages) and isinstance(messages[-1], dict) and any(messages[-1].get(k) for k in keys)
|
||||
|
||||
@@ -390,15 +368,12 @@ class SessionPersistenceMixin:
|
||||
conversation_history: Optional[List[Dict]] = None,
|
||||
_adoption_budget: int = 1,
|
||||
):
|
||||
"""Persist any un-flushed messages to the SQLite session store.
|
||||
|
||||
Dedup is an intrinsic ``_DB_PERSISTED_MARKER`` on each written dict — not positional slices
|
||||
(drift after sequence repair) nor a retained ``id(msg)`` set (address reuse). The persist
|
||||
user-message override is applied ONLY to the written row, never to the live dict. A
|
||||
compression-closed session adopts its live tip and retries exactly once.
|
||||
"""
|
||||
# Persistence-isolated agents (background review fork) share the parent's session_id for
|
||||
# cache warmth; a write here would land the curator's turn in the user's real history.
|
||||
"""Persist un-flushed messages to SQLite. Dedup is the intrinsic ``_DB_PERSISTED_MARKER`` on
|
||||
each written dict — not positional slices (drift after sequence repair) nor an ``id(msg)`` set
|
||||
(address reuse). The persist override touches the written row only. A compression-closed
|
||||
session adopts its live tip and retries exactly once."""
|
||||
# Persistence-isolated agents (background review fork) share the parent's session_id for cache
|
||||
# warmth; a write here would land the curator's turn in the user's real history.
|
||||
if getattr(self, "_persist_disabled", False):
|
||||
return None
|
||||
if not self._session_db:
|
||||
@@ -423,8 +398,7 @@ class SessionPersistenceMixin:
|
||||
return False
|
||||
|
||||
def _get_messages_up_to_last_assistant(self, messages: List[Dict]) -> List[Dict]:
|
||||
"""Messages up to (not including) the last assistant turn — the rollback point when the final
|
||||
assistant message is incomplete or malformed. All messages when there is none."""
|
||||
"""Messages before the last assistant turn (rollback point for a malformed final answer); all if none."""
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "assistant":
|
||||
return messages[:i]
|
||||
@@ -457,8 +431,7 @@ class SessionPersistenceMixin:
|
||||
|
||||
@staticmethod
|
||||
def _redact_message_content(content):
|
||||
"""Redact secrets in str or list-of-parts content; only text fields are touched.
|
||||
No-op when ``HERMES_REDACT_SECRETS`` disables redaction."""
|
||||
"""Redact secrets in str or list-of-parts content (text fields only; honours HERMES_REDACT_SECRETS)."""
|
||||
if isinstance(content, str):
|
||||
return redact_sensitive_text(content)
|
||||
if not isinstance(content, list):
|
||||
@@ -474,8 +447,7 @@ class SessionPersistenceMixin:
|
||||
return redacted
|
||||
|
||||
def _session_log_entry(self, msg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Copy of ``msg`` with scratchpad tags normalised and credentials redacted
|
||||
(defence-in-depth; respects HERMES_REDACT_SECRETS)."""
|
||||
"""Copy of ``msg`` with scratchpad tags normalised and credentials redacted (respects HERMES_REDACT_SECRETS)."""
|
||||
if msg.get("role") == "assistant" and msg.get("content"):
|
||||
msg = dict(msg)
|
||||
msg["content"] = self._clean_session_content(msg["content"])
|
||||
@@ -485,12 +457,9 @@ class SessionPersistenceMixin:
|
||||
return msg
|
||||
|
||||
def _save_session_log(self, messages: List[Dict[str, Any]] = None):
|
||||
"""Optional per-session JSON snapshot (``sessions.write_json_snapshots``, default False).
|
||||
|
||||
state.db is canonical; this exists for external tooling reading ``session_{sid}.json``.
|
||||
Rewrites the full list after every persistence point, never overwriting a larger log with
|
||||
fewer messages (resumed agent with partial history).
|
||||
"""
|
||||
"""Optional per-session JSON snapshot (``sessions.write_json_snapshots``, default False) for
|
||||
external tooling; state.db is canonical. Rewrites the full list after every persistence point,
|
||||
never overwriting a larger log with fewer messages (resumed agent with partial history)."""
|
||||
if not getattr(self, "_session_json_enabled", False):
|
||||
return
|
||||
messages = messages or self._session_messages
|
||||
|
||||
@@ -52,9 +52,9 @@ _TRUTHY = {"1", "true", "yes", "on"}
|
||||
# kwargs promoted to top-level payload keys; everything else lands under ``extra``.
|
||||
_TOP_LEVEL_PAYLOAD_KEYS = {"tool_name", "args", "session_id", "parent_session_id"}
|
||||
|
||||
# (home, event, matcher, command) tuples wired to the plugin manager in this process. Matcher is in
|
||||
# the key (one script may register per-tool under one event); home is in the key so multiplexed-
|
||||
# gateway profiles (each with their own plugin manager) can register identical triples.
|
||||
# (home, event, matcher, command) wired to the plugin manager in this process. Matcher is in the key
|
||||
# (one script may register per-tool under one event); home so multiplexed-gateway profiles can register
|
||||
# identical triples.
|
||||
_registered: Set[Tuple[str, str, Optional[str], str]] = set()
|
||||
_registered_lock = threading.Lock()
|
||||
# Non-POSIX fallback for allowlist read-modify-write. Must be separate from _registered_lock, which
|
||||
@@ -67,8 +67,7 @@ def _home_key() -> str:
|
||||
|
||||
|
||||
def _forget_home_registrations(registry: Set[tuple], lock: threading.Lock) -> None:
|
||||
"""Drop the current home's idempotence keys only (shared with outbound webhooks): a force-reload
|
||||
in profile A must never drop profile B's live registration."""
|
||||
"""Drop the current home's keys only (shared with outbound webhooks): profile A's reload must not drop B."""
|
||||
home_key = _home_key()
|
||||
with lock:
|
||||
registry.difference_update({k for k in registry if k[0] == home_key})
|
||||
@@ -212,11 +211,8 @@ def iter_configured_hooks(cfg: Optional[Dict[str, Any]]) -> List[ShellHookSpec]:
|
||||
|
||||
|
||||
def re_register_config_hooks() -> None:
|
||||
"""Re-register config hooks after a plugin force-reload cleared the manager's hooks.
|
||||
|
||||
Only the current home's idempotence keys are cleared (profile A's reload never drops profile
|
||||
B); allowlisted commands stay allowlisted, so this never re-prompts.
|
||||
"""
|
||||
"""Re-register config hooks after a plugin force-reload cleared the manager's hooks. Only the
|
||||
current home's keys are cleared (profile A's reload never drops profile B); never re-prompts."""
|
||||
_forget_home_registrations(_registered, _registered_lock)
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
@@ -405,13 +401,10 @@ def _fail_closed_block(spec: ShellHookSpec, reason: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _evaluate_result(spec: ShellHookSpec, r: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Turn a ``_spawn`` diagnostic dict into the hook's contribution (shared by the live callback
|
||||
and ``run_once``).
|
||||
|
||||
Spawn error/timeout fail open unless fail_closed; exit 2 on a blocking event blocks (message
|
||||
from stdout JSON, then stderr, then default); other non-zero exits warn then parse stdout;
|
||||
unparseable stdout on a fail_closed hook blocks.
|
||||
"""
|
||||
"""``_spawn`` diagnostic dict → the hook's contribution (shared by the live callback and
|
||||
``run_once``). Spawn error/timeout fail open unless fail_closed; exit 2 on a blocking event blocks
|
||||
(message from stdout JSON, then stderr, then default); other non-zero exits warn then parse
|
||||
stdout; unparseable stdout on a fail_closed hook blocks."""
|
||||
blocking_event = spec.event in _BLOCKING_EVENTS
|
||||
fail_closed = spec.fail_closed and blocking_event
|
||||
|
||||
|
||||
@@ -226,12 +226,9 @@ def _handle_is_well_formed(handle: Any) -> bool:
|
||||
|
||||
|
||||
class SubagentLifecycleService:
|
||||
"""Stable public service returned by :attr:`PluginContext.subagent_lifecycle`.
|
||||
|
||||
Running children are in-process only. Completed results remain available
|
||||
until process exit; ``reconnect`` reports that a serialized handle cannot
|
||||
reconnect after a restart instead of launching work again.
|
||||
"""
|
||||
"""Stable public service returned by :attr:`PluginContext.subagent_lifecycle`. Children run
|
||||
in-process only; completed results stay until process exit, and ``reconnect`` reports that a
|
||||
serialized handle cannot reconnect after a restart instead of launching work again."""
|
||||
|
||||
def __init__(self, parent_agent_resolver: Callable[[], Any]) -> None:
|
||||
self._parent_agent_resolver = parent_agent_resolver
|
||||
|
||||
@@ -19,13 +19,11 @@ from agent.message_content import flatten_message_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (task_name, exception) -> None; surfaces auxiliary failures to the user so silent drops don't
|
||||
# pile up as NULL titles.
|
||||
# (task_name, exception) -> None; surfaces auxiliary failures so silent drops don't pile up as NULL titles.
|
||||
FailureCallback = Callable[[str, BaseException], None]
|
||||
|
||||
# (title, source) -> None; source is the persisted provenance (``derived`` / ``llm``). Consumers
|
||||
# paying a rate-limited remote rename per title (Discord thread, Telegram topic) should act on
|
||||
# ``llm`` only; a local sidebar wants both.
|
||||
# (title, source) -> None; source is the persisted provenance (``derived`` / ``llm``). Consumers paying a
|
||||
# rate-limited remote rename per title (Discord thread, Telegram topic) should act on ``llm`` only.
|
||||
TitleCallback = Callable[[str, str], None]
|
||||
|
||||
# () -> bool, called right before the LLM request; False skips (e.g. the user switched models and
|
||||
@@ -36,8 +34,7 @@ RuntimeValidator = Callable[[], bool]
|
||||
MAX_TITLE_INPUT_CHARS = 1000
|
||||
# Cap on the instant derived title; a raw fragment reads worse the longer it runs.
|
||||
MAX_DERIVED_TITLE_CHARS = 48
|
||||
# Answer-shaped guard: a tiny model sometimes answers the user instead of titling; more words
|
||||
# than this is rejected rather than truncated and stored.
|
||||
# Answer-shaped guard: a tiny model sometimes answers instead of titling; longer is rejected, not truncated.
|
||||
_MAX_TITLE_WORDS = 12
|
||||
|
||||
_TITLE_PROMPT_TEMPLATE = (
|
||||
@@ -64,8 +61,7 @@ _TITLE_PROMPT_TEMPLATE = (
|
||||
_LANGUAGE_RULE_MATCH_USER = "- Write the title in the same language as the user's message."
|
||||
_LANGUAGE_RULE_PINNED = "- Write the title in {language}."
|
||||
|
||||
# Constrains the response to a single title field, removing the "model answered instead of
|
||||
# titling" failure class.
|
||||
# Constrains the response to a single title field ("model answered instead of titling" failure class).
|
||||
_TITLE_RESPONSE_FORMAT = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
@@ -80,8 +76,8 @@ _TITLE_RESPONSE_FORMAT = {
|
||||
},
|
||||
}
|
||||
|
||||
# Control-tag wrappers around machine-authored content inside a nominal "user" message (ported
|
||||
# from Codex CLI's RECOGNIZED_CONTROL_WRAPPERS): stripped, and titling continues on what remains.
|
||||
# Control-tag wrappers around machine-authored content inside a nominal "user" message (Codex CLI's
|
||||
# RECOGNIZED_CONTROL_WRAPPERS): stripped, titling continues on what remains.
|
||||
_CONTROL_WRAPPERS = tuple(
|
||||
(f"<{tag}>", f"</{tag}>")
|
||||
for tag in (
|
||||
@@ -91,23 +87,21 @@ _CONTROL_WRAPPERS = tuple(
|
||||
)
|
||||
)
|
||||
|
||||
# Hermes' own machine-authored openers: a compaction handoff or resumed session must not be
|
||||
# titled after its scaffolding.
|
||||
# Hermes' own machine-authored openers: a compaction handoff or resumed session must not be titled after them.
|
||||
_MACHINE_PREFIXES = (
|
||||
"[CONTEXT COMPACTION",
|
||||
LEGACY_SUMMARY_PREFIX,
|
||||
"[Runtime note:",
|
||||
"[System note:",
|
||||
"[SYSTEM]",
|
||||
# Model-switch marker (tui_gateway.server._MODEL_SWITCH_MARKER_PREFIX, keep in sync). Persisted
|
||||
# with role="user" because strict providers reject a non-first system message.
|
||||
# tui_gateway.server._MODEL_SWITCH_MARKER_PREFIX (keep in sync); persisted as role="user" because
|
||||
# strict providers reject a non-first system message.
|
||||
"[System: The active model for this chat has changed to ",
|
||||
)
|
||||
|
||||
|
||||
def _title_config() -> dict:
|
||||
"""``auxiliary.title_generation`` from config. Lazy read-only import: avoids hermes_cli
|
||||
circularity and config-migration writes."""
|
||||
"""``auxiliary.title_generation`` from config (lazy read-only import: no hermes_cli cycle, no migration writes)."""
|
||||
from hermes_cli.config import load_config_readonly
|
||||
|
||||
return ((load_config_readonly() or {}).get("auxiliary") or {}).get("title_generation") or {}
|
||||
@@ -132,8 +126,7 @@ def _auto_title_enabled() -> bool:
|
||||
|
||||
|
||||
def strip_control_wrappers(text: str) -> str:
|
||||
"""Remove leading control wrappers, including nested ones, so a slash-command turn reduces to
|
||||
the prose the user typed (still titleable, unlike a refusal)."""
|
||||
"""Remove leading control wrappers (nested too) so a slash-command turn reduces to the prose the user typed."""
|
||||
if not text:
|
||||
return ""
|
||||
current = text.strip()
|
||||
@@ -160,8 +153,7 @@ def strip_control_wrappers(text: str) -> str:
|
||||
|
||||
|
||||
def _summarize_user_message(user_message: str) -> str:
|
||||
"""Reduce a user turn to the text worth titling: a ``/skill`` invocation embeds the whole
|
||||
skill body, so parse the scaffolding first, then strip wrappers."""
|
||||
"""Text worth titling: a ``/skill`` invocation embeds the whole skill body, so describe it first, then strip wrappers."""
|
||||
if not user_message:
|
||||
return ""
|
||||
described = None
|
||||
@@ -175,8 +167,7 @@ def _summarize_user_message(user_message: str) -> str:
|
||||
|
||||
|
||||
def is_titleable_user_message(user_message: str) -> bool:
|
||||
"""False for machine-authored openers and turns that reduce to nothing once control
|
||||
scaffolding is stripped."""
|
||||
"""False for machine-authored openers and turns that reduce to nothing once scaffolding is stripped."""
|
||||
if not isinstance(user_message, str) or not user_message.strip():
|
||||
return False
|
||||
if user_message.lstrip().startswith(_MACHINE_PREFIXES):
|
||||
@@ -185,8 +176,7 @@ def is_titleable_user_message(user_message: str) -> bool:
|
||||
|
||||
|
||||
def derive_title(user_message: str) -> Optional[str]:
|
||||
"""Instant title: first meaningful line trimmed to a word boundary. No model, never fails;
|
||||
its job is to beat the model to the screen, not on quality."""
|
||||
"""Instant title: first meaningful line trimmed to a word boundary. No model, never fails."""
|
||||
line = " ".join(_first_line(_summarize_user_message(user_message)).split())
|
||||
if not line:
|
||||
return None
|
||||
@@ -208,8 +198,8 @@ def _first_line(text: str) -> str:
|
||||
|
||||
|
||||
def _extract_title_text(content: str) -> str:
|
||||
"""Pull the title out of a model response: strict JSON, then a loose JSON scan, then
|
||||
first-line prose so a provider ignoring ``response_format`` still titles."""
|
||||
"""Title from a model response: strict JSON, then a loose JSON scan, then first-line prose
|
||||
(a provider ignoring ``response_format`` still titles)."""
|
||||
if not content:
|
||||
return ""
|
||||
raw = content.strip()
|
||||
@@ -273,12 +263,8 @@ def generate_title(
|
||||
main_runtime: dict = None,
|
||||
runtime_validator: Optional[RuntimeValidator] = None,
|
||||
) -> Optional[str]:
|
||||
"""Generate a session title from the user's opening message alone (waiting for the assistant
|
||||
made this slow and bought nothing).
|
||||
|
||||
``failure_callback`` gets ``(task, exception)`` when the auxiliary call raises;
|
||||
``runtime_validator`` runs right before the request and False skips silently.
|
||||
"""
|
||||
"""Title from the user's opening message alone (waiting for the assistant made this slow and
|
||||
bought nothing). ``runtime_validator`` runs right before the request; False skips silently."""
|
||||
if not _auto_title_enabled():
|
||||
logger.debug("Auto-title skipped: auxiliary.title_generation.enabled=false")
|
||||
return None
|
||||
@@ -336,14 +322,12 @@ def _has_upgraded_title(session_db, session_id: str) -> bool:
|
||||
|
||||
def _persist_session_title(session_db, session_id, title, *, source, dedupe=True):
|
||||
"""Persist a title at *source* authority via ``set_auto_title`` (precedence check + write in
|
||||
one transaction, so a manual ``/title`` is never overwritten).
|
||||
one transaction, so a manual ``/title`` is never overwritten); None when a higher-authority
|
||||
title held the row.
|
||||
|
||||
``ValueError`` means the unique-title index rejected the name; append ``#N`` via
|
||||
``ValueError`` = unique-title index rejected the name → append ``#N`` via
|
||||
``get_next_title_in_lineage``. ``dedupe=False`` re-raises instead: the derived title is on the
|
||||
turn's critical path, collides constantly ("hi"), and the widening lineage scan is wasted on a
|
||||
name the model replaces a second later — the background stage picks the collision back up.
|
||||
|
||||
Returns the persisted title, or None when a higher-authority title held the row.
|
||||
critical path, collides constantly ("hi"), and the model replaces it a second later anyway.
|
||||
"""
|
||||
auto_fn = getattr(session_db, "set_auto_title", None)
|
||||
|
||||
@@ -379,11 +363,8 @@ def apply_instant_title(
|
||||
user_message: str,
|
||||
title_callback: Optional[TitleCallback] = None,
|
||||
) -> Optional[str]:
|
||||
"""Write the derived title synchronously (cheap enough to run inline).
|
||||
|
||||
Returns the title written, or None when nothing was (no usable text, or a title of at least
|
||||
``derived`` authority exists). Never raises.
|
||||
"""
|
||||
"""Write the derived title synchronously (cheap enough to run inline). Returns the title
|
||||
written, or None (no usable text, or a title of at least ``derived`` authority exists). Never raises."""
|
||||
if not session_db or not session_id:
|
||||
return None
|
||||
try:
|
||||
@@ -410,13 +391,10 @@ def auto_title_session(
|
||||
title_callback: Optional[TitleCallback] = None,
|
||||
runtime_validator: Optional[RuntimeValidator] = None,
|
||||
) -> None:
|
||||
"""Generate and store the model title (daemon-thread target).
|
||||
|
||||
Skips when the session already carries an ``llm``/``user`` title. Never lets an exception
|
||||
escape (the default threading excepthook would spray a raw traceback into the terminal); the
|
||||
canonical trigger is the post-``hermes update`` stale-module window, where lazy imports read
|
||||
NEW source against OLD cached modules until the process restarts.
|
||||
"""
|
||||
"""Generate and store the model title (daemon-thread target); skips sessions already carrying
|
||||
an ``llm``/``user`` title. Never lets an exception escape (the threading excepthook would spray
|
||||
a traceback into the terminal); the canonical trigger is the post-``hermes update``
|
||||
stale-module window, where lazy imports read NEW source against OLD cached modules."""
|
||||
try:
|
||||
# A derived title is expected here — upgrading it is the point.
|
||||
if not session_db or not session_id or _has_upgraded_title(session_db, session_id):
|
||||
@@ -464,8 +442,7 @@ def auto_title_session(
|
||||
|
||||
|
||||
def _is_real_user_turn(message: Any) -> bool:
|
||||
"""Whether a history entry is a question a person actually asked (Hermes persists machinery
|
||||
under ``role="user"``); a multimodal turn is judged on its text."""
|
||||
"""A question a person actually asked (Hermes persists machinery under ``role="user"``)."""
|
||||
if not isinstance(message, dict) or message.get("role") != "user":
|
||||
return False
|
||||
content = message.get("content")
|
||||
@@ -473,8 +450,7 @@ def _is_real_user_turn(message: Any) -> bool:
|
||||
|
||||
|
||||
def _session_is_untitled(session_db, session_id: str) -> bool:
|
||||
"""Whether the session carries no title of any provenance. False when it can't tell: an
|
||||
unreadable title is no reason to spend a model call per turn."""
|
||||
"""No title of any provenance. False when it can't tell — no model call per turn for an unreadable title."""
|
||||
getter = getattr(session_db, "get_session_title", None)
|
||||
if not callable(getter):
|
||||
return False
|
||||
@@ -495,14 +471,13 @@ def maybe_auto_title(
|
||||
title_callback: Optional[TitleCallback] = None,
|
||||
runtime_validator: Optional[RuntimeValidator] = None,
|
||||
) -> None:
|
||||
"""Title a session from its opening message: instant inline, then upgraded on a daemon
|
||||
thread. Call at the START of a turn, before the model is invoked."""
|
||||
"""Title a session from its opening message: instant inline, then upgraded on a daemon thread.
|
||||
Call at the START of a turn, before the model is invoked."""
|
||||
if not session_db or not session_id or not user_message:
|
||||
return
|
||||
|
||||
# History may be pre- or post-message depending on the caller. Skip only when BOTH past the
|
||||
# opening turn AND already named: count alone left a session that opened with machinery
|
||||
# nameless; title alone never titles on a store too old to report one.
|
||||
# History may be pre- or post-message. Skip only when BOTH past the opening turn AND already named:
|
||||
# count alone left a machinery-opened session nameless; title alone never titles on an old store.
|
||||
user_msg_count = sum(1 for m in (conversation_history or []) if _is_real_user_turn(m))
|
||||
if user_msg_count > 1 and not _session_is_untitled(session_db, session_id):
|
||||
return
|
||||
|
||||
@@ -171,13 +171,9 @@ def build_trace_jsonl(
|
||||
cwd: str = "",
|
||||
redact: bool = True,
|
||||
) -> str:
|
||||
"""Render Hermes conversation messages as Claude Code JSONL text.
|
||||
|
||||
Each non-system message becomes one line: ``user``/``tool`` -> ``{"type": "user"}``,
|
||||
``assistant`` -> ``{"type": "assistant"}`` with text + ``tool_use`` blocks. Tool results ride
|
||||
on user turns as a ``tool_result`` block keyed by ``tool_call_id``; turns link via ``uuid`` /
|
||||
``parentUuid``.
|
||||
"""
|
||||
"""Render messages as Claude Code JSONL: one line per non-system message (``user``/``tool`` ->
|
||||
type user, ``assistant`` -> type assistant with text + ``tool_use`` blocks; tool results ride
|
||||
on user turns as ``tool_result`` keyed by ``tool_call_id``; turns link via ``parentUuid``)."""
|
||||
lines: List[str] = []
|
||||
parent: Optional[str] = None
|
||||
base_ts = _now_iso()
|
||||
|
||||
Reference in New Issue
Block a user