Merge branch 'simp/r3-06' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 21:57:12 -07:00
53 changed files with 4879 additions and 9875 deletions
+36 -62
View File
@@ -1,11 +1,10 @@
"""Workspace and project-root resolution for LSP.
1. **Workspace gate** — LSP only runs when the cwd (or the edited file) sits
inside a git worktree. Files outside any git root never trigger LSP,
which keeps gateway users on user-home cwd's from spawning daemons.
2. **nearest_root** — the per-server project-root walk: up from a start path
looking for marker files (``pyproject.toml``, ``Cargo.toml``, ...),
optionally bailing if an exclude marker shows up first.
1. **Workspace gate** — LSP only runs when the cwd (or the edited file) sits inside a git
worktree, so gateway users on user-home cwd's never spawn daemons.
2. **nearest_root** — the per-server project-root walk: up from a start path looking for marker
files (``pyproject.toml``, ``Cargo.toml``, ...), optionally bailing if an exclude marker
shows up first.
"""
from __future__ import annotations
@@ -16,8 +15,7 @@ from typing import Iterable, Iterator, Optional, Tuple
logger = logging.getLogger("agent.lsp.workspace")
# Cache: start dir → (worktree_root, is_git) so repeated calls don't re-stat.
# Cleared on shutdown.
# Cache: start dir → (worktree_root, is_git) so repeated calls don't re-stat. Cleared on shutdown.
_workspace_cache: dict = {}
# Walk cap: the deepest reasonable monorepo is well under 64 levels; bounds a
@@ -26,11 +24,8 @@ _MAX_WALK = 64
def normalize_path(path: str) -> str:
"""Expand ``~``, make absolute, collapse ``.``/``..``.
Symlinks are deliberately NOT resolved — some servers (rust-analyzer's
Cargo workspace identity) care, and we want the path the user typed.
"""
"""Expand ``~``, make absolute, collapse ``.``/``..``. Symlinks are deliberately NOT resolved —
some servers (rust-analyzer's Cargo workspace identity) care, and we want the path the user typed."""
return os.path.abspath(os.path.expanduser(path))
@@ -62,11 +57,9 @@ def find_git_worktree(start: str) -> Optional[str]:
start_path = _start_dir(start)
if start_path is None:
return None
cached = _workspace_cache.get(str(start_path))
if cached is not None:
return cached[0]
for cur in _walk_up(start_path):
try:
if (cur / ".git").exists():
@@ -74,30 +67,24 @@ def find_git_worktree(start: str) -> Optional[str]:
_workspace_cache[str(start_path)] = (resolved, True)
return resolved
except OSError:
# Permission error on a parent dir — bail out cleanly.
break
break # permission error on a parent dir — bail out cleanly
_workspace_cache[str(start_path)] = (None, False)
return None
def is_inside_workspace(path: str, workspace_root: str) -> bool:
"""True iff ``path`` is inside (or equal to) ``workspace_root``.
Symlinks are not resolved: a symlink pointing outside still counts as
outside, matching servers that reject didOpen for unrelated files.
"""
"""True iff ``path`` is inside (or equal to) ``workspace_root``. Symlinks are not resolved: a
symlink pointing outside still counts as outside, matching servers that reject didOpen for
unrelated files."""
p = normalize_path(path)
root = normalize_path(workspace_root)
if p == root:
return True
# commonpath handles case-insensitive filesystems on macOS/Windows.
try:
common = os.path.commonpath([p, root])
return os.path.commonpath([p, root]) == root
except ValueError:
# Different drives on Windows.
return False
return common == root
return False # different drives on Windows
def nearest_root(
@@ -109,51 +96,42 @@ def nearest_root(
) -> Optional[str]:
"""Walk up from ``start`` for the directory containing the first matched marker.
Returns ``None`` past ``ceiling`` (or the filesystem root), or when an
exclude marker is found first — the server is gated off for that file
(e.g. typescript skips deno projects when ``deno.json`` precedes
``package.json``). Marker names are exact filenames — no globs.
Returns ``None`` past ``ceiling`` (or the filesystem root), or when an exclude marker is found
first — the server is gated off for that file (e.g. typescript skips deno projects when
``deno.json`` precedes ``package.json``). Marker names are exact filenames — no globs.
"""
start_path = _start_dir(start)
if start_path is None:
return None
ceiling_path = Path(normalize_path(ceiling)) if ceiling else None
markers_list = list(markers)
excludes_list = list(excludes) if excludes else []
def present(cur: Path, names: list) -> bool:
for name in names:
try:
if (cur / name).exists():
return True
except OSError:
continue
return False
for cur in _walk_up(start_path):
# Excludes are checked before markers at each level.
for exc in excludes_list:
try:
if (cur / exc).exists():
return None
except OSError:
continue
for marker in markers_list:
try:
if (cur / marker).exists():
return str(cur)
except OSError:
continue
if present(cur, excludes_list):
return None
if present(cur, markers_list):
return str(cur)
if ceiling_path is not None and cur == ceiling_path:
return None
return None
def resolve_workspace_for_file(
file_path: str,
*,
cwd: Optional[str] = None,
) -> Tuple[Optional[str], bool]:
"""Return ``(workspace_root, gated_in)`` for a file.
The cwd's worktree wins when the file is inside it; otherwise the file's
own worktree is the fallback anchor (monorepos / unrelated checkouts).
``(None, False)`` when neither is in a git worktree.
"""
cwd = cwd or os.getcwd()
cwd_root = find_git_worktree(cwd)
def resolve_workspace_for_file(file_path: str, *, cwd: Optional[str] = None) -> Tuple[Optional[str], bool]:
"""Return ``(workspace_root, gated_in)`` for a file. The cwd's worktree wins when the file is
inside it; otherwise the file's own worktree is the fallback anchor (monorepos / unrelated
checkouts). ``(None, False)`` when neither is in a git worktree."""
cwd_root = find_git_worktree(cwd or os.getcwd())
if cwd_root is not None and is_inside_workspace(file_path, cwd_root):
return cwd_root, True
file_root = find_git_worktree(file_path)
@@ -168,10 +146,6 @@ def clear_cache() -> None:
__all__ = [
"find_git_worktree",
"is_inside_workspace",
"nearest_root",
"normalize_path",
"resolve_workspace_for_file",
"find_git_worktree", "is_inside_workspace", "nearest_root", "normalize_path", "resolve_workspace_for_file",
"clear_cache",
]
+16 -19
View File
@@ -10,11 +10,10 @@ from agent.redact import redact_sensitive_text
def describe_compression_lock_skip(lock_signal: Any) -> str:
"""User-facing text for a manual /compress skipped by the compression lock.
``lock_signal`` is ``agent._compression_skipped_due_to_lock`` (or the TUI's
``CompressionLockHeld.holder``): a holder string when another compressor
CONFIRMED holds the lock, else ``True``/``None``. A failed acquire is NOT
proof another compression is running (``try_acquire_compression_lock``
swallows ``sqlite3.Error``), so the two cases are worded differently.
``lock_signal`` is a holder string when another compressor CONFIRMED holds
the lock, else ``True``/``None``. A failed acquire is NOT proof another
compression is running (``try_acquire_compression_lock`` swallows
``sqlite3.Error``), so the two cases are worded differently.
"""
if isinstance(lock_signal, str) and lock_signal.strip():
return (
@@ -22,16 +21,11 @@ def describe_compression_lock_skip(lock_signal: Any) -> str:
f"(holder: {lock_signal}). Please wait for it to finish."
)
return (
"⏳ Compression skipped: could not acquire this session's "
"compression lock. Another compression may still be running, or "
"the lock check failed — try again shortly."
"⏳ Compression skipped: could not acquire this session's compression lock. Another compression may "
"still be running, or the lock check failed — try again shortly."
)
def _state_flag(state: Any, name: str) -> bool:
return state is not None and getattr(state, name, False) is True
def summarize_manual_compression(
before_messages: Sequence[dict[str, Any]],
after_messages: Sequence[dict[str, Any]],
@@ -40,14 +34,18 @@ def summarize_manual_compression(
*,
compression_state: Any = None,
) -> dict[str, Any]:
"""Return consistent user-facing feedback for manual compression."""
"""Consistent user-facing feedback (headline, token line, optional note) for manual compression."""
before_count = len(before_messages)
after_count = len(after_messages)
noop = list(after_messages) == list(before_messages)
aborted = _state_flag(compression_state, "_last_compress_aborted")
refused_would_grow = _state_flag(compression_state, "_last_compress_refused_would_grow")
fallback_used = _state_flag(compression_state, "_last_summary_fallback_used")
failure_reason = getattr(compression_state, "_last_summary_error", None) if compression_state is not None else None
def flag(name: str) -> bool:
return getattr(compression_state, name, False) is True
aborted = flag("_last_compress_aborted")
refused_would_grow = flag("_last_compress_refused_would_grow")
fallback_used = flag("_last_summary_fallback_used")
failure_reason = getattr(compression_state, "_last_summary_error", None)
if not isinstance(failure_reason, str) or not failure_reason.strip():
failure_reason = None
@@ -85,8 +83,7 @@ def summarize_manual_compression(
if failure_reason and (aborted or fallback_used):
# Crosses a user-facing UI boundary: never let a disabled global redaction
# preference expose credentials embedded in provider exception text.
safe_reason = redact_sensitive_text(failure_reason.strip(), force=True)
note = f"{note} Reason: {safe_reason}"
note = f"{note} Reason: {redact_sensitive_text(failure_reason.strip(), force=True)}"
return {
"noop": noop,
+22 -45
View File
@@ -2,14 +2,11 @@
Models pad tables assuming one cell per character; CJK glyphs and most emoji
take two, so body rows drift right on real terminals. This rebuilds padding
with ``wcwidth.wcswidth`` while preserving pipes/dashes so the table still reads
as plain text in ``strip``/unrendered modes (Rich already aligns CJK itself).
with ``wcwidth.wcswidth`` while preserving pipes/dashes (Rich already aligns CJK).
Deliberately conservative: only contiguous ``| ... |`` blocks with a divider are
rewritten; everything else passes through; single-line/mid-stream fragments are
left alone (callers buffer rows and flush complete blocks). ``wcwidth`` returns
``-1`` for some emoji+variation-selector sequences (``⚠️``); those clamp to 0 —
a 1-cell drift on that glyph beats widening every table that contains one.
rewritten; single-line/mid-stream fragments pass through (callers buffer rows and
flush complete blocks). ``wcswidth`` returns ``-1`` for some emoji+variation-selector
sequences (``⚠️``); those clamp to 0 — a 1-cell drift beats widening every table.
"""
from __future__ import annotations
@@ -19,12 +16,7 @@ from typing import List
from wcwidth import wcswidth
__all__ = [
"is_table_divider",
"looks_like_table_row",
"realign_markdown_tables",
"split_table_row",
]
__all__ = ["is_table_divider", "looks_like_table_row", "realign_markdown_tables", "split_table_row"]
_DIVIDER_CELL_RE = re.compile(r"^\s*:?-{3,}:?\s*$")
@@ -33,8 +25,7 @@ _MIN_COL_WIDTH = 3 # matches the divider's minimum dash run.
def _disp_width(s: str) -> int:
"""``wcswidth`` clamped to >= 0 (it returns -1 for control/unknown sequences)."""
w = wcswidth(s)
return w if w > 0 else 0
return max(wcswidth(s), 0)
def _pad_to_width(s: str, target: int) -> str:
@@ -43,11 +34,7 @@ def _pad_to_width(s: str, target: int) -> str:
def split_table_row(row: str) -> List[str]:
"""Split ``| a | b | c |`` into ``["a", "b", "c"]`` with trims."""
s = row.strip()
if s.startswith("|"):
s = s[1:]
if s.endswith("|"):
s = s[:-1]
s = row.strip().removeprefix("|").removesuffix("|")
return [c.strip() for c in s.split("|")]
@@ -61,16 +48,12 @@ def looks_like_table_row(row: str) -> bool:
"""True when ``row`` could plausibly be a markdown table row.
Intentionally permissive for streaming callers deciding whether to buffer a
line: the realigner only rewrites divider-backed blocks, so a false positive
at most delays printing one line. A leading pipe is the strongest signal;
without it we accept >= 2 pipes so models that omit the leading pipe still match.
line (a false positive at most delays printing one line). A leading pipe is
the strongest signal; without it we accept >= 2 pipes so models that omit
the leading pipe still match.
"""
if "|" not in row:
return False
stripped = row.strip()
if not stripped:
return False
return stripped.startswith("|") or stripped.count("|") >= 2
return bool(stripped) and (stripped.startswith("|") or stripped.count("|") >= 2)
def _render_block(rows: List[List[str]], available_width: int | None = None) -> List[str]:
@@ -100,14 +83,12 @@ def _render_block(rows: List[List[str]], available_width: int | None = None) ->
def _hard_break(word: str, w: int) -> List[str]:
"""Split a single over-wide word into display-width-``w`` chunks."""
out: List[str] = []
buf = ""
bw = 0
buf, bw = "", 0
for ch in word:
cw = _disp_width(ch) or 1
if bw + cw > w and buf:
out.append(buf)
buf = ch
bw = cw
buf, bw = ch, cw
else:
buf += ch
bw += cw
@@ -119,7 +100,7 @@ def _hard_break(word: str, w: int) -> List[str]:
def _wrap_to_width(text: str, width: int) -> List[str]:
"""Soft-wrap ``text`` at word boundaries to ``width`` display cells.
Words wider than ``width`` are hard-broken. Empty input yields a single
Words wider than ``width`` are hard-broken. Empty input yields a single
empty string so the caller's row count stays predictable.
"""
if width <= 0 or not text:
@@ -129,8 +110,7 @@ def _wrap_to_width(text: str, width: int) -> List[str]:
return [""]
lines: List[str] = []
current = ""
current_w = 0
current, current_w = "", 0
def _start(word: str, ww: int) -> None:
nonlocal current, current_w
@@ -167,8 +147,7 @@ def _render_vertical(rows: List[List[str]], ncols: int, available_width: int) ->
return []
headers = rows[0] + [""] * (ncols - len(rows[0]))
labels = [h or f"Column {i + 1}" for i, h in enumerate(headers)]
sep_width = max(20, min(40, available_width - 2)) if available_width else 30
separator = "─" * sep_width
separator = "─" * (max(20, min(40, available_width - 2)) if available_width else 30)
indent = " "
cont_budget = max(10, available_width - _disp_width(indent))
@@ -184,11 +163,10 @@ def _render_vertical(rows: List[List[str]], ncols: int, available_width: int) ->
continue
wrapped = _wrap_to_width(value, max(10, available_width - _disp_width(label) - 2))
out.append(f"{label}: {wrapped[0]}")
if len(wrapped) > 1:
# Re-flow continuation text at the wider continuation budget.
for cl in _wrap_to_width(" ".join(wrapped[1:]), cont_budget):
if cl.strip():
out.append(f"{indent}{cl}")
# Re-flow continuation text at the wider continuation budget.
for cl in _wrap_to_width(" ".join(wrapped[1:]), cont_budget) if len(wrapped) > 1 else ():
if cl.strip():
out.append(f"{indent}{cl}")
return out
@@ -204,8 +182,7 @@ def realign_markdown_tables(text: str, available_width: int | None = None) -> st
lines = text.split("\n")
out: List[str] = []
i = 0
n = len(lines)
i, n = 0, len(lines)
while i < n:
line = lines[i]
# A table starts with a header row whose next line is a divider.
@@ -217,7 +194,7 @@ def realign_markdown_tables(text: str, available_width: int | None = None) -> st
if not is_table_divider(lines[j]):
body.append(split_table_row(lines[j]))
j += 1
if any(c for c in header) or body:
if any(header) or body:
out.extend(_render_block([header] + body, available_width))
i = j
continue
+226 -523
View File
File diff suppressed because it is too large Load Diff
+48 -105
View File
@@ -1,11 +1,8 @@
"""Abstract base class for pluggable memory providers.
Memory providers give the agent persistent recall across sessions. Plugins
ship in ``plugins/memory/<name>/`` and are activated via ``memory.provider``;
MemoryManager allows only ONE external provider at a time (tool-schema bloat,
conflicting backends). Lifecycle is driven by MemoryManager: initialize ->
system_prompt_block / prefetch / sync_turn per turn -> tool dispatch ->
shutdown, plus the optional ``on_*`` hooks below.
Plugins ship in ``plugins/memory/<name>/``, activated via ``memory.provider`` (ONE external
provider at a time). Lifecycle, driven by MemoryManager: initialize -> system_prompt_block /
prefetch / sync_turn per turn -> tool dispatch -> shutdown, plus optional ``on_*`` hooks.
"""
from __future__ import annotations
@@ -18,9 +15,8 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# v1 = historical implicit contract (best-effort on_pre_compress() with the raw
# message list); v2 = opt-in fail-closed checkpoint (normalized evidence handoff
# + strict-mode failure propagation).
# v1 = best-effort on_pre_compress() with the raw message list; v2 = opt-in fail-closed
# checkpoint (normalized evidence handoff + strict-mode failure propagation).
PRE_COMPRESS_CHECKPOINT_API_VERSION = 2
# Default glyph for recall indicators; providers may use their own brand mark.
@@ -38,10 +34,9 @@ class RecallStatus:
glyph: str = INDICATOR_GLYPH
# Prompts with no semantic signal. Single source of truth for the core prefetch
# gate (turn_context.py, run_agent.py) and provider-side classifiers (honcho).
# Anchored and followed only by whitespace/punctuation, so "k8s"/"yolo"/"note"
# do NOT match while "hi!"/"thanks :)"/"done???" do.
# Prompts with no semantic signal; single source of truth for the core prefetch gate and
# provider-side classifiers. Anchored and followed only by whitespace/punctuation, so
# "k8s"/"yolo"/"note" do NOT match while "hi!"/"thanks :)"/"done???" do.
TRIVIAL_PROMPT_RE = re.compile(
r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|'
r'hi|hey|hello|yo|sup|'
@@ -52,11 +47,8 @@ TRIVIAL_PROMPT_RE = re.compile(
def is_trivial_prompt(text: Optional[str]) -> bool:
"""True for empty input, slash commands and bare greetings/acknowledgements.
Skipping recall on these saves a blocking network round-trip and keeps
stale user-model context from derailing one-word replies.
"""
"""True for empty input, slash commands and bare greetings/acknowledgements (skipping
recall saves a round-trip and keeps stale context from derailing one-word replies)."""
stripped = (text or "").strip()
if not stripped or stripped.startswith("/"):
return True
@@ -66,8 +58,8 @@ def is_trivial_prompt(text: Optional[str]) -> bool:
class MemoryProvider(ABC):
"""Abstract base class for memory providers."""
# Providers that durably checkpoint every successful on_pre_compress() opt
# in by setting PRE_COMPRESS_CHECKPOINT_API_VERSION; 1 = best-effort legacy.
# Providers that durably checkpoint every successful on_pre_compress() set this to
# PRE_COMPRESS_CHECKPOINT_API_VERSION; 1 = best-effort legacy.
pre_compress_checkpoint_api_version = 1
@property
@@ -79,64 +71,48 @@ class MemoryProvider(ABC):
@abstractmethod
def is_available(self) -> bool:
"""Configured, credentialed and ready? Gates activation at agent init;
check config/deps only — no network calls."""
"""Configured, credentialed and ready? Gates activation; check config/deps only, no network."""
@abstractmethod
def initialize(self, session_id: str, **kwargs) -> None:
"""Initialize once at agent startup (connections, resources, threads).
kwargs always include ``hermes_home`` (use it for profile-scoped storage,
never hardcode ``~/.hermes``) and ``platform``. May include
``agent_context`` ("primary" | "subagent" | "cron" | "flush" — skip
writes for non-primary contexts, cron prompts would corrupt user
representations), ``agent_identity`` (profile name), ``agent_workspace``,
``parent_session_id``, ``user_id``, ``user_id_alt``.
kwargs always include ``hermes_home`` (profile-scoped storage; never hardcode
``~/.hermes``) and ``platform``; may include ``agent_context`` ("primary" |
"subagent" | "cron" | "flush" — skip writes for non-primary contexts),
``agent_identity``, ``agent_workspace``, ``parent_session_id``, ``user_id``, ``user_id_alt``.
"""
def unavailable_reason(self) -> str:
"""Short user-facing hint for the "provider unavailable" warning (e.g.
which package to install) — ``initialize()`` never runs when unavailable,
so this is the only place such a diagnostic can surface."""
"""User-facing hint for the "provider unavailable" warning (``initialize()`` never runs then)."""
return ""
def system_prompt_block(self) -> str:
"""STATIC system-prompt text (instructions, status); "" to skip.
Recalled context goes through prefetch(), not here."""
"""STATIC system-prompt text; "" to skip. Recalled context goes through prefetch(), not here."""
return ""
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""Formatted recall context for the upcoming turn ("" if none).
Must be fast — do the recall in the background and return cached
results. ``session_id`` scopes concurrent sessions (gateway, cached agents).
"""
"""Formatted recall context for the upcoming turn ("" if none). Must be fast — recall
in the background and return cached results; ``session_id`` scopes concurrent sessions."""
return ""
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
"""Queue a background recall after each turn; prefetch() consumes it next turn."""
def recall_status(self) -> Optional[RecallStatus]:
"""What the most recent :meth:`prefetch` injected, for a deterministic
"recalled N memories" indicator. ``None`` = nothing / no indicator.
Must reflect only the LAST prefetch, never a stale prior count."""
"""What the most recent :meth:`prefetch` injected (``None`` = no indicator). Must reflect
only the LAST prefetch, never a stale prior count."""
return None
def sync_turn(
self,
user_content: str,
assistant_content: str,
*,
session_id: str = "",
messages: Optional[List[Dict[str, Any]]] = None,
self, user_content: str, assistant_content: str, *,
session_id: str = "", messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""Persist a completed turn; should be non-blocking. ``messages`` is the
OpenAI-style list as of this turn, including tool calls/results."""
"""Persist a completed turn (non-blocking). ``messages`` is the OpenAI-style list so far."""
@abstractmethod
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""OpenAI function-calling schemas ({"name", "description", "parameters"});
[] for context-only providers."""
"""OpenAI function-calling schemas ({"name", "description", "parameters"}); [] if none."""
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
"""Handle one of this provider's tools; must return a JSON string."""
@@ -148,75 +124,42 @@ class MemoryProvider(ABC):
# -- Optional hooks (override to opt in) ---------------------------------
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
"""Per-turn tick (turn-counting, scope management, maintenance).
kwargs may include remaining_tokens, model, platform, tool_count."""
"""Per-turn tick. kwargs may include remaining_tokens, model, platform, tool_count."""
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
"""End-of-session extraction over the full history. Fires only at real
session boundaries (CLI exit, /reset, gateway expiry), never per-turn."""
"""End-of-session extraction; fires only at real session boundaries, never per-turn."""
def on_session_switch(
self,
new_session_id: str,
*,
parent_session_id: str = "",
reset: bool = False,
rewound: bool = False,
**kwargs,
self, new_session_id: str, *, parent_session_id: str = "", reset: bool = False, rewound: bool = False, **kwargs,
) -> None:
"""session_id reassigned mid-process (/resume, /branch, /reset, /new,
gateway equivalents, context compression) without a provider teardown.
Update or reset any per-session state cached in ``initialize()`` so
later writes land in the right record. ``parent_session_id`` carries
lineage for /branch, compression and /resume ("" when none). ``reset``
is True only for a genuinely new conversation (/reset, /new) — flush
per-session buffers; False when the logical conversation continues
under a new id. ``rewound``: same id but the transcript was truncated,
so invalidate per-turn document state.
"""
"""session_id reassigned mid-process (/resume, /branch, /reset, /new, compression)
without teardown: rebind per-session state so later writes land in the right record.
``reset`` is True only for a genuinely new conversation (flush buffers); ``rewound``:
same id but the transcript was truncated."""
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
"""Extract insights from ``messages`` about to be compressed; the returned
text is fed into the compression summary prompt ("" = nothing)."""
"""Extract insights from ``messages`` about to be compressed, fed into the summary prompt."""
return ""
def on_delegation(self, task: str, result: str, *,
child_session_id: str = "", **kwargs) -> None:
"""PARENT-side observation of a completed delegation (task prompt + final
result); the subagent itself has no provider session (skip_memory=True)."""
def on_delegation(self, task: str, result: str, *, child_session_id: str = "", **kwargs) -> None:
"""PARENT-side observation of a completed delegation (the subagent has no provider session)."""
def get_config_schema(self) -> List[Dict[str, Any]]:
"""Setup fields for ``hermes memory setup`` ([] if none).
Each field: ``key``, ``description``, optional ``secret`` (goes to .env),
``required``, ``default``, ``choices``, ``type`` (text | integer |
number | boolean), ``minimum`` / ``maximum`` / ``step`` (numeric,
Dashboard rendering), ``url`` (where to get the credential), ``env_var``
(explicit secret env var; default auto-generated).
"""
"""Setup fields for ``hermes memory setup`` ([] if none): ``key``, ``description``,
optional ``secret`` (goes to .env), ``required``, ``default``, ``choices``, ``type``
(text | integer | number | boolean), ``minimum``/``maximum``/``step``, ``url``,
``env_var`` (explicit secret env var; default auto-generated)."""
return []
def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
"""Write non-secret setup ``values`` (secrets go to .env) to the provider's
native config location. Plugins MUST either override this or use only
env vars (every schema field carrying ``env_var``) and keep the no-op."""
"""Write non-secret setup ``values`` to the provider's native config. Plugins MUST either
override this or use only env vars (every schema field carrying ``env_var``)."""
def on_memory_write(
self,
action: str,
target: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Mirror a built-in memory-tool write. ``action`` is add | replace |
remove, ``target`` is memory | user; ``metadata`` (when available) has
provenance such as write_origin, execution_context, session_id,
parent_session_id, platform, tool_name."""
def on_memory_write(self, action: str, target: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> None:
"""Mirror a built-in memory-tool write (``action``: add | replace | remove; ``target``:
memory | user; ``metadata``: provenance such as write_origin, session_id, tool_name)."""
def backup_paths(self) -> List[str]:
"""Absolute paths of provider state OUTSIDE HERMES_HOME (e.g. ``~/.honcho``)
so ``hermes backup`` can capture them under ``_external/`` and
``hermes import`` restore them; paths outside the home dir are skipped.
MUST work without ``initialize()`` or network — resolve from config/env."""
"""Absolute paths of provider state OUTSIDE HERMES_HOME for ``hermes backup``/``import``
(paths outside the home dir are skipped). MUST work without ``initialize()`` or network."""
return []
+3 -10
View File
@@ -9,9 +9,7 @@ _TEXT_KEYS = ("text", "content", "input_text", "output_text", "summary_text")
def _field(value: Any, key: str) -> Any:
if isinstance(value, Mapping):
return value.get(key)
return getattr(value, key, None)
return value.get(key) if isinstance(value, Mapping) else getattr(value, key, None)
def _text_from_part(part: Any) -> str:
@@ -19,11 +17,8 @@ def _text_from_part(part: Any) -> str:
return ""
if isinstance(part, str):
return part
part_type = str(_field(part, "type") or "").strip().lower()
if part_type in _NON_TEXT_PART_TYPES:
if str(_field(part, "type") or "").strip().lower() in _NON_TEXT_PART_TYPES:
return ""
for key in _TEXT_KEYS:
text = _field(part, key)
if isinstance(text, str):
@@ -38,9 +33,7 @@ def flatten_message_text(content: Any, *, sep: str = "\n") -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
chunks = [_text_from_part(part) for part in content]
return sep.join(chunk for chunk in chunks if chunk)
return sep.join(chunk for chunk in map(_text_from_part, content) if chunk)
text = _text_from_part(content)
if text:
return text
+3 -5
View File
@@ -20,9 +20,8 @@ def stamp_message_timestamp(
) -> _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 at the point the message enters the live transcript.
Returning the same mapping keeps the helper convenient at append sites.
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
@@ -36,6 +35,5 @@ def append_message(
timestamp: Optional[float] = None,
) -> _Message:
"""Stamp and append one live transcript message."""
stamp_message_timestamp(message, timestamp=timestamp)
messages.append(message)
messages.append(stamp_message_timestamp(message, timestamp=timestamp))
return message
+223 -429
View File
@@ -1,9 +1,8 @@
"""Message and tool-payload sanitization helpers.
"""Message and tool-payload sanitization helpers (pure; documented in-place mutation).
Pure functions (extracted from ``run_agent.py``) that walk OpenAI-format message
lists and structured payloads, repairing or stripping characters that would
crash ``json.dumps`` in the OpenAI SDK or be rejected upstream. Stateless except
for documented in-place mutation; ``run_agent`` re-exports them for old imports.
Walk OpenAI-format message lists and structured payloads, repairing or stripping
characters that would crash ``json.dumps`` in the OpenAI SDK or be rejected upstream.
``run_agent`` re-exports them for old imports.
"""
from __future__ import annotations
@@ -12,16 +11,16 @@ import hashlib
import json
import logging
import re
from functools import partial
from typing import Any, Callable
logger = logging.getLogger(__name__)
# Lone surrogate code points are invalid in UTF-8 and crash json.dumps inside
# the OpenAI SDK. Also used by run_agent and the CLI for paste scrubbing.
# Lone surrogates are invalid UTF-8 and crash json.dumps in the OpenAI SDK; also used for
# CLI paste scrubbing.
_SURROGATE_RE = re.compile(r'[\ud800-\udfff]')
# Message keys handled explicitly by _sanitize_messages; every OTHER key is
# swept generically (reasoning, reasoning_content, reasoning_details, ...).
# Keys handled explicitly by _sanitize_messages; every OTHER key is swept generically.
_MESSAGE_CORE_KEYS = frozenset({"content", "name", "tool_calls", "role"})
@@ -51,58 +50,46 @@ def _fix_str_field(container: Any, key: Any, fix: Callable[[str], str]) -> bool:
def _sanitize_structure(payload: Any, fix: Callable[[str], str]) -> bool:
"""Apply ``fix`` to every str inside nested dict/list ``payload`` in-place."""
found = False
def _walk(node):
nonlocal found
if isinstance(node, dict):
items = list(node.items())
elif isinstance(node, list):
items = list(enumerate(node))
else:
return
for key, value in items:
stack = [payload]
while stack:
node = stack.pop()
items = node.items() if isinstance(node, dict) else enumerate(node) if isinstance(node, list) else ()
for key, value in list(items):
if isinstance(value, str):
found |= _fix_str_field(node, key, fix)
elif isinstance(value, (dict, list)):
_walk(value)
_walk(payload)
stack.append(value)
return found
def _sanitize_messages(messages: list, fix: Callable[[str], str], *, deep: bool) -> bool:
"""Apply ``fix`` to the string fields of every message dict in-place.
Covers content / content-part text, name, tool_call function arguments, and
every non-core top-level str field (reasoning_content etc.) so retries don't
fail on a non-content field. ``deep=True`` additionally covers tool_call ids,
function names, and NESTED non-core fields (``reasoning_details`` arrays from
byte-level reasoning models such as xiaomi/mimo, kimi, glm).
Covers content / content-part text, name, tool_call arguments, and every non-core
top-level str field. ``deep=True`` additionally covers tool_call ids, function names,
and NESTED non-core fields (``reasoning_details`` arrays from byte-level reasoning models).
"""
found = False
for msg in messages:
if not isinstance(msg, dict):
continue
content = msg.get("content")
if isinstance(content, str):
found |= _fix_str_field(msg, "content", fix)
elif isinstance(content, list):
if isinstance(content, list):
for part in content:
if isinstance(part, dict):
found |= _fix_str_field(part, "text", fix)
else:
found |= _fix_str_field(msg, "content", fix)
found |= _fix_str_field(msg, "name", fix)
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
for tc in tool_calls:
if not isinstance(tc, dict):
continue
if deep:
found |= _fix_str_field(tc, "id", fix)
fn = tc.get("function")
if isinstance(fn, dict):
if deep:
found |= _fix_str_field(fn, "name", fix)
found |= _fix_str_field(fn, "arguments", fix)
for tc in tool_calls if isinstance(tool_calls, list) else ():
if not isinstance(tc, dict):
continue
fn = tc.get("function")
fn_fields = [(fn, "name"), (fn, "arguments")] if isinstance(fn, dict) else []
for container, key in [(tc, "id")] + fn_fields:
if deep or key == "arguments":
found |= _fix_str_field(container, key, fix)
for key, value in list(msg.items()):
if key in _MESSAGE_CORE_KEYS:
continue
@@ -113,75 +100,50 @@ def _sanitize_messages(messages: list, fix: Callable[[str], str], *, deep: bool)
return found
def _sanitize_structure_surrogates(payload: Any) -> bool:
"""Replace surrogates in nested dict/list payloads in-place; True if any replaced."""
return _sanitize_structure(payload, _sanitize_surrogates)
def _sanitize_messages_surrogates(messages: list) -> bool:
"""Replace surrogates in all string content of a messages list in-place; True if any found."""
return _sanitize_messages(messages, _sanitize_surrogates, deep=True)
def _sanitize_structure_non_ascii(payload: Any) -> bool:
"""Strip non-ASCII from nested dict/list payloads in-place; True if any stripped."""
return _sanitize_structure(payload, _strip_non_ascii)
def _sanitize_messages_non_ascii(messages: list) -> bool:
"""Strip non-ASCII from a messages list in-place (ASCII-only locales); True if any stripped."""
return _sanitize_messages(messages, _strip_non_ascii, deep=False)
def _sanitize_tools_non_ascii(tools: list) -> bool:
"""Strip non-ASCII characters from tool payloads in-place."""
return _sanitize_structure_non_ascii(tools)
# In-place sanitizers; each returns True when anything changed. Surrogate repair is deep
# (tool_call ids, nested reasoning_details); the ASCII-only-locale strip is shallow.
_sanitize_structure_surrogates = partial(_sanitize_structure, fix=_sanitize_surrogates)
_sanitize_messages_surrogates = partial(_sanitize_messages, fix=_sanitize_surrogates, deep=True)
_sanitize_structure_non_ascii = partial(_sanitize_structure, fix=_strip_non_ascii)
_sanitize_messages_non_ascii = partial(_sanitize_messages, fix=_strip_non_ascii, deep=False)
_sanitize_tools_non_ascii = _sanitize_structure_non_ascii
def _escape_invalid_chars_in_json_strings(raw: str) -> str:
"""Escape literal control chars (0x00-0x1F) inside JSON string values as ``\\uXXXX``.
Complements ``json.loads(strict=False)`` in ``_repair_tool_call_arguments``
for llama.cpp-style output that mixes control chars with other malformations.
"""
"""Escape literal control chars (0x00-0x1F) inside JSON string values as ``\\uXXXX``
(for llama.cpp-style output mixing control chars with other malformations)."""
out: list[str] = []
in_string = False
i = 0
n = len(raw)
while i < n:
while i < len(raw):
ch = raw[i]
if in_string:
if ch == "\\" and i + 1 < n:
out.append(ch)
out.append(raw[i + 1])
i += 2
continue
if ch == '"':
in_string = False
out.append(ch)
elif ord(ch) < 0x20:
out.append(f"\\u{ord(ch):04x}")
else:
out.append(ch)
else:
if ch == '"':
in_string = True
out.append(ch)
if in_string and ch == "\\" and i + 1 < len(raw):
out.append(raw[i:i + 2])
i += 2
continue
if ch == '"':
in_string = not in_string
out.append(f"\\u{ord(ch):04x}" if in_string and ord(ch) < 0x20 else ch)
i += 1
return "".join(out)
# When a repair rewrites arguments to "{}", the WARNING log is the last surviving
# copy of content that can hold real user data (e.g. a truncated write_file's
# streamed file content). Bound it here rather than at a short preview.
# When a repair rewrites arguments to "{}", the WARNING log is the last surviving copy of
# content that can hold real user data (a truncated write_file), so bound it generously.
_FULL_ARGS_LOG_BOUND = 100_000
def _loads_ok(text: str) -> bool:
try:
json.loads(text)
return True
except json.JSONDecodeError:
return False
def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
"""Repair malformed tool_call argument JSON (truncation, trailing commas,
Python ``None``, literal control chars); returns ``"{}"`` if unrepairable so
the request succeeds instead of crashing the session. Repairs log at WARNING.
"""
"""Repair malformed tool_call argument JSON (truncation, trailing commas, Python ``None``,
control chars); ``"{}"`` if unrepairable so the request succeeds. Repairs log at WARNING."""
raw_stripped = raw_args.strip() if isinstance(raw_args, str) else ""
if not raw_stripped:
@@ -192,68 +154,45 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
logger.warning("Sanitized Python-None tool_call arguments for %s", tool_name)
return "{}"
# Pass 0: strict=False accepts literal control chars inside strings (the
# most common local-model case) and re-serialises to wire-valid JSON.
# Pass 0: strict=False accepts literal control chars inside strings (the most common
# local-model case) and re-serialises to wire-valid JSON.
try:
parsed = json.loads(raw_stripped, strict=False)
reserialised = json.dumps(parsed, separators=(",", ":"))
reserialised = json.dumps(json.loads(raw_stripped, strict=False), separators=(",", ":"))
if reserialised != raw_stripped:
logger.warning(
"Repaired unescaped control chars in tool_call arguments for %s",
tool_name,
)
logger.warning("Repaired unescaped control chars in tool_call arguments for %s", tool_name)
return reserialised
except (json.JSONDecodeError, TypeError, ValueError):
pass
# Passes 1-3: strip trailing commas, close unclosed structures, then trim
# excess closers (bounded).
# Passes 1-3: strip trailing commas, close unclosed structures, trim excess closers (bounded).
fixed = re.sub(r',\s*([}\]])', r'\1', raw_stripped)
open_curly = fixed.count('{') - fixed.count('}')
open_bracket = fixed.count('[') - fixed.count(']')
if open_curly > 0:
fixed += '}' * open_curly
if open_bracket > 0:
fixed += ']' * open_bracket
fixed += '}' * max(0, fixed.count('{') - fixed.count('}'))
fixed += ']' * max(0, fixed.count('[') - fixed.count(']'))
for _ in range(50):
try:
json.loads(fixed)
if _loads_ok(fixed):
break
if (fixed.endswith('}') and fixed.count('}') > fixed.count('{')) or (
fixed.endswith(']') and fixed.count(']') > fixed.count('[')
):
fixed = fixed[:-1]
else:
break
except json.JSONDecodeError:
if (fixed.endswith('}') and fixed.count('}') > fixed.count('{')) or (
fixed.endswith(']') and fixed.count(']') > fixed.count('[')
):
fixed = fixed[:-1]
else:
break
try:
json.loads(fixed)
logger.warning(
"Repaired malformed tool_call arguments for %s: %s → %s",
tool_name, raw_stripped[:80], fixed[:80],
)
if _loads_ok(fixed):
logger.warning("Repaired malformed tool_call arguments for %s: %s → %s", tool_name, raw_stripped[:80], fixed[:80])
return fixed
except json.JSONDecodeError:
pass
# Pass 4: escape control chars inside strings (strict=False alone fails
# when other malformations are present too), then retry.
try:
escaped = _escape_invalid_chars_in_json_strings(fixed)
if escaped != fixed:
json.loads(escaped)
logger.warning(
"Repaired control-char-laced tool_call arguments for %s: %s → %s",
tool_name, raw_stripped[:80], escaped[:80],
)
return escaped
except (json.JSONDecodeError, TypeError, ValueError):
pass
# Pass 4: escape control chars inside strings (strict=False alone fails when other
# malformations are present too), then retry.
escaped = _escape_invalid_chars_in_json_strings(fixed)
if escaped != fixed and _loads_ok(escaped):
logger.warning(
"Repaired control-char-laced tool_call arguments for %s: %s → %s", tool_name, raw_stripped[:80], escaped[:80],
)
return escaped
logger.warning(
"Unrepairable tool_call arguments for %s — "
"replaced with empty object (was: %s)",
"Unrepairable tool_call arguments for %s — replaced with empty object (was: %s)",
tool_name, raw_stripped[:_FULL_ARGS_LOG_BOUND],
)
return "{}"
@@ -262,62 +201,45 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
def close_interrupted_tool_sequence(messages: list, final_response: Any = None) -> bool:
"""Append a synthetic assistant turn when an interrupted tail is a tool result.
A transcript ending on a raw ``tool`` message makes the next user message
land as ``tool → user`` — a role-alternation violation strict providers
(Gemini, Claude) answer by hallucinating a continuation and dropping prior
context. ``finalize_turn`` covers the happy interrupt path; the retry/backoff
early-returns in ``conversation_loop`` need this shared helper. Mutates in
place; returns True if a closing turn was appended.
A transcript ending on a raw ``tool`` message makes the next user message land as
``tool → user`` — an alternation violation strict providers (Gemini, Claude) answer by
hallucinating a continuation. Mutates in place; True if a closing turn was appended.
"""
if not messages:
return False
last = messages[-1]
last = messages[-1] if messages else None
if not isinstance(last, dict) or last.get("role") != "tool":
return False
text = final_response if isinstance(final_response, str) else ""
from agent.message_metadata import append_message
append_message(messages, {
"role": "assistant",
"content": text.strip() or "Operation interrupted.",
})
append_message(messages, {"role": "assistant", "content": text.strip() or "Operation interrupted."})
return True
def serialized_messages_bytes(messages: list) -> int:
"""Exact serialized byte size of the ``messages`` payload (HTTP 413 recovery).
A 413 is a BYTE-size error, but the token estimator deliberately prices an
image at a flat per-image cost, so it cannot score recovery from an
image-dominated 413 (compaction frees megabytes while the estimate barely
moves → "no progress"). This measures what the provider actually rejected,
identically before and after each pass. Non-serializable values fall back to
``str()`` so a malformed message can never crash recovery.
A 413 is a BYTE-size error, but the token estimator prices images at a flat cost, so
it cannot score recovery from an image-dominated 413. Non-serializable values fall
back to ``str()`` so a malformed message can never crash recovery.
"""
if not isinstance(messages, list) or not messages:
return 0
try:
return len(
json.dumps(
messages, ensure_ascii=False, separators=(",", ":"), default=str
).encode("utf-8")
)
return len(json.dumps(messages, ensure_ascii=False, separators=(",", ":"), default=str).encode("utf-8"))
except (TypeError, ValueError):
return sum(len(str(m)) for m in messages)
_IMAGE_PART_TYPES = {"image_url", "image", "input_image"}
def _strip_images_from_messages(messages: list) -> bool:
"""Remove image content parts from all messages in-place (server rejected images).
Preserves alternation invariants: ``tool`` messages and assistant messages
carrying ``tool_calls`` whose content was entirely images are replaced with a
placeholder, NOT deleted (deleting orphans the paired ``tool_call_id`` →
HTTP 400); other now-empty messages (synthetic image-only attachment turns)
are dropped. Any rewritten message also loses its ``api_content`` sidecar —
it carries the exact bytes previously sent, i.e. the images being removed,
and would be substituted back on the wire next turn.
Returns True if any image parts were removed.
``tool`` messages and assistant messages carrying ``tool_calls`` whose content was
entirely images get a placeholder, NOT deleted (deleting orphans the paired
``tool_call_id`` → HTTP 400); other now-empty messages are dropped. Rewritten messages
lose their ``api_content`` sidecar (it carries the images being removed).
"""
from agent.turn_context import drop_stale_api_content
@@ -329,13 +251,9 @@ def _strip_images_from_messages(messages: list) -> bool:
content = msg.get("content")
if not isinstance(content, list):
continue
new_parts = []
for part in content:
if isinstance(part, dict) and part.get("type") in {"image_url", "image", "input_image"}:
found = True
else:
new_parts.append(part)
new_parts = [p for p in content if not (isinstance(p, dict) and p.get("type") in _IMAGE_PART_TYPES)]
if len(new_parts) < len(content):
found = True
if new_parts:
msg["content"] = new_parts
elif msg.get("role") == "tool" or msg.get("tool_calls"):
@@ -348,40 +266,28 @@ def _strip_images_from_messages(messages: list) -> bool:
return found
# Provider error bodies (lowercased substring match) meaning "image/multimodal
# input unsupported" — the loop then strips images and retries text-only instead
# of cascading into compression / context-too-large recovery or wedging on retries.
# Provider error bodies (lowercased substring match) meaning "image/multimodal input
# unsupported" — the loop then strips images and retries text-only instead of cascading
# into compression / context-too-large recovery or wedging on retries.
_IMAGE_REJECTION_PHRASES = (
"only 'text' content type is supported",
"only text content type is supported",
"image_url is not supported",
"image content is not supported",
"multimodal is not supported",
"multimodal content is not supported",
"multimodal input is not supported",
"vision is not supported",
"vision input is not supported",
"does not support images",
"does not support image input",
"does not support multimodal",
"does not support vision",
"model does not support image",
"only 'text' content type is supported", "only text content type is supported",
"image_url is not supported", "image content is not supported",
"multimodal is not supported", "multimodal content is not supported", "multimodal input is not supported",
"vision is not supported", "vision input is not supported",
"does not support images", "does not support image input", "does not support multimodal",
"does not support vision", "model does not support image",
# DashScope-style gateways reject non-text blocks with this generic body.
"unexpected item type in content",
# ChatGPT-account Codex backend rejects data:image URLs in input_image
# ("Invalid 'input[N].content[K].image_url'. Expected a valid URL ...");
# keyed on the field-path apostrophe so other URL errors don't false-trip.
"image_url'. expected",
# ChatGPT-account Codex wording for corrupt/unsupported native image payloads.
"image data you provided does not represent a valid image",
# ChatGPT-account Codex backend rejects data:image URLs in input_image; keyed on the
# field-path apostrophe so other URL errors don't false-trip. Second: its wording for
# corrupt/unsupported native image payloads.
"image_url'. expected", "image data you provided does not represent a valid image",
# DeepSeek's text-only request-body variant error.
"unknown variant `image_url`, expected `text`",
"unknown variant image_url, expected text",
# OpenRouter HTTP 404 when no upstream endpoint accepts image input (passes
# the 4xx gate; without this the gateway queue wedges behind the stuck turn).
"unknown variant `image_url`, expected `text`", "unknown variant image_url, expected text",
# OpenRouter HTTP 404 when no upstream endpoint accepts image input (passes the 4xx
# gate; without this the gateway queue wedges behind the stuck turn).
"no endpoints found that support image input",
# Kimi/Moonshot et al. reject truncated/corrupt image bytes baked into
# immutable history ("prepare image failed ... failed to decode image").
# Kimi/Moonshot et al. reject truncated/corrupt image bytes baked into history.
"failed to decode image",
)
@@ -393,45 +299,25 @@ def _looks_like_image_content_rejection(error_body: str) -> bool:
__all__ = [
"_SURROGATE_RE",
"close_interrupted_tool_sequence",
"_sanitize_surrogates",
"_sanitize_structure_surrogates",
"_sanitize_messages_surrogates",
"_escape_invalid_chars_in_json_strings",
"_repair_tool_call_arguments",
"_strip_non_ascii",
"_sanitize_messages_non_ascii",
"_sanitize_tools_non_ascii",
"_strip_images_from_messages",
"_sanitize_structure_non_ascii",
"_SURROGATE_RE", "close_interrupted_tool_sequence",
"_sanitize_surrogates", "_sanitize_structure_surrogates", "_sanitize_messages_surrogates",
"_escape_invalid_chars_in_json_strings", "_repair_tool_call_arguments",
"_strip_non_ascii", "_sanitize_messages_non_ascii", "_sanitize_tools_non_ascii",
"_strip_images_from_messages", "_sanitize_structure_non_ascii",
# call_id policy owners
"deterministic_call_id",
"coalesce_tool_call_id",
"tool_call_id_variants",
"tool_result_id_variants",
"uniquify_tool_call_ids",
"deterministic_call_id", "coalesce_tool_call_id", "tool_call_id_variants",
"tool_result_id_variants", "uniquify_tool_call_ids",
# reasoning_content policy owners
"reasoning_echo_family",
"matches_reasoning_echo_family",
"needs_reasoning_echo",
"stale_thinking_reaches_wire",
"apply_reasoning_content_policy",
"reapply_reasoning_echo",
"reasoning_echo_family", "matches_reasoning_echo_family", "needs_reasoning_echo",
"stale_thinking_reaches_wire", "apply_reasoning_content_policy", "reapply_reasoning_echo",
]
# ---------------------------------------------------------------------------
# call_id policy — single owner for hash synthesis, ``call_id or id``
# coalescing, and duplicate-id repair.
#
# NOT consolidated on purpose: agent/transports/codex_event_projector's
# _deterministic_call_id maps codex app-server ITEM ids (`codex_<type>_<item_id>`),
# not chat tool-call content; merging would change ids and invalidate caches.
#
# HARD INVARIANT: everything here stays deterministic (never uuid4) and
# byte-identical for existing inputs — these ids feed prompt-cache prefixes.
# ---------------------------------------------------------------------------
# -- call_id policy: hash synthesis, ``call_id or id`` coalescing, duplicate-id repair ----
# NOT merged with codex_event_projector._deterministic_call_id (maps app-server ITEM ids,
# not chat tool-call content; merging would change ids and invalidate caches).
# HARD INVARIANT: deterministic (never uuid4) and byte-identical for existing inputs —
# these ids feed prompt-cache prefixes.
def _tc_field(tc: Any, key: str) -> Any:
@@ -439,41 +325,36 @@ def _tc_field(tc: Any, key: str) -> Any:
return tc.get(key) if isinstance(tc, dict) else getattr(tc, key, None)
def _tc_set(tc: Any, key: str, value: Any) -> None:
if isinstance(tc, dict):
tc[key] = value
else:
setattr(tc, key, value)
def deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
"""Deterministic call_id fallback when the API omits one (random ids would
make every prefix unique and break prompt caching)."""
"""Deterministic call_id fallback when the API omits one (random ids would break caching)."""
seed = f"{fn_name}:{arguments}:{index}"
digest = hashlib.sha256(seed.encode("utf-8", errors="replace")).hexdigest()[:12]
return f"call_{digest}"
return f"call_{hashlib.sha256(seed.encode('utf-8', errors='replace')).hexdigest()[:12]}"
def _expand_tool_id_variants(values: tuple[Any, ...]) -> frozenset[str]:
"""Every wire spelling of one tool-call identifier.
Responses bridges may expose the pairing id and response-item id separately
or encode both as ``call_id|response_item_id``; all are aliases for ONE call.
"""
"""Every wire spelling of one tool-call identifier: Responses bridges may expose the pairing
id and response-item id separately or as ``call_id|response_item_id``; all alias ONE call."""
variants: set[str] = set()
for raw in values:
if not isinstance(raw, str):
continue
value = raw.strip()
value = raw.strip() if isinstance(raw, str) else ""
if not value:
continue
variants.add(value)
if "|" in value:
for part in value.split("|"):
part = part.strip()
if part:
variants.add(part)
variants.update(p for p in (part.strip() for part in value.split("|")) if p)
return frozenset(variants)
def tool_call_id_variants(tc: Any) -> frozenset[str]:
"""Return all pairing-id variants carried by a tool-call entry."""
return _expand_tool_id_variants(
(_tc_field(tc, "call_id"), _tc_field(tc, "id"), _tc_field(tc, "response_item_id"))
)
return _expand_tool_id_variants(tuple(_tc_field(tc, k) for k in ("call_id", "id", "response_item_id")))
def tool_result_id_variants(tool_call_id: Any) -> frozenset[str]:
@@ -482,16 +363,13 @@ def tool_result_id_variants(tool_call_id: Any) -> frozenset[str]:
def coalesce_tool_call_id(tc: Any) -> str:
"""Effective call id of a tool_call entry (dict or object).
"""Effective call id of a tool_call entry (dict or object); ``""`` when none.
Codex Responses calls carry ``call_id`` (authoritative pairing key), Chat
Completions carry ``id`` only, and bridge ids may be ``call_id|response_item_id``.
Returns ``""`` when neither is set.
Codex Responses calls carry ``call_id`` (authoritative pairing key), Chat Completions
carry ``id`` only, and bridge ids may be ``call_id|response_item_id``.
"""
for raw in (_tc_field(tc, "call_id"), _tc_field(tc, "id")):
if not isinstance(raw, str):
continue
value = raw.strip()
value = raw.strip() if isinstance(raw, str) else ""
if value:
return value.split("|", 1)[0].strip() or value
return ""
@@ -500,23 +378,18 @@ def coalesce_tool_call_id(tc: Any) -> str:
def uniquify_tool_call_ids(tool_calls: list) -> list:
"""Ensure every tool call in one assistant turn has a distinct id.
Some providers reuse one id across calls in a batch; the pre-API sanitizer
then keeps only the first call/result pair per id (the later result silently
vanishes) and strict providers reject duplicates outright. First occurrence
keeps its id; later collisions get a deterministic ``<id>_d<n>`` suffix
(never uuid4 — cache-prefix stability). Mutates entries in place (SDK models
/ SimpleNamespace / dicts) and returns the same list. Blank ids are left for
the deterministic fallback in ``build_assistant_message``.
Some providers reuse one id across a batch; the pre-API sanitizer then keeps only the
first call/result pair per id and strict providers reject duplicates. First occurrence
keeps its id; later collisions get a deterministic ``<id>_d<n>`` suffix (never uuid4 —
cache-prefix stability). Mutates entries in place (SDK models / SimpleNamespace /
dicts). Blank ids are left for the deterministic fallback in ``build_assistant_message``.
"""
seen: set = set()
for tc in tool_calls or []:
# Same coalescing rule as coalesce_tool_call_id, tolerant of non-string ids.
raw = _tc_field(tc, "call_id") or _tc_field(tc, "id") or ""
raw = raw.strip() if isinstance(raw, str) else ""
if not raw:
continue
# Composite Responses ids ("call_x|fc_y") collide on the call half —
# that's the pairing key providers enforce per turn.
# Composite Responses ids ("call_x|fc_y") collide on the call half — the pairing key.
cid = raw.split("|", 1)[0]
if not cid:
continue
@@ -524,38 +397,23 @@ def uniquify_tool_call_ids(tool_calls: list) -> list:
seen.add(cid)
continue
n = 2
new_id = f"{cid}_d{n}"
while new_id in seen:
while f"{cid}_d{n}" in seen:
n += 1
new_id = f"{cid}_d{n}"
new_id = f"{cid}_d{n}"
seen.add(new_id)
def _renamed(value):
# Keep a composite id's response-item half so the provider's real
# fc_/item id survives the rename.
if isinstance(value, str) and "|" in value:
return f"{new_id}|{value.split('|', 1)[1]}"
return new_id
# Keep a composite id's response-item half so the provider's fc_/item id survives.
return f"{new_id}|{value.split('|', 1)[1]}" if isinstance(value, str) and "|" in value else new_id
try:
if isinstance(tc, dict):
if tc.get("id"):
tc["id"] = _renamed(tc["id"])
else:
tc["id"] = new_id
if tc.get("call_id"):
tc["call_id"] = new_id
else:
tc.id = _renamed(getattr(tc, "id", None))
if getattr(tc, "call_id", None):
tc.call_id = new_id
_tc_set(tc, "id", _renamed(_tc_field(tc, "id")))
if _tc_field(tc, "call_id"):
_tc_set(tc, "call_id", new_id)
except Exception:
logger.warning(
"Could not uniquify duplicate tool call id %s", cid
)
logger.warning("Could not uniquify duplicate tool call id %s", cid)
continue
_fn = _tc_field(tc, "function")
_fn_name = (_fn.get("name") if isinstance(_fn, dict) else getattr(_fn, "name", None)) or "?"
_fn_name = _tc_field(_tc_field(tc, "function"), "name") or "?"
logger.warning(
"Model reused tool call id %s within one turn; renamed the "
"duplicate to %s (tool=%s) to keep call/result pairing "
@@ -564,63 +422,47 @@ def uniquify_tool_call_ids(tool_calls: list) -> list:
return tool_calls
# ---------------------------------------------------------------------------
# reasoning_content policy — single owner. The POLICY (which provider direction
# gets strip vs re-pad) lives here as one rule table + apply functions; adapters
# keep only SYNTAX mapping (e.g. anthropic_adapter → thinking block).
#
# -- reasoning_content policy: single owner of strip-vs-re-pad; adapters keep only SYNTAX --
# require-side (echo-back enforced; replays 400 without the field):
# kimi — provider kimi-coding/kimi-coding-cn, or host api.kimi.com /
# moonshot.ai / moonshot.cn. Host-driven on purpose: aggregators
# re-exporting kimi models reject the echo.
# deepseek — provider "deepseek", model contains "deepseek", or host
# api.deepseek.com. V4 rejects empty-string pads → " " single space.
# kimi — provider kimi-coding/kimi-coding-cn, or host api.kimi.com / moonshot.ai /
# moonshot.cn. Host-driven on purpose: aggregators re-exporting kimi reject it.
# deepseek — provider "deepseek", model contains "deepseek", or host api.deepseek.com.
# V4 rejects empty-string pads → " " single space.
# mimo — provider "xiaomi", model contains "mimo", or host *.xiaomimimo.com.
# strict side (field rejected 400/422 "Extra inputs are not permitted"):
# everyone else — Mistral, Cerebras, Groq, SambaNova, … Strip the key
# entirely, even a single-space pad.
# ---------------------------------------------------------------------------
# strict side (field rejected 400/422 "Extra inputs are not permitted"): everyone else —
# Mistral, Cerebras, Groq, SambaNova, … Strip the key entirely, even a one-space pad.
_REASONING_ECHO_RULES: tuple = (
# (family, exact providers (raw), exact providers (lowered),
# model substrings (lowered), base_url hosts)
("kimi", frozenset({"kimi-coding", "kimi-coding-cn"}), frozenset(), (),
("api.kimi.com", "moonshot.ai", "moonshot.cn")),
("deepseek", frozenset(), frozenset({"deepseek"}), ("deepseek",),
("api.deepseek.com",)),
("mimo", frozenset(), frozenset({"xiaomi"}), ("mimo",),
("api.xiaomimimo.com", "xiaomimimo.com")),
# (family, exact providers (raw), exact providers (lowered), model substrings (lowered), hosts)
("kimi", frozenset({"kimi-coding", "kimi-coding-cn"}), frozenset(), (), ("api.kimi.com", "moonshot.ai", "moonshot.cn")),
("deepseek", frozenset(), frozenset({"deepseek"}), ("deepseek",), ("api.deepseek.com",)),
("mimo", frozenset(), frozenset({"xiaomi"}), ("mimo",), ("api.xiaomimimo.com", "xiaomimimo.com")),
)
_REASONING_ECHO_RULE_BY_FAMILY = {rule[0]: rule for rule in _REASONING_ECHO_RULES}
def matches_reasoning_echo_family(
family: str, provider: Any, model: Any, base_url: Any
) -> bool:
"""True when (provider, model, base_url) matches one echo-back family.
Families can overlap (a deepseek-named model on a kimi host); membership is
tested independently per family. Raises KeyError for an unknown family.
"""
def matches_reasoning_echo_family(family: str, provider: Any, model: Any, base_url: Any) -> bool:
"""True when (provider, model, base_url) matches one echo-back family (families can overlap;
membership is tested independently). Raises KeyError for an unknown family."""
from utils import base_url_host_matches
_, raw_providers, lowered_providers, model_subs, hosts = _REASONING_ECHO_RULE_BY_FAMILY[family]
provider_lower = (provider or "").lower()
model_lower = (model or "").lower()
if provider in raw_providers or provider_lower in lowered_providers:
return True
if any(sub in model_lower for sub in model_subs):
return True
return any(base_url_host_matches(base_url, host) for host in hosts)
return (
provider in raw_providers
or (provider or "").lower() in lowered_providers
or any(sub in model_lower for sub in model_subs)
or any(base_url_host_matches(base_url, host) for host in hosts)
)
def reasoning_echo_family(provider: Any, model: Any, base_url: Any) -> "str | None":
"""``"kimi"`` / ``"deepseek"`` / ``"mimo"`` (first match in table order) when the
endpoint enforces reasoning_content echo-back, else ``None`` (strip side)."""
for rule in _REASONING_ECHO_RULES:
if matches_reasoning_echo_family(rule[0], provider, model, base_url):
return rule[0]
return None
return next(
(rule[0] for rule in _REASONING_ECHO_RULES if matches_reasoning_echo_family(rule[0], provider, model, base_url)),
None,
)
def needs_reasoning_echo(provider: Any, model: Any, base_url: Any) -> bool:
@@ -628,99 +470,54 @@ def needs_reasoning_echo(provider: Any, model: Any, base_url: Any) -> bool:
return reasoning_echo_family(provider, model, base_url) is not None
def stale_thinking_reaches_wire(
api_mode: Any, provider: Any, model: Any, base_url: Any
) -> bool:
"""True when stale assistant ``reasoning``/``reasoning_content`` text is
actually replayed on the wire for the active route.
def stale_thinking_reaches_wire(api_mode: Any, provider: Any, model: Any, base_url: Any) -> bool:
"""True when stale assistant ``reasoning``/``reasoning_content`` text is actually replayed
on the wire for the active route.
The single wire-truth predicate the compaction TRIGGER estimator and the
tail-budget walks must share: if they disagree, a reasoning-heavy session
can look over-threshold to preflight yet fully tail-protected to the walk —
an infinite ineffective compaction loop.
* ``codex_responses``: the Responses input builder never reads the text keys
(continuity rides the encrypted ``codex_reasoning_items`` sidecar, already
charged by both estimators) → False.
* echo-back families: ``apply_reasoning_content_policy`` replays stored
``reasoning_content`` verbatim on every assistant turn → True.
* everything else: stripped or one-space-padded at send time → False.
The single wire-truth predicate the compaction TRIGGER estimator and the tail-budget
walks must share: if they disagree, a reasoning-heavy session can look over-threshold
to preflight yet fully tail-protected to the walk — an infinite compaction loop.
``codex_responses`` never reads the text keys (continuity rides the encrypted sidecar);
echo-back families replay stored ``reasoning_content`` verbatim; everyone else strips.
"""
if (api_mode or "") == "codex_responses":
return False
return needs_reasoning_echo(provider, model, base_url)
return (api_mode or "") != "codex_responses" and needs_reasoning_echo(provider, model, base_url)
def apply_reasoning_content_policy(
source_msg: dict, api_msg: dict, needs_thinking_pad: bool
) -> None:
"""Copy provider-facing reasoning fields onto an API replay message.
``needs_thinking_pad`` is the require-side flag (``needs_reasoning_echo``).
Mutates ``api_msg`` in place.
"""
def apply_reasoning_content_policy(source_msg: dict, api_msg: dict, needs_thinking_pad: bool) -> None:
"""Copy provider-facing reasoning fields onto an API replay message (mutates ``api_msg``).
``needs_thinking_pad`` is the require-side flag (``needs_reasoning_echo``)."""
if source_msg.get("role") != "assistant":
return
# 1. Explicit reasoning_content set. Require-side: preserve verbatim,
# upgrading legacy "" placeholders to " " (DeepSeek V4 400s on ""). Strict
# side: strip entirely — a reasoning primary pads history with " ", then a
# fallback to Mistral/Cerebras/Groq replays the pad and 422s. This covers
# the rebuild path; reapply_reasoning_echo covers already-built api_messages.
if not needs_thinking_pad:
# Strict side: never carry the field — a reasoning primary pads history with " ",
# then a fallback to Mistral/Cerebras/Groq replays the pad and 422s. Also drops a
# non-string value (None after compaction): never pass null to the API.
api_msg.pop("reasoning_content", None)
return
existing = source_msg.get("reasoning_content")
if isinstance(existing, str):
if not needs_thinking_pad:
api_msg.pop("reasoning_content", None)
elif existing == "":
api_msg["reasoning_content"] = " "
else:
api_msg["reasoning_content"] = existing
# Explicit value: preserve verbatim, upgrading legacy "" to " " (DeepSeek V4 400s on "").
api_msg["reasoning_content"] = existing or " "
return
# 2. Cross-provider poisoned history: tool_calls + 'reasoning' but no
# 'reasoning_content' key means the reasoning text came from ANOTHER
# provider (DeepSeek's own build pins reasoning_content for tool-call
# turns). Pad with " " to satisfy the API without leaking foreign CoT.
normalized_reasoning = source_msg.get("reasoning")
if (
needs_thinking_pad
and source_msg.get("tool_calls")
and isinstance(normalized_reasoning, str)
and normalized_reasoning
):
api_msg["reasoning_content"] = " "
reasoning = source_msg.get("reasoning")
if isinstance(reasoning, str) and reasoning and not source_msg.get("tool_calls"):
# Healthy session: promote internal 'reasoning' → 'reasoning_content'.
api_msg["reasoning_content"] = reasoning
return
# 3. Healthy session: promote internal 'reasoning' → 'reasoning_content'
# (must precede the unconditional pad so real reasoning isn't overwritten),
# but only for echo-back providers — strict ones reject the field.
if isinstance(normalized_reasoning, str) and normalized_reasoning:
if needs_thinking_pad:
api_msg["reasoning_content"] = normalized_reasoning
else:
api_msg.pop("reasoning_content", None)
return
# 4. Require-side with no reasoning at all: every assistant turn needs the
# field; " " (not "") because DeepSeek V4 rejects empty string.
if needs_thinking_pad:
api_msg["reasoning_content"] = " "
return
# 5. reasoning_content present but not a string (e.g. None after
# compaction) — never pass null to the API.
api_msg.pop("reasoning_content", None)
# tool_calls + 'reasoning' but no 'reasoning_content' means the reasoning came from
# ANOTHER provider (DeepSeek's own build pins reasoning_content for tool-call turns):
# pad without leaking foreign CoT. No reasoning at all: every assistant turn still needs
# the field; " " (not "") because DeepSeek V4 rejects empty string.
api_msg["reasoning_content"] = " "
def reapply_reasoning_echo(api_messages: list, needs_thinking_pad: bool) -> int:
"""Re-pad (or strip) assistant turns' reasoning_content for the ACTIVE provider.
``api_messages`` is built once before the retry loop under the primary
provider; a mid-conversation fallback can switch providers, so the baked-in
reasoning fields must be reconciled: switching TO a require-side provider
needs the pad re-applied (else 400), switching TO a strict provider needs
the stale pad stripped (else 422). Idempotent; call every iteration.
Returns the number of assistant turns whose reasoning_content changed.
``api_messages`` is built once under the primary provider; a mid-conversation
fallback can switch providers, so baked-in fields must be reconciled: TO a
require-side provider re-applies the pad (else 400), TO a strict provider strips it
(else 422). Idempotent. Returns the number of assistant turns changed.
"""
changed = 0
for api_msg in api_messages:
@@ -738,8 +535,5 @@ def reapply_reasoning_echo(api_messages: list, needs_thinking_pad: bool) -> int:
return changed
# Image / multimodal parts are deliberately NOT consolidated here: per-adapter
# handling (anthropic base64 source blocks, Responses input_image items) is
# format-specific SYNTAX. The one shared image POLICY — removing images when a
# server rejects them while preserving tool_call_id pairing — is
# ``_strip_images_from_messages`` above.
# Image / multimodal parts are deliberately NOT consolidated here: per-adapter handling is
# format-specific SYNTAX. The one shared image POLICY is ``_strip_images_from_messages``.
+158 -312
View File
@@ -13,126 +13,96 @@ from typing import Any, Dict, List, Optional
from agent.model_metadata import estimate_messages_tokens_rough, estimate_tokens_rough
# Origin-module constants/helpers (and call_llm) are imported lazily inside methods: it avoids the
# import cycle and keeps tests that patch ``agent.context_compressor.X`` effective.
# Log name parity with the origin module.
logger = logging.getLogger("agent.context_compressor")
def _cc():
"""The origin module, resolved lazily: avoids the import cycle and keeps tests that patch
``agent.context_compressor.X`` effective (attributes are read at call time)."""
from agent import context_compressor
return context_compressor
def _is_summary_marker(entry: Any) -> bool:
return isinstance(entry, dict) and bool(entry.get(_cc().COMPRESSED_SUMMARY_METADATA_KEY))
def _is_micro_marker(entry: Any) -> bool:
"""True for a summary marker provably absorbed into the rolling summary (micro, not batch)."""
return _is_summary_marker(entry) and bool(entry.get(_cc().MICRO_COMPACT_MARKER_KEY))
class MicroCompactionMixin:
"""Rolling micro-compaction; host must be a ``ContextCompressor``."""
def _resolve_compact_cursor(
self,
messages: List[Dict[str, Any]],
head_end: int,
tail_start: int,
) -> int:
"""Return the index of the first message not yet absorbed into the rolling summary.
def _resolve_compact_cursor(self, messages: List[Dict[str, Any]], head_end: int, tail_start: int) -> int:
"""Index of the first message not yet absorbed into the rolling summary.
Uses the in-memory cursor when valid; otherwise scans for the last summary marker.
"""
from agent.context_compressor import MICRO_COMPACT_MARKER_KEY
if self._micro_compact_cursor > head_end and self._micro_compact_cursor < tail_start:
if head_end < self._micro_compact_cursor < tail_start:
return self._micro_compact_cursor
last_summary_idx = -1
for idx in range(head_end, tail_start):
if self._is_context_summary_message(messages[idx]):
last_summary_idx = idx
last_summary_idx = max(
(idx for idx in range(head_end, tail_start) if self._is_context_summary_message(messages[idx])),
default=-1,
)
cursor = head_end
if last_summary_idx >= head_end:
cursor = last_summary_idx + 1
# Resumed session: rehydrate the rolling summary from the surviving marker so the next
# pass merges, not replaces.
if not self._micro_compact_rolling_summary.strip():
recovered = self._rolling_summary_from_marker(
messages[last_summary_idx].get("content")
recovered = "" if self._micro_compact_rolling_summary.strip() else (
self._rolling_summary_from_marker(messages[last_summary_idx].get("content"))
)
if recovered:
self._micro_compact_rolling_summary = recovered
# Rehydration proves containment: this marker (batch or micro) becomes
# supersede/defrag-eligible; unabsorbed markers never get the key.
messages[last_summary_idx][_cc().MICRO_COMPACT_MARKER_KEY] = True
logger.info(
"Micro-compaction: recovered rolling summary from "
"transcript (%d chars)", len(recovered),
)
if recovered:
self._micro_compact_rolling_summary = recovered
# Rehydration proves containment: this marker (batch or micro) becomes
# supersede/defrag-eligible; unabsorbed markers never get the key.
messages[last_summary_idx][MICRO_COMPACT_MARKER_KEY] = True
logger.info(
"Micro-compaction: recovered rolling summary from "
"transcript (%d chars)", len(recovered),
)
else:
cursor = head_end
self._micro_compact_cursor = cursor
return cursor
def _find_one_exchange(
self,
messages: List[Dict[str, Any]],
start: int,
tail_start: int,
self, messages: List[Dict[str, Any]], start: int, tail_start: int,
) -> Optional[tuple[int, int]]:
"""Find the next complete exchange (full agent turn) starting at *start*.
Returns ``(exchange_start, exchange_end)`` or ``None``. Spans assistant+tool rows up to the
next user message; user turns are never absorbed (alternation safety, verbatim user text).
"""
idx = start
n = len(messages)
if idx >= n or idx >= tail_start:
return None
limit = min(tail_start, len(messages))
def _real_assistant(msg: Dict[str, Any]) -> bool:
return msg.get("role") == "assistant" and not self._is_context_summary_message(msg)
# Skip user messages and (assistant-role) summary markers to reach a real assistant message;
# otherwise a rehydrated cursor could absorb the marker itself.
while idx < tail_start and idx < n:
msg = messages[idx]
if msg.get("role") == "assistant" and not self._is_context_summary_message(msg):
break
idx = start
while idx < limit and not _real_assistant(messages[idx]):
idx += 1
if idx >= tail_start or idx >= n:
if idx >= limit:
return None
exchange_start = idx
idx += 1
while idx < tail_start and idx < n:
msg = messages[idx]
role = msg.get("role")
if role not in ("assistant", "tool"):
break
if self._is_context_summary_message(msg):
break
while idx < limit and messages[idx].get("role") in ("assistant", "tool") and not self._is_context_summary_message(messages[idx]):
idx += 1
if idx <= exchange_start:
return None
# Boundary must close the turn: a mid-turn stop at tail_start would put the assistant marker
# beside assistant/tool rows. Any other role is a safe splice (avoids wedging the cursor).
if idx >= n:
return None
boundary = messages[idx]
boundary = messages[idx] if idx < len(messages) else None
if not isinstance(boundary, dict) or boundary.get("role") in ("assistant", "tool"):
return None
return (exchange_start, idx)
def _serialize_one_exchange(
self,
messages: List[Dict[str, Any]],
start: int,
end: int,
) -> str:
"""Serialize a single exchange for the micro-summarizer via ``_serialize_for_summary``."""
return self._serialize_for_summary(messages[start:end])
def _build_micro_summary_prompt(
self,
existing_summary: str,
exchange_text: str,
) -> List[Dict[str, str]]:
def _build_micro_summary_prompt(self, existing_summary: str, exchange_text: str) -> List[Dict[str, str]]:
"""Build the prompt messages for a single-exchange micro-summary."""
if existing_summary.strip():
summary_block = existing_summary
else:
summary_block = "(No previous summary yet.)"
summary_block = existing_summary if existing_summary.strip() else "(No previous summary yet.)"
user_prompt = (
"You are a summarization agent creating a compact record of an "
"ongoing conversation. You are given a running summary and the "
@@ -149,31 +119,18 @@ class MicroCompactionMixin:
"Return ONLY the updated summary text, no preamble or explanation. "
"Do not include this instruction block in your output."
)
return [
{"role": "system", "content": "You are a conversation summarization assistant."},
{"role": "user", "content": user_prompt},
]
def _micro_summarize_one(
self,
exchange_text: str,
) -> Optional[str]:
"""Micro-summarize one exchange into the rolling summary via the aux LLM.
Returns the updated summary text, or ``None`` on failure.
"""
def _micro_summarize_one(self, exchange_text: str) -> Optional[str]:
"""Micro-summarize one exchange into the rolling summary via the aux LLM (None on failure)."""
from agent.auxiliary_client import aux_interrupt_protection, call_llm
from agent.context_compressor import _response_finish_reason
messages = self._build_micro_summary_prompt(
self._micro_compact_rolling_summary,
exchange_text,
)
call_kwargs = {
"task": "compression",
"messages": messages,
"messages": self._build_micro_summary_prompt(self._micro_compact_rolling_summary, exchange_text),
"max_tokens": min(1500, self.max_summary_tokens or 1500),
"temperature": 0.1,
}
@@ -195,9 +152,8 @@ class MicroCompactionMixin:
logger.info("micro-summarization call failed: %s", exc)
return None
# A length stop means a partial merge; leave the exchange unabsorbed so a later pass retries
# (pi#7048).
if _response_finish_reason(response) == "length":
# A length stop means a partial merge; leave the exchange unabsorbed so a later pass retries.
if _cc()._response_finish_reason(response) == "length":
logger.warning(
"micro-summarization output hit the token cap "
"(finish_reason=length) — discarding partial summary",
@@ -205,39 +161,24 @@ class MicroCompactionMixin:
return None
message = response.choices[0].message
if isinstance(message, dict):
content = message.get("content")
else:
content = getattr(message, "content", message)
if not isinstance(content, str):
content = str(content) if content else ""
content = content.strip()
content = message.get("content") if isinstance(message, dict) else getattr(message, "content", message)
content = (content if isinstance(content, str) else str(content) if content else "").strip()
if not content:
logger.info("micro-summarization returned empty content")
return None
from agent.agent_runtime_helpers import strip_think_blocks
stripped = strip_think_blocks(None, content).strip()
return stripped if stripped else None
return strip_think_blocks(None, content).strip() or None
def _needs_defrag(self) -> bool:
"""Return True when the rolling summary is large enough to defrag."""
content_tokens = estimate_tokens_rough(self._micro_compact_rolling_summary)
return content_tokens >= self._micro_compact_defrag_threshold_tokens
return estimate_tokens_rough(self._micro_compact_rolling_summary) >= self._micro_compact_defrag_threshold_tokens
def _defrag_rolling_summary(
self,
messages: List[Dict[str, Any]],
) -> bool:
def _defrag_rolling_summary(self, messages: List[Dict[str, Any]]) -> bool:
"""Re-summarize the rolling summary text and rewrite the marker in place.
Transcript-shape-neutral (no splice, no cursor move). Returns True when it rewrote.
"""
from agent.context_compressor import (
_DB_PERSISTED_MARKER,
COMPRESSED_SUMMARY_METADATA_KEY,
MICRO_COMPACT_MARKER_KEY,
)
old_summary = self._micro_compact_rolling_summary
if not old_summary.strip():
return False
@@ -250,18 +191,13 @@ class MicroCompactionMixin:
self._micro_compact_rolling_summary = fresh_summary
# Rewrite only the newest MICRO marker (resume rehydrates from it); a batch marker holds
# history we lack.
for idx in range(len(messages) - 1, -1, -1):
entry = messages[idx]
if (
isinstance(entry, dict)
and entry.get(COMPRESSED_SUMMARY_METADATA_KEY)
and entry.get(MICRO_COMPACT_MARKER_KEY)
):
for entry in reversed(messages):
if _is_micro_marker(entry):
entry["content"] = self._render_micro_marker_content(fresh_summary)
# Content changed: clear the persisted stamp so the DB sync rewrites the row.
entry.pop(_DB_PERSISTED_MARKER, None)
# In-place pop on a live dict would be identity-skipped by the bounded flush scan;
# flag the finalizer (#75170).
# Content changed: clear the persisted stamp so the DB sync rewrites the row. An
# in-place pop on a live dict would be identity-skipped by the bounded flush scan;
# flag the finalizer.
entry.pop(_cc()._DB_PERSISTED_MARKER, None)
self._flush_scan_cursor_invalidated = True
break
logger.info(
@@ -270,16 +206,16 @@ class MicroCompactionMixin:
)
return True
def _micro_compact(
self,
messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
def _reset_micro_failure_tracking(self) -> None:
self._micro_compact_consecutive_failures = 0
self._micro_compact_last_failure_cursor = -1
def _micro_compact(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Run one round of micro-compaction; public entry point from ``finalize_turn()``.
Returns the (possibly modified) list and syncs the session DB via ``archive_and_compact``
(the append-only flush alone would double-load on resume).
"""
from agent.context_compressor import _MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES
if not self._micro_compact_enabled:
return messages
@@ -295,31 +231,20 @@ class MicroCompactionMixin:
n_messages = len(messages)
if n_messages < 4:
return messages
head_size = self._protect_head_size(messages)
compress_start = self._align_boundary_forward(messages, head_size)
compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
if compress_start >= compress_end:
return messages
cursor = self._resolve_compact_cursor(messages, compress_start, compress_end)
if cursor >= compress_end:
return messages
exchange = self._find_one_exchange(messages, cursor, compress_end)
exchange = self._next_exchange(messages)
if exchange is None:
return messages
exchange_start, exchange_end = exchange
# Telemetry baseline; taken only once an exchange exists so no-op turns don't pay.
_started_at = time.monotonic()
_tokens_before = estimate_messages_tokens_rough(messages)
_messages_before = n_messages
def _elapsed_ms() -> int:
return int((time.monotonic() - _started_at) * 1000)
def _telemetry(outcome: str, result: List[Dict[str, Any]], **extra: Any) -> None:
self._emit_micro_compaction_telemetry(
outcome=outcome, messages_before=n_messages, messages_after=len(result),
tokens_before=_tokens_before, duration_ms=int((time.monotonic() - _started_at) * 1000), **extra,
)
# Defrag rewrites summary text/marker in place (no splice, no cursor move) instead of
# absorbing this turn.
@@ -327,139 +252,114 @@ class MicroCompactionMixin:
defragged = self._defrag_rolling_summary(messages)
if defragged:
self._sync_micro_compact_to_db(messages)
self._micro_compact_consecutive_failures = 0
self._micro_compact_last_failure_cursor = -1
self._emit_micro_compaction_telemetry(
outcome="defrag" if defragged else "defrag_failed",
messages_before=_messages_before,
messages_after=len(messages),
tokens_before=_tokens_before,
self._reset_micro_failure_tracking()
_telemetry(
"defrag" if defragged else "defrag_failed", messages,
tokens_after=estimate_messages_tokens_rough(messages),
duration_ms=_elapsed_ms(),
)
return messages
# Cumulative iff it subsumes an earlier marker; captured before summarizing.
_cumulative = bool(self._micro_compact_rolling_summary.strip())
exchange_text = self._serialize_one_exchange(messages, exchange_start, exchange_end)
exchange_text = self._serialize_for_summary(messages[exchange_start:exchange_end])
_exchange_tokens = estimate_tokens_rough(exchange_text)
updated_summary = self._micro_summarize_one(exchange_text)
if updated_summary is None:
# Track consecutive failures at the same cursor to avoid busy-looping every turn.
if exchange_start == self._micro_compact_last_failure_cursor:
self._micro_compact_consecutive_failures += 1
else:
self._micro_compact_consecutive_failures = 1
self._micro_compact_last_failure_cursor = exchange_start
if self._micro_compact_consecutive_failures >= _MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES:
logger.info(
"Micro-compaction: skipping exchange at cursor %d "
"after %d consecutive failures",
exchange_start, self._micro_compact_consecutive_failures,
)
# Skip the stuck exchange; it stays in the transcript for batch compression/defrag.
self._micro_compact_cursor = exchange_end
self._micro_compact_consecutive_failures = 0
self._micro_compact_last_failure_cursor = -1
_outcome = "exchange_skipped"
else:
_outcome = "summarize_failed"
self._emit_micro_compaction_telemetry(
outcome=_outcome,
messages_before=_messages_before,
messages_after=len(messages),
tokens_before=_tokens_before,
tokens_after=_tokens_before,
exchange_tokens=_exchange_tokens,
duration_ms=_elapsed_ms(),
)
_outcome = self._record_micro_failure(exchange_start, exchange_end)
_telemetry(_outcome, messages, tokens_after=_tokens_before, exchange_tokens=_exchange_tokens)
return messages
self._micro_compact_rolling_summary = updated_summary
self._micro_compact_cursor = exchange_end
self._micro_compact_consecutive_failures = 0
self._micro_compact_last_failure_cursor = -1
self._reset_micro_failure_tracking()
result = self._splice_micro_compact_result(
messages, exchange_start, exchange_end, supersede=_cumulative,
)
result = self._splice_micro_compact_result(messages, exchange_start, exchange_end, supersede=_cumulative)
self._micro_compact_cursor = self._cursor_after_splice(result, exchange_start + 1)
self._sync_micro_compact_to_db(result)
self._emit_micro_compaction_telemetry(
outcome="absorbed",
messages_before=_messages_before,
messages_after=len(result),
tokens_before=_tokens_before,
tokens_after=estimate_messages_tokens_rough(result),
exchange_tokens=_exchange_tokens,
duration_ms=_elapsed_ms(),
_telemetry(
"absorbed", result,
tokens_after=estimate_messages_tokens_rough(result), exchange_tokens=_exchange_tokens,
)
return result
def _record_micro_failure(self, exchange_start: int, exchange_end: int) -> str:
"""Count a summarize failure at this cursor; skip the exchange after too many in a row."""
# Track consecutive failures at the same cursor to avoid busy-looping every turn.
if exchange_start == self._micro_compact_last_failure_cursor:
self._micro_compact_consecutive_failures += 1
else:
self._micro_compact_consecutive_failures = 1
self._micro_compact_last_failure_cursor = exchange_start
if self._micro_compact_consecutive_failures < _cc()._MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES:
return "summarize_failed"
logger.info(
"Micro-compaction: skipping exchange at cursor %d "
"after %d consecutive failures",
exchange_start, self._micro_compact_consecutive_failures,
)
# Skip the stuck exchange; it stays in the transcript for batch compression/defrag.
self._micro_compact_cursor = exchange_end
self._reset_micro_failure_tracking()
return "exchange_skipped"
def _next_exchange(self, messages: List[Dict[str, Any]]) -> Optional[tuple[int, int]]:
"""The next un-absorbed exchange inside the compressible window, or None."""
compress_start = self._align_boundary_forward(messages, self._protect_head_size(messages))
compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
if compress_start >= compress_end:
return None
cursor = self._resolve_compact_cursor(messages, compress_start, compress_end)
if cursor >= compress_end:
return None
return self._find_one_exchange(messages, cursor, compress_end)
@staticmethod
def _rolling_summary_from_marker(content: Any) -> str:
"""Recover the rolling-summary text from a summary marker (resume rehydration)."""
from agent.context_compressor import (
_SUMMARY_END_MARKER,
HISTORICAL_TASK_HEADING,
)
cc = _cc()
if not isinstance(content, str) or not content.strip():
return ""
body = content
# rfind: SUMMARY_PREFIX itself mentions the heading, so the first hit is in the preamble.
idx = body.rfind(HISTORICAL_TASK_HEADING)
idx = body.rfind(cc.HISTORICAL_TASK_HEADING)
if idx != -1:
body = body[idx + len(HISTORICAL_TASK_HEADING):]
end = body.find(_SUMMARY_END_MARKER)
body = body[idx + len(cc.HISTORICAL_TASK_HEADING):]
end = body.find(cc._SUMMARY_END_MARKER)
if end != -1:
body = body[:end]
return body.strip()
def _cursor_after_splice(
self,
result: List[Dict[str, Any]],
fallback: int,
) -> int:
def _cursor_after_splice(self, result: List[Dict[str, Any]], fallback: int) -> int:
"""Cursor position just past the summary marker in *result*.
Must derive from the SPLICED list: a splice collapses several rows into one marker
(and may drop a superseded one), so pre-splice indices land inside a later exchange
and silently skip it.
"""
from agent.context_compressor import COMPRESSED_SUMMARY_METADATA_KEY
for idx in range(len(result) - 1, -1, -1):
entry = result[idx]
if isinstance(entry, dict) and entry.get(COMPRESSED_SUMMARY_METADATA_KEY):
if _is_summary_marker(result[idx]):
return idx + 1
return fallback
def _emit_micro_compaction_telemetry(
self,
*,
outcome: str,
messages_before: int,
messages_after: int,
tokens_before: int | None,
tokens_after: int | None,
exchange_tokens: int | None = None,
duration_ms: int | None = None,
self, *, outcome: str, messages_before: int, messages_after: int,
tokens_before: int | None, tokens_after: int | None,
exchange_tokens: int | None = None, duration_ms: int | None = None,
) -> None:
"""Emit one content-free JSON log line for a micro-compaction pass.
``tokens_delta`` < 0 means the pass shrank the transcript; ``*_total`` fields accumulate.
"""
from agent.context_compressor import _safe_int
_safe_int = _cc()._safe_int
try:
delta = None
if tokens_before is not None and tokens_after is not None:
delta = tokens_after - tokens_before
self._micro_compact_tokens_saved_total -= delta
self._micro_compact_passes += 1
# Cached reads only: the lazy properties can fire a synchronous /models probe (#32221).
# Cached reads only: the lazy properties can fire a synchronous /models probe.
threshold = self._threshold_tokens
context_limit = self._resolved_context_length
occupancy = None
if threshold and tokens_after is not None and threshold > 0:
occupancy = round(tokens_after / threshold * 100, 1)
@@ -473,51 +373,36 @@ class MicroCompactionMixin:
"tokens_after": _safe_int(tokens_after),
"tokens_delta": _safe_int(delta),
"exchange_tokens": _safe_int(exchange_tokens),
"rolling_summary_tokens": estimate_tokens_rough(
self._micro_compact_rolling_summary
),
"rolling_summary_tokens": estimate_tokens_rough(self._micro_compact_rolling_summary),
"cursor": _safe_int(self._micro_compact_cursor),
"passes_total": self._micro_compact_passes,
"tokens_saved_total": self._micro_compact_tokens_saved_total,
"duration_ms": _safe_int(duration_ms),
# Headroom: how full the window is being kept.
"threshold_tokens": _safe_int(threshold),
"context_limit": _safe_int(context_limit),
"context_limit": _safe_int(self._resolved_context_length),
"occupancy_pct": occupancy,
"main_model": self.model or "",
"aux_model": self.summary_model or "",
}
logger.info(
"micro compaction telemetry: %s",
json.dumps(payload, sort_keys=True, separators=(",", ":")),
)
logger.info("micro compaction telemetry: %s", json.dumps(payload, sort_keys=True, separators=(",", ":")))
except Exception as exc:
logger.debug("failed to emit micro-compaction telemetry: %s", exc)
def _sync_micro_compact_to_db(
self,
compacted_messages: List[Dict[str, Any]],
) -> None:
def _sync_micro_compact_to_db(self, compacted_messages: List[Dict[str, Any]]) -> None:
"""Persist the micro-compacted set to the session DB atomically and stamp rows persisted.
Without this the old exchange rows stay ``active=1`` and a resume double-loads
both the summary and the originals.
"""
from agent.context_compressor import stamp_db_persisted_markers
session_db = getattr(self, "_session_db", None)
session_id = getattr(self, "_session_id", "")
session_db, session_id = getattr(self, "_session_db", None), getattr(self, "_session_id", "")
if not session_db or not session_id:
return
try:
# Every row except the marker is a carried-forward original: archive pre-splice
# originals rewind-style (#86366).
session_db.archive_and_compact(
session_id,
compacted_messages,
tail_count=max(0, len(compacted_messages) - 1),
)
# Shared post-commit stamp site with batch commit and proactive prune (#98450).
stamp_db_persisted_markers(compacted_messages)
# Every row except the marker is a carried-forward original: archive rewind-style.
session_db.archive_and_compact(session_id, compacted_messages, tail_count=max(0, len(compacted_messages) - 1))
# Shared post-commit stamp site with batch commit and proactive prune.
_cc().stamp_db_persisted_markers(compacted_messages)
except Exception:
logger.info(
"Micro-compaction DB sync failed — resume will double-load "
@@ -525,21 +410,13 @@ class MicroCompactionMixin:
)
def _splice_micro_compact_result(
self,
messages: List[Dict[str, Any]],
splice_start: int,
splice_end: int,
supersede: bool = True,
self, messages: List[Dict[str, Any]], splice_start: int, splice_end: int, supersede: bool = True,
) -> List[Dict[str, Any]]:
"""Replace *messages[splice_start:splice_end]* with an assistant-role summary marker.
Merges user turns left adjacent by a superseded marker so the result is alternation-valid.
"""
from agent.context_compressor import (
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY,
COMPRESSED_SUMMARY_METADATA_KEY,
MICRO_COMPACT_MARKER_KEY,
)
cc = _cc()
summary_text = self._micro_compact_rolling_summary
if not summary_text.strip():
return messages
@@ -547,29 +424,20 @@ class MicroCompactionMixin:
summary_msg = {
"role": "assistant",
"content": self._render_micro_marker_content(summary_text),
COMPRESSED_SUMMARY_METADATA_KEY: True,
cc.COMPRESSED_SUMMARY_METADATA_KEY: True,
# Micro marker: eligible for supersede/defrag; batch markers never carry this key.
MICRO_COMPACT_MARKER_KEY: True,
# Micro markers absorb only assistant/tool content; user turns stay in the transcript
# (#64650).
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: False,
cc.MICRO_COMPACT_MARKER_KEY: True,
# Micro markers absorb only assistant/tool content; user turns stay in the transcript.
cc.COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: False,
}
result = messages[:splice_start] + [summary_msg] + messages[splice_end:]
# Cumulative summary: keep only the newest marker. Drop an older one only if supersede AND
# it has MICRO_COMPACT_MARKER_KEY (provably absorbed); a batch marker holds MORE history.
if supersede:
marker_idxs = [
i for i, m in enumerate(result)
if isinstance(m, dict)
and m.get(COMPRESSED_SUMMARY_METADATA_KEY)
and m.get(MICRO_COMPACT_MARKER_KEY)
]
marker_idxs = [i for i, m in enumerate(result) if _is_micro_marker(m)]
if len(marker_idxs) > 1:
superseded = set(marker_idxs[:-1])
result = [m for i, m in enumerate(result) if i not in superseded]
result = self._merge_adjacent_user_turns(result)
result = self._merge_adjacent_user_turns([m for i, m in enumerate(result) if i not in marker_idxs[:-1]])
# Deliberately no _strip_persistence_markers: micro archives in place under the same session
# id, so stamps stay accurate and a failed archive keeps the append-only flush idempotent.
@@ -578,52 +446,30 @@ class MicroCompactionMixin:
@staticmethod
def _render_micro_marker_content(summary_text: str) -> str:
"""Assemble the marker content wrapper around *summary_text*."""
from agent.context_compressor import (
_SUMMARY_END_MARKER,
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
)
return (
f"{SUMMARY_PREFIX}\n\n"
f"{HISTORICAL_TASK_HEADING}\n"
f"{summary_text.strip()}"
f"\n\n{_SUMMARY_END_MARKER}"
)
cc = _cc()
return f"{cc.SUMMARY_PREFIX}\n\n{cc.HISTORICAL_TASK_HEADING}\n{summary_text.strip()}\n\n{cc._SUMMARY_END_MARKER}"
@staticmethod
def _merge_adjacent_user_turns(
result: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
def _merge_adjacent_user_turns(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Merge consecutive plain-text real user turns left by a supersede.
Same ``\\n\\n`` join as ``repair_message_sequence`` pass 2, done here so the marker
and cursor are never collateral damage of the downstream repair. Lists untouched.
"""
from agent.context_compressor import COMPRESSED_SUMMARY_METADATA_KEY
from agent.turn_context import drop_stale_api_content
def _plain_user(m: Any) -> bool:
return (
isinstance(m, dict) and m.get("role") == "user"
and not _is_summary_marker(m) and isinstance(m.get("content"), str)
)
merged: List[Dict[str, Any]] = []
for msg in result:
prev = merged[-1] if merged else None
if (
isinstance(msg, dict)
and isinstance(prev, dict)
and msg.get("role") == "user"
and prev.get("role") == "user"
and not msg.get(COMPRESSED_SUMMARY_METADATA_KEY)
and not prev.get(COMPRESSED_SUMMARY_METADATA_KEY)
and isinstance(prev.get("content"), str)
and isinstance(msg.get("content"), str)
):
prev_content = prev["content"]
new_content = msg["content"]
prev["content"] = (
(prev_content + "\n\n" + new_content)
if prev_content and new_content
else (prev_content or new_content)
)
# Merged content invalidates the api_content sidecar.
drop_stale_api_content(prev)
continue
merged.append(msg)
if _plain_user(msg) and _plain_user(prev):
prev["content"] = "\n\n".join(c for c in (prev["content"], msg["content"]) if c)
drop_stale_api_content(prev) # merged content invalidates the api_content sidecar
else:
merged.append(msg)
return merged
+529 -829
View File
File diff suppressed because it is too large Load Diff
+16 -33
View File
@@ -1,12 +1,9 @@
"""Full MoA turn trace persistence (opt-in via config ``moa.save_traces``).
When enabled, every Mixture-of-Agents turn that runs the reference fan-out (a
cache MISS in ``MoAChatCompletions.create``) appends one JSON line to
``<hermes_home>/moa-traces/<session_id>.jsonl``: the exact messages each
reference received, each reference's full output, and the exact aggregator input
plus its output when available — what every model saw, said, and cost.
Side-channel only: never enters the ``messages`` table, history or replay
Every MoA turn that runs the reference fan-out (a cache MISS in
``MoAChatCompletions.create``) appends one JSON line to
``<hermes_home>/moa-traces/<session_id>.jsonl``: what every model saw, said, and
cost. Side-channel only: never enters the ``messages`` table, history or replay
(references are advisory side-calls whose rows would corrupt role alternation).
Off by default; when off the only overhead is the config read.
"""
@@ -30,9 +27,8 @@ def _traces_enabled_and_dir() -> Optional[Path]:
call (once per cache-MISS turn); ``moa.trace_dir`` overrides the default."""
try:
from hermes_cli.config import load_config
moa_cfg = (load_config() or {}).get("moa") or {}
except Exception: # pragma: no cover - defensive: never break a turn over tracing
except Exception: # pragma: no cover - never break a turn over tracing
return None
if not moa_cfg.get("save_traces"):
return None
@@ -54,14 +50,12 @@ _COST_FIELDS = ("cost_usd", "cost_status", "cost_source")
def _slot_trace(acct: Any, label: str) -> dict[str, Any]:
"""Render one reference's _RefAccounting into a full trace dict, including
the FULL input messages and output (not the truncated display preview)."""
"""One reference's _RefAccounting as a full trace dict, including the FULL
input messages and output (not the truncated display preview)."""
usage = getattr(acct, "usage", None)
return {
"label": label,
**{f: getattr(acct, f, None) for f in _ACCT_FIELDS},
"input_messages": getattr(acct, "messages", None),
"output": getattr(acct, "output", None),
"label": label, **{f: getattr(acct, f, None) for f in _ACCT_FIELDS},
"input_messages": getattr(acct, "messages", None), "output": getattr(acct, "output", None),
"usage": {f: getattr(usage, f, 0) for f in _USAGE_FIELDS} if usage is not None else {},
**{f: getattr(acct, f, None) for f in _COST_FIELDS},
}
@@ -79,16 +73,9 @@ def slot_metrics(acct: Any, label: str, output: Any = None) -> dict[str, Any]:
def save_moa_turn(
*,
session_id: Optional[str],
preset_name: str,
reference_outputs: list[tuple[str, str, Any]],
aggregator_label: str,
aggregator_model: Optional[str],
aggregator_provider: Optional[str],
aggregator_temperature: Any,
aggregator_input_messages: Any,
aggregator_output: Optional[str],
*, session_id: Optional[str], preset_name: str, reference_outputs: list[tuple[str, str, Any]],
aggregator_label: str, aggregator_model: Optional[str], aggregator_provider: Optional[str],
aggregator_temperature: Any, aggregator_input_messages: Any, aggregator_output: Optional[str],
aggregator_streamed: bool,
) -> None:
"""Append one full MoA turn record to the session's trace JSONL, if enabled.
@@ -116,14 +103,10 @@ def save_moa_turn(
"preset": preset_name,
"references": [_slot_trace(acct, label) for label, _text, acct in reference_outputs],
"aggregator": {
"label": aggregator_label,
"model": aggregator_model,
"provider": aggregator_provider,
"temperature": aggregator_temperature,
"input_messages": aggregator_input_messages,
"output": aggregator_output,
"streamed": aggregator_streamed,
"output_location": output_location,
"label": aggregator_label, "model": aggregator_model,
"provider": aggregator_provider, "temperature": aggregator_temperature,
"input_messages": aggregator_input_messages, "output": aggregator_output,
"streamed": aggregator_streamed, "output_location": output_location,
},
}
with path.open("a", encoding="utf-8") as f:
+875 -1669
View File
File diff suppressed because it is too large Load Diff
+297 -626
View File
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -14,9 +14,4 @@ from . import emitter, events
emit = emitter.emit
get_emitter = emitter.get_emitter
__all__ = [
"emitter",
"events",
"emit",
"get_emitter",
]
__all__ = ["emitter", "events", "emit", "get_emitter"]
+16 -45
View File
@@ -9,11 +9,7 @@ from datetime import datetime
from typing import Any, Callable, Optional
from agent.monitoring.events import CronExecutionEvent
from agent.monitoring.gateway_health import (
GatewayMetric,
_contains_any,
_safe_instance_id,
)
from agent.monitoring.gateway_health import GatewayMetric, _contains_any, _safe_instance_id
from cron.jobs import (
_compute_grace_seconds,
get_catch_up_occurrence_count,
@@ -27,9 +23,7 @@ from hermes_time import now as _now
logger = logging.getLogger(__name__)
_KNOWN_STATUSES = {"claimed", "running", "completed", "failed", "unknown"}
_KNOWN_SOURCES = {"builtin", "direct", "external"}
_KNOWN_DELIVERY_OUTCOMES = {
"delivered", "failed", "suppressed", "suppressed_acked", "not_configured",
}
_KNOWN_DELIVERY_OUTCOMES = {"delivered", "failed", "suppressed", "suppressed_acked", "not_configured"}
_TERMINAL_STATUSES = {"completed", "failed", "unknown"}
@@ -43,11 +37,10 @@ class CronHealthSnapshot:
_job_key = _safe_instance_id
_AUTH_RE = re.compile(
r"\b(?:authentication|authenticated|authenticate|authorization|authorized|authorize"
r"|unauthorized|forbidden|bearer|401|403)\b|\b(?:access|api|refresh) token\b"
r"\b(?:authentication|authenticated|authenticate|authorization|authorized|authorize|unauthorized|forbidden|bearer|401|403)\b"
r"|\b(?:access|api|refresh) token\b"
)
# Ordered (predicate, class) rules; first match wins. Auth uses word boundaries so
# "oauth"/"tokenizer"/"HTTP 4015" do not false-positive.
_CRON_ERROR_RULES: tuple[tuple[Callable[[str], bool], str], ...] = (
@@ -86,9 +79,7 @@ def _duration_ms(record: dict[str, Any]) -> Optional[int]:
return max(0, duration)
def project_execution_event(
record: dict[str, Any], *, delivery_outcome: Optional[str] = None
) -> CronExecutionEvent:
def project_execution_event(record: dict[str, Any], *, delivery_outcome: Optional[str] = None) -> CronExecutionEvent:
status = str(record.get("status") or "unknown").lower()
source = str(record.get("source") or "unknown").lower()
outcome = str(delivery_outcome).lower() if delivery_outcome is not None else None
@@ -98,26 +89,17 @@ def project_execution_event(
# Unknown non-empty sources are bucketed as "external" (not dropped to unknown).
source=source if source in _KNOWN_SOURCES or source == "unknown" else "external",
duration_ms=_duration_ms(record),
delivery_outcome=(
outcome if outcome in _KNOWN_DELIVERY_OUTCOMES else None
),
error_class=(
classify_cron_error(record.get("error"))
if status in {"failed", "unknown"}
else None
),
delivery_outcome=outcome if outcome in _KNOWN_DELIVERY_OUTCOMES else None,
error_class=classify_cron_error(record.get("error")) if status in {"failed", "unknown"} else None,
)
def emit_execution_state(
record: Optional[dict[str, Any]], *, delivery_outcome: Optional[str] = None
) -> None:
def emit_execution_state(record: Optional[dict[str, Any]], *, delivery_outcome: Optional[str] = None) -> None:
"""Best-effort lifecycle emit; terminal states synchronously cross the queue barrier."""
if not record:
return
try:
from agent.monitoring import emitter
event = project_execution_event(record, delivery_outcome=delivery_outcome)
target = emitter.get_emitter()
target.emit(event)
@@ -137,8 +119,7 @@ def _is_overdue(job: dict[str, Any], now: datetime) -> bool:
try:
if next_run.tzinfo is None and now.tzinfo is not None:
next_run = next_run.replace(tzinfo=now.tzinfo)
lateness = (now - next_run).total_seconds()
return lateness > _compute_grace_seconds(schedule)
return (now - next_run).total_seconds() > _compute_grace_seconds(schedule)
except (TypeError, ValueError):
return False
@@ -146,11 +127,7 @@ def _is_overdue(job: dict[str, Any], now: datetime) -> bool:
def _job_metrics(metrics: list[GatewayMetric]) -> None:
enabled = [job for job in load_jobs() if job.get("enabled", True)]
metrics.append(GatewayMetric("hermes.cron.jobs.enabled", len(enabled), {}))
metrics.append(GatewayMetric(
"hermes.cron.jobs.overdue",
sum(1 for job in enabled if _is_overdue(job, _now())),
{},
))
metrics.append(GatewayMetric("hermes.cron.jobs.overdue", sum(1 for job in enabled if _is_overdue(job, _now())), {}))
def _freshness_metric(name: str, reader: Callable[[], Optional[float]]) -> Callable[[list[GatewayMetric]], None]:
@@ -158,6 +135,7 @@ def _freshness_metric(name: str, reader: Callable[[], Optional[float]]) -> Calla
value = reader()
if value is not None:
metrics.append(GatewayMetric(name, max(0.0, float(value)), {}))
return build
@@ -168,15 +146,11 @@ def _single_metric(name: str, reader: Callable[[], Any]) -> Callable[[list[Gatew
# Each group is independently fail-open so one unavailable source never hides the rest.
# Readers are wrapped in lambdas so monkeypatching this module's names still takes effect.
_METRIC_GROUPS: tuple[tuple[Callable[[list[GatewayMetric]], None], str], ...] = (
(_freshness_metric("hermes.cron.scheduler.heartbeat_age_seconds", lambda: get_ticker_heartbeat_age()),
"cron freshness metric unavailable"),
(_freshness_metric("hermes.cron.scheduler.last_success_age_seconds", lambda: get_ticker_success_age()),
"cron freshness metric unavailable"),
(_single_metric("hermes.cron.scheduler.catch_up_occurrences", lambda: get_catch_up_occurrence_count()),
"cron catch-up metric unavailable"),
(_freshness_metric("hermes.cron.scheduler.heartbeat_age_seconds", lambda: get_ticker_heartbeat_age()), "cron freshness metric unavailable"),
(_freshness_metric("hermes.cron.scheduler.last_success_age_seconds", lambda: get_ticker_success_age()), "cron freshness metric unavailable"),
(_single_metric("hermes.cron.scheduler.catch_up_occurrences", lambda: get_catch_up_occurrence_count()), "cron catch-up metric unavailable"),
(_job_metrics, "cron job metrics unavailable"),
(_single_metric("hermes.cron.jobs.running", lambda: len(get_running_job_ids())),
"cron running-job metric unavailable"),
(_single_metric("hermes.cron.jobs.running", lambda: len(get_running_job_ids())), "cron running-job metric unavailable"),
)
@@ -191,9 +165,6 @@ def build_cron_health_snapshot() -> CronHealthSnapshot:
__all__ = [
"CronHealthSnapshot",
"build_cron_health_snapshot",
"classify_cron_error",
"emit_execution_state",
"CronHealthSnapshot", "build_cron_health_snapshot", "classify_cron_error", "emit_execution_state",
"project_execution_event",
]
+13 -36
View File
@@ -1,11 +1,9 @@
"""Monitoring emitter: fire-and-forget queue + background dispatcher.
The single seam between producers (gateway status hooks, diagnostic log handler)
and consumers (OTLP streamers). Hot-path invariant: ``emit()`` MUST return in
O(microseconds), MUST NOT block on disk/network, and MUST NEVER raise into the
caller — a monitoring failure is logged locally and dropped. On a full queue the
*oldest* event is dropped. A daemon thread fans batches out to fail-isolated
subscribers. Nothing is persisted: monitoring is an egress path, not a store.
"""Monitoring emitter: fire-and-forget queue + background dispatcher — the single seam between
producers (gateway status hooks, diagnostic log handler) and consumers (OTLP streamers).
Hot-path invariant: ``emit()`` MUST return in O(microseconds), MUST NOT block on disk/network, and
MUST NEVER raise into the caller — a monitoring failure is logged locally and dropped. On a full
queue the *oldest* event is dropped. A daemon thread fans batches out to fail-isolated
subscribers. Nothing is persisted: monitoring is an egress path, not a store.
"""
from __future__ import annotations
@@ -14,6 +12,7 @@ import logging
import queue
import threading
import time
from contextlib import suppress
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
@@ -67,9 +66,7 @@ class MonitoringEmitter:
with self._lock:
if self._started:
return
self._thread = threading.Thread(
target=self._run, name="hermes-monitoring-dispatch", daemon=True
)
self._thread = threading.Thread(target=self._run, name="hermes-monitoring-dispatch", daemon=True)
self._thread.start()
self._started = True
@@ -106,10 +103,8 @@ class MonitoringEmitter:
self._enabled = True
def unsubscribe(self, callback) -> None:
try:
with suppress(ValueError):
self._subscribers.remove(callback)
except ValueError:
pass
if not self._subscribers:
self._enabled = False
@@ -118,28 +113,17 @@ class MonitoringEmitter:
"""Wait boundedly for queued and in-flight batches to finish dispatch."""
if timeout <= 0:
return
finished = threading.Event()
def _wait_for_completion() -> None:
self._q.join()
finished.set()
waiter = threading.Thread(
target=_wait_for_completion,
name="hermes-monitoring-flush",
daemon=True,
)
waiter.start()
threading.Thread(target=_wait_for_completion, name="hermes-monitoring-flush", daemon=True).start()
finished.wait(timeout=timeout)
def stats(self) -> Dict[str, int]:
return {
"queued": self._q.qsize(),
"dispatched": self._dispatched,
"dropped": self._dropped,
"subscribers": len(self._subscribers),
}
return {"queued": self._q.qsize(), "dispatched": self._dispatched, "dropped": self._dropped, "subscribers": len(self._subscribers)}
def close(self) -> None:
self._stop.set()
@@ -175,16 +159,9 @@ def reset_emitter_for_tests(emitter: Optional[MonitoringEmitter] = None) -> None
global _EMITTER
with _EMITTER_LOCK:
if _EMITTER is not None and emitter is not _EMITTER:
try:
with suppress(Exception):
_EMITTER.close()
except Exception:
pass
_EMITTER = emitter
__all__ = [
"MonitoringEmitter",
"get_emitter",
"emit",
"reset_emitter_for_tests",
]
__all__ = ["MonitoringEmitter", "get_emitter", "emit", "reset_emitter_for_tests"]
+1 -11
View File
@@ -24,9 +24,7 @@ class _MonitoringEvent:
@dataclass(slots=True)
class GatewayHealthEvent(_MonitoringEvent):
"""Content-free gateway health snapshot or lifecycle event."""
EVENT: ClassVar[str] = "gateway_health"
name: str
gateway_state: Optional[str] = None
old_state: Optional[str] = None
@@ -49,9 +47,7 @@ class GatewayHealthEvent(_MonitoringEvent):
@dataclass(slots=True)
class GatewayDiagnosticEvent(_MonitoringEvent):
"""Redacted gateway diagnostic event for operator-owned observability."""
EVENT: ClassVar[str] = "gateway_diagnostic"
name: str
subsystem: str
error_class: str = "unknown"
@@ -69,9 +65,7 @@ class GatewayDiagnosticEvent(_MonitoringEvent):
@dataclass(slots=True)
class CronExecutionEvent(_MonitoringEvent):
"""Content-free durable cron execution lifecycle projection."""
EVENT: ClassVar[str] = "cron_execution"
status: str
job_key: str
source: str = "unknown"
@@ -81,8 +75,4 @@ class CronExecutionEvent(_MonitoringEvent):
ts_ns: int = field(default_factory=time.time_ns)
__all__ = [
"GatewayHealthEvent",
"GatewayDiagnosticEvent",
"CronExecutionEvent",
]
__all__ = ["GatewayHealthEvent", "GatewayDiagnosticEvent", "CronExecutionEvent"]
+55 -111
View File
@@ -60,10 +60,8 @@ _GATEWAY_ERROR_RULES: tuple[tuple[Callable[[str], bool], str], ...] = (
(_contains_any("auth", "token", "unauthorized", "forbidden", "401", "403"), "auth_failed"),
(lambda s: "rate" in s and "limit" in s, "rate_limited"),
(_contains_any("timeout", "timed out"), "timeout"),
(_contains_any(
"network", "connection", "dns", "socket", "connect call failed",
"failed to connect", "cannot connect", "unreachable", "name resolution",
), "network_error"),
(_contains_any("network", "connection", "dns", "socket", "connect call failed", "failed to connect", "cannot connect",
"unreachable", "name resolution"), "network_error"),
(_contains_any("config", "missing", "invalid"), "invalid_config"),
(_contains_any("startup"), "startup_failed"),
(_contains_any("fatal"), "platform_fatal"),
@@ -75,9 +73,7 @@ def classify_gateway_error(raw: Any) -> str:
return next((label for match, label in _GATEWAY_ERROR_RULES if match(s)), "unknown")
def classify_exit_reason(
raw: Any, *, state: Any, restart_requested: bool
) -> Optional[str]:
def classify_exit_reason(raw: Any, *, state: Any, restart_requested: bool) -> Optional[str]:
"""Reduce free-form shutdown text to a bounded operational class."""
if restart_requested:
return "restart_requested"
@@ -128,33 +124,34 @@ def platform_for_subsystem(subsystem: str) -> Optional[str]:
return (subsystem.split(".", 1)[1] or None) if subsystem.startswith("platform.") else None
def _parse_active_agents(raw: Any) -> int:
def _gateway_status(name: str, fallback: Callable[[], Any], /, **kwargs: Any) -> Any:
"""Prefer ``gateway.status.<name>`` (the runtime-status contract); fall back to the local approximation."""
try:
from gateway.status import parse_active_agents
return parse_active_agents(raw)
import gateway.status as status
return getattr(status, name)(**kwargs)
except Exception:
try:
return max(0, int(raw))
except (TypeError, ValueError):
return 0
return fallback()
def _int_or_zero(raw: Any) -> int:
try:
return max(0, int(raw))
except (TypeError, ValueError):
return 0
def _parse_active_agents(raw: Any) -> int:
return _gateway_status("parse_active_agents", lambda: _int_or_zero(raw), raw=raw)
def _derive_busy(gateway_running: bool, gateway_state: Any, active_agents: Any) -> bool:
try:
from gateway.status import derive_gateway_busy
return derive_gateway_busy(
gateway_running=gateway_running, gateway_state=gateway_state, active_agents=active_agents
)
except Exception:
return bool(gateway_running and gateway_state == "running" and _parse_active_agents(active_agents) > 0)
fallback = lambda: bool(gateway_running and gateway_state == "running" and _parse_active_agents(active_agents) > 0) # noqa: E731
return _gateway_status("derive_gateway_busy", fallback, gateway_running=gateway_running, gateway_state=gateway_state, active_agents=active_agents)
def _derive_drainable(gateway_running: bool, gateway_state: Any) -> bool:
try:
from gateway.status import derive_gateway_drainable
return derive_gateway_drainable(gateway_running=gateway_running, gateway_state=gateway_state)
except Exception:
return bool(gateway_running and gateway_state == "running")
fallback = lambda: bool(gateway_running and gateway_state == "running") # noqa: E731
return _gateway_status("derive_gateway_drainable", fallback, gateway_running=gateway_running, gateway_state=gateway_state)
def _base_attrs(*, install_id: str, version: str, supervision_mode: str) -> Dict[str, str]:
@@ -179,13 +176,13 @@ def _platforms_of(runtime: Optional[dict[str, Any]]) -> dict[str, Any]:
return raw if isinstance(raw, dict) else {}
def _platform_error_code(pdata: dict[str, Any]) -> str:
# classify_* is idempotent on its own labels, so error_class == error_code downstream.
return classify_gateway_error(pdata.get("error_code") or pdata.get("error_message"))
def build_gateway_health_snapshot(
runtime: Optional[dict[str, Any]],
*,
gateway_running: bool,
profile: str,
install_id: str,
version: str,
runtime: Optional[dict[str, Any]], *, gateway_running: bool, profile: str, install_id: str, version: str,
supervision_mode: str = "unknown",
) -> GatewayHealthSnapshot:
"""Convert gateway_state.json-compatible runtime state into P0 signals."""
@@ -196,7 +193,6 @@ def build_gateway_health_snapshot(
drainable = _derive_drainable(gateway_running, gateway_state)
platforms = _platforms_of(runtime)
base = _base_attrs(install_id=install_id, version=version, supervision_mode=supervision_mode)
metrics: list[GatewayMetric] = [
_metric("hermes.gateway.up", 1 if gateway_running else 0, base),
_metric("hermes.gateway.active_agents", active_agents, base),
@@ -205,14 +201,12 @@ def build_gateway_health_snapshot(
_metric("hermes.gateway.restart_requested", 1 if runtime.get("restart_requested") else 0, base),
_metric("hermes.gateway.state", 1, base, **{"hermes.gateway.state": gateway_state}),
]
fatal_count = 0
events: list[GatewayHealthEvent | GatewayDiagnosticEvent] = []
for platform, pdata in platforms.items():
pdata = pdata if isinstance(pdata, dict) else {}
state = _bounded_state(pdata.get("state"), allowed=_KNOWN_PLATFORM_STATES)
# classify_* is idempotent on its own labels, so error_class == error_code here.
error_code = classify_gateway_error(pdata.get("error_code") or pdata.get("error_message"))
error_code = _platform_error_code(pdata)
is_degraded = state in _FATAL_PLATFORM_STATES
if is_degraded:
fatal_count += 1
@@ -223,29 +217,14 @@ def build_gateway_health_snapshot(
))
if is_degraded:
events.append(GatewayDiagnosticEvent(
name="platform.fatal",
subsystem=f"platform.{platform}",
platform=str(platform),
error_code=error_code,
error_class=error_code,
profile=profile,
version=version,
name="platform.fatal", subsystem=f"platform.{platform}", platform=str(platform),
error_code=error_code, error_class=error_code, profile=profile, version=version,
severity="error" if state == "fatal" else "warning",
))
events.insert(0, GatewayHealthEvent(
name="gateway.health_snapshot",
gateway_state=gateway_state,
active_agents=active_agents,
gateway_busy=busy,
gateway_drainable=drainable,
platform_count=len(platforms),
fatal_platform_count=fatal_count,
profile=profile,
install_id=install_id,
version=version,
supervision_mode=supervision_mode,
pid=_coerce_pid(runtime.get("pid")),
name="gateway.health_snapshot", gateway_state=gateway_state, active_agents=active_agents, gateway_busy=busy,
gateway_drainable=drainable, platform_count=len(platforms), fatal_platform_count=fatal_count, profile=profile,
install_id=install_id, version=version, supervision_mode=supervision_mode, pid=_coerce_pid(runtime.get("pid")),
))
return GatewayHealthSnapshot(metrics=metrics, events=events)
@@ -278,31 +257,18 @@ def _lifecycle_events(
def health(name: str) -> GatewayHealthEvent:
return GatewayHealthEvent(
name=name,
gateway_state=new_state,
old_state=old_state,
new_state=new_state,
exit_reason=classify_exit_reason(
current.get("exit_reason"), state=new_state, restart_requested=restart_requested
),
restart_requested=restart_requested,
active_agents=_parse_active_agents(current.get("active_agents", 0)),
profile=profile,
version=version,
pid=_coerce_pid(current.get("pid")),
name=name, gateway_state=new_state, old_state=old_state, new_state=new_state,
exit_reason=classify_exit_reason(current.get("exit_reason"), state=new_state, restart_requested=restart_requested),
restart_requested=restart_requested, active_agents=_parse_active_agents(current.get("active_agents", 0)),
profile=profile, version=version, pid=_coerce_pid(current.get("pid")),
)
out: list[GatewayHealthEvent | GatewayDiagnosticEvent] = [health("gateway.lifecycle")]
if new_state == "startup_failed":
error_class = classify_gateway_error(current.get("exit_reason") or "startup_failed")
out.append(GatewayDiagnosticEvent(
name="gateway.startup_failed",
subsystem="gateway",
error_class=error_class,
error_code=error_class,
profile=profile,
version=version,
severity="error",
name="gateway.startup_failed", subsystem="gateway", error_class=error_class, error_code=error_class,
profile=profile, version=version, severity="error",
))
if new_state == "stopped":
out.append(health("gateway.exit"))
@@ -323,30 +289,20 @@ def _platform_events(
new_state = _optional_state(pdata.get("state"), allowed=_KNOWN_PLATFORM_STATES)
if old_state == new_state or not new_state:
continue
error_code = classify_gateway_error(pdata.get("error_code") or pdata.get("error_message"))
error_code = _platform_error_code(pdata)
common: dict[str, Any] = dict(
subsystem=f"platform.{platform}",
platform=str(platform),
error_code=error_code,
error_class=error_code,
profile=profile,
version=version,
severity="error" if new_state in {"fatal", "failed", "error"} else "warning",
subsystem=f"platform.{platform}", platform=str(platform), error_code=error_code, error_class=error_code,
profile=profile, version=version, severity="error" if new_state in {"fatal", "failed", "error"} else "warning",
)
out.append(GatewayDiagnosticEvent(
name="platform.state_change", old_state=old_state, new_state=new_state, **common
))
out.append(GatewayDiagnosticEvent(name="platform.state_change", old_state=old_state, new_state=new_state, **common))
if new_state in _FATAL_PLATFORM_STATES:
out.append(GatewayDiagnosticEvent(name="platform.fatal", **common))
return out
def emit_runtime_status_transition(previous: Optional[dict[str, Any]], current: dict[str, Any]) -> None:
"""Emit immediate content-free gateway events for runtime status changes.
Called by gateway.status.write_runtime_status after persisting the new status.
Fully fail-open: failures never affect gateway status writes.
"""
"""Emit immediate content-free gateway events for runtime status changes. Called by
gateway.status.write_runtime_status after persisting; fully fail-open."""
try:
ctx = dict(profile=_safe_profile(), version=_safe_version())
for ev in _lifecycle_events(previous, current, **ctx) + _platform_events(previous, current, **ctx):
@@ -373,33 +329,21 @@ class GatewayDiagnosticLogHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
try:
if record.levelno < logging.WARNING:
return
if not (record.name == "gateway" or record.name.startswith("gateway.")):
if record.levelno < logging.WARNING or not (record.name == "gateway" or record.name.startswith("gateway.")):
return
subsystem = subsystem_for_logger(record.name)
error_class = classify_gateway_error(record.getMessage())
event = GatewayDiagnosticEvent(
name=f"gateway.log.{record.levelname.lower()}",
subsystem=subsystem,
source_logger=source_logger_for_export(record.name),
platform=platform_for_subsystem(subsystem),
error_class=error_class,
error_code=error_class,
profile=self.profile,
version=self.version,
emitter.get_emitter().emit(GatewayDiagnosticEvent(
name=f"gateway.log.{record.levelname.lower()}", subsystem=subsystem,
source_logger=source_logger_for_export(record.name), platform=platform_for_subsystem(subsystem),
error_class=error_class, error_code=error_class, profile=self.profile, version=self.version,
severity=record.levelname.lower(),
)
emitter.get_emitter().emit(event)
))
except Exception:
logger.debug("gateway diagnostic emit failed", exc_info=True)
__all__ = [
"GatewayMetric",
"GatewayHealthSnapshot",
"GatewayDiagnosticLogHandler",
"build_gateway_health_snapshot",
"classify_gateway_error",
"source_logger_for_export",
"GatewayMetric", "GatewayHealthSnapshot", "GatewayDiagnosticLogHandler",
"build_gateway_health_snapshot", "classify_gateway_error", "source_logger_for_export",
]
+33 -96
View File
@@ -1,9 +1,8 @@
"""Gateway Health & Diagnostics OTLP export runtime.
Emits operator-owned gateway service-health metrics plus narrow redacted
diagnostic events. Deliberately in-process and fail-open so it works under
systemd, launchd, s6, containers, tmux, nohup, or a plain shell without a
sidecar/watchdog dependency.
Emits operator-owned gateway service-health metrics plus narrow redacted diagnostic events.
Deliberately in-process and fail-open so it works under systemd, launchd, s6, containers,
tmux, nohup, or a plain shell without a sidecar/watchdog dependency.
"""
from __future__ import annotations
@@ -12,6 +11,7 @@ import importlib
import logging
import os
import threading
from contextlib import suppress
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional
@@ -47,21 +47,11 @@ _METRICS_SDK = (
)
# Every gauge the runtime snapshot can emit MUST be listed here or it is silently dropped.
_OBSERVABLE_METRIC_NAMES = (
"hermes.gateway.up",
"hermes.gateway.state",
"hermes.gateway.active_agents",
"hermes.gateway.busy",
"hermes.gateway.drainable",
"hermes.gateway.restart_requested",
"hermes.gateway.background_work",
"hermes.gateway.background_delegations",
"hermes.platform.up",
"hermes.platform.degraded",
"hermes.cron.scheduler.heartbeat_age_seconds",
"hermes.cron.scheduler.last_success_age_seconds",
"hermes.cron.scheduler.catch_up_occurrences",
"hermes.cron.jobs.enabled",
"hermes.cron.jobs.running",
"hermes.gateway.up", "hermes.gateway.state", "hermes.gateway.active_agents", "hermes.gateway.busy",
"hermes.gateway.drainable", "hermes.gateway.restart_requested", "hermes.gateway.background_work",
"hermes.gateway.background_delegations", "hermes.platform.up", "hermes.platform.degraded",
"hermes.cron.scheduler.heartbeat_age_seconds", "hermes.cron.scheduler.last_success_age_seconds",
"hermes.cron.scheduler.catch_up_occurrences", "hermes.cron.jobs.enabled", "hermes.cron.jobs.running",
"hermes.cron.jobs.overdue",
)
@@ -88,40 +78,29 @@ class GatewayHealthExportRuntime:
if self.thread is not None:
self.thread.join(timeout=0.25)
if self.log_handler is not None:
try:
with suppress(Exception):
logging.getLogger().removeHandler(self.log_handler)
except Exception:
pass
# Producers are stopped; drain queued/in-flight events BEFORE detaching subscribers
# so the terminal lifecycle event cannot race exporter shutdown. Bounded, fail-open.
subscribers = [item for item in (self.streamer, self.log_streamer) if item is not None]
try:
with suppress(Exception):
bus = emitter.get_emitter()
bus.flush(timeout=1.0)
for sub in subscribers:
bus.unsubscribe(sub)
except Exception:
pass
# Network flush/close runs under one bounded daemon-thread deadline so it can
# never delay gateway teardown indefinitely.
closeables = subscribers + ([self.metric_provider] if self.metric_provider is not None else [])
def _close() -> None:
for item in closeables:
try:
with suppress(Exception):
item.shutdown()
except Exception:
pass
if closeables:
worker = threading.Thread(
target=_close, name="hermes-gateway-health-export-shutdown", daemon=True
)
worker = threading.Thread(target=_close, name="hermes-gateway-health-export-shutdown", daemon=True)
worker.start()
worker.join(timeout=2.0)
self.streamer = self.log_streamer = self.metric_provider = self.thread = self.stop_event = None
@@ -172,18 +151,13 @@ def _read_gateway_snapshot(config: Dict[str, Any]):
except Exception:
runtime = {}
return build_gateway_health_snapshot(
runtime,
gateway_running=True,
profile=_profile(),
install_id=_install_id(config),
version=_version(),
runtime, gateway_running=True, profile=_profile(), install_id=_install_id(config), version=_version(),
supervision_mode=_supervision_mode(),
)
def _read_cron_snapshot():
from agent.monitoring.cron_health import build_cron_health_snapshot
return build_cron_health_snapshot()
@@ -197,27 +171,20 @@ def _count(failure_msg: str, module: str, read: Callable[[Any], Any]) -> int:
def _read_background_work_count() -> int:
"""Live background/subagent work that ``active_agents`` deliberately does NOT include.
``active_agents`` counts foreground turns + in-flight cron + API runs; backgrounded
``delegate_task`` subagents, ``terminal(background=true)`` processes and kanban workers
are tracked only by the scale-to-zero guard, so without this a peer churning through
subagents shows ``active_agents=0``. TASK-granular: a fan-out batch of N contributes N
(real concurrent load), unlike the pool's one-slot-per-batch accounting. Content-free.
"""
"""Live background/subagent work that ``active_agents`` (foreground turns + in-flight cron + API
runs) deliberately does NOT include: backgrounded ``delegate_task`` subagents,
``terminal(background=true)`` processes, kanban workers. TASK-granular: a fan-out batch of N
contributes N (real concurrent load), unlike the pool's one-slot-per-batch accounting."""
return (
_count("background-work async-delegation count failed", "tools.async_delegation",
lambda m: m.active_task_count())
_count("background-work async-delegation count failed", "tools.async_delegation", lambda m: m.active_task_count())
+ _count("background-work process-registry count failed", "tools.process_registry",
lambda m: m.process_registry.count_running())
)
def _read_background_delegations_count() -> int:
"""Live async delegation UNITS (dispatch/pool slots): a batch counts ONE regardless of
fan-out width, matching the pool's capacity accounting — so operators can see slot
pressure (alert vs ``max_concurrent_children``) alongside ``background_work``'s real load.
Delegations only; terminal/kanban work is already folded into ``background_work``."""
"""Live async delegation UNITS (pool slots): a batch counts ONE regardless of fan-out width, so
operators see slot pressure (vs ``max_concurrent_children``) alongside ``background_work``."""
return _count("background-delegations count failed", "tools.async_delegation", lambda m: m.active_count())
@@ -233,20 +200,14 @@ def _read_runtime_snapshot(config: Dict[str, Any]):
):
gateway_snapshot.metrics.append(GatewayMetric(name=name, value=read(), attributes=base))
except Exception as exc:
logger.warning(
"background-work snapshot unavailable; metric not exported (error_type=%s)",
type(exc).__name__,
)
logger.warning("background-work snapshot unavailable; metric not exported (error_type=%s)", type(exc).__name__)
logger.debug("background-work snapshot traceback", exc_info=True)
try:
cron_snapshot = _read_cron_snapshot()
except Exception as exc:
# Cron telemetry silently dropping out is a release-relevant regression: WARN with only
# the exception *type* (the message could carry paths); exc_info stays on DEBUG.
logger.warning(
"cron health snapshot unavailable; cron telemetry not exported (error_type=%s)",
type(exc).__name__,
)
logger.warning("cron health snapshot unavailable; cron telemetry not exported (error_type=%s)", type(exc).__name__)
logger.debug("cron health snapshot traceback", exc_info=True)
return gateway_snapshot
gateway_snapshot.metrics.extend(cron_snapshot.metrics)
@@ -268,20 +229,18 @@ def _start_metric_provider(config: Dict[str, Any], sdk: Dict[str, Any]) -> Any:
exporter = sdk["OTLPMetricExporter"](**_exporter_kwargs(config, "metrics"))
interval_ms = max(5, int(gh.get("export_interval_seconds", 60))) * 1000
reader = sdk["PeriodicExportingMetricReader"](exporter, export_interval_millis=interval_ms)
provider = sdk["MeterProvider"](
metric_readers=[reader], resource=_resource(config, sdk, "gateway_health")
)
provider = sdk["MeterProvider"](metric_readers=[reader], resource=_resource(config, sdk, "gateway_health"))
meter = provider.get_meter("hermes.gateway.health")
Observation = sdk["Observation"]
def callback(name: str):
def _cb(_options=None):
try:
snapshot = _read_runtime_snapshot(config)
return [Observation(m.value, m.attributes) for m in snapshot.metrics if m.name == name]
return [Observation(m.value, m.attributes) for m in _read_runtime_snapshot(config).metrics if m.name == name]
except Exception:
logger.debug("gateway metric callback failed", exc_info=True)
return []
return _cb
for metric_name in _OBSERVABLE_METRIC_NAMES:
@@ -290,12 +249,7 @@ def _start_metric_provider(config: Dict[str, Any], sdk: Dict[str, Any]) -> Any:
_SEVERITY_NAMES = {
"critical": "FATAL",
"fatal": "FATAL",
"error": "ERROR",
"info": "INFO",
"information": "INFO",
"debug": "DEBUG",
"critical": "FATAL", "fatal": "FATAL", "error": "ERROR", "info": "INFO", "information": "INFO", "debug": "DEBUG",
}
@@ -309,9 +263,7 @@ class GatewayDiagnosticLogStreamer(EmitterStreamer):
def __init__(self, config: Dict[str, Any], sdk: Dict[str, Any]):
self._provider = sdk["LoggerProvider"](resource=_resource(config, sdk, "gateway_diagnostics"))
self._processor = sdk["BatchLogRecordProcessor"](
sdk["OTLPLogExporter"](**_exporter_kwargs(config, "logs"))
)
self._processor = sdk["BatchLogRecordProcessor"](sdk["OTLPLogExporter"](**_exporter_kwargs(config, "logs")))
self._provider.add_log_record_processor(self._processor)
self._logger = self._provider.get_logger(_DEFAULT_DIAGNOSTIC_SCOPE)
self._sdk = sdk
@@ -328,13 +280,9 @@ class GatewayDiagnosticLogStreamer(EmitterStreamer):
source_logger = source_logger_for_export(ev.get("source_logger"))
otel_logger = self._provider.get_logger(source_logger) if source_logger is not None else self._logger
otel_logger.emit(sdk["LogRecord"](
timestamp=ev.get("ts_ns"),
trace_id=sdk["INVALID_TRACE_ID"],
span_id=sdk["INVALID_SPAN_ID"],
trace_flags=sdk["TraceFlags"].DEFAULT,
severity_text=str(ev.get("severity") or "warning").upper(),
severity_number=_severity_number(sdk, ev.get("severity")),
body=redact_bounded("gateway diagnostic"),
timestamp=ev.get("ts_ns"), trace_id=sdk["INVALID_TRACE_ID"], span_id=sdk["INVALID_SPAN_ID"],
trace_flags=sdk["TraceFlags"].DEFAULT, severity_text=str(ev.get("severity") or "warning").upper(),
severity_number=_severity_number(sdk, ev.get("severity")), body=redact_bounded("gateway diagnostic"),
attributes=_diagnostic_log_attributes(ev),
))
self.exported += 1
@@ -376,18 +324,12 @@ def start_gateway_health_export(config: Dict[str, Any]) -> GatewayHealthExportRu
diagnostics_on = gh.get("diagnostic_events_enabled", True)
runtime = GatewayHealthExportRuntime(enabled=True, reason="enabled")
sdk: Optional[Dict[str, Any]] = None
if metrics_on or diagnostics_on:
try:
sdk = _require_metrics_sdk(prompt=False)
except Exception:
logger.warning(
"monitoring.gateway_health_export.enabled but OTLP SDK is unavailable; "
"install 'hermes-agent[otlp]'",
exc_info=True,
)
logger.warning("monitoring.gateway_health_export.enabled but OTLP SDK is unavailable; install 'hermes-agent[otlp]'", exc_info=True)
return GatewayHealthExportRuntime(enabled=False, reason="otlp_unavailable")
if metrics_on and sdk is not None:
try:
runtime.metric_provider = _start_metric_provider(config, sdk)
@@ -395,7 +337,6 @@ def start_gateway_health_export(config: Dict[str, Any]) -> GatewayHealthExportRu
logger.warning("gateway health OTLP metrics failed to start", exc_info=True)
runtime.shutdown()
return GatewayHealthExportRuntime(enabled=False, reason="metrics_start_failed")
if diagnostics_on and sdk is not None:
try:
runtime.streamer = otlp_exporter.start_streaming(config, event_filter=_gateway_health_event)
@@ -408,7 +349,6 @@ def start_gateway_health_export(config: Dict[str, Any]) -> GatewayHealthExportRu
logger.debug("gateway diagnostic OTLP export failed to start", exc_info=True)
runtime.shutdown()
return GatewayHealthExportRuntime(enabled=False, reason="diagnostics_start_failed")
try:
runtime.log_handler = _attach_log_handler(config)
except Exception:
@@ -423,7 +363,4 @@ def start_gateway_health_export(config: Dict[str, Any]) -> GatewayHealthExportRu
return runtime
__all__ = [
"GatewayHealthExportRuntime",
"start_gateway_health_export",
]
__all__ = ["GatewayHealthExportRuntime", "start_gateway_health_export"]
+50 -106
View File
@@ -1,18 +1,10 @@
"""Export monitoring events to an OpenTelemetry Collector over OTLP/HTTP.
Maps gateway monitoring events to OTel spans and sends them to the operator
configured ``monitoring.export.otlp`` endpoint (no default destination ships).
Also hosts the OTLP plumbing shared with ``gateway_health_export`` (SDK loading,
header resolution, resource attributes, endpoint mapping).
* The OTel SDK is an optional extra (``pip install hermes-agent[otlp]``),
imported lazily so it is only required when export is actually used.
* ``headers_env`` maps a header name to an environment variable name; values
are read from the environment at export time and never logged or stored.
* The continuous subscriber runs in the emitter's dispatcher thread and is
fail-isolated, so an export error cannot affect the gateway. The
``event_filter`` seam keeps future planes sharing the emitter from silently
riding along on this exporter.
Maps gateway monitoring events to OTel spans for the operator-configured ``monitoring.export.otlp``
endpoint (no default destination ships) and hosts the OTLP plumbing shared with
``gateway_health_export``. The OTel SDK is an optional extra (``hermes-agent[otlp]``) imported
lazily; ``headers_env`` values are read at export time and never logged or stored. The streaming
subscriber runs fail-isolated on the emitter thread; ``event_filter`` keeps other planes off it.
"""
from __future__ import annotations
@@ -21,6 +13,7 @@ import importlib
import logging
import os
import re
from contextlib import suppress
from typing import Any, Callable, Dict, Iterable, List, Optional
from agent.monitoring.gateway_health import _safe_instance_id
@@ -34,49 +27,39 @@ class OTLPUnavailable(RuntimeError):
# ── SDK loading ──────────────────────────────────────────────────────────────
_SDK_SYMBOLS: Dict[str, str] = {
"TracerProvider": "opentelemetry.sdk.trace",
"BatchSpanProcessor": "opentelemetry.sdk.trace.export",
"Resource": "opentelemetry.sdk.resources",
"OTLPSpanExporter": "opentelemetry.exporter.otlp.proto.http.trace_exporter",
"SpanKind": "opentelemetry.trace",
"OTLPLogExporter": "opentelemetry.exporter.otlp.proto.http._log_exporter",
"OTLPMetricExporter": "opentelemetry.exporter.otlp.proto.http.metric_exporter",
"Observation": "opentelemetry.metrics",
"INVALID_SPAN_ID": "opentelemetry.trace",
"INVALID_TRACE_ID": "opentelemetry.trace",
"TraceFlags": "opentelemetry.trace",
"LogRecord": "opentelemetry._logs",
"SeverityNumber": "opentelemetry._logs.severity",
"LoggerProvider": "opentelemetry.sdk._logs",
"BatchLogRecordProcessor": "opentelemetry.sdk._logs.export",
"MeterProvider": "opentelemetry.sdk.metrics",
"PeriodicExportingMetricReader": "opentelemetry.sdk.metrics.export",
_SDK_MODULES: Dict[str, tuple[str, ...]] = {
"opentelemetry.sdk.trace": ("TracerProvider",),
"opentelemetry.sdk.trace.export": ("BatchSpanProcessor",),
"opentelemetry.sdk.resources": ("Resource",),
"opentelemetry.exporter.otlp.proto.http.trace_exporter": ("OTLPSpanExporter",),
"opentelemetry.exporter.otlp.proto.http._log_exporter": ("OTLPLogExporter",),
"opentelemetry.exporter.otlp.proto.http.metric_exporter": ("OTLPMetricExporter",),
"opentelemetry.trace": ("SpanKind", "INVALID_SPAN_ID", "INVALID_TRACE_ID", "TraceFlags"),
"opentelemetry.metrics": ("Observation",),
"opentelemetry._logs": ("LogRecord",),
"opentelemetry._logs.severity": ("SeverityNumber",),
"opentelemetry.sdk._logs": ("LoggerProvider",),
"opentelemetry.sdk._logs.export": ("BatchLogRecordProcessor",),
"opentelemetry.sdk.metrics": ("MeterProvider",),
"opentelemetry.sdk.metrics.export": ("PeriodicExportingMetricReader",),
}
_SDK_SYMBOLS: Dict[str, str] = {name: module for module, names in _SDK_MODULES.items() for name in names}
_SPAN_SDK = ("TracerProvider", "BatchSpanProcessor", "Resource", "OTLPSpanExporter", "SpanKind")
def _require_sdk(
names: Iterable[str] = _SPAN_SDK, *, auto_install: bool = True, prompt: bool = True
) -> Dict[str, Any]:
def _require_sdk(names: Iterable[str] = _SPAN_SDK, *, auto_install: bool = True, prompt: bool = True) -> Dict[str, Any]:
"""Import the named OTel SDK symbols, lazily installing the extra on first use.
Routes through tools.lazy_deps (feature 'export.otlp') — gated by
security.allow_lazy_installs and TTY-prompted (``prompt=False`` from
non-interactive contexts). Any lazy-install failure falls through to the
import attempt, which raises OTLPUnavailable with a manual install hint.
Routes through tools.lazy_deps (feature 'export.otlp') — gated by security.allow_lazy_installs
and TTY-prompted (``prompt=False`` from non-interactive contexts). Any lazy-install failure
falls through to the import attempt, which raises OTLPUnavailable with a manual install hint.
"""
if auto_install:
try:
with suppress(Exception):
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("export.otlp", prompt=prompt)
except Exception:
pass
try:
return {
name: getattr(importlib.import_module(_SDK_SYMBOLS[name]), name)
for name in names
}
return {name: getattr(importlib.import_module(_SDK_SYMBOLS[name]), name) for name in names}
except Exception as e: # ImportError or partial install
raise OTLPUnavailable(
"OTLP export requires the optional dependency. Install with:\n"
@@ -122,15 +105,8 @@ def _signal_endpoint(endpoint: str, signal: str) -> str:
_RESOURCE_ATTRIBUTE_KEYS = frozenset({
"service.name",
"service.namespace",
"service.version",
"service.instance.id",
"deployment.environment.name",
"cloud.provider",
"cloud.platform",
"cloud.region",
"telemetry.scope",
"service.name", "service.namespace", "service.version", "service.instance.id",
"deployment.environment.name", "cloud.provider", "cloud.platform", "cloud.region", "telemetry.scope",
})
_SAFE_RESOURCE_VALUE = re.compile(r"^[A-Za-z0-9._:/-]{1,128}$")
@@ -158,12 +134,9 @@ def _safe_resource_attributes(raw: Any) -> Dict[str, str]:
return attrs
def _runtime_resource_attributes(
config: Dict[str, Any], *, telemetry_scope: str
) -> Dict[str, str]:
def _runtime_resource_attributes(config: Dict[str, Any], *, telemetry_scope: str) -> Dict[str, str]:
"""Build the safe OTLP resource shared by spans, metrics and diagnostic logs."""
gh = _monitoring_section(config, "gateway_health_export")
attrs = _safe_resource_attributes(gh.get("resource_attributes"))
attrs = _safe_resource_attributes(_monitoring_section(config, "gateway_health_export").get("resource_attributes"))
attrs["service.name"] = "hermes-gateway"
attrs["service.instance.id"] = _safe_instance_id(_install_id(config))
attrs["telemetry.scope"] = telemetry_scope
@@ -177,8 +150,7 @@ def build_exporter(config: Dict[str, Any]):
endpoint = otlp.get("endpoint")
if not endpoint:
raise ValueError("monitoring.export.otlp.endpoint is not set")
headers = _resolve_headers(otlp.get("headers_env"))
return sdk["OTLPSpanExporter"](endpoint=endpoint, headers=headers or None)
return sdk["OTLPSpanExporter"](endpoint=endpoint, headers=_resolve_headers(otlp.get("headers_env")) or None)
def _resource_attributes(config: Dict[str, Any]) -> Dict[str, str]:
@@ -187,8 +159,7 @@ def _resource_attributes(config: Dict[str, Any]) -> Dict[str, str]:
def _make_provider(config: Dict[str, Any]):
sdk = _require_sdk()
resource = sdk["Resource"].create(_resource_attributes(config))
provider = sdk["TracerProvider"](resource=resource)
provider = sdk["TracerProvider"](resource=sdk["Resource"].create(_resource_attributes(config)))
processor = sdk["BatchSpanProcessor"](build_exporter(config))
provider.add_span_processor(processor)
return provider, processor
@@ -197,16 +168,12 @@ def _make_provider(config: Dict[str, Any]):
# ── event -> span attribute mapping ──────────────────────────────────────────
# Per-kind attribute allowlists: everything else (profile, install_id, ...) never egresses.
_KEEP_BY_KIND: Dict[str, tuple[str, ...]] = {
"gateway_health": ("name", "gateway_state", "old_state", "new_state",
"exit_reason", "restart_requested", "active_agents",
"gateway_busy", "gateway_drainable", "platform_count",
"fatal_platform_count", "version",
"supervision_mode", "pid"),
"gateway_diagnostic": ("name", "subsystem", "error_class", "error_code",
"platform", "old_state", "new_state",
"version", "severity"),
"cron_execution": ("status", "job_key", "source", "duration_ms",
"delivery_outcome", "error_class"),
"gateway_health": (
"name", "gateway_state", "old_state", "new_state", "exit_reason", "restart_requested", "active_agents",
"gateway_busy", "gateway_drainable", "platform_count", "fatal_platform_count", "version", "supervision_mode", "pid",
),
"gateway_diagnostic": ("name", "subsystem", "error_class", "error_code", "platform", "old_state", "new_state", "version", "severity"),
"cron_execution": ("status", "job_key", "source", "duration_ms", "delivery_outcome", "error_class"),
}
@@ -234,9 +201,7 @@ def export_batch(provider, batch: List[Dict[str, Any]]) -> int:
n = 0
for ev in batch:
try:
name = f"hermes.{ev.get('event', 'event')}"
span = tracer.start_span(name, attributes=_span_attrs(ev))
span.end()
tracer.start_span(f"hermes.{ev.get('event', 'event')}", attributes=_span_attrs(ev)).end()
n += 1
except Exception:
logger.debug("OTLP span map failed", exc_info=True)
@@ -246,36 +211,24 @@ def export_batch(provider, batch: List[Dict[str, Any]]) -> int:
# ── continuous streaming subscribers ─────────────────────────────────────────
class EmitterStreamer:
"""Base for emitter subscribers owning an OTel provider + batch processor.
Register with ``emitter.subscribe(streamer)``. Fail-isolated by the emitter.
"""
Register with ``emitter.subscribe(streamer)``. Fail-isolated by the emitter."""
_provider: Any
_processor: Any
exported: int = 0
def shutdown(self) -> None:
try:
with suppress(Exception):
from agent.monitoring.emitter import get_emitter
get_emitter().unsubscribe(self)
except Exception:
pass
try:
with suppress(Exception):
self._processor.force_flush()
self._provider.shutdown()
except Exception:
pass
class OTLPStreamer(EmitterStreamer):
"""A live subscriber that pushes each emitter batch to OTLP as spans."""
def __init__(
self,
config: Dict[str, Any],
*,
event_filter: Optional[Callable[[Dict[str, Any]], bool]] = None,
):
def __init__(self, config: Dict[str, Any], *, event_filter: Optional[Callable[[Dict[str, Any]], bool]] = None):
self._provider, self._processor = _make_provider(config)
self._event_filter = event_filter
self.exported = 0
@@ -303,16 +256,13 @@ def is_enabled(config: Dict[str, Any]) -> bool:
def start_streaming(
config: Dict[str, Any],
*,
event_filter: Optional[Callable[[Dict[str, Any]], bool]] = None,
config: Dict[str, Any], *, event_filter: Optional[Callable[[Dict[str, Any]], bool]] = None,
) -> Optional[OTLPStreamer]:
"""If OTLP is enabled, attach a streamer to the singleton emitter.
``event_filter`` scopes the exporter to its plane. Startup is non-interactive:
a configured-but-missing SDK is lazily installed once (prompt=False, gated by
security.allow_lazy_installs); if it still can't load, log and no-op — never
raise into startup.
``event_filter`` scopes the exporter to its plane. Startup is non-interactive: a
configured-but-missing SDK is lazily installed once (prompt=False, gated by
security.allow_lazy_installs); if it still can't load, log and no-op — never raise into startup.
"""
if not is_enabled(config):
return None
@@ -329,11 +279,5 @@ def start_streaming(
__all__ = [
"OTLPUnavailable",
"OTLPStreamer",
"build_exporter",
"export_batch",
"is_available",
"is_enabled",
"start_streaming",
"OTLPUnavailable", "OTLPStreamer", "build_exporter", "export_batch", "is_available", "is_enabled", "start_streaming",
]
+1 -5
View File
@@ -25,11 +25,9 @@ def ensure_install_id(config: Dict[str, Any]) -> str:
existing = (mon or {}).get("install_id") if isinstance(mon, dict) else None
if isinstance(existing, str) and existing.strip():
return existing
minted = str(uuid.uuid4())
try:
from hermes_cli.config import load_config, save_config
fresh = load_config()
if isinstance(fresh, dict):
slot = fresh.setdefault("monitoring", {})
@@ -46,6 +44,4 @@ def ensure_install_id(config: Dict[str, Any]) -> str:
return minted
__all__ = [
"ensure_install_id",
]
__all__ = ["ensure_install_id"]
+7 -22
View File
@@ -1,11 +1,9 @@
"""Redaction applied to monitoring data before egress.
One unconditional scrub, no modes, no knobs. Every string that leaves the
process passes through ``redact_for_export``: secrets first (wraps
``agent/redact.py::redact_sensitive_text(force=True)`` plus bearer/token
shapes, failing CLOSED so a broken redactor never emits the raw string), then
One unconditional scrub, no modes, no knobs. Every string that leaves the process passes
through ``redact_for_export``: secrets first (``agent/redact.py::redact_sensitive_text(force=True)``
plus bearer/token shapes, failing CLOSED so a broken redactor never emits the raw string), then
PII (e-mail, phone, UUID-shaped ids -> ``[email]`` / ``[phone]`` / ``[id]``).
There is deliberately no setting to weaken this.
"""
from __future__ import annotations
@@ -15,9 +13,7 @@ from typing import Any, Optional
# ── secret shapes (belt-and-suspenders on top of agent/redact.py) ───────────
_BEARER_RE = re.compile(r"\bBearer\s+[A-Za-z0-9._~+\-/]+=*", re.IGNORECASE)
_TOKEN_RE = re.compile(
r"\b(xox[baprs]-[A-Za-z0-9-]+|sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9_]{8,})\b"
)
_TOKEN_RE = re.compile(r"\b(xox[baprs]-[A-Za-z0-9-]+|sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9_]{8,})\b")
_SECRET_LITERAL_RE = re.compile(r"\*{3,}")
_BEARER_RESIDUE_RE = re.compile(r"\bBearer\s+\[[^\]]+\]", re.IGNORECASE)
@@ -27,9 +23,7 @@ _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
_PHONE_RE = re.compile(
r"(?<!\w)(?:\+?\d{1,3}[\s.\-]?)?(?:\(\d{2,4}\)[\s.\-]?)?\d{3}[\s.\-]?\d{3,4}(?:[\s.\-]?\d{2,4})?(?!\w)"
)
_UUID_RE = re.compile(
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
)
_UUID_RE = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b")
UNAVAILABLE = "[redaction-unavailable]"
@@ -58,13 +52,7 @@ def redact_for_export(text: Optional[str]) -> Optional[str]:
return out
def redact_bounded(
raw: Any,
*,
limit: int = 500,
empty: str = "[redacted]",
unavailable: str = UNAVAILABLE,
) -> str:
def redact_bounded(raw: Any, *, limit: int = 500, empty: str = "[redacted]", unavailable: str = UNAVAILABLE) -> str:
"""Redact ``str(raw or "")`` and length-bound it; ``empty`` replaces an empty
result, ``unavailable`` is returned if redaction itself raises."""
try:
@@ -73,7 +61,4 @@ def redact_bounded(
return unavailable
__all__ = [
"redact_for_export",
"redact_bounded",
]
__all__ = ["redact_for_export", "redact_bounded"]
+30 -46
View File
@@ -1,16 +1,11 @@
"""Helpers for translating OpenAI-style tool schemas to Moonshot's schema subset.
"""Translate OpenAI-style tool schemas to Moonshot's (Kimi) stricter JSON Schema subset.
Moonshot (Kimi) accepts a stricter subset of JSON Schema than OpenAI tool
calling; violations fail with HTTP 400 "tools.function.parameters is not a
valid moonshot flavored json schema". Rules applied here:
1. Every property schema must carry a ``type`` (JSON Schema allows omitting it).
2. With ``anyOf``, ``type`` belongs on the children, never the parent.
3. Enum arrays under scalar types may not contain null / empty string.
4. Every object schema must carry a ``required`` array, even an empty one.
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs lives in
``tools/mcp_tool._normalize_mcp_input_schema`` so it applies to all providers.
Violations fail with HTTP 400 "tools.function.parameters is not a valid moonshot
flavored json schema". Rules: (1) every property schema carries a ``type``;
(2) with ``anyOf``, ``type`` belongs on the children, never the parent; (3) enum
arrays under scalar types may not contain null / empty string; (4) every object
schema carries a ``required`` array, even an empty one. The ``#/definitions/`` →
``#/$defs/`` rewrite lives in ``tools/mcp_tool`` so it applies to all providers.
"""
from __future__ import annotations
@@ -26,6 +21,10 @@ _SCHEMA_LIST_KEYS = frozenset({"anyOf", "oneOf", "allOf", "prefixItems"})
# Values are a single nested schema (additionalProperties may also be a bool).
_SCHEMA_NODE_KEYS = frozenset({"items", "contains", "not", "additionalProperties", "propertyNames"})
_SCALAR_TYPES = frozenset({"string", "integer", "number", "boolean"})
# bool before int: bool is an int subclass.
_ENUM_SAMPLE_TYPES = ((bool, "boolean"), (int, "integer"), (float, "number"))
def _empty_object_schema() -> Dict[str, Any]:
return {"type": "object", "properties": {}, "required": []}
@@ -42,9 +41,9 @@ def _repair_schema(node: Any) -> Any:
for key, value in node.items():
if key in _SCHEMA_MAP_KEYS and isinstance(value, dict):
repaired[key] = {sub_key: _repair_schema(sub_val) for sub_key, sub_val in value.items()}
elif key in _SCHEMA_LIST_KEYS and isinstance(value, list):
repaired[key] = [_repair_schema(v) for v in value]
elif key in _SCHEMA_NODE_KEYS and isinstance(value, dict):
elif (key in _SCHEMA_LIST_KEYS and isinstance(value, list)) or (
key in _SCHEMA_NODE_KEYS and isinstance(value, dict)
):
repaired[key] = _repair_schema(value)
else:
repaired[key] = value
@@ -60,9 +59,7 @@ def _repair_schema(node: Any) -> Any:
if len(non_null) > 1:
repaired["anyOf"] = non_null
return repaired
merge = {k: v for k, v in repaired.items() if k != "anyOf"}
merge.update(non_null[0])
repaired = merge
repaired = {**{k: v for k, v in repaired.items() if k != "anyOf"}, **non_null[0]}
# Moonshot also rejects the non-standard ``nullable`` keyword.
repaired.pop("nullable", None)
@@ -73,7 +70,7 @@ def _repair_schema(node: Any) -> Any:
repaired = _fill_missing_type(repaired)
# Rule 3: drop null/"" enum values under scalar types; drop an emptied enum.
if isinstance(repaired.get("enum"), list) and repaired.get("type") in {"string", "integer", "number", "boolean"}:
if isinstance(repaired.get("enum"), list) and repaired.get("type") in _SCALAR_TYPES:
cleaned = [v for v in repaired["enum"] if v is not None and v != ""]
if cleaned:
repaired["enum"] = cleaned
@@ -88,9 +85,8 @@ def _repair_schema(node: Any) -> Any:
def _ensure_required_array(node: Dict[str, Any]) -> Dict[str, Any]:
"""Guarantee an object schema carries a ``required`` list (Moonshot 400s
otherwise), pruning names that don't exist in ``properties`` — Moonshot
also rejects dangling names. Mutates and returns ``node``."""
"""Guarantee an object schema carries a ``required`` list, pruning names not in
``properties`` (Moonshot also rejects dangling names). Mutates and returns ``node``."""
props = node.get("properties")
req = node.get("required")
if isinstance(req, list):
@@ -102,7 +98,7 @@ def _ensure_required_array(node: Dict[str, Any]) -> Dict[str, Any]:
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""Infer a reasonable ``type`` if this schema node has none.
"""Infer a ``type`` if this schema node has none.
A type list collapses to its first concrete member; otherwise
``properties``/``required``/``additionalProperties`` → object,
@@ -111,10 +107,7 @@ def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""
node_type = node.get("type")
if isinstance(node_type, list):
concrete = next(
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
"string",
)
concrete = next((t for t in node_type if isinstance(t, str) and t not in {"", "null"}), "string")
return {**node, "type": concrete}
if "type" in node and node_type not in {None, ""}:
return node
@@ -124,24 +117,20 @@ def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
elif "items" in node or "prefixItems" in node:
inferred = "array"
elif isinstance(node.get("enum"), list) and node["enum"]:
sample = node["enum"][0] # bool before int: bool is an int subclass
scalar_types = ((bool, "boolean"), (int, "integer"), (float, "number"))
inferred = next((t for cls, t in scalar_types if isinstance(sample, cls)), "string")
sample = node["enum"][0]
inferred = next((t for cls, t in _ENUM_SAMPLE_TYPES if isinstance(sample, cls)), "string")
else:
inferred = "string"
return {**node, "type": inferred}
def sanitize_moonshot_tool_parameters(parameters: Any) -> Dict[str, Any]:
"""Return a deep-copied, Moonshot-compatible object schema; input is not mutated."""
"""Deep-copied, Moonshot-compatible object schema; input is not mutated."""
if not isinstance(parameters, dict):
return _empty_object_schema()
repaired = _repair_schema(copy.deepcopy(parameters))
if not isinstance(repaired, dict):
return _empty_object_schema()
# Top-level must be an object schema.
repaired["type"] = "object"
repaired.setdefault("properties", {})
@@ -155,22 +144,17 @@ def sanitize_moonshot_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]
"""
if not tools:
return tools
sanitized: List[Dict[str, Any]] = []
any_change = False
for tool in tools:
fn = tool.get("function") if isinstance(tool, dict) else None
if not isinstance(fn, dict):
sanitized.append(tool)
continue
params = fn.get("parameters")
repaired = sanitize_moonshot_tool_parameters(params)
if repaired is not params:
any_change = True
sanitized.append({**tool, "function": {**fn, "parameters": repaired}})
else:
sanitized.append(tool)
if isinstance(fn, dict):
params = fn.get("parameters")
repaired = sanitize_moonshot_tool_parameters(params)
if repaired is not params:
any_change = True
tool = {**tool, "function": {**fn, "parameters": repaired}}
sanitized.append(tool)
return sanitized if any_change else tools
+116 -210
View File
@@ -1,24 +1,11 @@
"""Native OpenAI Responses server-side compaction — gpt-5.6 on direct OpenAI routes only.
Including ``context_management=[{"type": "compaction", "compact_threshold": N}]``
in a ``/v1/responses`` request makes the server summarize older context into an
opaque ``compaction`` item (``encrypted_content``, sealed to the issuing
endpoint) once the input crosses N tokens; replaying that item stands in for
the pruned history. Docs: https://developers.openai.com/api/docs/guides/compaction
Support is deliberately narrow (live-verified):
* gpt-5.6 family only — gpt-5.1/5.2 fail server-side (HTTP 500 blocking, a
permanent stall streaming) with no structured "unsupported" rejection, so an
explicit model-family check is the only safe gate.
* Direct OpenAI routes only (api.openai.com or the ChatGPT Codex backend) —
other Responses surfaces would 400 on the field and cannot mint/decrypt the blob.
Hermes' local compressor stays armed as fallback owner: the native threshold is
clamped below the local trigger so the server compacts first, and captured
compaction items ride the existing ``codex_reasoning_items`` sidecar (persistence,
replay, cross-issuer stamping, kill switch). This module stays free of
transport/adapter imports so transport, adapter, and loop share the gate
without cycles; ``context_compressor`` and ``message_content`` sit below it.
``context_management=[{"type": "compaction", "compact_threshold": N}]`` makes the server
summarize older context into an opaque ``compaction`` item once the input crosses N tokens.
Deliberately narrow (live-verified): gpt-5.6 only (5.1/5.2 fail server-side with no
structured rejection) on api.openai.com or the ChatGPT Codex backend. The local compressor
stays armed as fallback (native threshold clamped below the local trigger); compaction items
ride the ``codex_reasoning_items`` sidecar. No transport imports (shared gate, no cycles).
"""
from __future__ import annotations
@@ -32,8 +19,7 @@ from agent.message_content import flatten_message_text
logger = logging.getLogger(__name__)
# Native compaction fires this many tokens below the local compressor's
# trigger so the server always gets the first shot.
# Native compaction fires this far below the local trigger so the server gets the first shot.
LOCAL_TRIGGER_SAFETY_MARGIN = 8_192
# Fallback when automatic mode has no local trigger to follow.
DEFAULT_COMPACT_THRESHOLD = 200_000
@@ -47,30 +33,18 @@ def is_native_compaction_model(model: Optional[str]) -> bool:
def resolve_native_compaction_capabilities(
*,
model: Optional[str],
base_url: Optional[str],
provider: Optional[str] = None,
is_codex_backend: bool = False,
*, model: Optional[str], base_url: Optional[str], provider: Optional[str] = None, is_codex_backend: bool = False,
) -> Dict[str, bool]:
"""Resolve the native-compaction capability for a runtime destination.
A resolved ``False`` is distinct from "unresolved" and must survive model
switches unchanged.
"""
"""Resolve the native-compaction capability for a runtime destination (a resolved ``False``
is distinct from "unresolved" and must survive model switches unchanged)."""
direct_default = (provider or "").strip().lower() == "openai" and not base_url
eligible = is_native_compaction_model(model) and (
direct_default
or is_direct_openai_route(base_url, is_codex_backend=is_codex_backend)
direct_default or is_direct_openai_route(base_url, is_codex_backend=is_codex_backend)
)
return {"native_compaction": eligible}
def is_direct_openai_route(
base_url: Optional[str],
*,
is_codex_backend: bool = False,
) -> bool:
def is_direct_openai_route(base_url: Optional[str], *, is_codex_backend: bool = False) -> bool:
"""True for api.openai.com or the ChatGPT Codex backend — nothing else."""
if is_codex_backend:
return True
@@ -81,42 +55,31 @@ def is_direct_openai_route(
return hostname == "api.openai.com"
def resolve_compact_threshold(
configured_threshold: Any,
local_trigger_tokens: Any = None,
) -> int:
def _positive_int(value: Any, *, reject: tuple = (bool,)) -> Optional[int]:
"""``int(value)`` when it is a positive integer-like (never a bool), else None."""
if value is None or isinstance(value, reject):
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def resolve_compact_threshold(configured_threshold: Any, local_trigger_tokens: Any = None) -> int:
"""Resolve automatic mode or clamp an explicit native threshold.
An omitted/invalid setting follows the local compressor trigger
(``ContextCompressor.threshold_tokens``) minus the safety margin. An
explicit positive integer is absolute unless it must be clamped so native
compaction fires first. Booleans are never thresholds.
Omitted/invalid follows the local compressor trigger minus the safety margin. An
explicit positive integer is absolute unless it must be clamped so native compaction
fires first. Booleans are never thresholds.
"""
local = None
try:
if local_trigger_tokens is not None and not isinstance(local_trigger_tokens, bool):
local = int(local_trigger_tokens)
except (TypeError, ValueError):
local = None
if local is not None and local <= 0:
local = None
local = _positive_int(local_trigger_tokens)
upper = None
if local is not None:
if local > LOCAL_TRIGGER_SAFETY_MARGIN:
upper = max(1_024, local - LOCAL_TRIGGER_SAFETY_MARGIN)
else:
upper = max(1_024, int(local * 0.8))
upper = max(1_024, local - LOCAL_TRIGGER_SAFETY_MARGIN if local > LOCAL_TRIGGER_SAFETY_MARGIN else int(local * 0.8))
try:
configured = (
None
if isinstance(configured_threshold, (bool, float))
else int(configured_threshold)
)
except (TypeError, ValueError):
configured = None
if configured is None or configured <= 0:
configured = _positive_int(configured_threshold, reject=(bool, float))
if configured is None:
return upper if upper is not None else DEFAULT_COMPACT_THRESHOLD
if upper is None:
return configured
@@ -140,43 +103,29 @@ def _warn_native_compaction_suppressed_by_checkpoint_gate() -> None:
def native_compaction_context_management(
agent: Any,
*,
is_codex_backend: bool,
is_xai_responses: bool = False,
is_github_responses: bool = False,
agent: Any, *, is_codex_backend: bool, is_xai_responses: bool = False, is_github_responses: bool = False,
) -> Optional[List[Dict[str, Any]]]:
"""Return the ``context_management`` payload for this request, or None.
"""Return the ``context_management`` payload for this request, or None ("do not send").
None means "do not send the field" (request byte-identical to pre-feature).
Every gate is re-checked per request so a mid-session model switch or the
in-session kill switch (``agent.codex_responses_native_compaction = False``,
set by rejection recovery) takes effect on the next call.
Every gate is re-checked per request so a mid-session model switch or the in-session
kill switch (``agent.codex_responses_native_compaction = False``) takes effect next call.
"""
capabilities = getattr(agent, "runtime_capabilities", None)
if isinstance(capabilities, dict) and not capabilities.get("native_compaction", False):
return None
if not getattr(agent, "codex_responses_native_compaction", False):
return None
# compression.enabled: false disables ALL automatic compaction, native included.
if not getattr(agent, "compression_enabled", True):
if not getattr(agent, "codex_responses_native_compaction", False) or not getattr(agent, "compression_enabled", True):
return None
# Server-side compaction is a lossy boundary the provider owns — no
# pre-compress checkpoint can run first — so the checkpoint-aware Hermes
# compressor stays authoritative. Explicit-True matches compress_context().
# Server-side compaction is a lossy boundary the provider owns (no pre-compress checkpoint
# can run first), so the checkpoint-aware compressor stays authoritative. Explicit-True
# matches compress_context().
if getattr(agent, "compression_checkpoint_required", False) is True:
_warn_native_compaction_suppressed_by_checkpoint_gate()
return None
if is_xai_responses or is_github_responses:
if is_xai_responses or is_github_responses or not is_native_compaction_model(getattr(agent, "model", None)):
return None
if not is_native_compaction_model(getattr(agent, "model", None)):
return None
trusted_proxy = bool(
getattr(agent, "capabilities", {}).get("openai_native_compaction", False)
)
if not trusted_proxy and not is_direct_openai_route(
getattr(agent, "base_url", None), is_codex_backend=is_codex_backend
):
trusted_proxy = bool(getattr(agent, "capabilities", {}).get("openai_native_compaction", False))
if not trusted_proxy and not is_direct_openai_route(getattr(agent, "base_url", None), is_codex_backend=is_codex_backend):
return None
compressor = getattr(agent, "context_compressor", None)
@@ -187,9 +136,8 @@ def native_compaction_context_management(
return [{"type": "compaction", "compact_threshold": threshold}]
# Retention budgets for plaintext user messages / local compression summaries
# carried across a native compaction boundary (mirrors Codex CLI's
# RETAINED_MESSAGE_TOKEN_BUDGET; the summary budget prevents summary inflation).
# Retention budgets for plaintext user messages / local summaries carried across a native
# compaction boundary (mirrors Codex CLI's RETAINED_MESSAGE_TOKEN_BUDGET).
RETAINED_USER_MESSAGE_TOKEN_BUDGET = 64_000
RETAINED_SUMMARY_TOKEN_BUDGET = 32_000
@@ -211,54 +159,47 @@ def _extract_item_text(item: Any) -> Optional[str]:
if isinstance(content, str):
return content if content.strip() else None
if isinstance(content, list):
parts = []
for part in content:
if isinstance(part, str):
if part.strip():
parts.append(part.strip())
elif isinstance(part, dict):
part_text = part.get("text") or part.get("input_text") or part.get("output_text")
if isinstance(part_text, str) and part_text.strip():
parts.append(part_text.strip())
part_meta = part.get("metadata")
if isinstance(part_meta, dict) and isinstance(part_meta.get("text"), str) and part_meta["text"].strip():
parts.append(part_meta["text"].strip())
text = " ".join(parts)
return text if text.strip() else None
return None
if not isinstance(content, list):
return None
parts = []
for part in content:
if isinstance(part, str):
candidates = (part,)
elif isinstance(part, dict):
part_meta = part.get("metadata")
candidates = (
part.get("text") or part.get("input_text") or part.get("output_text"),
part_meta.get("text") if isinstance(part_meta, dict) else None,
)
else:
continue
parts.extend(c.strip() for c in candidates if isinstance(c, str) and c.strip())
text = " ".join(parts)
return text if text.strip() else None
def _has_retainable_image_content(item: Any) -> bool:
"""True for a converted Responses message with a valid ``input_image`` part.
Only the adapter-owned ``input_image`` shape counts: unknown or empty
multipart placeholders must not become durable history for being non-empty.
"""
if not isinstance(item, dict):
return False
content = item.get("content")
if not isinstance(content, list):
return False
for part in content:
if not isinstance(part, dict):
continue
if str(part.get("type") or "").strip().lower() != "input_image":
continue
image_url = part.get("image_url")
if isinstance(image_url, str) and image_url.strip():
return True
return False
"""True for a converted Responses message with a valid ``input_image`` part (only the
adapter-owned shape counts, so empty multipart placeholders never become durable history)."""
content = item.get("content") if isinstance(item, dict) else None
return isinstance(content, list) and any(
isinstance(part, dict)
and str(part.get("type") or "").strip().lower() == "input_image"
and isinstance(part.get("image_url"), str)
and part["image_url"].strip()
for part in content
)
# Canonical provenance check (metadata marker, then canonical prefix classifier).
# Deliberately NOT a second heuristic: no underscore-key scan, no matching on
# ad-hoc headings — either could promote ordinary or adversarial content to
# durable retained history.
# Canonical provenance check. Deliberately NOT a second heuristic (no underscore-key scan,
# no ad-hoc headings) — either could promote adversarial content to durable history.
_is_summary_item = is_compaction_summary_message
def _is_compaction_item(item: Any) -> bool:
return isinstance(item, dict) and item.get("type") == "compaction"
def prune_pre_checkpoint_items(
items: List[Dict[str, Any]],
retained_user_token_budget: int = RETAINED_USER_MESSAGE_TOKEN_BUDGET,
@@ -268,56 +209,36 @@ def prune_pre_checkpoint_items(
) -> List[Dict[str, Any]]:
"""Restructure Responses input around the newest compaction checkpoint.
The server drops every input item preceding a replayed ``compaction`` item,
which silently erases the user's plaintext asks and any local-compression
summary (``role="assistant"``). With a checkpoint present, rebuild as::
The server drops every input item preceding a replayed ``compaction`` item, erasing the
user's plaintext asks and any local-compression summary. Rebuild as::
[checkpoint run] + [retained user & summary messages (newest-first budget)] + [post]
- The NEWEST contiguous run of checkpoints wins.
- User messages are kept verbatim within ``retained_user_token_budget``;
the boundary message is head-truncated when it only partially fits
(string content only — goals are stated up front). A recognized
image-only user message is retained whole at one-token cost.
- Summaries are retained whole within ``retained_summary_token_budget`` and
never sliced (their structural framing would corrupt); one that doesn't
fit is dropped. Identical summary text is never retained twice.
- Relative order between user messages and summaries is preserved.
- ``item_sources`` (parallel to ``items``) is the raw chat message each item
was converted from. Conversion can be lossy for summaries (a
merge-into-tail carrier becomes a typed ``function_call_output``, or an
assistant carrier is shadowed by a stale exact replay), so when a source
is itself a canonical summary carrier its content is read from the
SOURCE and retained as a synthesized ``role="assistant"`` message.
- ``enable_summary_retention`` is a function-level override for tests, not
a config surface.
- User messages are kept verbatim within ``retained_user_token_budget``; the boundary
message is head-truncated when it only partially fits (string content only). A
recognized image-only user message is retained whole at one-token cost.
- Summaries are retained whole within ``retained_summary_token_budget``, never sliced
(framing would corrupt) and never duplicated. Relative order is preserved.
- ``item_sources`` (parallel to ``items``) is the raw chat message each item came from.
Conversion can be lossy for summaries (merge-into-tail carrier → typed
``function_call_output``; assistant carrier shadowed by a stale replay), so a source
that is itself a canonical summary carrier is read from the SOURCE and retained as a
synthesized ``role="assistant"`` message.
- ``enable_summary_retention`` is a test override, not a config surface.
"""
if not isinstance(items, list) or not items:
return items
last_cp = None
for i, item in enumerate(items):
if isinstance(item, dict) and item.get("type") == "compaction":
last_cp = i
last_cp = max((i for i, item in enumerate(items) if _is_compaction_item(item)), default=None)
if last_cp is None:
return items
first_cp = last_cp
while (
first_cp > 0
and isinstance(items[first_cp - 1], dict)
and items[first_cp - 1].get("type") == "compaction"
):
while first_cp > 0 and _is_compaction_item(items[first_cp - 1]):
first_cp -= 1
pre = items[:first_cp]
checkpoint_run = items[first_cp : last_cp + 1]
post = items[last_cp + 1 :]
if isinstance(item_sources, list) and len(item_sources) == len(items):
pre_sources: List[Any] = item_sources[:first_cp]
else:
pre_sources = [None] * len(pre)
has_sources = isinstance(item_sources, list) and len(item_sources) == len(items)
pre_sources: List[Any] = item_sources[:first_cp] if has_sources else [None] * len(pre)
retained_reversed: List[Dict[str, Any]] = []
user_remaining = max(0, int(retained_user_token_budget))
@@ -362,29 +283,25 @@ def prune_pre_checkpoint_items(
continue
text = _extract_item_text(item)
has_retainable_image = is_user and _has_retainable_image_content(item)
if text is None and not has_retainable_image:
continue
if text is None:
if not (is_user and _has_retainable_image_content(item)):
continue
text = ""
if is_summary:
_retain_summary(text, item)
elif is_user:
if user_remaining <= 0:
continue
elif user_remaining > 0:
cost = _approx_tokens(text)
if cost <= user_remaining:
retained_reversed.append(item)
user_remaining -= cost
elif isinstance(item.get("content"), str):
truncated = dict(item)
truncated["content"] = item["content"][: user_remaining * 4]
truncated = {**item, "content": item["content"][: user_remaining * 4]}
if truncated["content"].strip():
retained_reversed.append(truncated)
user_remaining = 0
result = checkpoint_run + list(reversed(retained_reversed)) + post
result = items[first_cp : last_cp + 1] + list(reversed(retained_reversed)) + items[last_cp + 1 :]
logger.debug(
"Pruned pre-checkpoint items: %d input -> %d retained (user_rem=%d, summary_rem=%d)",
@@ -403,51 +320,40 @@ _REJECTION_MARKERS = (
def is_native_compaction_rejection(error: Any, status_code: Any = None) -> bool:
"""True when a provider error is a STRUCTURED rejection of ``context_management``.
Drives the loop's one-shot recovery (strip the field, disable for the
session, retry), so matching is narrow: a transient 5xx whose body merely
ECHOES the request must not permanently downgrade native compaction. Requires
``status_code`` 400 (or unknown — some transports surface only a message)
AND the field name alongside rejection language.
Drives one-shot recovery (strip, disable for the session, retry), so matching is
narrow: a transient 5xx that merely ECHOES the request must not downgrade native
compaction. Requires ``status_code`` 400 (or unknown) AND the field name with rejection
language.
"""
text = str(error or "").lower()
if "context_management" not in text and "compact_threshold" not in text:
return False
if status_code is not None:
try:
if int(status_code) != 400:
return False
except (TypeError, ValueError):
pass
try:
if status_code is not None and int(status_code) != 400:
return False
except (TypeError, ValueError):
pass
return any(marker in text for marker in _REJECTION_MARKERS)
def has_compaction_checkpoint(items: Any) -> bool:
"""Does this ``codex_reasoning_items`` sidecar carry a compaction checkpoint?
A ``type: "compaction"`` item is cumulative context, not per-turn
reasoning, and exists in exactly one place: anything that rewrites or
discards the sidecar must ask this first or lose the compacted history.
A compaction item is cumulative context that exists in exactly one place: anything
that rewrites or discards the sidecar must ask this first or lose the history.
"""
return any(
isinstance(item, dict) and item.get("type") == "compaction"
for item in (items if isinstance(items, list) else ())
)
return isinstance(items, list) and any(_is_compaction_item(item) for item in items)
def merge_interim_reasoning_items(
prior_items: Any,
new_items: Any,
) -> List[Dict[str, Any]]:
def merge_interim_reasoning_items(prior_items: Any, new_items: Any) -> List[Dict[str, Any]]:
"""Merge ``codex_reasoning_items`` across Codex incomplete-continuation dedup.
A checkpoint captured on the EARLIER response is not re-emitted by the
continuation, so a blind overwrite drops the only copy. Rule: newer items
win, but prior checkpoints are prepended unless the newer payload has its own.
A checkpoint on the EARLIER response is not re-emitted by the continuation, so a blind
overwrite drops the only copy: newer items win, prior checkpoints are prepended unless
the newer payload has its own.
"""
kept_checkpoints = [
item
for item in (prior_items if isinstance(prior_items, list) else [])
if isinstance(item, dict) and item.get("type") == "compaction"
item for item in (prior_items if isinstance(prior_items, list) else []) if _is_compaction_item(item)
]
new_list = list(new_items) if isinstance(new_items, list) else []
if has_compaction_checkpoint(new_list) or not kept_checkpoints:
+15 -56
View File
@@ -1,9 +1,9 @@
"""Cross-session rate limit guard for Nous Portal.
Writes rate limit state to a shared file so all sessions (CLI, gateway,
cron, auxiliary) can check whether Nous Portal is currently rate-limited
before making requests. Without it each 429 fans out into up to 9 calls per
turn (3 SDK retries x 3 Hermes retries), all counted against RPH.
Writes rate limit state to a shared file so all sessions (CLI, gateway, cron,
auxiliary) can check whether Nous Portal is currently rate-limited before making
requests. Without it each 429 fans out into up to 9 calls per turn (3 SDK
retries x 3 Hermes retries), all counted against RPH.
"""
from __future__ import annotations
@@ -12,17 +12,11 @@ import contextlib
import json
import logging
import os
import tempfile
import time
from typing import Any, Mapping, Optional
from utils import atomic_replace
from utils import atomic_write_text
from agent.rate_limit_tracker import (
_BUCKET_TAGS,
_fmt_seconds,
_safe_float,
_safe_int,
has_rate_limit_headers,
lower_headers,
_BUCKET_TAGS, _fmt_seconds, _safe_float, _safe_int, has_rate_limit_headers, lower_headers,
)
logger = logging.getLogger(__name__)
@@ -35,7 +29,7 @@ format_remaining = _fmt_seconds
def _state_path() -> str:
"""Return the path to the Nous rate limit state file."""
"""Path to the Nous rate limit state file."""
try:
from hermes_constants import get_hermes_home
base = get_hermes_home()
@@ -47,11 +41,7 @@ def _state_path() -> str:
def _parse_reset_seconds(headers: Optional[Mapping[str, str]]) -> Optional[float]:
"""Best reset estimate (seconds from now) from hourly, per-minute, then retry-after headers."""
lowered = lower_headers(headers)
for key in (
"x-ratelimit-reset-requests-1h",
"x-ratelimit-reset-requests",
"retry-after",
):
for key in ("x-ratelimit-reset-requests-1h", "x-ratelimit-reset-requests", "retry-after"):
val = _safe_float(lowered.get(key), 0.0)
if val > 0:
return val
@@ -59,9 +49,7 @@ def _parse_reset_seconds(headers: Optional[Mapping[str, str]]) -> Optional[float
def record_nous_rate_limit(
*,
headers: Optional[Mapping[str, str]] = None,
error_context: Optional[dict[str, Any]] = None,
*, headers: Optional[Mapping[str, str]] = None, error_context: Optional[dict[str, Any]] = None,
default_cooldown: float = 300.0,
) -> None:
"""Record that Nous Portal is rate-limited in the shared state file.
@@ -71,44 +59,20 @@ def record_nous_rate_limit(
"""
now = time.time()
reset_at = None
header_seconds = _parse_reset_seconds(headers)
if header_seconds is not None:
reset_at = now + header_seconds
if reset_at is None and isinstance(error_context, dict):
ctx_reset = error_context.get("reset_at")
if isinstance(ctx_reset, (int, float)) and ctx_reset > now:
reset_at = float(ctx_reset)
if reset_at is None:
reset_at = now + default_cooldown
path = _state_path()
state = {"reset_at": reset_at, "recorded_at": now, "reset_seconds": reset_at - now}
try:
state_dir = os.path.dirname(path)
os.makedirs(state_dir, exist_ok=True)
state = {
"reset_at": reset_at,
"recorded_at": now,
"reset_seconds": reset_at - now,
}
fd, tmp_path = tempfile.mkstemp(dir=state_dir, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(state, f)
atomic_replace(tmp_path, path)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
logger.info(
"Nous rate limit recorded: resets in %.0fs (at %.0f)",
reset_at - now, reset_at,
)
atomic_write_text(_state_path(), json.dumps(state))
logger.info("Nous rate limit recorded: resets in %.0fs (at %.0f)", reset_at - now, reset_at)
except Exception as exc:
logger.debug("Failed to write Nous rate limit state: %s", exc)
@@ -150,9 +114,7 @@ def _is_exhausted(remaining: Optional[int], reset: Optional[float]) -> bool:
def is_genuine_nous_rate_limit(
*,
headers: Optional[Mapping[str, str]] = None,
last_known_state: Optional[Any] = None,
*, headers: Optional[Mapping[str, str]] = None, last_known_state: Optional[Any] = None,
) -> bool:
"""Decide whether a 429 from Nous Portal is a real account rate limit.
@@ -170,11 +132,10 @@ def is_genuine_nous_rate_limit(
def _parse_buckets_from_headers(
headers: Optional[Mapping[str, str]],
) -> dict[str, tuple[Optional[int], Optional[float]]]:
"""Extract (remaining, reset_seconds) per bucket from x-ratelimit-* headers ({} if none)."""
"""(remaining, reset_seconds) per bucket from x-ratelimit-* headers ({} if none)."""
lowered = lower_headers(headers)
if not has_rate_limit_headers(lowered):
return {}
result: dict[str, tuple[Optional[int], Optional[float]]] = {}
for _attr, tag in _BUCKET_TAGS:
remaining = _safe_int(lowered.get(f"x-ratelimit-remaining-{tag}"), None)
@@ -184,9 +145,7 @@ def _parse_buckets_from_headers(
return result
def _has_exhausted_bucket(
buckets: Mapping[str, tuple[Optional[int], Optional[float]]],
) -> bool:
def _has_exhausted_bucket(buckets: Mapping[str, tuple[Optional[int], Optional[float]]]) -> bool:
return any(_is_exhausted(remaining, reset) for remaining, reset in buckets.values())
+63 -103
View File
@@ -1,9 +1,8 @@
"""Contextual first-touch onboarding hints.
Each hint is shown once per install the *first* time a user hits a behavior
fork (message-while-running, first long tool, ...), tracked in ``config.yaml``
under ``onboarding.seen.<flag>``. Kept tiny and dependency-free so both the CLI
and gateway can import it.
fork, tracked in ``config.yaml`` under ``onboarding.seen.<flag>``. Kept tiny and
dependency-free so both the CLI and gateway can import it.
"""
from __future__ import annotations
@@ -22,57 +21,47 @@ OPENCLAW_RESIDUE_FLAG = "openclaw_residue_cleanup"
PROFILE_BUILD_FLAG = "profile_build_offered"
# ── Hint content ──────────────────────────────────────────────────────────
# Busy-input hints are keyed by the effective busy_input_mode that was just
# applied so the message matches reality; "interrupt" is the default branch.
_BUSY_INPUT_HINTS_GATEWAY = {
"queue": (
"💡 First-time tip — I queued your message instead of interrupting. "
"Send `/busy interrupt` to make new messages stop the current task "
"immediately, or `/busy status` to check. This notice won't appear again."
"💡 First-time tip — I queued your message instead of interrupting. Send `/busy interrupt` to make new messages "
"stop the current task immediately, or `/busy status` to check. This notice won't appear again."
),
"steer": (
"💡 First-time tip — I steered your message into the current run; "
"it will arrive after the next tool call instead of interrupting. "
"Send `/busy interrupt` or `/busy queue` to change this, or "
"`/busy status` to check. This notice won't appear again."
"💡 First-time tip — I steered your message into the current run; it will arrive after the next tool "
"call instead of interrupting. Send `/busy interrupt` or `/busy queue` to change this, or `/busy "
"status` to check. This notice won't appear again."
),
"redirect": (
"💡 First-time tip — I redirected the current run using your message. "
"Completed work stays in context, and `/stop` still cancels the task. "
"Send `/busy queue` to wait for a separate turn, or `/busy status` "
"to check. This notice won't appear again."
"💡 First-time tip — I redirected the current run using your message. Completed work stays in "
"context, and `/stop` still cancels the task. Send `/busy queue` to wait for a separate turn, or "
"`/busy status` to check. This notice won't appear again."
),
}
_BUSY_INPUT_HINT_GATEWAY_DEFAULT = (
"💡 First-time tip — I just interrupted my current task to answer you. "
"Send `/busy queue` to queue follow-ups for after the current task instead, "
"`/busy steer` to inject them mid-run without interrupting, or "
"`/busy status` to check. This notice won't appear again."
"💡 First-time tip — I just interrupted my current task to answer you. Send `/busy queue` to queue "
"follow-ups for after the current task instead, `/busy steer` to inject them mid-run without "
"interrupting, or `/busy status` to check. This notice won't appear again."
)
_BUSY_INPUT_HINTS_CLI = {
"queue": (
"(tip) Your message was queued for the next turn. "
"Use /busy interrupt to make Enter stop the current run instead, "
"or /busy steer to inject mid-run. This tip only shows once."
"(tip) Your message was queued for the next turn. Use /busy interrupt to make Enter stop the current "
"run instead, or /busy steer to inject mid-run. This tip only shows once."
),
"steer": (
"(tip) Your message was steered into the current run; it arrives "
"after the next tool call. Use /busy interrupt or /busy queue to "
"change this. This tip only shows once."
"(tip) Your message was steered into the current run; it arrives after the next tool call. Use /busy "
"interrupt or /busy queue to change this. This tip only shows once."
),
"redirect": (
"(tip) Your correction redirected the current run without discarding "
"completed work. Use /stop to cancel or /busy queue to wait for a "
"separate turn. This tip only shows once."
"(tip) Your correction redirected the current run without discarding completed work. Use /stop to "
"cancel or /busy queue to wait for a separate turn. This tip only shows once."
),
}
_BUSY_INPUT_HINT_CLI_DEFAULT = (
"(tip) Your message interrupted the current run. "
"Use /busy queue to queue messages for the next turn instead, "
"or /busy steer to inject mid-run. This tip only shows once."
"(tip) Your message interrupted the current run. Use /busy queue to queue messages for the next turn "
"instead, or /busy steer to inject mid-run. This tip only shows once."
)
@@ -88,9 +77,8 @@ def busy_input_hint_cli(mode: str) -> str:
def tool_progress_hint_gateway() -> str:
return (
"💡 First-time tip — that tool took a while and I'm streaming every step. "
"If the progress messages feel noisy, send `/verbose` to cycle modes "
"(all → new → off). This notice won't appear again."
"💡 First-time tip — that tool took a while and I'm streaming every step. If the progress messages "
"feel noisy, send `/verbose` to cycle modes (all → new → off). This notice won't appear again."
)
@@ -105,81 +93,65 @@ def openclaw_residue_hint_cli() -> str:
"""Banner shown the first time Hermes finds ``~/.openclaw/``: migrate first, cleanup (which breaks OpenClaw) after."""
return (
"A legacy OpenClaw directory was detected at ~/.openclaw/.\n"
"To port your config, memory, and skills over to Hermes, run "
"`hermes claw migrate`.\n"
"If you've already migrated and want to archive the old directory, "
"run `hermes claw cleanup` (renames it to ~/.openclaw.pre-migration — "
"OpenClaw will stop working after this).\n"
"To port your config, memory, and skills over to Hermes, run `hermes claw migrate`.\n"
"If you've already migrated and want to archive the old directory, run `hermes claw cleanup` "
"(renames it to ~/.openclaw.pre-migration — OpenClaw will stop working after this).\n"
"This tip only shows once."
)
def detect_openclaw_residue(home: Optional[Path] = None) -> bool:
"""True if ``$HOME/.openclaw`` is a directory (pure check; ``home`` override for tests)."""
base = home or Path.home()
"""True if ``$HOME/.openclaw`` is a directory (``home`` override for tests)."""
try:
return (base / ".openclaw").is_dir()
return ((home or Path.home()) / ".openclaw").is_dir()
except OSError:
return False
# ── Onboarding profile-build path (opt-in, consent-gated) ─────────────────
def _onboarding_section(config: Mapping[str, Any]) -> Mapping[str, Any]:
onboarding = config.get("onboarding") if isinstance(config, Mapping) else None
return onboarding if isinstance(onboarding, Mapping) else {}
def profile_build_mode(config: Mapping[str, Any]) -> str:
"""``config.onboarding.profile_build``: ``"off"`` never offers; anything else -> ``"ask"`` (offer on first contact).
"""``config.onboarding.profile_build``: ``"off"`` never offers; anything else -> ``"ask"``.
This only governs whether the offer is made; lookups inside the flow are
Only governs whether the offer is made; lookups inside the flow are
consented to separately in conversation.
"""
onboarding = config.get("onboarding") if isinstance(config, Mapping) else None
if not isinstance(onboarding, Mapping):
return "ask"
mode = onboarding.get("profile_build")
if isinstance(mode, str) and mode.strip().lower() == "off":
return "off"
return "ask"
mode = _onboarding_section(config).get("profile_build")
return "off" if isinstance(mode, str) and mode.strip().lower() == "off" else "ask"
def profile_build_directive() -> str:
"""System-note directive appended to the very first message ever.
Runs a short opt-in profile-build flow persisting to the user-profile memory
store; phrased so the agent ASKS before any lookup and never silently reads
Short opt-in profile-build flow persisting to the user-profile memory store;
phrased so the agent ASKS before any lookup and never silently reads
connected accounts.
"""
return (
"\n\n[System note: This is the user's very first message ever. "
"After a one-sentence introduction (mention /help shows commands), "
"OFFER — do not assume — to build a short profile of them so you can "
"be more useful, and explain they can decline or do it later. If and "
"ONLY IF they accept:\n"
" 1. Ask for whatever they're comfortable sharing (name, what they "
"do, how they like you to work). Volunteered facts come first.\n"
" 2. Before ANY external lookup, say what you intend to look up and "
"get explicit consent for that step. Never read their connected "
"accounts (email, calendar, etc.) silently — ask each time.\n"
" 3. With consent, you may use web_search to confirm public details "
"(e.g. employer, public profiles) from the data points they gave.\n"
" 4. Save each confirmed, durable fact with the memory tool using "
"target=\"user\" — keep entries compact and high-signal.\n"
"If they decline at any point, stop immediately and continue normally. "
"Keep the whole exchange light and conversational, not an interrogation.]"
"\n\n"
"[System note: This is the user's very first message ever. After a one-sentence introduction (mention /help "
"shows commands), OFFER — do not assume — to build a short profile of them so you can be more useful, and "
"explain they can decline or do it later. If and ONLY IF they accept:\n"
" 1. Ask for whatever they're comfortable sharing (name, what they do, how they like you to work). "
"Volunteered facts come first.\n"
" 2. Before ANY external lookup, say what you intend to look up and get explicit consent for that step. Never "
"read their connected accounts (email, calendar, etc.) silently — ask each time.\n"
" 3. With consent, you may use web_search to confirm public details (e.g. employer, public profiles) from the "
"data points they gave.\n"
" 4. Save each confirmed, durable fact with the memory tool using target=\"user\" — keep entries compact and "
"high-signal.\n"
"If they decline at any point, stop immediately and continue normally. Keep the whole exchange light and "
"conversational, not an interrogation.]"
)
# ── State read / write ────────────────────────────────────────────────────
def _get_seen_dict(config: Mapping[str, Any]) -> Mapping[str, Any]:
onboarding = config.get("onboarding") if isinstance(config, Mapping) else None
if not isinstance(onboarding, Mapping):
return {}
seen = onboarding.get("seen")
return seen if isinstance(seen, Mapping) else {}
def is_seen(config: Mapping[str, Any], flag: str) -> bool:
"""Return True if the user has already been shown this first-touch hint."""
return bool(_get_seen_dict(config).get(flag))
"""True if the user has already been shown this first-touch hint."""
seen = _onboarding_section(config).get("seen")
return bool(seen.get(flag)) if isinstance(seen, Mapping) else False
def mark_seen(config_path: Path, flag: str) -> bool:
@@ -200,12 +172,10 @@ def mark_seen(config_path: Path, flag: str) -> bool:
cfg["onboarding"] = {}
seen = cfg["onboarding"].get("seen")
if not isinstance(seen, dict):
seen = {}
cfg["onboarding"]["seen"] = seen
if seen.get(flag) is True:
return True
seen[flag] = True
atomic_config_write(config_path, cfg)
seen = cfg["onboarding"]["seen"] = {}
if seen.get(flag) is not True:
seen[flag] = True
atomic_config_write(config_path, cfg)
return True
except Exception as e:
logger.debug("onboarding: failed to mark flag %s: %s", flag, e)
@@ -213,18 +183,8 @@ def mark_seen(config_path: Path, flag: str) -> bool:
__all__ = [
"BUSY_INPUT_FLAG",
"TOOL_PROGRESS_FLAG",
"OPENCLAW_RESIDUE_FLAG",
"PROFILE_BUILD_FLAG",
"busy_input_hint_gateway",
"busy_input_hint_cli",
"tool_progress_hint_gateway",
"tool_progress_hint_cli",
"openclaw_residue_hint_cli",
"detect_openclaw_residue",
"profile_build_mode",
"profile_build_directive",
"is_seen",
"mark_seen",
"BUSY_INPUT_FLAG", "TOOL_PROGRESS_FLAG", "OPENCLAW_RESIDUE_FLAG", "PROFILE_BUILD_FLAG",
"busy_input_hint_gateway", "busy_input_hint_cli", "tool_progress_hint_gateway", "tool_progress_hint_cli",
"openclaw_residue_hint_cli", "detect_openclaw_residue", "profile_build_mode", "profile_build_directive",
"is_seen", "mark_seen",
]
+22 -36
View File
@@ -1,12 +1,12 @@
"""Shared one-off LLM requests for non-conversational helpers.
A "one-shot" is a single stateless model call outside any conversation: it
never touches session history or prompt caching and returns plain text (commit
messages, rename suggestions, summaries). Call with explicit
``instructions``/``user_input`` or a registered ``template`` + ``variables`` so
prompt engineering stays consistent across CLI/TUI/desktop. Model selection
rides :func:`agent.auxiliary_client.call_llm`: ``main_runtime`` inherits the
live session's provider/model, else ``task`` resolves a cheap backend.
A "one-shot" is a single stateless model call outside any conversation (commit
messages, rename suggestions, summaries): it never touches session history or
prompt caching. Call with explicit ``instructions``/``user_input`` or a registered
``template`` + ``variables`` so prompt engineering stays consistent across
CLI/TUI/desktop. Model selection rides :func:`agent.auxiliary_client.call_llm`:
``main_runtime`` inherits the live session's provider/model, else ``task``
resolves a cheap backend.
"""
import logging
@@ -23,31 +23,26 @@ PromptTemplate = Callable[[Dict[str, Any]], Tuple[str, str]]
def _truncate(text: str, limit: int) -> str:
text = text or ""
if len(text) <= limit:
return text
return text[:limit].rstrip() + "\n…(truncated)"
return text if len(text) <= limit else text[:limit].rstrip() + "\n…(truncated)"
_COMMIT_INSTRUCTIONS = (
"You write git commit messages. Given a diff of staged changes, write ONE "
"concise Conventional Commits message describing what the change does and why.\n"
"You write git commit messages. Given a diff of staged changes, write ONE concise Conventional Commits "
"message describing what the change does and why.\n"
"Rules:\n"
"- Subject line: type(scope): summary — imperative mood, lower-case, no "
"trailing period, ≤ 72 characters. Types: feat, fix, refactor, perf, docs, "
"test, build, chore, style, ci.\n"
"- Subject line: type(scope): summary — imperative mood, lower-case, no trailing period, ≤ 72 "
"characters. Types: feat, fix, refactor, perf, docs, test, build, chore, style, ci.\n"
"- Omit the scope if it isn't obvious.\n"
"- Add a short body (wrapped at ~72 cols) ONLY when the change needs "
"explanation; skip it for small/obvious changes.\n"
"- Add a short body (wrapped at ~72 cols) ONLY when the change needs explanation; skip it for "
"small/obvious changes.\n"
"- Describe the actual change, never restate the diff line-by-line.\n"
"- Return ONLY the commit message text — no quotes, no markdown fences, no "
"preamble."
"- Return ONLY the commit message text — no quotes, no markdown fences, no preamble."
)
def _commit_message_template(variables: Dict[str, Any]) -> Tuple[str, str]:
diff = _truncate(str(variables.get("diff") or ""), 12000)
recent = _truncate(str(variables.get("recent_commits") or ""), 1500)
parts = []
if recent.strip():
parts.append(
@@ -55,19 +50,16 @@ def _commit_message_template(variables: Dict[str, Any]) -> Tuple[str, str]:
f"{recent}"
)
parts.append("Diff to describe:\n" + (diff or "(no textual diff available)"))
# "Regenerate" must yield something new even on greedy/server-pinned
# temperature models; a nonce isn't enough, so hand back the previous
# message and require a genuinely different one.
avoid = _truncate(str(variables.get("avoid") or "").strip(), 1000)
if avoid:
parts.append(
"You already proposed the message below and the user wants a "
"different one. Write a NEW message with different wording (and, if "
"reasonable, a different emphasis or scope framing) — do not repeat "
"You already proposed the message below and the user wants a different one. Write a NEW message with "
"different wording (and, if reasonable, a different emphasis or scope framing) — do not repeat "
f"it:\n{avoid}"
)
return _COMMIT_INSTRUCTIONS, "\n\n".join(parts)
@@ -104,15 +96,11 @@ def run_oneshot(
"""
if template:
instructions, user_input = render_template(template, variables)
if not (instructions or "").strip() and not (user_input or "").strip():
has_instructions = bool((instructions or "").strip())
if not has_instructions and not (user_input or "").strip():
raise ValueError("run_oneshot requires a template or instructions/user_input")
messages = []
if (instructions or "").strip():
messages.append({"role": "system", "content": instructions})
messages = [{"role": "system", "content": instructions}] if has_instructions else []
messages.append({"role": "user", "content": user_input or ""})
response = call_llm(
task=task,
messages=messages,
@@ -121,9 +109,7 @@ def run_oneshot(
timeout=timeout,
main_runtime=main_runtime,
)
text = (extract_content_or_reasoning(response) or "").strip()
return _strip_code_fence(text)
return _strip_code_fence((extract_content_or_reasoning(response) or "").strip())
def _strip_code_fence(text: str) -> str:
@@ -131,6 +117,6 @@ def _strip_code_fence(text: str) -> str:
if not text.startswith("```"):
return text
lines = text.splitlines()
if len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```":
if len(lines) >= 2 and lines[-1].strip() == "```":
return "\n".join(lines[1:-1]).strip()
return text
+55 -154
View File
@@ -1,31 +1,10 @@
"""Outbound webhook notifications.
Reads ``hooks.outbound:`` from config.yaml and registers notify-only callbacks on
the plugin hook manager, so every ``invoke_hook()`` site can push lifecycle events
to external HTTP endpoints. Outbound mirror of ``gateway/platforms/webhook.py``.
* Delivery is fire-and-forget through a bounded queue and one daemon worker
thread; callbacks serialize, enqueue, and return ``None`` immediately, so a
target can never block a tool call or influence agent flow.
* Payloads are HMAC-SHA256 signed (``X-Hermes-Signature-256: sha256=<hex>`` over
the raw body) when a secret is configured.
* No consent prompt (no code runs on this machine); ``HERMES_SAFE_MODE=1`` still
skips registration. Registration is idempotent.
Config::
hooks:
outbound:
- url: https://ci.example.com/hermes-events
events: [on_session_end, subagent_stop]
secret_env: HERMES_OUTBOUND_WEBHOOK_SECRET # or inline ``secret``
matcher: "terminal|delegate_task" # pre/post_tool_call only
timeout: 10 # seconds, clamped to [1, 60]
name: ci-notify
POST body: ``{hook_event_name, profile, tool_name, tool_input, session_id, cwd,
extra, delivery_id, timestamp}``. Headers: ``Content-Type``, ``User-Agent``,
``X-Hermes-Event``, ``X-Hermes-Delivery``, ``X-Hermes-Signature-256`` (if secret).
"""Outbound webhooks: ``hooks.outbound`` entries (url, events, secret_env|secret, matcher for
pre/post_tool_call, timeout clamped to [1, 60], name) -> notify-only callbacks on the plugin hook
manager, so every ``invoke_hook()`` site can POST lifecycle events (mirror of
``gateway/platforms/webhook.py``). Fire-and-forget through a bounded queue + one daemon worker,
so a target can never block a tool call or influence agent flow. HMAC-SHA256 signed
(``X-Hermes-Signature-256: sha256=<hex>`` over the raw body) when a secret is configured;
``HERMES_SAFE_MODE=1`` skips registration; registration is idempotent.
"""
from __future__ import annotations
@@ -68,9 +47,7 @@ QUEUE_MAX_SIZE = 256
_registered: Set[Tuple[str, str, str]] = set()
_registered_lock = threading.Lock()
_delivery_queue: "queue.Queue[Optional[Dict[str, Any]]]" = queue.Queue(
maxsize=QUEUE_MAX_SIZE
)
_delivery_queue: "queue.Queue[Optional[Dict[str, Any]]]" = queue.Queue(maxsize=QUEUE_MAX_SIZE)
_worker_lock = threading.Lock()
_worker: Optional[threading.Thread] = None
@@ -78,9 +55,7 @@ _worker: Optional[threading.Thread] = None
@dataclass
class WebhookTarget(_ToolMatcherMixin):
"""Parsed and validated representation of one ``hooks.outbound`` entry."""
_MATCHER_KIND = "outbound webhook"
url: str
events: List[str]
name: str = ""
@@ -97,29 +72,20 @@ class WebhookTarget(_ToolMatcherMixin):
# --- Public API -----------------------------------------------------------------
def register_from_config(cfg: Optional[Dict[str, Any]]) -> List[WebhookTarget]:
"""Register every configured outbound webhook on the plugin manager.
Malformed ``hooks.outbound`` means zero targets — never raises. Returns the
targets that ended up wired (deduplicated across repeat calls).
"""
"""Register every configured outbound webhook on the plugin manager. Malformed ``hooks.outbound``
means zero targets — never raises. Returns the targets that ended up wired (deduplicated)."""
if not isinstance(cfg, dict):
return []
from utils import env_var_enabled
if env_var_enabled("HERMES_SAFE_MODE"):
logger.info("HERMES_SAFE_MODE=1 — outbound webhook registration skipped")
return []
targets = iter_configured_targets(cfg)
if not targets:
return []
from hermes_cli.plugins import get_plugin_manager
manager = get_plugin_manager()
home_key = _home_key()
registered: List[WebhookTarget] = []
with _registered_lock:
for target in targets:
@@ -128,19 +94,15 @@ def register_from_config(cfg: Optional[Dict[str, Any]]) -> List[WebhookTarget]:
key = (home_key, event, target.url)
if key in _registered:
continue
manager._hooks.setdefault(event, []).append(
_make_callback(event, target)
)
manager._hooks.setdefault(event, []).append(_make_callback(event, target))
_registered.add(key)
wired_any = True
logger.info(
"outbound webhook registered: %s -> %s (matcher=%s, "
"timeout=%ds)",
"outbound webhook registered: %s -> %s (matcher=%s, timeout=%ds)",
event, target.label, target.matcher, target.timeout,
)
if wired_any:
registered.append(target)
return registered
@@ -149,9 +111,14 @@ def iter_configured_targets(cfg: Optional[Dict[str, Any]]) -> List[WebhookTarget
if not isinstance(cfg, dict):
return []
hooks_cfg = cfg.get("hooks")
return _parse_outbound_block(
hooks_cfg.get("outbound") if isinstance(hooks_cfg, dict) else None
)
raw = hooks_cfg.get("outbound") if isinstance(hooks_cfg, dict) else None
if raw is None:
return []
if not isinstance(raw, list):
logger.warning("hooks.outbound must be a list of webhook targets; got %s", type(raw).__name__)
return []
targets = (_parse_single_target(i, entry) for i, entry in enumerate(raw))
return [t for t in targets if t is not None]
def flush(timeout: float = 5.0) -> bool:
@@ -167,13 +134,10 @@ def flush(timeout: float = 5.0) -> bool:
def re_register_config_hooks() -> None:
"""Re-register outbound webhooks after a plugin force-reload cleared ``_hooks``.
Only the current home's idempotence keys are cleared so a force-reload in one
profile cannot invalidate another profile's still-live registration.
"""
"""Re-register outbound webhooks after a plugin force-reload cleared ``_hooks``. Only the
current home's idempotence keys are cleared so a force-reload in one profile cannot
invalidate another profile's still-live registration."""
from hermes_cli.config import load_config
_forget_home_registrations(_registered, _registered_lock)
register_from_config(load_config())
@@ -192,19 +156,6 @@ def reset_for_tests() -> None:
# --- Config parsing -------------------------------------------------------------
def _parse_outbound_block(raw: Any) -> List[WebhookTarget]:
if raw is None:
return []
if not isinstance(raw, list):
logger.warning(
"hooks.outbound must be a list of webhook targets; got %s",
type(raw).__name__,
)
return []
targets = (_parse_single_target(i, entry) for i, entry in enumerate(raw))
return [t for t in targets if t is not None]
def _parse_single_target(index: int, raw: Any) -> Optional[WebhookTarget]:
from hermes_cli.plugins import VALID_HOOKS
@@ -214,7 +165,6 @@ def _parse_single_target(index: int, raw: Any) -> Optional[WebhookTarget]:
if not isinstance(raw, dict):
warn(" must be a mapping with 'url' and 'events' keys; got %s", type(raw).__name__)
return None
url = raw.get("url")
if not isinstance(url, str) or not url.strip():
warn(" is missing a non-empty 'url'")
@@ -225,22 +175,18 @@ def _parse_single_target(index: int, raw: Any) -> Optional[WebhookTarget]:
return None
if url.lower().startswith("http://"):
warn(".url uses plain http:// — payloads (including tool inputs) travel unencrypted. Prefer https.")
events_raw = raw.get("events")
valid_list = ", ".join(sorted(VALID_HOOKS))
if not isinstance(events_raw, list) or not events_raw:
warn(" needs a non-empty 'events' list (valid: %s)", valid_list)
return None
events: List[str] = []
events: List[str] = [ev for ev in events_raw if ev in VALID_HOOKS]
for ev in events_raw:
if ev in VALID_HOOKS:
events.append(ev)
else:
if ev not in VALID_HOOKS:
warn(": unknown event %r ignored (valid: %s)", ev, valid_list)
if not events:
warn(" has no valid events — skipped")
return None
matcher = raw.get("matcher")
if matcher is not None and not isinstance(matcher, str):
warn(".matcher must be a string regex; ignoring")
@@ -248,23 +194,16 @@ def _parse_single_target(index: int, raw: Any) -> Optional[WebhookTarget]:
if matcher is not None and not any(e in _TOOL_SCOPED_EVENTS for e in events):
warn(".matcher=%r will be ignored — matcher is only honored for pre_tool_call / post_tool_call.", matcher)
matcher = None
timeout_raw = raw.get("timeout", DEFAULT_TIMEOUT_SECONDS)
try:
timeout = int(timeout_raw)
except (TypeError, ValueError):
warn(".timeout must be an int (got %r); using default %ds", timeout_raw, DEFAULT_TIMEOUT_SECONDS)
timeout = DEFAULT_TIMEOUT_SECONDS
timeout = max(1, min(timeout, MAX_TIMEOUT_SECONDS))
name = raw.get("name")
return WebhookTarget(
url=url,
events=events,
name=name.strip() if isinstance(name, str) else "",
secret=_resolve_secret(index, raw),
matcher=matcher,
timeout=timeout,
url=url, events=events, name=name.strip() if isinstance(name, str) else "", secret=_resolve_secret(index, raw),
matcher=matcher, timeout=max(1, min(timeout, MAX_TIMEOUT_SECONDS)),
)
@@ -276,8 +215,8 @@ def _resolve_secret(index: int, raw: Dict[str, Any]) -> Optional[str]:
if value:
return value
logger.warning(
"hooks.outbound[%d].secret_env=%r is not set in the environment "
"— deliveries will be UNSIGNED", index, secret_env.strip(),
"hooks.outbound[%d].secret_env=%r is not set in the environment — deliveries will be UNSIGNED",
index, secret_env.strip(),
)
return None
secret = raw.get("secret")
@@ -297,8 +236,7 @@ def _make_callback(event: str, target: WebhookTarget):
body = _serialize_payload(event, kwargs, delivery_id)
except Exception: # a bad payload must not hurt the loop
logger.warning(
"outbound webhook payload serialization failed (event=%s "
"target=%s)", event, target.label, exc_info=True,
"outbound webhook payload serialization failed (event=%s target=%s)", event, target.label, exc_info=True,
)
return None
_enqueue(_build_delivery(event, target, body, delivery_id))
@@ -309,48 +247,30 @@ def _make_callback(event: str, target: WebhookTarget):
return _callback
def _serialize_payload(
event: str, kwargs: Dict[str, Any], delivery_id: str,
) -> bytes:
"""Render the POST body: shell-hooks stdin shape plus delivery metadata.
``delivery_id`` (also the ``X-Hermes-Delivery`` header) and ``timestamp`` live
inside the HMAC-signed body, so they double as replay protection.
"""
def _serialize_payload(event: str, kwargs: Dict[str, Any], delivery_id: str) -> bytes:
"""Render the POST body: shell-hooks stdin shape plus delivery metadata. ``delivery_id``
(also the ``X-Hermes-Delivery`` header) and ``timestamp`` live inside the HMAC-signed
body, so they double as replay protection."""
# Profile resolved at fire time so a multiplexed gateway's receivers can tell which profile emitted.
from hermes_cli.profiles import get_active_profile_name
payload = {
"hook_event_name": event,
"profile": get_active_profile_name(),
**_payload_fields(kwargs),
"delivery_id": delivery_id,
"timestamp": _utc_now_iso(),
"hook_event_name": event, "profile": get_active_profile_name(), **_payload_fields(kwargs),
"delivery_id": delivery_id, "timestamp": _utc_now_iso(),
}
return json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
def _build_delivery(
event: str, target: WebhookTarget, body: bytes, delivery_id: str,
) -> Dict[str, Any]:
def _build_delivery(event: str, target: WebhookTarget, body: bytes, delivery_id: str) -> Dict[str, Any]:
headers = {
"Content-Type": "application/json",
"User-Agent": "Hermes-Agent-Outbound-Webhook",
"X-Hermes-Event": event,
"X-Hermes-Delivery": delivery_id,
"Content-Type": "application/json", "User-Agent": "Hermes-Agent-Outbound-Webhook",
"X-Hermes-Event": event, "X-Hermes-Delivery": delivery_id,
}
if target.secret:
digest = hmac.new(
target.secret.encode("utf-8"), body, hashlib.sha256
).hexdigest()
digest = hmac.new(target.secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
headers["X-Hermes-Signature-256"] = f"sha256={digest}"
return {
"url": target.url,
"label": target.label,
"event": event,
"body": body,
"headers": headers,
"timeout": target.timeout,
"url": target.url, "label": target.label, "event": event,
"body": body, "headers": headers, "timeout": target.timeout,
}
@@ -360,8 +280,8 @@ def _enqueue(delivery: Dict[str, Any]) -> None:
_delivery_queue.put_nowait(delivery)
except queue.Full:
logger.warning(
"outbound webhook queue full (%d pending) — dropping %s event "
"for %s", QUEUE_MAX_SIZE, delivery["event"], delivery["label"],
"outbound webhook queue full (%d pending) — dropping %s event for %s",
QUEUE_MAX_SIZE, delivery["event"], delivery["label"],
)
@@ -372,13 +292,10 @@ def _ensure_worker() -> None:
with _worker_lock:
if _worker is not None and _worker.is_alive():
return
_worker = threading.Thread(
target=_worker_loop, name="outbound-webhooks", daemon=True,
)
_worker = threading.Thread(target=_worker_loop, name="outbound-webhooks", daemon=True)
_worker.start()
# Daemon worker: a short-lived process could exit right after enqueuing
# on_session_end. Drain at interpreter shutdown, bounded so a dead
# endpoint can only delay exit, never hang it.
# Daemon worker: a short-lived process could exit right after enqueuing on_session_end.
# Drain at interpreter shutdown, bounded so a dead endpoint can only delay exit, never hang it.
atexit.register(flush, timeout=5.0)
@@ -411,49 +328,33 @@ _opener = urlrequest.build_opener(_NoRedirectHandler)
def _deliver(delivery: Dict[str, Any]) -> None:
"""POST with bounded retries: retry on connection errors and 5xx; 4xx and 3xx are final."""
event, label = delivery["event"], delivery["label"]
last_error = ""
for attempt in range(1, MAX_DELIVERY_ATTEMPTS + 1):
req = urlrequest.Request(
delivery["url"],
data=delivery["body"],
headers=delivery["headers"],
method="POST",
)
req = urlrequest.Request(delivery["url"], data=delivery["body"], headers=delivery["headers"], method="POST")
try:
with _opener.open(req, timeout=delivery["timeout"]) as resp:
status = getattr(resp, "status", 200)
if 200 <= status < 300:
logger.debug(
"outbound webhook delivered: %s -> %s (HTTP %d)",
delivery["event"], delivery["label"], status,
)
logger.debug("outbound webhook delivered: %s -> %s (HTTP %d)", event, label, status)
return
last_error = f"HTTP {status}"
except urlerror.HTTPError as exc:
last_error = f"HTTP {exc.code}"
if 300 <= exc.code < 400:
logger.warning(
"outbound webhook target redirected (event=%s target=%s): "
"%s -> %s — redirects are not followed; update the "
"configured url", delivery["event"], delivery["label"],
last_error, exc.headers.get("Location", "?"),
"outbound webhook target redirected (event=%s target=%s): %s -> %s — redirects are not "
"followed; update the configured url", event, label, last_error, exc.headers.get("Location", "?"),
)
return
if 400 <= exc.code < 500:
logger.warning(
"outbound webhook rejected (event=%s target=%s): %s — "
"not retrying", delivery["event"], delivery["label"],
last_error,
)
logger.warning("outbound webhook rejected (event=%s target=%s): %s — not retrying", event, label, last_error)
return
except Exception as exc:
last_error = str(exc) or type(exc).__name__
if attempt < MAX_DELIVERY_ATTEMPTS:
time.sleep(RETRY_BACKOFF_SECONDS * attempt)
logger.warning(
"outbound webhook delivery failed after %d attempt(s) (event=%s "
"target=%s): %s",
MAX_DELIVERY_ATTEMPTS, delivery["event"], delivery["label"], last_error,
"outbound webhook delivery failed after %d attempt(s) (event=%s target=%s): %s",
MAX_DELIVERY_ATTEMPTS, event, label, last_error,
)
+10 -44
View File
@@ -1,51 +1,17 @@
"""Petdex pet engine — shared core for the CLI, TUI, and desktop surfaces.
Petdex (https://github.com/crafter-station/petdex) is a public gallery of
animated sprite "pets" for coding agents. Each pet is a ``pet.json`` plus a
``spritesheet.{webp,png}`` of 192×208 px cells. Current Codex/petdex sheets use
an 8-column × 9-row atlas; older Hermes/petdex sheets used an 8-row atlas.
Hermes infers the row taxonomy from the sheet and maps agent activity onto
idle/run/review/failed/wave/jump.
This package is the **single source of truth** for the feature so the base
CLI (Python) and TUI (Ink, via ``tui_gateway``) never duplicate the hard
parts:
- :mod:`agent.pet.constants` — frame geometry + the :class:`PetState` enum.
- :mod:`agent.pet.state` — map agent activity → a :class:`PetState`.
- :mod:`agent.pet.manifest` — fetch the public petdex manifest.
- :mod:`agent.pet.store` — install / list / resolve pets on disk
(profile-aware via ``get_hermes_home()``).
- :mod:`agent.pet.render` — decode a spritesheet and encode frames for a
terminal (kitty / iTerm2 / sixel graphics
protocols, with a Unicode half-block
fallback).
Rendering in the Electron desktop is necessarily TypeScript (canvas), but it
reuses the same on-disk store and the same state semantics.
The whole feature is a *display* concern: it adds no model tool, mutates no
system prompt or toolset, and therefore has zero effect on prompt caching.
animated sprite "pets": a ``pet.json`` plus a ``spritesheet.{webp,png}`` of
192×208 px cells (8-col × 9-row Codex atlas, or the older 8-row atlas). This
package is the single source of truth for the feature so the CLI (Python) and
TUI (Ink, via ``tui_gateway``) never duplicate the hard parts: ``constants``
(geometry + :class:`PetState`), ``state`` (activity → state), ``manifest``,
``store`` (on-disk pets), ``render`` (kitty / iTerm2 / sixel / half-blocks).
A pure display concern: no model tool, no prompt/toolset mutation, so zero
effect on prompt caching.
"""
from agent.pet.constants import (
DEFAULT_SCALE,
FRAME_H,
FRAME_W,
FRAMES_PER_STATE,
LOOP_MS,
STATE_ROWS,
PetState,
)
from agent.pet.constants import DEFAULT_SCALE, FRAME_H, FRAME_W, FRAMES_PER_STATE, LOOP_MS, STATE_ROWS, PetState
from agent.pet.state import derive_pet_state
__all__ = [
"DEFAULT_SCALE",
"FRAME_H",
"FRAME_W",
"FRAMES_PER_STATE",
"LOOP_MS",
"STATE_ROWS",
"PetState",
"derive_pet_state",
]
__all__ = ["DEFAULT_SCALE", "FRAME_H", "FRAME_W", "FRAMES_PER_STATE", "LOOP_MS", "STATE_ROWS", "PetState", "derive_pet_state"]
+31 -93
View File
@@ -1,43 +1,30 @@
"""Pet sprite geometry + animation-state taxonomy.
These values are the common petdex/Codex pet geometry. The real ``pet.json``
usually only carries ``id``/``displayName``/``description``/``spritesheetPath``;
row taxonomy is inferred from the atlas shape so Hermes can render both legacy
8-row sheets and current 9-row Codex sheets.
Common petdex/Codex pet geometry. ``pet.json`` usually only carries
``id``/``displayName``/``description``/``spritesheetPath``; row taxonomy is
inferred from the atlas shape so Hermes renders both legacy 8-row sheets and
current 9-row Codex sheets.
"""
from __future__ import annotations
from enum import Enum
# Frame geometry (pixels). Current Codex/petdex spritesheets are 8 columns x 9
# rows (1536x1872), while older Hermes/petdex sheets used 9 columns x 8 rows
# (1728x1664). Renderers derive both row taxonomy and real column count from the
# concrete sheet, so either shape works.
# Frame geometry (pixels). Codex/petdex sheets are 8 cols x 9 rows (1536x1872);
# older sheets 9 x 8 (1728x1664). Renderers derive taxonomy/columns from the sheet.
FRAME_W = 192
FRAME_H = 208
# Frames consumed per animation state (the petdex web app uses CSS
# ``steps(6)``). A sheet may physically contain more columns; we only step
# through the first ``FRAMES_PER_STATE``.
# Frames stepped per state (petdex CSS ``steps(6)``); extra physical columns are ignored.
FRAMES_PER_STATE = 6
LOOP_MS = 1100 # full-loop duration for one state, ms (petdex default)
# Full-loop duration for one state, milliseconds (petdex default).
LOOP_MS = 1100
# Default on-screen scale relative to native frame size. ``display.pet.scale``
# is the single master scalar: the desktop canvas multiplies its native pixels
# by it and every terminal surface derives its half-block/kitty column width
# from it (see :func:`cols_for_scale`), so one number shrinks all three
# interfaces together. (petdex's own clients render at 0.7; we default smaller
# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block
# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its
# legibility floor instead.)
# ``display.pet.scale`` is the single master scalar: the desktop canvas multiplies
# native pixels by it and every terminal surface derives its column width from it
# (:func:`cols_for_scale`). petdex clients render at 0.7; we default smaller so the
# mascot stays a glanceable corner sprite (half-blocks clamp to ``UNICODE_MIN_COLS``).
DEFAULT_SCALE = 0.33
# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the
# pet clickable/visible; ceiling stops a fat-fingered value from filling the
# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``.
# User-settable bounds (``/pet scale``, desktop slider): floor keeps the pet
# clickable/visible; ceiling stops a fat-fingered value from filling the screen.
MIN_SCALE = 0.1
MAX_SCALE = 3.0
@@ -46,27 +33,18 @@ def clamp_scale(scale: float) -> float:
"""Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point)."""
return max(MIN_SCALE, min(MAX_SCALE, scale))
# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px
# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty
# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees.
BASE_UNICODE_COLS = FRAME_W // 8
# Legibility floor for the half-block fallback. A half-block cell samples the
# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208
# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw
# true pixels and have no such floor — that's why the same ``scale: 0.33`` is
# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down
# TO this floor (and grows it above), instead of past it into noise.
# Cells one native frame spans at ``scale == 1.0`` (~8px cells → 24); mirrors the
# kitty placement (``scaled_px // 8``) so at full scale every renderer agrees.
BASE_UNICODE_COLS = FRAME_W // 8
# Legibility floor for half-blocks: a cell samples 1 horizontal + 2 vertical taps,
# so below this width the pet is an unreadable blob regardless of scale (kitty/GUI
# draw true pixels, no floor). ``scale`` shrinks the unicode pet TO this floor, not past it.
UNICODE_MIN_COLS = 16
def cols_for_scale(scale: float) -> int:
"""Half-block width implied by *scale*, clamped to the legibility floor.
Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two
renderers converge at larger sizes; below it the floor keeps the sprite
readable rather than letting it devolve into a blob.
"""
"""Half-block width implied by *scale*, clamped to the legibility floor (tracks the kitty cell box above it)."""
return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE)))
@@ -76,12 +54,7 @@ def resolve_cols(scale: float, unicode_cols: int = 0) -> int:
class PetState(str, Enum):
"""Animation state a pet can be shown in.
These are Hermes' activity state names. They are not always identical to the
source atlas row names: Codex-format pets use rows like ``jumping`` /
``running`` while the UI keeps the shorter ``jump`` / ``run`` names.
"""
"""Animation state a pet can be shown in (Hermes names; Codex rows say ``jumping``/``running`` for ``jump``/``run``)."""
IDLE = "idle"
WAVE = "wave"
@@ -92,48 +65,18 @@ class PetState(str, Enum):
WAITING = "waiting"
# Legacy Hermes/petdex row order (top -> bottom) used by the older 8-row,
# 9-column atlas shape.
LEGACY_STATE_ROWS: list[str] = [
PetState.IDLE.value,
PetState.WAVE.value,
PetState.RUN.value,
PetState.FAILED.value,
PetState.REVIEW.value,
PetState.JUMP.value,
"extra1",
"extra2",
]
# Legacy Hermes/petdex row order (top -> bottom) for the older 8-row, 9-column atlas.
LEGACY_STATE_ROWS: list[str] = ["idle", "wave", "run", "failed", "review", "jump", "extra1", "extra2"]
# Current Petdex row order (top -> bottom) used by 1536x1872 atlases:
# 8 columns x 9 rows of 192x208 cells.
CODEX_STATE_ROWS: list[str] = [
PetState.IDLE.value,
"running-right",
"running-left",
"waving",
"jumping",
PetState.FAILED.value,
PetState.WAITING.value,
"running",
PetState.REVIEW.value,
]
# Current Petdex row order (top -> bottom) for 1536x1872 atlases (8 cols x 9 rows).
CODEX_STATE_ROWS: list[str] = ["idle", "running-right", "running-left", "waving", "jumping", "failed", "waiting", "running", "review"]
# Default/fallback for callers without a sheet. Prefer the current 9-row Codex
# format because generated pets and the public Codex pet contract use it.
# Default for callers without a sheet: generated pets and the Codex contract use 9 rows.
STATE_ROWS: list[str] = CODEX_STATE_ROWS
# Canonical Hermes activity names -> accepted row-name aliases in descending
# preference. This keeps our internal state names stable (`wave`/`jump`/`run`)
# while matching Petdex's current `waving`/`jumping`/`running` taxonomy.
# Canonical Hermes names -> accepted row-name aliases in descending preference.
_CODEX_NAMES = {"wave": "waving", "jump": "jumping", "run": "running"}
STATE_ALIASES: dict[str, tuple[str, ...]] = {
PetState.IDLE.value: (PetState.IDLE.value,),
PetState.WAVE.value: (PetState.WAVE.value, "waving"),
PetState.JUMP.value: (PetState.JUMP.value, "jumping"),
PetState.RUN.value: (PetState.RUN.value, "running"),
PetState.FAILED.value: (PetState.FAILED.value,),
PetState.REVIEW.value: (PetState.REVIEW.value,),
PetState.WAITING.value: (PetState.WAITING.value,),
s: (s, _CODEX_NAMES[s]) if s in _CODEX_NAMES else (s,) for s in ("idle", "wave", "jump", "run", "failed", "review", "waiting")
}
@@ -155,9 +98,4 @@ def state_rows_for_grid(row_count: int | None) -> list[str]:
def state_row_index(state: "PetState | str", row_count: int | None = None) -> int:
"""Return the spritesheet row index for *state* (clamped, never raises)."""
rows = state_rows_for_grid(row_count)
for name in state_aliases_for(state):
try:
return rows.index(name)
except ValueError:
continue
return 0 # fall back to the idle row
return next((rows.index(name) for name in state_aliases_for(state) if name in rows), 0) # 0 = idle row
+6 -21
View File
@@ -1,29 +1,14 @@
"""Pet generation — base-draft → hatch pipeline.
Public surface used by the gateway RPCs, the CLI ``hermes pets generate``
command, and tests:
- :func:`generate_base_drafts` / :func:`hatch_pet` — the two-step flow.
- :class:`HatchResult`, :class:`GenerationError`.
- :mod:`atlas` — deterministic frame extraction + atlas composition/validation.
Image generation is delegated to the active reference-capable
:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea);
atlas assembly is fully deterministic so it's testable without any API calls.
Public surface for the gateway RPCs, ``hermes pets generate``, and tests:
:func:`generate_base_drafts` / :func:`hatch_pet`, :class:`HatchResult`,
:class:`GenerationError`, and :mod:`atlas` (deterministic frame extraction +
atlas composition/validation, testable without any API calls).
"""
from __future__ import annotations
from agent.pet.generate.imagegen import GenerationError
from agent.pet.generate.orchestrate import (
HatchResult,
generate_base_drafts,
hatch_pet,
)
from agent.pet.generate.orchestrate import HatchResult, generate_base_drafts, hatch_pet
__all__ = [
"GenerationError",
"HatchResult",
"generate_base_drafts",
"hatch_pet",
]
__all__ = ["GenerationError", "HatchResult", "generate_base_drafts", "hatch_pet"]
+183 -431
View File
@@ -1,22 +1,22 @@
"""Deterministic spritesheet assembly — generated row strips → Hermes atlas.
Image models draw a row of poses well but can't do exact grid geometry, so the
model never owns the layout: it emits one loose horizontal strip per state and
these ops slice it into centered transparent ``192x208`` cells packed into the
petdex/Codex atlas (8 columns x 9 rows, ``1536x1872``) that
:mod:`agent.pet.render` reads via :data:`agent.pet.constants.CODEX_STATE_ROWS`.
``running`` is the in-place *working* state; ``running-right``/``-left`` are the
directional walk cycles. Segmentation/fit/residue logic is adapted from
OpenAI's ``hatch-pet`` skill (openai/skills, Apache-2.0).
model never owns the layout: it emits one loose strip per state and these ops
slice it into centered transparent 192x208 cells packed into the petdex/Codex
atlas (8 cols x 9 rows, 1536x1872; see ``constants.CODEX_STATE_ROWS``).
Segmentation/fit/residue logic adapted from OpenAI's ``hatch-pet`` skill (Apache-2.0).
"""
from __future__ import annotations
import logging
import math
from collections import deque
from collections import Counter, deque
from itertools import groupby
from pathlib import Path
from PIL import Image, ImageChops, ImageFilter
from agent.pet.constants import FRAME_H, FRAME_W
logger = logging.getLogger(__name__)
@@ -27,32 +27,21 @@ CELL_HEIGHT = FRAME_H
# (state, row index, frame count). Order/row indices MUST match
# ``constants.CODEX_STATE_ROWS``; frame counts mirror the petdex ``hatch-pet``
# spec. Rows shorter than 8 leave their tail transparent (renderer trims it).
# ``running`` is the in-place *working* state; ``running-right``/``-left`` walk.
ROW_SPECS: list[tuple[str, int, int]] = [
("idle", 0, 6),
("running-right", 1, 8),
("running-left", 2, 8),
("waving", 3, 4),
("jumping", 4, 5),
("failed", 5, 8),
("waiting", 6, 6),
("running", 7, 6),
("review", 8, 6),
("idle", 0, 6), ("running-right", 1, 8), ("running-left", 2, 8), ("waving", 3, 4), ("jumping", 4, 5),
("failed", 5, 8), ("waiting", 6, 6), ("running", 7, 6), ("review", 8, 6),
]
ROWS = len(ROW_SPECS)
COLUMNS = max(count for _, _, count in ROW_SPECS)
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
ATLAS_WIDTH = max(count for _, _, count in ROW_SPECS) * CELL_WIDTH
ATLAS_HEIGHT = len(ROW_SPECS) * CELL_HEIGHT
_ALPHA_FLOOR = 16 # alpha at/below which a pixel is "background"
_CELL_PAD = 10 # padding kept around a fitted sprite
# Small margin for the normalized pass so cells fill like real petdex pets
# (~5px from the edges); the width clamp, not the pad, prevents clipping.
_NORMALIZE_PAD = 14
# Side-lobe cutoff: adjacent-pose bleed shows as a small separated lobe; keep
# sizeable lobes so a legitimate wide pose isn't punished.
_SIDE_LOBE_RATIO = 0.18
_NORMALIZE_PAD = 14 # normalized cells fill like real petdex pets (~5px from the edges)
_SIDE_LOBE_RATIO = 0.18 # adjacent-pose bleed is a small lobe; sizeable lobes (wide poses) survive
_NEIGHBOURS = ((1, 0), (-1, 0), (0, 1), (0, -1))
_NEAREST = Image.Resampling.NEAREST # interpolating resamples blur hard pixel-art edges
def _median(values) -> int:
@@ -61,8 +50,6 @@ def _median(values) -> int:
def _blank(size=(CELL_WIDTH, CELL_HEIGHT)):
from PIL import Image
return Image.new("RGBA", size, (0, 0, 0, 0))
@@ -80,24 +67,12 @@ def _clear_region(image, box: tuple[int, int, int, int]) -> None:
def _load_rgba(image):
"""Open a path (or take an image) as RGBA."""
from PIL import Image
if isinstance(image, (str, Path)):
with Image.open(image) as opened:
return opened.convert("RGBA")
return image.convert("RGBA")
def _border_coords(w: int, h: int):
"""Every border pixel: top/bottom rows column-wise, then left/right columns."""
for x in range(w):
for y in (0, h - 1):
yield x, y
for y in range(h):
for x in (0, w - 1):
yield x, y
def _flood(w: int, h: int, visited: bytearray, seeds, accept) -> list[tuple[int, int]]:
"""4-connected BFS from *seeds* over pixels passing *accept*; returns the visited pixels."""
queue = deque(seeds)
@@ -107,19 +82,18 @@ def _flood(w: int, h: int, visited: bytearray, seeds, accept) -> list[tuple[int,
pixels.append((x, y))
for dx, dy in _NEIGHBOURS:
nx, ny = x + dx, y + dy
if 0 <= nx < w and 0 <= ny < h:
idx = ny * w + nx
if not visited[idx]:
visited[idx] = 1
if accept(nx, ny):
queue.append((nx, ny))
if 0 <= nx < w and 0 <= ny < h and not visited[ny * w + nx]:
visited[ny * w + nx] = 1
if accept(nx, ny):
queue.append((nx, ny))
return pixels
def _border_flood(w: int, h: int, visited: bytearray, accept) -> list[tuple[int, int]]:
"""Flood from every border pixel passing *accept* (edge-connected region only)."""
border = [(x, y) for x in range(w) for y in (0, h - 1)] + [(x, y) for y in range(h) for x in (0, w - 1)]
seeds: list[tuple[int, int]] = []
for x, y in _border_coords(w, h):
for x, y in border: # corners appear twice in ``border``; the visited mark dedupes them
if not visited[y * w + x] and accept(x, y):
visited[y * w + x] = 1
seeds.append((x, y))
@@ -137,28 +111,18 @@ def _unvisited_components(w: int, h: int, visited: bytearray, accept):
yield _flood(w, h, visited, [(x, y)], accept)
# ───────────────────────── background removal ─────────────────────────
def _has_transparency(image) -> bool:
"""True if the strip already carries a real alpha background."""
if image.getchannel("A").getextrema()[0] > _ALPHA_FLOOR:
return False
transparent = sum(image.getchannel("A").histogram()[: _ALPHA_FLOOR + 1])
return transparent > image.width * image.height * 0.05
"""True if the strip already carries a real alpha background (>5% of pixels at/below the floor)."""
alpha = image.getchannel("A")
return alpha.getextrema()[0] <= _ALPHA_FLOOR and sum(alpha.histogram()[: _ALPHA_FLOOR + 1]) > image.width * image.height * 0.05
def _dominant_corner_color(image) -> tuple[int, int, int]:
"""Most common opaque color among the four corners."""
from collections import Counter
w, h = image.width, image.height
px = image.load()
counter: Counter = Counter()
for x, y in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)):
r, g, b, a = px[x, y]
if a > _ALPHA_FLOOR:
counter[(r, g, b)] += 1
corners = [px[x, y] for x, y in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1))]
counter = Counter(tuple(c[:3]) for c in corners if c[3] > _ALPHA_FLOOR)
return counter.most_common(1)[0][0] if counter else (0, 255, 0)
@@ -168,17 +132,8 @@ def _near_key_mask(image, key: tuple[int, int, int], tol: int = 48):
Tight on purpose: marks only near-pure backdrop so trapped chroma pockets
seed the flood while chroma-tinted character pixels stay outside it.
"""
from PIL import ImageChops
r, g, b, _a = image.split()
kr, kg, kb = key
return ImageChops.darker(
ImageChops.darker(
r.point(lambda v: 255 if abs(v - kr) <= tol else 0),
g.point(lambda v: 255 if abs(v - kg) <= tol else 0),
),
b.point(lambda v: 255 if abs(v - kb) <= tol else 0),
)
r, g, b = (ch.point(lambda v, k=k: 255 if abs(v - k) <= tol else 0) for ch, k in zip(image.split()[:3], key))
return ImageChops.darker(ImageChops.darker(r, g), b)
def _remove_masked(rgba, mask):
@@ -187,8 +142,6 @@ def _remove_masked(rgba, mask):
The erosion drops the antialiased key/sprite blend ring (too far from the key to
match); the sprite's own thick outline keeps the silhouette intact.
"""
from PIL import Image, ImageFilter
out = Image.composite(_blank(rgba.size), rgba, mask)
out.putalpha(out.getchannel("A").filter(ImageFilter.MinFilter(3)))
return out
@@ -198,33 +151,29 @@ def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None,
"""Return *image* (RGBA) with its flat background keyed out to transparent.
Already-transparent strips are left alone (holes repaired). Otherwise key out
*chroma_key* (or the dominant corner color) via a border flood-fill: a global
*chroma_key* (or the dominant corner color) from the border inward: a global
color match punched holes wherever an interior highlight matched the backdrop.
"""
from PIL import Image, ImageChops
rgba = image.convert("RGBA")
if _has_transparency(rgba):
return _repair_internal_alpha_holes(rgba)
key = chroma_key or _dominant_corner_color(rgba)
w, h = rgba.width, rgba.height
px = rgba.load()
def _is_bg(x: int, y: int) -> bool:
r, g, b, a = px[x, y]
return a > _ALPHA_FLOOR and math.sqrt((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) <= threshold
# Fast path for saturated chroma keys (our prompts use hot magenta): C-level
# channel ops clear border backdrop and enclosed pockets alike, no Python flood.
if max(key) - min(key) >= 120:
opaque = rgba.getchannel("A").point(lambda a: 255 if a > _ALPHA_FLOOR else 0)
return _remove_masked(rgba, ImageChops.darker(_near_key_mask(rgba, key), opaque))
w, h = rgba.size
px = rgba.load()
def _is_bg(x: int, y: int) -> bool:
r, g, b, a = px[x, y]
return a > _ALPHA_FLOOR and math.sqrt((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) <= threshold
# Border-only flood on purpose: a desaturated near-white/gray key must never
# seed from the character's interior (that is the hole-punching case).
# Mark removals in a flat mask and composite once in C — per-pixel writes
# were ~3M PixelAccess calls and stalled the gateway.
# seed from the character's interior (that is the hole-punching case). Mark
# removals in a flat mask and composite once in C — per-pixel writes stalled the gateway.
remove = bytearray(w * h)
for x, y in _border_flood(w, h, bytearray(w * h), _is_bg):
remove[y * w + x] = 1
@@ -235,8 +184,7 @@ def _repair_internal_alpha_holes(image):
"""Fill transparent islands fully enclosed by opaque sprite pixels.
Some providers return "transparent" PNGs with swiss-cheese alpha inside the
character. Edge-connected transparent components stay background; enclosed
ones are filled with the average color of their opaque neighbours.
character; enclosed holes get the average color of their opaque neighbours.
"""
rgba = image.convert("RGBA")
w, h = rgba.size
@@ -247,108 +195,68 @@ def _repair_internal_alpha_holes(image):
return px[x, y][3] <= _ALPHA_FLOOR
_border_flood(w, h, visited, _is_transparent) # edge-connected transparency = background
def _fill_color(hole: list[tuple[int, int]]) -> tuple[int, int, int, int]:
samples: list[tuple[int, int, int]] = []
seen = set(hole)
for x, y in hole:
for dx, dy in _NEIGHBOURS:
nx, ny = x + dx, y + dy
if 0 <= nx < w and 0 <= ny < h and (nx, ny) not in seen:
r, g, b, a = px[nx, ny]
if a > _ALPHA_FLOOR:
samples.append((r, g, b))
if not samples:
return (0, 0, 0, 255)
r, g, b = (round(sum(c[i] for c in samples) / len(samples)) for i in range(3))
return (r, g, b, 255)
for hole in _unvisited_components(w, h, visited, _is_transparent):
color = _fill_color(hole)
seen = set(hole)
samples = [
px[nx, ny][:3]
for x, y in hole
for dx, dy in _NEIGHBOURS
if 0 <= (nx := x + dx) < w and 0 <= (ny := y + dy) < h and (nx, ny) not in seen and px[nx, ny][3] > _ALPHA_FLOOR
]
color = (*(round(sum(c[i] for c in samples) / len(samples)) for i in range(3)), 255) if samples else (0, 0, 0, 255)
for hx, hy in hole:
px[hx, hy] = color
return rgba
# ───────────────────────── frame extraction ─────────────────────────
def _fit_to_cell(image):
"""Crop to content, scale to fit a padded cell, and center on transparent."""
from PIL import Image
image = _drop_side_bleed(image)
bbox = image.getbbox()
if bbox is None:
if (bbox := image.getbbox()) is None:
return _blank()
sprite = image.crop(bbox)
scale = min((CELL_WIDTH - _CELL_PAD) / sprite.width, (CELL_HEIGHT - _CELL_PAD) / sprite.height, 1.0)
if scale != 1.0:
# NEAREST: interpolating resamples blur the hard pixel-art edges.
sprite = sprite.resize(
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
Image.Resampling.NEAREST,
)
sprite = sprite.resize((max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))), _NEAREST)
return _place(sprite, (CELL_WIDTH, CELL_HEIGHT), ((CELL_WIDTH - sprite.width) // 2, (CELL_HEIGHT - sprite.height) // 2))
def _drop_side_bleed(image):
"""Remove tiny separated left/right lobes (neighbour-pose slivers) before fitting.
Component extraction may already have grouped a near sliver with the subject;
a horizontal alpha projection still reveals it as a low-mass side lobe. Only
those are dropped so wide poses and real limbs survive.
Component extraction may have grouped a near sliver with the subject; the
column profile still shows it as a low-mass lobe. Only those go, so wide poses survive.
"""
rgba = image.convert("RGBA")
w, h = rgba.size
runs = _content_runs(_column_profile(rgba))
if len(runs) < 2:
keep = [run for run, m in runs if m >= max(m for _run, m in runs) * _SIDE_LOBE_RATIO] if runs else []
if len(runs) < 2 or len(keep) == len(runs):
return rgba
keep_mass = max(m for _run, m in runs) * _SIDE_LOBE_RATIO
keep = [run for run, m in runs if m >= keep_mass]
if len(keep) == len(runs):
return rgba
rgba = rgba.copy()
prev = 0
for left, right in keep:
for left, right in [*keep, (w, w)]: # (w, w) sentinel clears the trailing gap
if left > prev:
_clear_region(rgba, (prev, 0, left, h))
prev = right
if prev < w:
_clear_region(rgba, (prev, 0, w, h))
return rgba
def _erase_long_axis_lines(image):
"""Remove thin slot-spanning guide/floor/divider lines.
def _thin_groups(indices: list[int]) -> list[tuple[int, int]]:
"""``(start, end)`` for each run of consecutive *indices* at most 4 long."""
runs = ([i for _, i in g] for _k, g in groupby(enumerate(indices), key=lambda p: p[1] - p[0]))
return [(run[0], run[-1] + 1) for run in runs if len(run) <= 4]
Models sometimes draw literal floors or panel dividers; they survive keying
and connect otherwise clean poses. Only *thin* near-full-span rows/columns go.
"""
def _erase_long_axis_lines(image):
"""Remove thin slot-spanning guide/floor/divider lines (they survive keying and bridge clean poses)."""
rgba = image.convert("RGBA").copy()
w, h = rgba.size
alpha = rgba.getchannel("A")
def _thin_groups(indices: list[int]) -> list[tuple[int, int]]:
groups: list[tuple[int, int]] = []
start = prev = -2
for idx in [*indices, -2]: # -2 sentinel flushes the last run
if start >= 0 and idx == prev + 1:
prev = idx
continue
if start >= 0 and prev - start + 1 <= 4:
groups.append((start, prev + 1))
start = prev = idx
return groups
wide_rows = [y for y in range(h) if sum(1 for x in range(w) if alpha.getpixel((x, y)) > _ALPHA_FLOOR) >= w * 0.85]
tall_cols = [x for x in range(w) if sum(1 for y in range(h) if alpha.getpixel((x, y)) > _ALPHA_FLOOR) >= h * 0.85]
for top, bottom in _thin_groups(wide_rows):
opaque = [[alpha.getpixel((x, y)) > _ALPHA_FLOOR for x in range(w)] for y in range(h)]
for top, bottom in _thin_groups([y for y in range(h) if sum(opaque[y]) >= w * 0.85]):
_clear_region(rgba, (0, top, w, bottom))
for left, right in _thin_groups(tall_cols):
for left, right in _thin_groups([x for x in range(w) if sum(row[x] for row in opaque) >= h * 0.85]):
_clear_region(rgba, (left, 0, right, h))
return rgba
@@ -356,20 +264,15 @@ def _erase_long_axis_lines(image):
def _component_boxes(image) -> list[tuple[tuple[int, int, int, int], int]]:
"""Connected opaque components as ``[(bbox, mass)]``."""
rgba = image.convert("RGBA")
bbox = rgba.getbbox()
if bbox is None:
if (bbox := rgba.getbbox()) is None:
return []
l0, t0, r0, b0 = bbox
w, h = r0 - l0, b0 - t0
alpha = rgba.getchannel("A").load()
components = _unvisited_components(w, h, bytearray(w * h), lambda x, y: alpha[l0 + x, t0 + y] > _ALPHA_FLOOR)
out: list[tuple[tuple[int, int, int, int], int]] = []
def _opaque(x: int, y: int) -> bool:
return alpha[l0 + x, t0 + y] > _ALPHA_FLOOR
for pixels in _unvisited_components(w, h, bytearray(w * h), _opaque):
xs = [x for x, _ in pixels]
ys = [y for _, y in pixels]
for pixels in components:
xs, ys = zip(*pixels)
out.append(((l0 + min(xs), t0 + min(ys), l0 + max(xs) + 1, t0 + max(ys) + 1), len(pixels)))
return out
@@ -380,26 +283,21 @@ def _isolate_slot_subject(image):
comps = _component_boxes(rgba)
if not comps:
return rgba
main_box, main_mass = max(comps, key=lambda item: item[1])
ml, mt, mr, mb = main_box
ml, _mt, mr, _mb = main_box
mw = max(1, mr - ml)
keep: list[tuple[int, int, int, int]] = []
for box, mass in comps:
if box == main_box:
keep.append(box)
continue
def _keep(box, mass) -> bool:
# Keep attached-looking accessories (halos); drop sparkles/tears/noise.
left, _top, right, _bottom = box
overlap = max(0, min(right, mr) - max(left, ml))
center_x = (left + right) / 2
near_main = (ml - mw * 0.25) <= center_x <= (mr + mw * 0.25)
# Keep attached-looking accessories (halos); drop sparkles/tears/noise.
if mass >= max(24, main_mass * 0.035) and (overlap >= mw * 0.3 or near_main):
keep.append(box)
near_main = (ml - mw * 0.25) <= (left + right) / 2 <= (mr + mw * 0.25)
return box == main_box or (mass >= max(24, main_mass * 0.035) and (overlap >= mw * 0.3 or near_main))
out = _blank(rgba.size)
for box in keep:
out.alpha_composite(rgba.crop(box), (box[0], box[1]))
for box, mass in comps:
if _keep(box, mass):
out.alpha_composite(rgba.crop(box), (box[0], box[1]))
return out
@@ -407,8 +305,7 @@ def _has_margin(size: tuple[int, int], box: tuple[int, int, int, int], fx: float
"""True when *box* leaves empty room on all four edges of an image of *size* (≥4px, ≤12/16px)."""
w, h = size
left, top, right, bottom = box
min_x = max(4, min(12, round(w * fx)))
min_y = max(4, min(16, round(h * fy)))
min_x, min_y = max(4, min(12, round(w * fx))), max(4, min(16, round(h * fy)))
return left >= min_x and top >= min_y and w - right >= min_x and h - bottom >= min_y
@@ -416,19 +313,18 @@ def _group_component_rows(boxes: list[tuple[int, int, int, int]]) -> list[list[t
"""Group component boxes into visual rows, then sort left→right."""
if not boxes:
return []
cy = lambda b: (b[1] + b[3]) / 2 # noqa: E731
row_tol = max(12, _median(max(1, b[3] - b[1]) for b in boxes) * 0.55)
rows: list[list[tuple[int, int, int, int]]] = []
centers: list[float] = []
for box in sorted(boxes, key=lambda b: (b[1] + b[3]) / 2):
cy = (box[1] + box[3]) / 2
for i, center in enumerate(centers):
if abs(cy - center) <= row_tol:
rows[i].append(box)
centers[i] = sum((b[1] + b[3]) / 2 for b in rows[i]) / len(rows[i])
break
else:
for box in sorted(boxes, key=cy):
i = next((i for i, center in enumerate(centers) if abs(cy(box) - center) <= row_tol), None)
if i is None:
rows.append([box])
centers.append(cy)
centers.append(cy(box))
else:
rows[i].append(box)
centers[i] = sum(cy(b) for b in rows[i]) / len(rows[i])
ordered = [row for _center, row in sorted(zip(centers, rows, strict=False), key=lambda item: item[0])]
for row in ordered:
row.sort(key=lambda b: (b[0] + b[2]) / 2)
@@ -441,6 +337,13 @@ def _merge_related_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[i
Merges when vertical spans overlap and the horizontal gap is tiny relative to
the component size; never bridges the larger gaps between separate poses.
"""
def related(a, b) -> bool:
(al, at, ar, ab), (bl, bt, br, bb) = a, b
v_overlap, min_h = max(0, min(ab, bb) - max(at, bt)), max(1, min(ab - at, bb - bt))
gap, min_w = max(0, max(al, bl) - min(ar, br)), max(1, min(ar - al, br - bl))
return v_overlap >= min_h * 0.45 and gap <= max(14, min_w * 0.22)
boxes = list(boxes)
changed = True
while changed:
@@ -450,92 +353,52 @@ def _merge_related_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[i
for i, a in enumerate(boxes):
if used[i]:
continue
al, at, ar, ab = a
used[i] = True
for j in range(i + 1, len(boxes)):
if used[j]:
continue
bl, bt, br, bb = boxes[j]
v_overlap = max(0, min(ab, bb) - max(at, bt))
min_h = max(1, min(ab - at, bb - bt))
gap = max(0, max(al, bl) - min(ar, br))
min_w = max(1, min(ar - al, br - bl))
if v_overlap >= min_h * 0.45 and gap <= max(14, min_w * 0.22):
al, at, ar, ab = min(al, bl), min(at, bt), max(ar, br), max(ab, bb)
used[j] = True
changed = True
merged.append((al, at, ar, ab))
if not used[j] and related(a, boxes[j]):
b = boxes[j]
a = (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]))
used[j] = changed = True
merged.append(a)
boxes = merged
return boxes
def _component_crops(strip, frame_count: int, *, require_padding: bool = False) -> list | None:
"""Extract frame subjects as connected non-background objects.
Robust path for models that emit a 2D grid instead of one row: count real
subject components, discard tiny effects, sort in reading order, return
exactly *frame_count* frames (or ``None`` when the contract can't be met).
"""
"""Frames as connected subjects in reading order (robust to 2D grids); ``None`` when *frame_count* can't be met."""
def attempt(source) -> list | None:
subjects = _significant_subject_boxes(source, min_mass=64)
if len(subjects) < frame_count:
return None
rows = _group_component_rows(subjects)
ordered = [box for row in rows for box in row][:frame_count]
if len(ordered) < frame_count:
return None
ordered = [box for row in rows for box in row][:frame_count] # rows hold every subject, so exactly frame_count
if require_padding and not all(_has_margin(source.size, box, 0.01, 0.015) for box in ordered):
return None
multirow = len(rows) > 1
frames = []
for left, top, right, bottom in ordered:
pad_x = max(8, round((right - left) * 0.08))
pad_y = max(8, round((bottom - top) * 0.08))
if multirow:
crop_box = (
max(0, left - pad_x),
max(0, top - pad_y),
min(source.width, right + pad_x),
min(source.height, bottom + pad_y),
)
elif frame_count == 1:
crop_box = (0, 0, source.width, source.height)
else:
# Keep full height for true one-row strips so vertical motion survives.
crop_box = (max(0, left - pad_x), 0, min(source.width, right + pad_x), source.height)
pad_x, pad_y = max(8, round((right - left) * 0.08)), max(8, round((bottom - top) * 0.08))
# One-row strips keep full height so vertical motion survives; grids pad the box.
cl, cr = (0, source.width) if frame_count == 1 else (max(0, left - pad_x), min(source.width, right + pad_x))
ct, cb = (max(0, top - pad_y), min(source.height, bottom + pad_y)) if multirow else (0, source.height)
# No second component filter here: capes/tails can be legitimate
# disconnected lobes inside the chosen subject box.
frames.append(
_place(
source.crop((left, top, right, bottom)),
(crop_box[2] - crop_box[0], crop_box[3] - crop_box[1]),
(left - crop_box[0], top - crop_box[1]),
)
)
frames.append(_place(source.crop((left, top, right, bottom)), (cr - cl, cb - ct), (left - cl, top - ct)))
return frames
return attempt(strip) or attempt(_erase_long_axis_lines(strip))
def _sever_expected_gutters(strip, frame_count: int):
"""Cut narrow transparent bands at expected frame boundaries before labeling.
Shared shadows/smears/1px bridges connect neighbouring poses into one blob;
losing a few boundary pixels beats exporting merged frames.
"""
"""Cut narrow transparent bands at expected frame boundaries (shared shadows/1px bridges merge poses)."""
if frame_count <= 1:
return strip
out = strip.copy()
alpha = out.getchannel("A") # zero alpha only; RGB is left untouched
slot = out.width / frame_count
half = max(3, min(18, round(slot * 0.06)))
for i in range(1, frame_count):
x = round(i * slot)
for x in (round(i * slot) for i in range(1, frame_count)):
alpha.paste(0, (max(0, x - half), 0, min(out.width, x + half + 1), out.height))
out.putalpha(alpha)
return out
@@ -546,19 +409,11 @@ def _clean_slot(image):
def _slot_crops(strip, frame_count: int, *, require_padding: bool = False) -> list | None:
"""Slice *strip* into *frame_count* uniform, independently cleaned columns.
Equal-width columns keep every frame in one shared coordinate frame so
:func:`normalize_cells` preserves the row's real motion without sliding.
"""
"""Slice *strip* into *frame_count* equal, independently cleaned columns (one shared coordinate frame)."""
w, h = strip.size
frames = []
for i in range(frame_count):
slot = _clean_slot(strip.crop((round(i * w / frame_count), 0, round((i + 1) * w / frame_count), h)))
bbox = slot.getbbox()
if require_padding and (bbox is None or not _has_margin(slot.size, bbox, 0.025, 0.02)):
return None
frames.append(slot)
frames = [_clean_slot(strip.crop((round(i * w / frame_count), 0, round((i + 1) * w / frame_count), h))) for i in range(frame_count)]
if require_padding and any((b := f.getbbox()) is None or not _has_margin(f.size, b, 0.025, 0.02) for f in frames):
return None
return frames
@@ -579,20 +434,13 @@ def _content_runs(profile: list[int], *, threshold: int = 2) -> list[tuple[tuple
def _frame_x_ranges(strip, frame_count: int) -> list[tuple[int, int]] | None:
"""Per-frame ``(left, right)`` column ranges from the row's empty gutters.
spans == frames → one per frame; spans > frames → merge across the smallest
gaps (a detached halo sits a tiny gap from its body, the inter-pose gutter is
the big gap that survives); spans < frames → ``None`` (poses touching).
Ranges span X only; the caller crops full height so tall ears/halos survive.
Extra spans merge across the smallest gaps (a detached halo sits closer to its
body than to the next pose); fewer spans than frames → ``None`` (poses touching).
"""
runs = _content_runs(_column_profile(strip))
if not runs:
return None
floor = max(m for _run, m in runs) * 0.02
groups = [[l, r] for (l, r), m in runs if m >= floor]
groups = [[l, r] for (l, r), m in runs if m >= max(m for _run, m in runs) * 0.02] if runs else []
if len(groups) < frame_count:
return None
while len(groups) > frame_count:
gi = min(range(len(groups) - 1), key=lambda i: groups[i + 1][0] - groups[i][1])
groups[gi][1] = groups[gi + 1][1]
@@ -615,26 +463,18 @@ def _is_multi_pose_outlier(width: int, height: int, med_w: int, med_h: int) -> b
def _validate_extracted_frames(frames: list, frame_count: int) -> None:
"""Reject rows where one "frame" is really multiple poses.
A collapsed strip of tiny repeated poses would make normalization shrink the
whole pet to postage-stamp size; catching it here lets hatch regenerate.
"""
"""Reject rows where one "frame" is really multiple poses (normalization would shrink the whole pet)."""
if len(frames) != frame_count:
raise ValueError(f"expected {frame_count} frames, got {len(frames)}")
boxes = []
for i, frame in enumerate(frames):
bbox = frame.getbbox()
if bbox is None:
if (bbox := frame.getbbox()) is None:
raise ValueError(f"frame {i} is empty")
if len(_significant_subject_boxes(frame)) >= 3:
raise ValueError(f"frame {i} contains multiple separated subjects")
boxes.append(bbox)
if frame_count <= 1:
return
med_w = max(1, _median(b[2] - b[0] for b in boxes))
med_h = max(1, _median(b[3] - b[1] for b in boxes))
for i, (left, top, right, bottom) in enumerate(boxes):
@@ -643,211 +483,140 @@ def _validate_extracted_frames(frames: list, frame_count: int) -> None:
def extract_strip_frames(
strip,
frame_count: int,
*,
chroma_key: tuple[int, int, int] | None = None,
method: str = "auto",
fit: bool = True,
strip, frame_count: int, *, chroma_key: tuple[int, int, int] | None = None, method: str = "auto", fit: bool = True
) -> list:
"""Turn one generated row strip into *frame_count* frames.
Keys out the background, then treats the frame count as source of truth:
isolate padded subjects (component pass, then equal slots). When that fails,
``components`` raises and ``auto`` falls back to lenient salvage (gutters →
severed gutters → raw slots). *fit* centers each frame into a 192x208 cell;
hatching passes ``fit=False`` so :func:`normalize_cells` can register the
whole pet with one shared scale + baseline.
Keys out the background, then isolates padded subjects (components, then equal
slots). When that fails ``components`` raises while ``auto`` salvages leniently.
*fit* centers each frame into a cell; hatching passes ``fit=False`` so
:func:`normalize_cells` can register the whole pet with one shared scale.
"""
strip = remove_background(_load_rgba(strip), chroma_key=chroma_key)
frames = _component_crops(strip, frame_count, require_padding=True)
frames = _component_crops(strip, frame_count, require_padding=True) or _slot_crops(strip, frame_count, require_padding=True)
if frames is None and method == "components":
raise ValueError(f"could not segment {frame_count} padded sprites from strip")
if frames is None:
frames = _slot_crops(strip, frame_count, require_padding=True)
if frames is None:
if method == "components":
raise ValueError(f"could not segment {frame_count} padded sprites from strip")
frames = _component_crops(strip, frame_count, require_padding=False)
if frames is None:
source = strip
ranges = _frame_x_ranges(source, frame_count)
if ranges is None:
source = _sever_expected_gutters(strip, frame_count)
ranges = _frame_x_ranges(source, frame_count)
if ranges is None:
frames = _slot_crops(source, frame_count, require_padding=False) or []
else:
h = source.height
pad = max(2, min(16, round((source.width / max(1, frame_count)) * 0.04)))
frames = [
_clean_slot(source.crop((max(0, left - pad), 0, min(source.width, right + pad), h)))
for left, right in ranges
]
frames = _salvage_frames(strip, frame_count)
_validate_extracted_frames(frames, frame_count)
return [_fit_to_cell(f) for f in frames] if fit else frames
def _salvage_frames(strip, frame_count: int) -> list:
"""Lenient last resort: gutter ranges (severing expected gutters if needed), else raw slots."""
source = strip
ranges = _frame_x_ranges(source, frame_count)
if ranges is None:
source = _sever_expected_gutters(strip, frame_count)
ranges = _frame_x_ranges(source, frame_count)
if ranges is None:
return _slot_crops(source, frame_count, require_padding=False) or []
w, h = source.size
pad = max(2, min(16, round((w / max(1, frame_count)) * 0.04)))
return [_clean_slot(source.crop((max(0, left - pad), 0, min(w, right + pad), h))) for left, right in ranges]
def _column_profile(image) -> list[int]:
"""Per-column alpha mass — collapse to a 1px-tall strip (fast in C)."""
from PIL import Image
return list(image.getchannel("A").resize((image.width, 1), Image.BILINEAR).getdata())
def _best_shift(ref: list[int], prof: list[int], window: int) -> int:
"""Integer dx that best aligns *prof* onto *ref* (1-D cross-correlation).
The body dominates the column profile, so the peak locks onto the body and a
flipping arm/cape barely moves the match (~9px drift → ~1px).
"""
"""Integer dx that best aligns *prof* onto *ref* (1-D cross-correlation; the body dominates, limbs barely move it)."""
n = len(ref)
def score(d: int) -> int:
return sum(ref[x] * prof[x - d] for x in range(max(0, d), min(n, n + d)))
return max(range(-window, window + 1), key=score) # ties → smallest dx, as before
return max(range(-window, window + 1), key=score) # ties → smallest dx
def normalize_cells(frames_by_state: dict[str, list], *, pad: int = _NORMALIZE_PAD) -> dict[str, list]:
"""Register every frame into a 192x208 cell — the deterministic anti-jitter math.
Per-frame crop→scale→center jitters (bbox shifts with a limb, per-frame scale
pulses). Instead: cross-correlate each frame's column profile against the
state's median profile to lock the body, union-crop through one shared state
window, then scale every state by a single global factor keyed to its median
pose height so the character is the same size in every row.
Per-frame crop→scale→center jitters. Instead: align each frame's column profile
to the state's median (locks the body), union-crop through one shared window,
then scale all states by one global factor keyed to median pose height.
"""
from PIL import Image
out: dict[str, list] = {}
prepared: dict[str, tuple[list, tuple[int, int, int, int], tuple[int, int]]] = {}
target_w = CELL_WIDTH - pad
target_h = CELL_HEIGHT - pad
prepared: dict[str, tuple[list, tuple[int, int, int, int], int]] = {}
target_w, target_h = CELL_WIDTH - pad, CELL_HEIGHT - pad
for state, frames in frames_by_state.items():
rgba = [f.convert("RGBA") for f in frames]
if not any(f.getbbox() for f in rgba):
out[state] = [_blank() for _ in frames]
continue
# Pad every frame to a common canvas so column profiles are comparable.
w0 = max(f.width for f in rgba)
h0 = max(f.height for f in rgba)
w0, h0 = max(f.width for f in rgba), max(f.height for f in rgba)
canvas = [f if f.size == (w0, h0) else _place(f, (w0, h0)) for f in rgba]
profiles = [_column_profile(f) for f in canvas]
ref = [_median(p[x] for p in profiles) for x in range(w0)]
window = max(8, w0 // 5)
margin = window
aligned = [
_place(f, (w0 + 2 * margin, h0), (margin + _best_shift(ref, prof, window), 0))
for f, prof in zip(canvas, profiles)
_place(f, (w0 + 2 * window, h0), (window + _best_shift(ref, prof, window), 0)) for f, prof in zip(canvas, profiles)
]
boxes = [b for b in (a.getbbox() for a in aligned) if b]
prepared[state] = (
aligned,
(min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes)),
(_median(b[2] - b[0] for b in boxes), _median(b[3] - b[1] for b in boxes)),
)
union = (min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes))
prepared[state] = (aligned, union, _median(b[3] - b[1] for b in boxes))
if not prepared:
return out
# K is the one global cap keeping the tallest/widest motion envelope (a
# jump's lift) inside the cell; a still row's union ≈ pose so it fills fully.
K = target_h
for _aligned, (left, top, right, bottom), (_pose_w, pose_h) in prepared.values():
uw, uh = right - left, bottom - top
K = min(K, target_h * pose_h / max(1, uh), target_w * pose_h / max(1, uw))
for state, (aligned, (left, top, right, bottom), (_pose_w, pose_h)) in prepared.items():
uw, uh = right - left, bottom - top
for _aligned, (left, top, right, bottom), pose_h in prepared.values():
K = min(K, target_h * pose_h / max(1, bottom - top), target_w * pose_h / max(1, right - left))
for state, (aligned, box, pose_h) in prepared.items():
scale = K / max(1, pose_h)
sw, sh = max(1, round(uw * scale)), max(1, round(uh * scale))
px, py = round((CELL_WIDTH - sw) / 2), round((CELL_HEIGHT - pad // 2) - sh)
cells = []
for a in aligned:
crop = a.crop((left, top, right, bottom))
if crop.size != (sw, sh):
crop = crop.resize((sw, sh), Image.Resampling.NEAREST) # keep pixel edges crisp
cells.append(_place(crop, (CELL_WIDTH, CELL_HEIGHT), (px, py)))
out[state] = cells
sw, sh = max(1, round((box[2] - box[0]) * scale)), max(1, round((box[3] - box[1]) * scale))
offset = round((CELL_WIDTH - sw) / 2), round((CELL_HEIGHT - pad // 2) - sh)
crops = [a.crop(box) for a in aligned]
out[state] = [_place(c if c.size == (sw, sh) else c.resize((sw, sh), _NEAREST), (CELL_WIDTH, CELL_HEIGHT), offset) for c in crops]
return out
# ───────────────────────── atlas composition ─────────────────────────
def single_frame(image, *, fit: bool = True):
"""One frame from a standalone image (idle fallback so a pet always renders).
*fit* yields a finished cell; ``fit=False`` the raw keyed sprite for
:func:`normalize_cells`.
"""
"""One frame from a standalone image (idle fallback); ``fit=False`` yields the raw keyed sprite for :func:`normalize_cells`."""
keyed = remove_background(_load_rgba(image))
return _fit_to_cell(keyed) if fit else _drop_side_bleed(keyed)
def _clear_transparent_rgb(image):
"""Zero the RGB of fully-transparent pixels (no colored-halo residue)."""
from PIL import Image
rgba = image.convert("RGBA")
data = bytearray(rgba.tobytes())
for i in range(0, len(data), 4):
if data[i + 3] == 0:
data[i] = data[i + 1] = data[i + 2] = 0
for i in range(3, len(data), 4):
if data[i] == 0:
data[i - 3 : i] = b"\0\0\0"
return Image.frombytes("RGBA", rgba.size, bytes(data))
def mirror_frames(frames: list) -> list:
"""Flip each frame horizontally (per-frame, so order/timing is preserved).
Derives ``running-left`` from ``running-right``; NOT a strip reverse.
"""
from PIL import Image
flip = getattr(Image, "Transpose", Image).FLIP_LEFT_RIGHT
return [frame.convert("RGBA").transpose(flip) for frame in frames]
"""Flip each frame horizontally (``running-left`` from ``running-right``; per-frame, NOT a strip reverse)."""
return [frame.convert("RGBA").transpose(Image.Transpose.FLIP_LEFT_RIGHT) for frame in frames]
def compose_atlas(frames_by_state: dict[str, list]):
"""Pack per-state frame lists into the atlas; short states leave trailing cells transparent."""
atlas = _blank((ATLAS_WIDTH, ATLAS_HEIGHT))
for state, row, count in ROW_SPECS:
frames = frames_by_state.get(state) or []
for col, frame in enumerate(frames[:count]):
for col, frame in enumerate((frames_by_state.get(state) or [])[:count]):
cell = frame.convert("RGBA")
if cell.size != (CELL_WIDTH, CELL_HEIGHT):
cell = _fit_to_cell(cell)
cell = cell if cell.size == (CELL_WIDTH, CELL_HEIGHT) else _fit_to_cell(cell)
atlas.alpha_composite(cell, (col * CELL_WIDTH, row * CELL_HEIGHT))
return _clear_transparent_rgb(atlas)
def validate_atlas(atlas) -> dict:
"""Check geometry, per-cell occupancy, and transparency invariants.
Returns ``{ok, width, height, errors, warnings, filled_states}``; errors are
blockers, warnings soft (a whole state row blank).
"""
"""Geometry/occupancy/transparency checks → ``{ok, width, height, errors, warnings, filled_states}``."""
atlas = _load_rgba(atlas)
if atlas.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
errors: list[str] = [f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {atlas.width}x{atlas.height}"]
warnings: list[str] = []
filled_states: list[str] = []
if atlas.size == (ATLAS_WIDTH, ATLAS_HEIGHT):
errors, warnings, filled = _check_atlas_cells(atlas)
else:
errors, warnings, filled_states = _check_atlas_cells(atlas)
return {
"ok": not errors,
"width": atlas.width,
"height": atlas.height,
"errors": errors,
"warnings": warnings,
"filled_states": filled_states,
}
errors, warnings, filled = [f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {atlas.width}x{atlas.height}"], [], []
return {"ok": not errors, "width": atlas.width, "height": atlas.height, "errors": errors, "warnings": warnings, "filled_states": filled}
def _check_atlas_cells(atlas) -> tuple[list[str], list[str], list[str]]:
@@ -857,53 +626,36 @@ def _check_atlas_cells(atlas) -> tuple[list[str], list[str], list[str]]:
filled_states: list[str] = []
cell_boxes_by_state: dict[str, list[tuple[int, int, int, int]]] = {}
for state, row, count in ROW_SPECS:
row_pixels = 0
boxes: list[tuple[int, int, int, int]] = []
for col in range(count):
left, top = col * CELL_WIDTH, row * CELL_HEIGHT
cell = atlas.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
row_pixels += sum(cell.getchannel("A").histogram()[1:])
bbox = cell.getbbox()
if bbox is not None:
boxes.append(bbox)
if row_pixels > 0:
cells = [atlas.crop((c * CELL_WIDTH, row * CELL_HEIGHT, (c + 1) * CELL_WIDTH, (row + 1) * CELL_HEIGHT)) for c in range(count)]
if any(sum(cell.getchannel("A").histogram()[1:]) for cell in cells):
filled_states.append(state)
cell_boxes_by_state[state] = boxes
cell_boxes_by_state[state] = [bbox for cell in cells if (bbox := cell.getbbox()) is not None]
else:
warnings.append(f"state '{state}' has no frames")
if not filled_states:
errors.append("atlas is empty — no state produced any frames")
# A valid pet must occupy the cell: one bad row can poison global
# normalization and shrink every state while still passing "non-empty".
all_boxes = [b for boxes in cell_boxes_by_state.values() for b in boxes]
global_med_w = global_med_h = 0
if all_boxes:
global_med_w = _median(r - l for l, _t, r, _b in all_boxes)
global_med_h = _median(b - t for _l, t, _r, b in all_boxes)
if global_med_h < max(56, round(CELL_HEIGHT * 0.28)):
errors.append(f"atlas sprites are too small after normalization (median frame height {global_med_h}px)")
global_med_w = _median(r - l for l, _t, r, _b in all_boxes) if all_boxes else 0
global_med_h = _median(b - t for _l, t, _r, b in all_boxes) if all_boxes else 0
if all_boxes and global_med_h < max(56, round(CELL_HEIGHT * 0.28)):
errors.append(f"atlas sprites are too small after normalization (median frame height {global_med_h}px)")
for state, boxes in cell_boxes_by_state.items():
if len(boxes) <= 1:
continue
widths = [right - left for left, _top, right, _bottom in boxes]
heights = [bottom - top for _left, top, _right, bottom in boxes]
widths, heights = [r - l for l, _t, r, _b in boxes], [b - t for _l, t, _r, b in boxes]
med_w, med_h = max(1, _median(widths)), max(1, _median(heights))
if _is_multi_pose_outlier(max(widths), max(heights), med_w, med_h):
errors.append(f"state '{state}' contains a multi-pose frame outlier")
# Per-state collapse guard: one malformed row must not pass on the
# strength of the healthy ones.
if (global_med_w and global_med_h) and (
med_w < max(32, round(global_med_w * 0.42)) or med_h < max(40, round(global_med_h * 0.50))
):
errors.append(
f"state '{state}' appears collapsed (median {med_w}x{med_h}px, global median {global_med_w}x{global_med_h}px)"
)
collapsed = med_w < max(32, round(global_med_w * 0.42)) or med_h < max(40, round(global_med_h * 0.50))
if (global_med_w and global_med_h) and collapsed:
errors.append(f"state '{state}' appears collapsed (median {med_w}x{med_h}px, global median {global_med_w}x{global_med_h}px)")
data = atlas.tobytes()
residue = sum(1 for i in range(0, len(data), 4) if data[i + 3] == 0 and (data[i] or data[i + 1] or data[i + 2]))
residue = sum(1 for i in range(3, len(data), 4) if data[i] == 0 and any(data[i - 3 : i]))
if residue:
errors.append(f"{residue} transparent pixels retain RGB residue")
return errors, warnings, filled_states
+36 -81
View File
@@ -1,14 +1,9 @@
"""Thin image-generation layer for pet sprites.
Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the
two things sprite generation needs that the agent-facing ``image_generate`` tool
doesn't expose: **N variants** (loop) and **reference-image grounding** (so each
animation row stays the same character as the chosen base).
Reference grounding only works on providers that support it — currently OpenAI
``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of
those and surface a clear, actionable error otherwise rather than silently
producing an ungrounded, drifting pet.
Wraps the active ``ImageGenProvider`` with what the ``image_generate`` tool
doesn't expose: N variants and reference-image grounding (each animation row
stays the same character as the chosen base). Grounding needs a ref-capable
provider; we resolve to one or raise an actionable error rather than drift.
"""
from __future__ import annotations
@@ -20,21 +15,12 @@ from pathlib import Path
logger = logging.getLogger(__name__)
# Providers that can ground generation on a reference image, in preference order
# (Nous Portal → OpenAI → OpenRouter → …). OpenRouter/Nous run a quality-first
# model chain and may fall back depending on account access and endpoint behavior,
# so fidelity can vary by configured backend + model availability.
# Providers that can ground generation on a reference image, in preference order.
# OpenRouter/Nous run a quality-first model chain and may fall back depending on
# account access, so fidelity can vary by configured backend.
_REF_CAPABLE = ("nous", "openai", "openai-codex", "openrouter", "krea")
# Friendly display label per reference-capable provider, surfaced in the desktop
# pet-gen picker.
_PROVIDER_LABELS: dict[str, str] = {
"nous": "Nous Portal",
"openrouter": "OpenRouter",
"openai": "OpenAI",
"openai-codex": "OpenAI (Codex)",
"krea": "Krea",
}
# Friendly display label per reference-capable provider (desktop pet-gen picker).
_PROVIDER_LABELS = {"nous": "Nous Portal", "openrouter": "OpenRouter", "openai": "OpenAI", "openai-codex": "OpenAI (Codex)", "krea": "Krea"}
class GenerationError(RuntimeError):
@@ -68,46 +54,32 @@ def _available(name: str):
def resolve_provider(*, require_references: bool = True, prefer: str | None = None) -> SpriteProvider:
"""Pick the image provider to use for sprite work.
"""Pick the image provider for sprite work.
Preference: an explicit *prefer* choice (the desktop pet-gen picker) when it's
reference-capable and configured, then the configured/active provider when
it's reference-capable, else the first available reference-capable provider.
With *require_references* off we fall back to any available provider (used for
prompt-only base drafts).
Preference: ``HERMES_PET_IMAGE_PROVIDER`` (QA override, unknown values ignored),
then *prefer* (desktop picker), then the active provider, then the first available
— each only if ref-capable and configured. With *require_references* off, any
available active provider is accepted (prompt-only base drafts).
"""
_discover()
from agent.image_gen_registry import get_active_provider
# QA override: ``HERMES_PET_IMAGE_PROVIDER=<name>`` forces one ref-capable backend
# for pet-gen only (unknown values ignored). Then an explicit user pick (desktop
# picker) wins when ref-capable and configured; otherwise fall through.
forced = os.environ.get("HERMES_PET_IMAGE_PROVIDER", "").strip().lower()
for name in (forced, prefer):
if name in _REF_CAPABLE and (chosen := _available(name)) is not None:
return SpriteProvider(name=name, provider=chosen, supports_references=True)
# Configured / active provider first.
try:
active = get_active_provider()
except Exception: # noqa: BLE001
active = None
if active is not None:
name = getattr(active, "name", "")
if name in _REF_CAPABLE and active.is_available():
return SpriteProvider(name=name, provider=active, supports_references=True)
# Any available reference-capable provider.
active_name = getattr(active, "name", "") if active is not None else ""
if active_name in _REF_CAPABLE and active.is_available():
return SpriteProvider(name=active_name, provider=active, supports_references=True)
for name in _REF_CAPABLE:
provider = _available(name)
if provider is not None:
if (provider := _available(name)) is not None:
return SpriteProvider(name=name, provider=provider, supports_references=True)
if not require_references and active is not None and active.is_available():
return SpriteProvider(
name=getattr(active, "name", "unknown"), provider=active, supports_references=False
)
return SpriteProvider(name=getattr(active, "name", "unknown"), provider=active, supports_references=False)
raise GenerationError(
"Pet generation needs an image backend that supports reference images. "
"Open `hermes tools` → Image Generation and configure Nous Portal, "
@@ -116,14 +88,7 @@ def resolve_provider(*, require_references: bool = True, prefer: str | None = No
def list_sprite_providers() -> list[dict]:
"""The reference-capable providers available to pick for pet generation.
Returns ``[{name, label, default}]`` for every ref-capable provider the user
actually has credentials for, in preference order, marking the one
:func:`resolve_provider` would choose with no explicit preference. Empty when
none is configured (the picker hides itself). Best-effort: discovery hiccups
yield an empty list.
"""
"""``[{name, label, default}]`` per configured ref-capable provider, in preference order; empty hides the picker."""
_discover()
try:
default_name = resolve_provider(require_references=True).name
@@ -146,13 +111,7 @@ def _save_local(image_ref: str, *, prefix: str) -> Path:
def _rejected_background(error: str) -> bool:
"""True when a provider error is specifically about the ``background`` param.
Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers
reject ``background=transparent`` outright). We detect that one rejection so
we can retry without the flag rather than failing the whole pet — our chroma
key pass makes the result transparent regardless.
"""
"""True when a provider error is specifically about ``background=transparent`` (a per-model capability; we retry without it)."""
lowered = (error or "").lower()
return "background" in lowered and ("not supported" in lowered or "transparent" in lowered)
@@ -169,13 +128,10 @@ def generate(
"""Generate *n* sprite images and return their local paths.
*reference_images* grounds the output on a base image (required for rows).
*aspect_ratio* picks the canvas: ``"square"`` for single-character base
drafts, ``"landscape"`` for multi-frame row strips (the wider 1536px canvas
gives every frame real horizontal room so winged poses don't have to be
shrunk to avoid touching their neighbors).
We *ask* for a transparent background, but fall back to an opaque generation
(cleaned up downstream by the chroma-key pass) on models that reject the
flag. Raises :class:`GenerationError` if nothing usable comes back.
*aspect_ratio* ``"landscape"`` (row strips) gives every frame horizontal room so
winged poses needn't shrink. We *ask* for a transparent background but fall
back to opaque on models that reject the flag. Raises :class:`GenerationError`
if nothing usable comes back.
"""
sprite = provider or resolve_provider(require_references=bool(reference_images))
if reference_images and not sprite.supports_references:
@@ -186,23 +142,22 @@ def generate(
refs = [str(p) for p in (reference_images or [])]
# Providers disagree on the ref kwarg name: our OpenRouter/Nous backends read
# ``reference_images``, OpenAI's gpt-image-2 reads ``reference_image_urls``.
# Send both; each ignores the other.
ref_kwargs = {"reference_images": refs, "reference_image_urls": refs} if refs else {}
def _run(extra: dict) -> tuple[Path | None, str]:
kwargs: dict = {"aspect_ratio": aspect_ratio, **extra}
if refs:
# Providers disagree on the ref kwarg name: our OpenRouter/Nous
# backends read ``reference_images``, OpenAI's gpt-image-2 reads
# ``reference_image_urls``. Send both; each ignores the other.
kwargs["reference_images"] = refs
kwargs["reference_image_urls"] = refs
try:
result = sprite.provider.generate(prompt, **kwargs)
result = sprite.provider.generate(prompt, aspect_ratio=aspect_ratio, **extra, **ref_kwargs)
except Exception as exc: # noqa: BLE001 - normalize provider crashes
logger.debug("provider.generate crashed: %s", exc)
return None, str(exc)
if not isinstance(result, dict) or not result.get("success"):
return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result"
image_ref = result.get("image")
if not image_ref:
if not isinstance(result, dict):
return None, "no result"
if not result.get("success"):
return None, result.get("error", "unknown error")
if not (image_ref := result.get("image")):
return None, "provider returned no image"
try:
return _save_local(str(image_ref), prefix=prefix), ""
+78 -134
View File
@@ -1,18 +1,18 @@
"""Pet generation orchestration — the base-draft → hatch flow.
1. :func:`generate_base_drafts` — a few cheap prompt-only "what should this pet
look like" variants; the user picks one (or retries).
2. :func:`hatch_pet` — grounds one row strip per Hermes state on the chosen base,
slices frames, composes + validates the atlas, and writes it to the store.
Splitting bounds cost (4 cheap base calls per round; the ~8 row calls happen
once, on the pet you keep) and gives each UI a natural preview/loading point.
:func:`generate_base_drafts` makes a few cheap prompt-only look variants the user
picks between; :func:`hatch_pet` grounds one row strip per state on the chosen
base, slices frames, composes + validates the atlas, and writes it to the store.
Splitting bounds cost (the ~8 row calls happen once, on the pet you keep) and
gives each UI a natural preview/loading point.
"""
from __future__ import annotations
import contextlib
import logging
import time
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
@@ -26,11 +26,9 @@ logger = logging.getLogger(__name__)
# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", "<slug>").
ProgressFn = Callable[[str, str], None]
# Generations are independent network calls, fanned out so a hatch (~8 rows)
# doesn't blow the client's RPC timeout; capped to respect provider rate limits.
# Fan-out so a hatch (~8 rows) doesn't blow the client's RPC timeout; capped for provider rate limits.
_MAX_PARALLEL_GENERATIONS = 4
# Row (re)generation attempts: early ones demand clean per-pose gutters, the
# last is lenient so a stubborn row still yields frames instead of dropping out.
# Row attempts: early ones demand clean per-pose gutters, the last is lenient so a stubborn row still yields.
_ROW_GEN_ATTEMPTS = 3
_MIN_FILLED_STATES = 6
_REQUIRED_STATES = frozenset({"idle", "running-right", "waving"})
@@ -38,15 +36,11 @@ _REQUIRED_STATES = frozenset({"idle", "running-right", "waving"})
# (substrings, friendly message), first match wins. Moderation is the big one:
# image models refuse trademarked characters / real people as an opaque 400.
_IMAGE_ERROR_HINTS: tuple[tuple[tuple[str, ...], str], ...] = (
(
("moderation_blocked", "safety system", "content policy", "content_policy"),
"The image provider blocked this prompt — its safety filter rejects "
"trademarked characters and real people. Try an original description.",
),
(
("api key", "unauthorized", "401", "auth"),
"The image provider rejected the request — check your API key in Settings → Providers.",
),
(("moderation_blocked", "safety system", "content policy", "content_policy"),
"The image provider blocked this prompt — its safety filter rejects "
"trademarked characters and real people. Try an original description."),
(("api key", "unauthorized", "401", "auth"),
"The image provider rejected the request — check your API key in Settings → Providers."),
(("rate limit", "429"), "The image provider is rate-limiting — wait a moment and try again."),
)
@@ -63,34 +57,25 @@ class HatchResult:
def _unlink_quietly(path: Path) -> None:
try:
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
except OSError:
pass
def _harden_transparency(path: Path) -> Path:
"""Key out any solid backdrop the provider painted; save as an RGBA PNG.
``background=transparent`` is requested but honored inconsistently, so run the
row extractor's chroma-key pass on every base draft. Best-effort: a decode
failure leaves the original untouched.
``background=transparent`` is honored inconsistently, so every base draft gets
the chroma-key pass. Best-effort: a decode failure leaves the original untouched.
"""
from PIL import Image
try:
with Image.open(path) as opened:
keyed = atlas.remove_background(opened.convert("RGBA"))
keyed = atlas._clear_transparent_rgb(keyed) # no colored halo on the dark UI
keyed = atlas._clear_transparent_rgb(atlas.remove_background(atlas._load_rgba(path))) # no halo on the dark UI
# PNGs (any case) are hardened in place: with_suffix(".png") on ".PNG" would
# name the same file on case-insensitive filesystems and the unlink below
# would delete the hardened output.
out = path if path.suffix.lower() == ".png" else path.with_suffix(".png")
keyed.save(out, format="PNG")
if out != path:
# Nothing prunes cache/images outside the gateway loop: drop the
# non-PNG original the hardened PNG replaces.
_unlink_quietly(path)
_unlink_quietly(path) # nothing else prunes cache/images outside the gateway loop
return out
except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image
logger.debug("base draft transparency hardening failed for %s: %s", path, exc)
@@ -100,9 +85,9 @@ def _harden_transparency(path: Path) -> Path:
def _run_parallel(fn, items, *, cancelled, on_cancel_log: str) -> Iterator:
"""Fan *fn* over *items* in a pool, yielding results in completion order.
``as_completed`` runs on the caller's (request) thread, so callbacks fired
from the results inherit the request's bound transport, unlike the workers.
Once *cancelled* trips, queued work is cancelled and in-flight results dropped.
``as_completed`` runs on the caller's thread, so result callbacks inherit the
request's bound transport (workers don't). Once *cancelled* trips, queued work
is cancelled and in-flight results dropped.
"""
workers = max(1, min(len(items), _MAX_PARALLEL_GENERATIONS))
with ThreadPoolExecutor(max_workers=workers) as pool:
@@ -126,26 +111,22 @@ def generate_base_drafts(
on_draft: Callable[[int, Path], None] | None = None,
is_cancelled: Callable[[], bool] | None = None,
) -> list[Path]:
"""Generate *n* candidate base looks for *concept*; returns image paths.
"""Generate *n* candidate base looks for *concept* concurrently; returns image paths.
Drafts run concurrently and are hardened to transparent cutouts;
*on_draft(index, path)* fires as each finishes so UIs can stream previews.
*is_cancelled* is polled cooperatively (unstarted drafts skipped, queued work
cancelled, in-flight results dropped).
Drafts are hardened to transparent cutouts; *on_draft(index, path)* fires as each
finishes so UIs can stream previews. *is_cancelled* is polled cooperatively.
"""
# A user reference image grounds every draft, so it needs a reference-capable provider.
refs = reference_images or None
sprite = provider or imagegen.resolve_provider(require_references=bool(refs))
cancelled = is_cancelled or (lambda: False)
logger.info("pet generate: drafting %d base looks for %r (style=%s)", n, concept, style)
def _one(index: int) -> tuple[int, Path | None, str | None]:
if cancelled():
return index, None, None
t0 = time.monotonic()
# Distinct variation nudge per draft so the options aren't near-duplicates.
variation = prompts.BASE_VARIATIONS[index % len(prompts.BASE_VARIATIONS)]
variation = prompts.BASE_VARIATIONS[index % len(prompts.BASE_VARIATIONS)] # distinct look per draft
prompt = prompts.build_base_prompt(concept, style=style, variation=variation)
try:
out = imagegen.generate(prompt, n=1, reference_images=refs, provider=sprite, prefix="pet_base")
@@ -160,12 +141,10 @@ def generate_base_drafts(
results: dict[int, Path] = {}
errors: list[str] = []
for index, path, err in _run_parallel(
_one, range(n), cancelled=cancelled, on_cancel_log="pet generate: cancelled — dropping remaining drafts"
):
cancel_log = "pet generate: cancelled — dropping remaining drafts"
for index, path, err in _run_parallel(_one, range(n), cancelled=cancelled, on_cancel_log=cancel_log):
if path is None:
if err:
errors.append(err)
errors += [err] if err else []
continue
results[index] = path
if on_draft is not None:
@@ -173,14 +152,11 @@ def generate_base_drafts(
on_draft(index, path)
except Exception as exc: # noqa: BLE001 - progress is best-effort
logger.debug("on_draft callback failed: %s", exc)
drafts = [results[i] for i in sorted(results)]
if not drafts and not cancelled():
# Surface *why*: the most common failure reason is the representative cause.
if not errors:
raise GenerationError("image generation produced no usable drafts")
from collections import Counter
raise GenerationError(_humanize_image_error(Counter(errors).most_common(1)[0][0]))
return drafts
@@ -188,10 +164,46 @@ def generate_base_drafts(
def _humanize_image_error(error: str) -> str:
"""Turn a raw provider error into a friendly, actionable sentence."""
low = error.lower()
for needles, message in _IMAGE_ERROR_HINTS:
if any(s in low for s in needles):
return message
return error.splitlines()[0].strip()[:200] # first line, sans provider envelope
hint = next((message for needles, message in _IMAGE_ERROR_HINTS if any(s in low for s in needles)), None)
return hint or error.splitlines()[0].strip()[:200] # first line, sans provider envelope
def _generate_row(spec: tuple[str, int, int], *, base: Path, label: str, style: str, slug: str, sprite, cancelled) -> tuple[str, list | None]:
"""Generate + slice one animation row, retrying up to ``_ROW_GEN_ATTEMPTS`` times.
Self-healing: a roll whose poses touch (no gutters) slices badly, so
``components`` (raises on touching poses) drives regeneration and only the
final attempt uses lenient ``auto`` slicing. Returns ``(state, None)`` when
cancelled or every attempt failed.
"""
state, _row, count = spec
t0 = time.monotonic()
last_exc: Exception | None = None
for attempt in range(_ROW_GEN_ATTEMPTS):
if cancelled():
return state, None
strips: list[Path] = []
try:
# Landscape: each frame gets real horizontal room and clean gutters.
strips = imagegen.generate(
prompts.build_row_prompt(state, count, label, style=style), n=1, reference_images=[base],
provider=sprite, prefix=f"pet_row_{state}", aspect_ratio="landscape",
)
# fit=False keeps raw columns so normalize_cells registers the whole pet at once.
method = "components" if attempt < _ROW_GEN_ATTEMPTS - 1 else "auto"
frames = atlas.extract_strip_frames(strips[0], count, method=method, fit=False)
logger.info("pet hatch %r: row %r ready in %.1fs (attempt %d)", slug, state, time.monotonic() - t0, attempt + 1)
return state, frames
except Exception as exc: # noqa: BLE001 - retried; one bad row is tolerated
last_exc = exc
logger.warning("pet hatch %r: row %r attempt %d/%d failed: %s", slug, state, attempt + 1, _ROW_GEN_ATTEMPTS, exc)
finally:
# Strips are intermediates already decoded into memory; nothing
# prunes cache/images outside the gateway loop, so drop them now.
for strip in strips:
_unlink_quietly(Path(strip))
logger.warning("pet hatch %r: row %r gave up after %.1fs: %s", slug, state, time.monotonic() - t0, last_exc)
return state, None
def hatch_pet(
@@ -209,86 +221,32 @@ def hatch_pet(
"""Turn an approved base image into a full, installed Hermes pet.
Idle falls back to the base look so the pet always renders. Raises
:class:`GenerationError` on failure. *is_cancelled* is polled cooperatively
and, once tripped, aborts before composing/saving so a stopped hatch never
writes a half-built pet.
:class:`GenerationError` on failure. Once *is_cancelled* trips, aborts before
composing/saving so a stopped hatch never writes a half-built pet.
"""
base = Path(base_image)
if not base.is_file():
raise GenerationError(f"base image not found: {base}")
sprite = provider or imagegen.resolve_provider(require_references=True)
progress = on_progress or (lambda *_: None)
cancelled = is_cancelled or (lambda: False)
label = concept or display_name or slug
frames_by_state: dict[str, list] = {}
total_rows = len(atlas.ROW_SPECS)
logger.info("pet hatch %r: generating %d animation rows", slug, total_rows)
def _gen_row(spec: tuple[str, int, int]) -> tuple[str, list | None]:
state, _row, count = spec
if cancelled():
return state, None
t0 = time.monotonic()
last_exc: Exception | None = None
# Self-healing: a roll whose poses touch (no gutters) slices badly, so
# ``components`` (raises on touching poses) drives regeneration and only
# the final attempt uses lenient ``auto`` slicing.
for attempt in range(_ROW_GEN_ATTEMPTS):
if cancelled():
return state, None
strict = attempt < _ROW_GEN_ATTEMPTS - 1
strips: list[Path] = []
try:
strips = imagegen.generate(
prompts.build_row_prompt(state, count, label, style=style),
n=1,
reference_images=[base],
provider=sprite,
prefix=f"pet_row_{state}",
# Wider canvas: each frame gets real horizontal room and clean gutters.
aspect_ratio="landscape",
)
# fit=False keeps raw columns so normalize_cells registers the whole pet at once.
method = "components" if strict else "auto"
frames = atlas.extract_strip_frames(strips[0], count, method=method, fit=False)
logger.info(
"pet hatch %r: row %r ready in %.1fs (attempt %d)",
slug, state, time.monotonic() - t0, attempt + 1,
)
return state, frames
except Exception as exc: # noqa: BLE001 - retried; one bad row is tolerated
last_exc = exc
logger.warning(
"pet hatch %r: row %r attempt %d/%d failed: %s",
slug, state, attempt + 1, _ROW_GEN_ATTEMPTS, exc,
)
finally:
# Strips are intermediates already decoded into memory; nothing
# prunes cache/images outside the gateway loop, so drop them now.
for strip in strips:
_unlink_quietly(Path(strip))
logger.warning(
"pet hatch %r: row %r gave up after %.1fs: %s",
slug, state, time.monotonic() - t0, last_exc,
)
return state, None
return _generate_row(spec, base=base, label=label, style=style, slug=slug, sprite=sprite, cancelled=cancelled)
# running-left is mirrored from running-right (consistent, one fewer generation).
generated_specs = [spec for spec in atlas.ROW_SPECS if spec[0] != "running-left"]
cancel_log = f"pet hatch {slug!r}: cancelled — dropping remaining rows"
done = 0
for state, frames in _run_parallel(
_gen_row,
generated_specs,
cancelled=cancelled,
on_cancel_log=f"pet hatch {slug!r}: cancelled — dropping remaining rows",
):
for state, frames in _run_parallel(_gen_row, generated_specs, cancelled=cancelled, on_cancel_log=cancel_log):
done += 1
progress("row", f"{state}:{done}:{total_rows}")
if frames:
frames_by_state[state] = frames
if cancelled():
raise GenerationError("hatch cancelled")
@@ -315,28 +273,14 @@ def hatch_pet(
if not validation["ok"]:
raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed")
filled_states = set(validation["filled_states"])
missing_required = sorted(_REQUIRED_STATES - filled_states)
if missing_required:
if missing_required := sorted(_REQUIRED_STATES - filled_states):
raise GenerationError(f"missing required animation row(s): {', '.join(missing_required)}")
if len(filled_states) < _MIN_FILLED_STATES:
raise GenerationError(
f"only {len(filled_states)}/{len(atlas.ROW_SPECS)} animation rows were usable; regenerate"
)
raise GenerationError(f"only {len(filled_states)}/{total_rows} animation rows were usable; regenerate")
from agent.pet import store
progress("save", slug)
logger.info("pet hatch %r: saving pet", slug)
pet = store.register_local_pet(
sheet,
slug=slug,
display_name=display_name or slug,
description=description,
)
return HatchResult(
slug=pet.slug,
display_name=pet.display_name,
spritesheet=pet.spritesheet,
states=validation["filled_states"],
validation=validation,
)
pet = store.register_local_pet(sheet, slug=slug, display_name=display_name or slug, description=description)
return HatchResult(pet.slug, pet.display_name, pet.spritesheet, validation["filled_states"], validation)
+15 -38
View File
@@ -2,22 +2,16 @@
Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the
user picks between) and per-*state* *row* prompts (grounded on the chosen base,
produce one horizontal strip of N poses). Prompts stay concise and
sprite-production oriented; the identity lock and "one transparent row" framing
matter more than flowery description.
We generate the full petdex/Codex nine-state set (see
:data:`agent.pet.generate.atlas.ROW_SPECS`) so a hatched pet is a valid
``petdex submit`` spritesheet.
produce one horizontal strip of N poses). The identity lock and "one transparent
row" framing matter more than flowery description. We generate the full
petdex/Codex nine-state set (:data:`agent.pet.generate.atlas.ROW_SPECS`) so a
hatched pet is a valid ``petdex submit`` spritesheet.
"""
from __future__ import annotations
# What each petdex/Codex state should depict (kept short — these go straight into
# the row prompt). Phrased to avoid the common sprite-gen failure modes (detached
# effects, motion lines, shadows). Critical distinction: ``running`` is the
# *working* state (in place), while ``running-right`` / ``running-left`` are the
# actual directional walk/run cycles.
# What each state depicts, phrased to avoid sprite-gen failure modes (detached
# effects, motion lines, shadows). ``running`` = working in place; ``-right/-left`` = walk.
STATE_ACTIONS: dict[str, str] = {
"idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures",
"running-right": (
@@ -78,33 +72,23 @@ def style_hint(style: str | None) -> str:
return _STYLE_HINTS.get((style or "auto").strip().lower(), "")
# Row strips are generated on the wider landscape canvas (see imagegen.generate /
# orchestrate). The extra width is what lets each pose stay a healthy size AND
# leave a real gutter — used here only to cite concrete pixel numbers.
_ASSUMED_STRIP_WIDTH = 1536
_ASSUMED_STRIP_WIDTH = 1536 # landscape row canvas; used only to cite concrete pixel numbers
def _spacing_spec(frame_count: int) -> tuple[int, int]:
"""(per-pose width px, gap px) for a row of *frame_count* poses.
Pixel counts alone don't hold — the model fills each slot edge-to-edge with
the full wingspan, so neighbors touch even when bodies are spaced. The lever
that works is proportional containment on a wide canvas: give each pose its
own equal cell and keep the ENTIRE silhouette (wings/tail/halo included)
inside it. On the 1536px landscape strip ~70% occupancy still leaves a
generous gutter, so the pet stays a normal, good-looking size — no shrinking.
Pixel counts alone don't hold — the model fills each slot edge-to-edge. The lever
that works is proportional containment: equal cells, whole silhouette inside,
~70% occupancy (still a generous gutter on 1536px, no shrinking).
"""
slots = max(1, frame_count)
slot_w = _ASSUMED_STRIP_WIDTH / slots
pose_px = round(slot_w * 0.7)
gap_px = max(48, round(slot_w * 0.3))
return pose_px, gap_px
return round(slot_w * 0.7), max(48, round(slot_w * 0.3))
# Per-draft nudges so the 4 base options are actually distinct — gpt-image returns
# near-duplicates for a single prompt. We vary the *look* (palette, build,
# expression, accents), NOT the pose, so the chosen base still grounds clean,
# consistent animation rows.
# Per-draft nudges so base options are distinct (gpt-image near-duplicates one
# prompt). Vary the *look*, NOT the pose, so the base still grounds consistent rows.
BASE_VARIATIONS: tuple[str, ...] = (
"",
"a distinctly different colour palette and markings",
@@ -116,10 +100,7 @@ BASE_VARIATIONS: tuple[str, ...] = (
def build_base_prompt(concept: str, *, style: str | None = "auto", variation: str = "") -> str:
"""The base look: a single, clean, centered full-body mascot.
*variation* differentiates one draft from the next (see :data:`BASE_VARIATIONS`).
"""
"""The base look: a single, clean, centered full-body mascot; *variation* differentiates drafts."""
concept = (concept or "a distinctive mascot creature").strip()
nudge = f" Make this design distinct: {variation}." if variation else ""
return (
@@ -138,11 +119,7 @@ def build_base_prompt(concept: str, *, style: str | None = "auto", variation: st
def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str:
"""A row strip: *frame_count* poses of the SAME character, left→right.
The attached base image is the identity source of truth; the prompt locks
species, palette, face, and props to it.
"""
"""A row strip: *frame_count* poses of the SAME character, left→right, identity locked to the attached base image."""
action = STATE_ACTIONS.get(state, "a simple idle pose")
concept = (concept or "the mascot").strip()
pose_px, gap_px = _spacing_spec(frame_count)
+10 -44
View File
@@ -1,20 +1,8 @@
"""Fetch the public petdex manifest.
``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2:
{
"generatedAt": "...",
"total": 2926,
"pets": [
{"slug": "boba", "displayName": "Boba", "kind": "creature",
"submittedBy": "railly",
"spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp",
"petJsonUrl": "https://assets.petdex.dev/.../pet.json",
"zipUrl": "https://assets.petdex.dev/.../boba.zip"},
...
]
}
``{"generatedAt", "total", "pets": [{"slug", "displayName", "kind",
"submittedBy", "spritesheetUrl", "petJsonUrl", "zipUrl"}, ...]}``.
Read-only and unauthenticated; no credentials involved.
"""
@@ -31,11 +19,9 @@ MANIFEST_URL = "https://petdex.dev/api/manifest"
_DEFAULT_TIMEOUT = 10.0
# In-process cache for the (large, slow, identical-per-call) manifest. The list
# is a static CDN object that barely changes, yet a single session can ask for
# it many times — every gallery open, plus a full re-fetch per install/select
# (``find_entry``). A short TTL collapses those into one network hit without
# going stale for long. Cleared by :func:`clear_cache` (tests).
# In-process cache for the (large, slow, identical-per-call) manifest: a static
# CDN object a single session may ask for many times (every gallery open, plus a
# re-fetch per install/select). A short TTL collapses those into one network hit.
_MANIFEST_TTL = 300.0
_cache: tuple[float, list[ManifestEntry]] | None = None
@@ -56,15 +42,12 @@ def _cache_is_warm() -> bool:
def prefetch(*, timeout: float = _DEFAULT_TIMEOUT) -> None:
"""Warm the manifest cache in a daemon thread — idempotent, never blocks.
The desktop picker calls this when it loads the (instant) local-only gallery
so the full petdex catalog is usually cached by the time it's requested,
without ever holding up the user's own pets on a network round-trip.
The desktop picker calls this when loading the instant local-only gallery so
the full catalog is usually cached by the time it's requested.
"""
global _prefetching
if _cache_is_warm():
return
with _prefetch_lock:
if _prefetching:
return
@@ -112,29 +95,16 @@ class ManifestError(RuntimeError):
def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]:
"""Return every approved pet from the public manifest.
Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to
bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on
any network/parse failure so callers can surface a clean message.
"""
"""Every approved pet from the public manifest; cached for ``_MANIFEST_TTL`` s unless *force*. Raises :class:`ManifestError`."""
global _cache
if not force and _cache_is_warm():
return _cache[1]
try:
import httpx
except ImportError as exc: # pragma: no cover - httpx is a core dep
raise ManifestError("httpx is required to fetch the petdex manifest") from exc
try:
resp = httpx.get(
MANIFEST_URL,
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": "hermes-agent-petdex"},
)
resp = httpx.get(MANIFEST_URL, timeout=timeout, follow_redirects=True, headers={"User-Agent": "hermes-agent-petdex"})
resp.raise_for_status()
payload = resp.json()
except Exception as exc: # noqa: BLE001 - normalize to one error type
@@ -143,7 +113,6 @@ def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) ->
pets = payload.get("pets") if isinstance(payload, dict) else None
if not isinstance(pets, list):
raise ManifestError("petdex manifest had no 'pets' array")
parsed = (ManifestEntry.from_dict(raw) for raw in pets if isinstance(raw, dict))
entries = [entry for entry in parsed if entry.slug and entry.spritesheet_url]
_cache = (time.monotonic(), entries)
@@ -153,7 +122,4 @@ def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) ->
def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None:
"""Return the manifest entry for *slug*, or ``None`` if not listed."""
slug = slug.strip().lower()
for entry in fetch_manifest(timeout=timeout):
if entry.slug.lower() == slug:
return entry
return None
return next((entry for entry in fetch_manifest(timeout=timeout) if entry.slug.lower() == slug), None)
+116 -280
View File
@@ -1,11 +1,11 @@
"""Decode a pet spritesheet and encode frames for a terminal.
Shared by the base CLI (writes escape bytes to stdout) and the TUI (ships the
encoded bytes to Ink) so decode + capability detection + protocol encoding
exist once. Output modes, in fidelity order: ``kitty`` (kitty, Ghostty,
WezTerm), ``iterm`` (iTerm2, WezTerm), ``sixel`` (xterm -ti vt340, foot,
mlterm, …), ``unicode`` (24-bit half-blocks; any truecolor terminal). Missing
Pillow or spritesheet degrades to an empty string rather than raising.
Shared by the base CLI (escape bytes to stdout) and the TUI (bytes shipped to
Ink) so decode + capability detection + protocol encoding exist once. Modes in
fidelity order: ``kitty`` (kitty, Ghostty, WezTerm), ``iterm`` (iTerm2, WezTerm),
``sixel`` (xterm -ti vt340, foot, mlterm, …), ``unicode`` (24-bit half-blocks).
Missing Pillow or spritesheet degrades to an empty string rather than raising
(PIL is imported lazily on purpose).
"""
from __future__ import annotations
@@ -15,17 +15,13 @@ import io
import logging
import os
import sys
import zlib
from dataclasses import KW_ONLY, dataclass
from functools import lru_cache
from itertools import groupby
from pathlib import Path
from agent.pet.constants import (
DEFAULT_SCALE,
FRAME_H,
FRAME_W,
FRAMES_PER_STATE,
PetState,
state_row_index,
)
from agent.pet.constants import DEFAULT_SCALE, FRAME_H, FRAME_W, FRAMES_PER_STATE, PetState, state_row_index
logger = logging.getLogger(__name__)
@@ -33,22 +29,14 @@ logger = logging.getLogger(__name__)
RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off")
# ───────────────────────── terminal capability detection ─────────────────────────
def _is_wezterm() -> bool:
return os.environ.get("TERM_PROGRAM", "").lower() == "wezterm" or bool(os.environ.get("WEZTERM_PANE"))
def detect_terminal_graphics() -> str:
"""Best-effort richest protocol from env vars only (never a DA1 query that could hang a pipe).
Returns ``kitty`` / ``iterm`` / ``sixel`` / ``unicode``; unknown terminals get
``unicode``, which works anywhere with truecolor.
"""
"""Richest protocol (``kitty``/``iterm``/``sixel``/``unicode``) from env vars only — never a DA1 query that could hang a pipe."""
term = os.environ.get("TERM", "").lower()
term_program = os.environ.get("TERM_PROGRAM", "").lower()
# VS Code/Cursor set TERM_PROGRAM=vscode but don't scrub inherited
# ITERM_SESSION_ID/KITTY_WINDOW_ID; trusting those emits a protocol xterm.js
# can't show (blank frame). Inline images there are opt-in, so default to
@@ -67,37 +55,27 @@ def detect_terminal_graphics() -> str:
def supports_kitty_placeholders() -> bool:
"""True when the terminal paints kitty Unicode placeholders (U+10EEEE).
Narrower than ``detect_terminal_graphics() == "kitty"``: WezTerm accepts
kitty APC transmits but lacks the placeholder grid (cells render as tofu).
"""
"""True when the terminal paints kitty Unicode placeholders; WezTerm speaks kitty APC but renders placeholders as tofu."""
return detect_terminal_graphics() == "kitty" and not _is_wezterm()
def resolve_mode(configured: str | None, *, stream=None) -> str:
"""Effective render mode from ``display.pet.render_mode`` + env; ``off`` when not a TTY."""
mode = (configured or "auto").strip().lower()
if mode not in RENDER_MODES:
mode = "auto"
mode = mode if mode in RENDER_MODES else "auto"
if mode == "off":
return "off"
stream = stream or sys.stdout
try:
if not (hasattr(stream, "isatty") and stream.isatty()):
return "off"
except (ValueError, OSError):
return "off"
return detect_terminal_graphics() if mode == "auto" else mode
# ───────────────────────── frame decoding ─────────────────────────
# Max alpha at/below which a frame is blank padding. petdex sheets are
# left-packed, so a state with fewer real frames than FRAMES_PER_STATE has
# fully transparent trailing cells; animating into one flashes the pet blank.
# Max alpha at/below which a frame is blank padding: petdex sheets are left-packed,
# so short states have transparent trailing cells; animating into one flashes blank.
_BLANK_ALPHA = 8
@@ -107,25 +85,17 @@ def _frame_is_blank(frame) -> bool:
@lru_cache(maxsize=16)
def _raw_frames(sheet_path: str, state_value: str, frame_w: int, frame_h: int, frames_per_state: int) -> tuple:
"""Cropped RGBA frames for one state row, stopping at the first blank column.
Cached; returns ``()`` on any decode failure.
"""
"""Cropped RGBA frames for one state row, stopping at the first blank column; ``()`` on any decode failure."""
try:
from PIL import Image
sheet = Image.open(Path(sheet_path)).convert("RGBA")
cols = max(1, sheet.width // frame_w)
rows = max(1, sheet.height // frame_h)
top = state_row_index(state_value, rows) * frame_h
cols, rows = max(1, sheet.width // frame_w), max(1, sheet.height // frame_h)
# Clamp to the sheet: some pets ship fewer rows than the taxonomy reserves.
if top + frame_h > sheet.height:
top = max(0, sheet.height - frame_h)
top = min(state_row_index(state_value, rows) * frame_h, max(0, sheet.height - frame_h))
frames = []
for i in range(min(frames_per_state, cols)):
left = i * frame_w
frame = sheet.crop((left, top, left + frame_w, top + frame_h))
frame = sheet.crop((i * frame_w, top, (i + 1) * frame_w, top + frame_h))
if _frame_is_blank(frame):
break
frames.append(frame)
@@ -136,15 +106,7 @@ def _raw_frames(sheet_path: str, state_value: str, frame_w: int, frame_h: int, f
@lru_cache(maxsize=8)
def _frames_for(
sheet_path: str,
state_value: str,
frame_w: int,
frame_h: int,
frames_per_state: int,
scale_w: int,
scale_h: int,
):
def _frames_for(sheet_path: str, state_value: str, frame_w: int, frame_h: int, frames_per_state: int, scale_w: int, scale_h: int):
"""Scaled :func:`_raw_frames` (both cached, so animation-time requests are free)."""
raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state)
if not raw or (scale_w, scale_h) == (frame_w, frame_h):
@@ -155,23 +117,10 @@ def _frames_for(
def state_frame_counts(
sheet_path: str | Path,
*,
frame_w: int = FRAME_W,
frame_h: int = FRAME_H,
frames_per_state: int = FRAMES_PER_STATE,
sheet_path: str | Path, *, frame_w: int = FRAME_W, frame_h: int = FRAME_H, frames_per_state: int = FRAMES_PER_STATE
) -> dict[str, int]:
"""Each driven :class:`PetState` → its real (padding-trimmed) frame count.
The gateway ships this map to the desktop canvas, which steps its own loop.
"""
return {
state.value: len(_raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state))
for state in PetState
}
# ───────────────────────── encoders ─────────────────────────
"""Each driven :class:`PetState` → its real (padding-trimmed) frame count (shipped to the desktop canvas)."""
return {s.value: len(_raw_frames(str(sheet_path), s.value, frame_w, frame_h, frames_per_state)) for s in PetState}
def _png_b64(frame) -> str:
@@ -181,31 +130,18 @@ def _png_b64(frame) -> str:
def _crop_frames_to_alpha_union(frames):
"""Crop every frame to the union opaque bbox (a stable trim across the animation).
kitty paints the whole transmitted rectangle, transparent margins included,
so an untrimmed pet looks small and adrift inside its cell box.
"""
boxes = []
for frame in frames:
try:
bbox = frame.getchannel("A").getbbox()
except Exception: # noqa: BLE001 - cosmetic; fail open
bbox = None
if bbox:
boxes.append(bbox)
"""Crop every frame to the union opaque bbox: kitty paints transparent margins too, so an untrimmed pet looks small and adrift."""
boxes = [b for b in (f.getchannel("A").getbbox() for f in frames) if b]
if not boxes:
return frames
union = (min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes))
return [f.crop(union) for f in frames]
# Nominal terminal cell size in pixels. kitty fits an image to its cell
# rectangle preserving aspect, so a frame that isn't a whole cell multiple
# rounds up, clipping the bottom row ("clipped feet") and letterboxing a blank
# row. Snapping to an exact multiple avoids that (cf. ratatui-image #57).
_CELL_W = 8
_CELL_H = 16
# Nominal terminal cell size in pixels. kitty fits an image to its cell rectangle
# preserving aspect, so a frame that isn't a whole cell multiple rounds up, clipping
# the bottom row ("clipped feet"); snapping to an exact multiple avoids that.
_CELL_W, _CELL_H = 8, 16
def _snap_frames_to_cell_grid(frames):
@@ -216,80 +152,50 @@ def _snap_frames_to_cell_grid(frames):
w, h = frames[0].size
target = (max(1, round(w / _CELL_W)) * _CELL_W, max(1, round(h / _CELL_H)) * _CELL_H)
if (w, h) == target:
return frames
return [f.resize(target, Image.LANCZOS) for f in frames]
return frames if (w, h) == target else [f.resize(target, Image.LANCZOS) for f in frames]
def _kitty_apc(ctrl: str, data: str) -> str:
"""kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces."""
"""kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces (``m=1`` = more chunks follow)."""
pieces = [data[i : i + 4096] for i in range(0, len(data), 4096)] or [""]
last = len(pieces) - 1
return "".join(
f"\x1b_G{ctrl + ',' if i == 0 else ''}m={0 if i == last else 1};{piece}\x1b\\" for i, piece in enumerate(pieces)
)
return "".join(f"\x1b_G{ctrl + ',' if i == 0 else ''}m={int(i != last)};{piece}\x1b\\" for i, piece in enumerate(pieces))
def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
"""kitty transmit+display at the cursor; ``c``/``r`` pin the cell box so frames overwrite each other."""
ctrl = "f=100,a=T,q=2"
if cell_cols:
ctrl += f",c={cell_cols}"
if cell_rows:
ctrl += f",r={cell_rows}"
ctrl = "f=100,a=T,q=2" + (f",c={cell_cols}" if cell_cols else "") + (f",r={cell_rows}" if cell_rows else "")
return _kitty_apc(ctrl, _png_b64(frame))
# ───────────────────────── kitty Unicode placeholders ─────────────────────────
# Ink owns the screen and measures every cell's width, so it can't host raw
# kitty image escapes. The placeholder protocol is the grid-safe path: transmit
# once as a virtual placement (U=1), then print ordinary-width placeholder cells
# (U+10EEEE + diacritics) whose foreground color encodes the image id; Ink counts
# them as width-1 text and the terminal paints the image underneath.
# kitty Unicode placeholders: Ink owns the screen and measures every cell, so it
# can't host raw kitty image escapes. Transmit once as a virtual placement (U=1),
# then print ordinary-width placeholder cells (U+10EEEE + row diacritic) whose
# foreground color encodes the image id; the terminal paints the image underneath.
# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
_KITTY_PLACEHOLDER = "\U0010eeee"
# Row/column diacritics, index → diacritic, verbatim from kitty's
# gen/rowcolumn-diacritics.txt. We only ever need the row index.
# Row diacritics by index, verbatim from kitty's gen/rowcolumn-diacritics.txt.
_ROWCOL_DIACRITICS: tuple[int, ...] = (
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A,
0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365,
0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F,
0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9,
0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6,
0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736,
0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A,
0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817,
0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951,
0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD,
0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C,
0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6,
0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF,
0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0,
0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6,
0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0,
0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1,
0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9,
0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2,
0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188,
0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365,
0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A,
0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD,
0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6,
0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9,
0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
)
def kitty_image_id(slug: str) -> int:
"""Deterministic per-slug image id in ``[1, 0x7FFF]`` (non-zero; encoded in the placeholder fg color) so re-renders reuse the terminal-side image."""
import zlib
return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1
@@ -299,23 +205,16 @@ def kitty_color_hex(image_id: int) -> str:
def kitty_placeholder_rows(cols: int, rows: int) -> list[str]:
"""Placeholder text grid: first cell carries the row diacritic, the rest auto-increment the column.
The foreground color (image id) is applied by the caller / Ink, not here.
"""
"""Placeholder text grid: first cell carries the row diacritic, the rest auto-increment the column (fg color applied by Ink)."""
cols = max(1, cols)
out: list[str] = []
for r in range(max(1, rows)):
idx = min(r, len(_ROWCOL_DIACRITICS) - 1)
out.append(_KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx]) + _KITTY_PLACEHOLDER * (cols - 1))
return out
return [
_KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[min(r, len(_ROWCOL_DIACRITICS) - 1)]) + _KITTY_PLACEHOLDER * (cols - 1)
for r in range(max(1, rows))
]
def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str:
"""Transmit a frame as a kitty virtual placement (``U=1``; ``q=2`` mutes replies that would corrupt Ink's output).
Re-sending with the same ``i`` replaces the image, so static placeholder cells animate underneath.
"""
"""kitty virtual placement (``U=1``; ``q=2`` mutes replies that would corrupt Ink). Re-sending the same ``i`` animates in place."""
return _kitty_apc(f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2", _png_b64(frame))
@@ -323,18 +222,19 @@ def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None
"""iTerm2 inline image (OSC 1337 File)."""
payload = _png_b64(frame)
args = ["inline=1", f"size={len(payload)}", "preserveAspectRatio=1"]
if cell_cols:
args.append(f"width={cell_cols}")
if cell_rows:
args.append(f"height={cell_rows}")
args += [f"width={cell_cols}"] if cell_cols else []
args += [f"height={cell_rows}"] if cell_rows else []
return f"\x1b]1337;File={';'.join(args)}:{payload}\x07"
def _encode_sixel(frame) -> str:
"""DEC sixel via a compact hand-rolled encoder (Pillow has no sixel writer).
def _sixel_runs(chars: list[str]) -> str:
"""Run-length encode one sixel band line (``!<n><ch>`` for runs longer than 3)."""
runs = ((ch, len(list(group))) for ch, group in groupby(chars))
return "".join("!%d%s" % (n, ch) if n > 3 else ch * n for ch, n in runs)
Quantizes to ≤255 adaptive colors; transparent pixels are skipped (render as background).
"""
def _encode_sixel(frame) -> str:
"""DEC sixel via a compact hand-rolled encoder (Pillow has none); ≤255 adaptive colors, transparent pixels skipped."""
from PIL import Image
pal = frame.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT)
@@ -342,7 +242,6 @@ def _encode_sixel(frame) -> str:
px = pal.load()
alpha = frame.getchannel("A").load()
w, h = pal.size
out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)]
used = sorted({px[x, y] for y in range(h) for x in range(w)})
for idx in used: # color registers on a 0..100 scale
@@ -350,54 +249,25 @@ def _encode_sixel(frame) -> str:
out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255))
for band in range(0, h, 6):
ys = range(band, min(band + 6, h))
for color_idx in used:
line = ["#%d" % color_idx]
run_char = None
run_len = 0
def flush():
nonlocal run_char, run_len
if run_char is None:
return
line.append("!%d%s" % (run_len, run_char) if run_len > 3 else run_char * run_len)
run_char, run_len = None, 0
for x in range(w):
bits = 0
for bit in range(6):
y = band + bit
if y < h and alpha[x, y] > 32 and px[x, y] == color_idx:
bits |= 1 << bit
ch = chr(63 + bits)
if ch == run_char:
run_len += 1
else:
flush()
run_char, run_len = ch, 1
flush()
out.append("".join(line) + "$") # carriage return within band
chars = [chr(63 + sum(1 << (y - band) for y in ys if alpha[x, y] > 32 and px[x, y] == color_idx)) for x in range(w)]
out.append("#%d" % color_idx + _sixel_runs(chars) + "$") # carriage return within band
out.append("-") # next band
out.append("\x1b\\")
return "".join(out)
return "".join(out) + "\x1b\\"
_HALF_BLOCK = "▀"
# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples.
Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]]
def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]:
"""Downscale a frame to rows of half-block cells (one terminal row = two pixel rows).
Framework-neutral representation shared by the ANSI encoder (CLI) and the
structured ``cells`` API (Ink).
"""
"""Downscale to rows of half-block cells (one terminal row = two pixel rows); shared by the ANSI encoder and Ink."""
from PIL import Image
target_cols = max(4, target_cols)
aspect = frame.height / max(1, frame.width)
target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2)
target_rows = max(2, int(round(target_cols * (frame.height / max(1, frame.width)) * 0.5)) * 2)
px = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA").load()
return [
[(px[x, y], px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0)) for x in range(target_cols)]
@@ -407,44 +277,40 @@ def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]:
def _encode_unicode(frame, *, target_cols: int) -> str:
"""Truecolor ANSI half-blocks (one char = 2 vertical pixels)."""
lines: list[str] = []
for row in _downscale_cells(frame, target_cols=target_cols):
cells = [
"\x1b[0m " if ta < 32 and ba < 32 else f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}"
for (tr, tg, tb, ta), (br, bg, bb, ba) in row
]
lines.append("".join(cells) + "\x1b[0m")
return "\n".join(lines)
def cell(top, bottom) -> str:
(tr, tg, tb, ta), (br, bg, bb, ba) = top, bottom
return "\x1b[0m " if ta < 32 and ba < 32 else f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}"
return "\n".join("".join(cell(t, b) for t, b in row) + "\x1b[0m" for row in _downscale_cells(frame, target_cols=target_cols))
# ───────────────────────── public renderer ─────────────────────────
def _cell_box(frame) -> tuple[int, int]:
"""Terminal cell box (~8×16 px per cell) for a scaled frame.
class PetRenderer:
"""Holds a pet's spritesheet and yields encoded frames per (state, index).
Construct once per pet, then call :meth:`frame` on an animation timer;
decoded frames are cached so repeated calls are cheap.
kitty stretches the image to fill ``c``×``r`` cells, so this must track the
scaled pixel size, not a native-aspect column count (that upscales small pets).
"""
return max(1, frame.width // _CELL_W), max(1, frame.height // _CELL_H)
def __init__(
self,
spritesheet: str | Path,
*,
mode: str = "unicode",
scale: float = DEFAULT_SCALE,
unicode_cols: int = 20,
frame_w: int = FRAME_W,
frame_h: int = FRAME_H,
frames_per_state: int = FRAMES_PER_STATE,
) -> None:
self.spritesheet = str(spritesheet)
self.mode = mode if mode in RENDER_MODES else "unicode"
self.scale = scale
self.unicode_cols = unicode_cols
self.frame_w = frame_w
self.frame_h = frame_h
self.frames_per_state = frames_per_state
@dataclass(eq=False)
class PetRenderer:
"""Holds a pet's spritesheet and yields encoded frames per (state, index); decoded frames are cached."""
spritesheet: str | Path
_: KW_ONLY
mode: str = "unicode"
scale: float = DEFAULT_SCALE
unicode_cols: int = 20
frame_w: int = FRAME_W
frame_h: int = FRAME_H
frames_per_state: int = FRAMES_PER_STATE
def __post_init__(self) -> None:
self.spritesheet = str(self.spritesheet)
if self.mode not in RENDER_MODES:
self.mode = "unicode"
@property
def available(self) -> bool:
@@ -454,15 +320,9 @@ class PetRenderer:
return len(self._frames(state))
def _frames(self, state: PetState | str):
return _frames_for(
self.spritesheet,
state.value if isinstance(state, PetState) else str(state),
self.frame_w,
self.frame_h,
self.frames_per_state,
max(1, int(self.frame_w * self.scale)),
max(1, int(self.frame_h * self.scale)),
)
name = state.value if isinstance(state, PetState) else str(state)
scaled = max(1, int(self.frame_w * self.scale)), max(1, int(self.frame_h * self.scale))
return _frames_for(self.spritesheet, name, self.frame_w, self.frame_h, self.frames_per_state, *scaled)
def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]:
"""One frame as a half-block cell grid for Ink's native color props; ``[]`` when unavailable."""
@@ -471,31 +331,15 @@ class PetRenderer:
return []
return _downscale_cells(frames[index % len(frames)], target_cols=cols or self.unicode_cols)
def _cell_box(self, frame) -> tuple[int, int]:
"""Terminal cell box (~8×16 px per cell) for a scaled frame.
kitty stretches the image to fill ``c``×``r`` cells, so this must track
the scaled pixel size, not a native-aspect column count (that upscales small pets).
"""
return max(1, frame.width // 8), max(1, frame.height // 16)
def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None:
"""kitty Unicode-placeholder payload ``{cols, rows, placeholder, frames}`` for one state.
``frames`` are transmit escapes (all reusing ``image_id``); ``placeholder``
is the static text grid Ink paints. ``None`` when no frame is available.
"""
"""kitty placeholder payload ``{cols, rows, placeholder, frames}`` (transmit escapes + static text grid); ``None`` if no frames."""
frames = self._frames(state)
if not frames:
return None
frames = _snap_frames_to_cell_grid(_crop_frames_to_alpha_union(frames))
cols, rows = self._cell_box(frames[0])
return {
"cols": cols,
"rows": rows,
"placeholder": kitty_placeholder_rows(cols, rows),
"frames": [_encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames],
}
cols, rows = _cell_box(frames[0])
encoded = [_encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames]
return {"cols": cols, "rows": rows, "placeholder": kitty_placeholder_rows(cols, rows), "frames": encoded}
def frame(self, state: PetState | str, index: int) -> str:
"""Encoded escape string for one frame (``index`` taken modulo the frame count), or ``""``."""
@@ -505,13 +349,11 @@ class PetRenderer:
if not frames:
return ""
frame = frames[index % len(frames)]
cell_cols, cell_rows = self._cell_box(frame)
try:
if self.mode == "kitty":
return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows)
if self.mode == "iterm":
return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows)
if self.mode in ("kitty", "iterm"):
cell_cols, cell_rows = _cell_box(frame)
encode = _encode_kitty if self.mode == "kitty" else _encode_iterm
return encode(frame, cell_cols=cell_cols, cell_rows=cell_rows)
if self.mode == "sixel":
return _encode_sixel(frame)
return _encode_unicode(frame, target_cols=self.unicode_cols)
@@ -521,13 +363,7 @@ class PetRenderer:
def build_renderer(
spritesheet: str | Path,
*,
configured_mode: str | None = None,
scale: float = DEFAULT_SCALE,
unicode_cols: int = 20,
stream=None,
spritesheet: str | Path, *, configured_mode: str | None = None, scale: float = DEFAULT_SCALE, unicode_cols: int = 20, stream=None
) -> PetRenderer:
"""Resolve the mode from config+env, then construct a :class:`PetRenderer`."""
mode = resolve_mode(configured_mode, stream=stream)
return PetRenderer(spritesheet, mode=mode, scale=scale, unicode_cols=unicode_cols)
return PetRenderer(spritesheet, mode=resolve_mode(configured_mode, stream=stream), scale=scale, unicode_cols=unicode_cols)
+10 -29
View File
@@ -1,16 +1,7 @@
"""Map agent activity → a :class:`PetState`.
This is the one place the "what is the agent doing right now?" → "which
animation row?" decision lives. Each surface feeds it the signals it already
tracks:
- CLI — ``KawaiiSpinner`` waiting/thinking state + tool outcomes.
- TUI — gateway ``tool.start/complete`` + ``message.delta/complete`` events.
- Desktop — the ``$busy``/``$awaitingResponse``/tool-event nanostores
(re-implemented in TS, but mirroring this priority order).
Keeping the priority order here (and documenting it) lets the TypeScript
mirror stay faithful without a second design.
The one place the "what is the agent doing?" → "which row?" decision lives; CLI,
TUI and Desktop (TS mirror of this priority order) feed it the signals they track.
"""
from __future__ import annotations
@@ -22,20 +13,11 @@ from agent.pet.constants import PetState
def todos_all_done(todos: Iterable[Any] | None) -> bool:
"""True iff there's ≥1 todo and every one is completed/cancelled.
The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors
the TUI's ``isTodoDone`` so the trigger is defined once across surfaces.
Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr.
"""
"""True iff ≥1 todo and all completed/cancelled (the ``JUMP`` celebrate beat; mirrors the TUI's ``isTodoDone``)."""
items = list(todos or [])
if not items:
return False
def _status(t: Any) -> Any:
return t.get("status") if isinstance(t, dict) else getattr(t, "status", None)
return all(_status(t) in ("completed", "cancelled") for t in items)
return bool(items) and all(
(t.get("status") if isinstance(t, dict) else getattr(t, "status", None)) in ("completed", "cancelled") for t in items
)
def derive_pet_state(
@@ -56,11 +38,10 @@ def derive_pet_state(
1. ``error`` → ``FAILED`` (a tool/turn just failed)
2. ``celebrate`` → ``JUMP`` (explicit success beat, e.g. todos done)
3. ``just_completed`` → ``WAVE`` (turn finished cleanly / greeting)
4. ``awaiting_input`` → ``WAITING`` (blocked on the user — a clarify/approval
prompt is open; this outranks the in-flight signals below because the turn
is paused on *you*, even though a tool is technically mid-call)
5. ``tool_running`` → ``RUN`` (a tool is executing)
6. ``reasoning`` → ``REVIEW`` (model is thinking / reading)
4. ``awaiting_input`` → ``WAITING`` (blocked on the user — outranks the in-flight
signals below because the turn is paused on *you*, even mid tool call)
5. ``tool_running`` → ``RUN``
6. ``reasoning`` → ``REVIEW``
7. ``busy`` → ``RUN`` (turn in flight, unspecified work)
8. otherwise → ``IDLE``
"""
+96 -177
View File
@@ -1,19 +1,24 @@
"""On-disk pet store — install / list / resolve pets.
Pets live under ``get_hermes_home()/pets/<slug>/`` (profile-scoped; we do NOT
reuse petdex's ``~/.codex/pets``, which the petdex CLI owns). Each pet dir holds
``pet.json`` ({id, displayName, description, spritesheetPath}) plus
``spritesheet.webp`` (or .png). The active pet is resolved from the
caller-supplied ``display.pet.slug`` so this module stays free of the config loader.
Pets live under ``get_hermes_home()/pets/<slug>/`` (profile-scoped; NOT petdex's
``~/.codex/pets``, which its CLI owns): ``pet.json`` ({id, displayName,
description, spritesheetPath}) plus ``spritesheet.webp`` (or .png). The active
pet comes from the caller-supplied ``display.pet.slug`` (no config loader here).
"""
from __future__ import annotations
import contextlib
import io
import itertools
import json
import logging
import re
import shutil
import zipfile
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
from hermes_constants import get_hermes_home
@@ -21,6 +26,9 @@ logger = logging.getLogger(__name__)
_DOWNLOAD_TIMEOUT = 60.0
_HTTP_HEADERS = {"User-Agent": "hermes-agent-petdex"}
_THUMB_FRAME_W = 192
_THUMB_FRAME_H = 208
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
class PetStoreError(RuntimeError):
@@ -63,10 +71,8 @@ def _thumb_path(slug: str) -> Path:
def _read_pet_json(directory: Path) -> dict:
pet_json = directory / "pet.json"
if not pet_json.is_file():
return {}
try:
return json.loads(pet_json.read_text(encoding="utf-8"))
return json.loads(pet_json.read_text(encoding="utf-8")) if pet_json.is_file() else {}
except (OSError, ValueError) as exc:
logger.debug("unreadable pet.json in %s: %s", directory, exc)
return {}
@@ -79,19 +85,12 @@ def _write_pet_json(directory: Path, meta: dict) -> None:
def _resolve_spritesheet(directory: Path, meta: dict) -> Path:
"""Honor ``spritesheetPath``, else probe conventional names (incl. petdex R2's ``sprite.webp``)."""
declared = str(meta.get("spritesheetPath", "") or "").strip()
for name in ([declared] if declared else []) + ["spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"]:
candidate = directory / name
if candidate.is_file():
return candidate
return directory / "spritesheet.webp" # stable default even when missing
names = ([declared] if declared else []) + ["spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"]
return next((directory / n for n in names if (directory / n).is_file()), directory / "spritesheet.webp") # stable default
def _safe_slug(slug: str) -> str:
"""Normalize a slug to a single bare path segment.
Slugs index ``pets_dir()/<slug>/`` for load/remove, so separators (``../``,
absolute paths) must not escape the pets directory; ``.``/``..`` are rejected.
"""
"""Normalize a slug to one bare path segment so ``pets_dir()/<slug>`` can never escape the pets directory."""
segment = Path(str(slug).strip()).name
return "" if segment in ("", ".", "..") else segment
@@ -99,10 +98,8 @@ def _safe_slug(slug: str) -> str:
def load_pet(slug: str) -> InstalledPet | None:
"""Return the :class:`InstalledPet` for *slug*, or ``None`` if absent."""
slug = _safe_slug(slug)
if not slug:
return None
directory = pets_dir() / slug
if not directory.is_dir():
if not slug or not directory.is_dir():
return None
meta = _read_pet_json(directory)
return InstalledPet(
@@ -115,54 +112,42 @@ def load_pet(slug: str) -> InstalledPet | None:
)
def _loaded_pet_or_raise(slug: str, error: str) -> InstalledPet:
def _usable_pet(slug: str, error: str = "") -> InstalledPet | None:
""":func:`load_pet` result only when its spritesheet is on disk; raises :class:`PetStoreError` *error* if given."""
pet = load_pet(slug)
if pet is None or not pet.exists:
if pet and pet.exists:
return pet
if error:
raise PetStoreError(error)
return pet
return None
def installed_pets() -> list[InstalledPet]:
"""Return every installed pet (dirs containing a usable spritesheet)."""
out: list[InstalledPet] = []
for child in sorted(pets_dir().iterdir()):
if not child.is_dir():
continue
pet = load_pet(child.name)
if pet and pet.exists:
out.append(pet)
return out
pets = (_usable_pet(child.name) for child in sorted(pets_dir().iterdir()) if child.is_dir())
return [pet for pet in pets if pet]
def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None:
"""The configured slug (``display.pet.slug``) if installed, else the first pet alphabetically."""
if configured_slug:
pet = load_pet(configured_slug.strip())
if pet and pet.exists:
return pet
if configured_slug and (pet := _usable_pet(configured_slug.strip())):
return pet
pets = installed_pets()
return pets[0] if pets else None
def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet:
"""Download *slug* from the manifest into the pets directory.
Idempotent: a fully-installed pet is returned as-is unless *force*. Raises
:class:`PetStoreError` / :class:`~agent.pet.manifest.ManifestError` on failure.
"""
"""Download *slug* from the manifest; idempotent unless *force*. Raises :class:`PetStoreError` / ``ManifestError``."""
from agent.pet.manifest import find_entry
slug = _safe_slug(slug)
if not slug:
raise PetStoreError("invalid pet slug")
existing = load_pet(slug)
if existing and existing.exists and not force:
if not force and (existing := _usable_pet(slug)):
return existing
entry = find_entry(slug, timeout=timeout)
if entry is None:
raise PetStoreError(f"pet '{slug}' is not in the petdex manifest")
# Host-pin asset URLs so a compromised/spoofed manifest can't redirect the
# download to an arbitrary host (matches thumbnail_png).
if not _is_petdex_host(entry.spritesheet_url):
@@ -170,7 +155,6 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI
directory = pets_dir() / slug
directory.mkdir(parents=True, exist_ok=True)
sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp"
sprite_path = directory / f"spritesheet{sprite_ext}"
_download(entry.spritesheet_url, sprite_path, timeout=timeout)
@@ -179,16 +163,16 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI
meta: dict = {}
if entry.pet_json_url and _is_petdex_host(entry.pet_json_url):
try:
meta = _download_json(entry.pet_json_url, timeout=timeout)
data = _http_get(entry.pet_json_url, timeout).json()
meta = data if isinstance(data, dict) else {}
except Exception as exc: # noqa: BLE001 - non-fatal, fall back below
logger.debug("pet.json fetch failed for %s: %s", slug, exc)
if not meta:
meta = {"id": slug, "displayName": entry.display_name, "description": ""}
meta["spritesheetPath"] = sprite_path.name
meta = meta or {"id": slug, "displayName": entry.display_name, "description": ""}
meta["spritesheetPath"] = sprite_path.name # key order matters: pet.json is written verbatim
meta.setdefault("id", slug)
meta.setdefault("displayName", entry.display_name)
_write_pet_json(directory, meta)
return _loaded_pet_or_raise(slug, f"install of '{slug}' did not produce a spritesheet")
return _usable_pet(slug, f"install of '{slug}' did not produce a spritesheet")
def slugify(name: str) -> str:
@@ -200,12 +184,8 @@ def slugify(name: str) -> str:
def unique_slug(name: str) -> str:
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
base = slugify(name)
slug = base
counter = 2
while (pets_dir() / slug).exists():
slug = f"{base}-{counter}"
counter += 1
return slug
candidates = (base if i == 1 else f"{base}-{i}" for i in itertools.count(1))
return next(slug for slug in candidates if not (pets_dir() / slug).exists())
def _write_spritesheet(source, dest: Path) -> None:
@@ -213,29 +193,16 @@ def _write_spritesheet(source, dest: Path) -> None:
if isinstance(source, (bytes, bytearray)):
dest.write_bytes(bytes(source))
return
from agent.pet.generate.atlas import _load_rgba
from PIL import Image
if isinstance(source, (str, Path)):
with Image.open(source) as opened:
image = opened.convert("RGBA")
else:
image = source.convert("RGBA")
image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
_load_rgba(source).save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
def register_local_pet(
spritesheet,
*,
slug: str,
display_name: str = "",
description: str = "",
) -> InstalledPet:
def register_local_pet(spritesheet, *, slug: str, display_name: str = "", description: str = "") -> InstalledPet:
"""Write a locally-generated pet (PIL image, WebP/PNG bytes, or path) into the store.
It appears in :func:`installed_pets` immediately and, because :func:`install_pet`
returns an on-disk pet before consulting the manifest, can be adopted without
a manifest entry.
Appears in :func:`installed_pets` immediately; :func:`install_pet` returns on-disk
pets before consulting the manifest, so no manifest entry is needed.
"""
slug = slugify(slug)
directory = pets_dir() / slug
@@ -245,49 +212,28 @@ def register_local_pet(
_write_spritesheet(spritesheet, sprite_path)
except Exception as exc: # noqa: BLE001 - normalize to one error type
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
_write_pet_json(
directory,
{
"id": slug,
"displayName": display_name or slug,
"description": description or "",
"spritesheetPath": sprite_path.name,
"createdBy": "generator",
},
)
return _loaded_pet_or_raise(slug, f"register of generated pet '{slug}' did not produce a spritesheet")
meta = {"id": slug, "displayName": display_name or slug, "description": description or "", "spritesheetPath": sprite_path.name}
_write_pet_json(directory, {**meta, "createdBy": "generator"})
return _usable_pet(slug, f"register of generated pet '{slug}' did not produce a spritesheet")
def export_pet(slug: str) -> tuple[str, bytes]:
"""Zip an installed pet's folder → ``(filename, bytes)``; dotfiles (thumbs, backups) skipped."""
import io
import zipfile
root = pets_dir()
directory = root / slug.strip()
# Traversal guard: the target must be a direct child of pets_dir.
if directory.resolve().parent != root.resolve() or not directory.is_dir():
raise PetStoreError(f"pet '{slug}' is not installed")
name = directory.name
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(directory.iterdir()):
if path.is_file() and not path.name.startswith("."):
archive.write(path, f"{name}/{path.name}")
return f"{name}.zip", buf.getvalue()
_THUMB_FRAME_W = 192
_THUMB_FRAME_H = 208
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
archive.write(path, f"{directory.name}/{path.name}")
return f"{directory.name}.zip", buf.getvalue()
def _is_petdex_host(url: str) -> bool:
"""True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF)."""
from urllib.parse import urlparse
try:
host = (urlparse(url).hostname or "").lower()
except ValueError:
@@ -295,82 +241,61 @@ def _is_petdex_host(url: str) -> bool:
return host == "petdex.dev" or host.endswith(".petdex.dev")
def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None:
"""Small idle-frame (top-left cell) PNG for *slug*, cached on disk.
def _thumb_source_bytes(slug: str, source_url: str, timeout: float) -> bytes | None:
"""Spritesheet bytes for a thumbnail: the installed sheet, else a petdex-hosted *source_url*."""
pet = _usable_pet(slug)
if pet:
with contextlib.suppress(OSError):
return pet.spritesheet.read_bytes()
if source_url and _is_petdex_host(source_url):
try:
return _http_get(source_url, timeout).content
except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder
logger.debug("thumb fetch failed for %s: %s", slug, exc)
return None
Source: the installed spritesheet, else *source_url* only when it points at
petdex (the gateway never fetches an arbitrary client URL). ``None`` when no
usable source or Pillow/network fails. Server-side so the result rides the
authenticated gateway as a same-origin data URL, sidestepping CSP/hotlink limits.
def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None:
"""Small idle-frame (top-left cell) PNG for *slug*, cached on disk; ``None`` on any failure.
Source: the installed sheet, else *source_url* only when petdex-hosted (the gateway
never fetches arbitrary client URLs). Server-side so it rides the authenticated
gateway as a same-origin data URL, sidestepping CSP/hotlink limits.
"""
slug = slug.strip()
if not slug:
return None
cache = _thumb_path(slug)
if cache.is_file():
try:
with contextlib.suppress(OSError):
return cache.read_bytes()
except OSError:
pass
sheet_bytes: bytes | None = None
pet = load_pet(slug)
if pet and pet.exists:
try:
sheet_bytes = pet.spritesheet.read_bytes()
except OSError:
pass
if sheet_bytes is None and source_url and _is_petdex_host(source_url):
try:
import httpx
resp = httpx.get(source_url, timeout=timeout, follow_redirects=True, headers=_HTTP_HEADERS)
resp.raise_for_status()
sheet_bytes = resp.content
except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder
logger.debug("thumb fetch failed for %s: %s", slug, exc)
sheet_bytes = _thumb_source_bytes(slug, source_url, timeout)
if not sheet_bytes:
return None
try:
import io
from PIL import Image
with Image.open(io.BytesIO(sheet_bytes)) as im:
frame = im.convert("RGBA").crop((0, 0, min(_THUMB_FRAME_W, im.width), min(_THUMB_FRAME_H, im.height)))
frame = frame.resize((_THUMB_W, round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W)), Image.NEAREST)
buf = io.BytesIO()
frame.save(buf, format="PNG")
data = buf.getvalue()
frame = frame.resize((_THUMB_W, round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W)), Image.NEAREST)
buf = io.BytesIO()
frame.save(buf, format="PNG")
except Exception as exc: # noqa: BLE001
logger.debug("thumb crop failed for %s: %s", slug, exc)
return None
try:
cache.write_bytes(data)
except OSError:
pass
return data
with contextlib.suppress(OSError):
cache.write_bytes(buf.getvalue())
return buf.getvalue()
def remove_pet(slug: str) -> bool:
"""Delete an installed pet directory. Returns True if anything was removed."""
import shutil
slug = _safe_slug(slug)
if not slug:
return False
# Drop the cached thumb too or a later pet reusing this slug shows the stale one.
try:
with contextlib.suppress(OSError):
_thumb_path(slug).unlink(missing_ok=True)
except OSError:
pass
directory = pets_dir() / slug
if not directory.is_dir():
return False
@@ -381,37 +306,30 @@ def remove_pet(slug: str) -> bool:
def rename_pet(slug: str, display_name: str) -> str | None:
"""Rename a pet's ``displayName`` AND realign its slug/dir to match.
Generated pets hatch under a provisional prompt-derived slug; naming on the
reveal screen makes that name the real identity. The dir (and cached thumb)
moves to ``slugify(name)`` when that's a free, different slug; otherwise the
slug stays. Returns the resulting slug, or ``None`` on failure.
Generated pets hatch under a provisional slug; naming makes that the identity.
The dir (and thumb) moves to ``slugify(name)`` when that's a free, different
slug. Returns the resulting slug, or ``None`` on failure.
"""
slug = _safe_slug(slug)
display_name = (display_name or "").strip()
if not slug or not display_name:
return None
directory = pets_dir() / slug
if not (directory / "pet.json").is_file():
if not slug or not display_name or not (directory / "pet.json").is_file():
return None
meta = _read_pet_json(directory)
if not isinstance(meta, dict):
meta = {}
meta = meta if isinstance(meta, dict) else {}
meta["displayName"] = display_name
new_slug = slug
desired = slugify(display_name)
if desired and desired != slug and not (pets_dir() / desired).exists():
try:
directory.rename(pets_dir() / desired)
try:
_thumb_path(slug).rename(_thumb_path(desired))
except OSError:
pass
directory = pets_dir() / desired
new_slug = desired
meta["id"] = new_slug
except OSError:
new_slug = slug # keep the provisional slug if the move fails
pass # keep the provisional slug if the move fails
else:
with contextlib.suppress(OSError):
_thumb_path(slug).rename(_thumb_path(desired))
directory = pets_dir() / desired
new_slug = meta["id"] = desired
try:
_write_pet_json(directory, meta)
@@ -420,7 +338,17 @@ def rename_pet(slug: str, display_name: str) -> str | None:
return new_slug
def _http_get(url: str, timeout: float):
"""GET *url* with the petdex UA, following redirects; raises on HTTP errors."""
import httpx
resp = httpx.get(url, timeout=timeout, follow_redirects=True, headers=_HTTP_HEADERS)
resp.raise_for_status()
return resp
def _download(url: str, dest: Path, *, timeout: float) -> None:
"""Stream *url* to *dest* via a ``.part`` temp file so a failed download never leaves a truncated sheet."""
import httpx
try:
@@ -433,12 +361,3 @@ def _download(url: str, dest: Path, *, timeout: float) -> None:
tmp.replace(dest)
except Exception as exc: # noqa: BLE001
raise PetStoreError(f"download failed for {url}: {exc}") from exc
def _download_json(url: str, *, timeout: float) -> dict:
import httpx
resp = httpx.get(url, timeout=timeout, follow_redirects=True, headers=_HTTP_HEADERS)
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, dict) else {}
+9 -23
View File
@@ -1,16 +1,13 @@
#!/usr/bin/env python3
"""``/plan`` — build the plan-mode prompt: a saved markdown implementation plan, no execution.
A first-class built-in (the former bundled skill fell off capped Telegram/Discord
command menus). No engine, no model-tool footprint: every surface feeds
:func:`build_plan_prompt` to the agent as a normal turn, like ``/learn`` and
``/init``, so system prompt and history stay untouched (prompt-cache safe).
A first-class built-in with no engine and no model-tool footprint: every surface
feeds :func:`build_plan_prompt` to the agent as a normal turn (like ``/learn``),
so system prompt and history stay untouched (prompt-cache safe).
"""
from __future__ import annotations
# Plan-mode ground rules + authoring craft, distilled from the retired bundled
# skill (writing-craft adapted from obra/superpowers).
# Ground rules + authoring craft (writing-craft adapted from obra/superpowers).
_PLAN_MODE_RULES = """\
For this turn, you are in PLAN MODE — planning only.
@@ -63,20 +60,9 @@ Interaction style:
def build_plan_prompt(task: str = "") -> str:
"""Build the plan-mode prompt; empty *task* asks the agent to infer it from conversation context."""
task = (task or "").strip()
if task:
task_block = f"Task to plan:\n{task}\n"
else:
task_block = (
"No explicit task was given with /plan — infer the task from the "
"current conversation context (the thing we have been discussing "
"or working toward). If the conversation does not imply a task, "
"ask a brief clarifying question.\n"
)
return (
"[/plan — plan mode]\n\n"
+ _PLAN_MODE_RULES
+ "\n"
+ task_block
+ "\n"
+ _PLAN_CRAFT
task_block = f"Task to plan:\n{task}\n" if task else (
"No explicit task was given with /plan — infer the task from the "
"current conversation context (the thing we have been discussing "
"or working toward). If the conversation does not imply a task, ask a brief clarifying question.\n"
)
return "[/plan — plan mode]\n\n" + _PLAN_MODE_RULES + "\n" + task_block + "\n" + _PLAN_CRAFT
+138 -316
View File
@@ -1,39 +1,12 @@
"""
Plugin LLM facade — host-owned LLM access for trusted plugins.
==============================================================
"""Plugin LLM facade — host-owned LLM access for trusted plugins (``ctx.llm``).
Plugins that need their own out-of-band model call (rewrite a tool error,
translate inbound text, summarise a paste, score a scheduled job) get
``ctx.llm`` on :class:`~hermes_cli.plugins.PluginContext`: ``complete`` /
``complete_structured`` (text + image inputs, JSON schema validation) and their
async siblings ``acomplete`` / ``acomplete_structured``.
Provider/model/agent_id/profile are explicit keyword arguments mirroring the
host config shape (``model.provider`` + ``model.model``) — no embedded slugs.
The host owns routing, auth, timeouts, and fallback; the plugin never sees raw
tokens or keys. Every override knob is gated by per-plugin trust flags::
plugins:
entries:
my-plugin:
llm:
allow_provider_override: true
allow_model_override: true
allowed_providers: [openrouter, anthropic] # optional
allowed_models: [openai/gpt-4o-mini] # optional
allow_agent_id_override: false
allow_profile_override: false
allow_task_override: false # borrow the host's built-in aux tasks
The gate is fail-closed: a missing config block means "no overrides".
``task=`` routes a call through a plugin-registered auxiliary model slot
(``ctx.register_auxiliary_task``). A plugin may always name a slot it
registered itself; ``allow_task_override`` additionally lets it use the host's
*built-in* auxiliary tasks. A foreign or unknown key is rejected loudly
(error + logged warning), never silently downgraded to the main model.
Backed by :func:`agent.auxiliary_client.call_llm`.
``complete`` / ``complete_structured`` (text + image inputs, JSON schema validation)
and their async siblings. Provider/model/agent_id/profile are explicit keyword
arguments mirroring the host config shape; the host owns routing, auth, timeouts
and fallback, so the plugin never sees raw tokens or keys. Every override knob is
gated by the per-plugin ``plugins.entries.<id>.llm.allow_*_override`` trust flags
(fail-closed: a missing block means "no overrides"). Backed by
:func:`agent.auxiliary_client.call_llm`.
"""
from __future__ import annotations
@@ -48,9 +21,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Uni
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Public dataclasses
# ---------------------------------------------------------------------------
# -- public dataclasses -------------------------------------------------------
@dataclass
@@ -63,7 +34,7 @@ class PluginLlmTextInput:
@dataclass
class PluginLlmImageInput:
"""Image block. Provide ``data`` (raw bytes) or ``url`` (http(s)/data: URL).
"""Image block: ``data`` (raw bytes) or ``url`` (http(s)/data: URL).
``mime_type`` is required for non-PNG bytes to render across providers."""
data: Optional[bytes] = None
@@ -74,16 +45,12 @@ class PluginLlmImageInput:
PluginLlmInput = Union[PluginLlmTextInput, PluginLlmImageInput, Dict[str, Any]]
"""A single structured input block: one of the dataclasses above or a plain dict
of the same shape (``{"type": "text", "text": ...}`` /
``{"type": "image", "data": <bytes>, "mime_type": ..., "file_name": ...}`` /
``{"type": "image", "url": ...}``)."""
"""One structured input block: a dataclass above or a plain dict of the same shape."""
@dataclass
class PluginLlmUsage:
"""Token + cost usage. All fields optional — providers differ on what they
return. ``cost_usd`` is the host's best estimate."""
"""Token + cost usage; every field optional. ``cost_usd`` is the host's best estimate."""
input_tokens: int = 0
output_tokens: int = 0
@@ -109,9 +76,8 @@ class PluginLlmCompleteResult:
class PluginLlmStructuredResult:
"""Result of :meth:`PluginLlm.complete_structured`.
``parsed`` is set only when JSON output was requested (``json_mode`` or
``json_schema``) AND the response was valid JSON; ``content_type`` is then
``"json"``, otherwise ``"text"``."""
``parsed`` is set only when JSON output was requested AND the response was
valid JSON; ``content_type`` is then ``"json"``, otherwise ``"text"``."""
text: str
provider: str
@@ -123,9 +89,7 @@ class PluginLlmStructuredResult:
audit: Dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Trust gate
# ---------------------------------------------------------------------------
# -- trust gate ---------------------------------------------------------------
@dataclass(frozen=True)
@@ -148,16 +112,12 @@ class _TrustPolicy:
# The ``allow_*_override`` config keys; each is a same-named ``_TrustPolicy`` field.
_OVERRIDE_FLAGS = (
"allow_provider_override",
"allow_model_override",
"allow_agent_id_override",
"allow_profile_override",
"allow_task_override",
"allow_provider_override", "allow_model_override", "allow_agent_id_override",
"allow_profile_override", "allow_task_override",
)
def _normalize_ref(raw: str) -> str:
"""Lower-case + strip whitespace. Used for allowlist matching."""
return (raw or "").strip().lower()
@@ -167,16 +127,12 @@ def _coerce_allowlist(raw: Any) -> tuple[Optional[frozenset], bool]:
if not isinstance(raw, list):
return None, False
normalized = [_normalize_ref(item) for item in raw if isinstance(item, str)]
allow_any = "*" in normalized
cleaned = {item for item in normalized if item and item != "*"}
return frozenset(cleaned), allow_any
return frozenset(item for item in normalized if item and item != "*"), "*" in normalized
def _resolve_trust_policy(plugin_id: str) -> _TrustPolicy:
"""Read ``plugins.entries.<plugin_id>.llm`` from config.yaml.
Missing config → fully restrictive policy. Resolved per call (not cached)
so config edits take effect without restarting the agent."""
"""Read ``plugins.entries.<plugin_id>.llm`` from config.yaml (missing → fully
restrictive). Resolved per call so config edits apply without a restart."""
if not plugin_id:
return _TrustPolicy(plugin_id="")
@@ -195,11 +151,8 @@ def _resolve_trust_policy(plugin_id: str) -> _TrustPolicy:
allowed_models, allow_any_model = _coerce_allowlist(llm_cfg.get("allowed_models"))
allowed_providers, allow_any_provider = _coerce_allowlist(llm_cfg.get("allowed_providers"))
return _TrustPolicy(
plugin_id=plugin_id,
allowed_providers=allowed_providers,
allow_any_provider=allow_any_provider,
allowed_models=allowed_models,
allow_any_model=allow_any_model,
plugin_id=plugin_id, allowed_providers=allowed_providers, allow_any_provider=allow_any_provider,
allowed_models=allowed_models, allow_any_model=allow_any_model,
**{name: bool(llm_cfg.get(name, False)) for name in _OVERRIDE_FLAGS},
)
@@ -209,7 +162,6 @@ class PluginLlmTrustError(PermissionError):
def _denied(plugin_id: str, what: str, flag: str) -> PluginLlmTrustError:
"""Uniform "flag not set" trust error."""
return PluginLlmTrustError(
f"Plugin {plugin_id!r} cannot {what} "
f"(set plugins.entries.{plugin_id}.llm.{flag} to true to allow)."
@@ -217,8 +169,7 @@ def _denied(plugin_id: str, what: str, flag: str) -> PluginLlmTrustError:
def _gate_ref_override(policy: _TrustPolicy, kind: str, requested: str) -> str:
"""Gate a ``provider`` / ``model`` override: trust flag, then optional
allowlist. Returns the stripped value or raises."""
"""Gate a ``provider`` / ``model`` override: trust flag, then optional allowlist."""
if not getattr(policy, f"allow_{kind}_override"):
raise _denied(policy.plugin_id, f"override the {kind}", f"allow_{kind}_override")
allowed = getattr(policy, f"allowed_{kind}s")
@@ -233,8 +184,7 @@ def _gate_ref_override(policy: _TrustPolicy, kind: str, requested: str) -> str:
# Overrides gated by a bare trust flag (no allowlist): ``kind`` -> denial wording.
_FLAG_ONLY_OVERRIDES = {
"agent_id": "run completions against a non-default agent id",
"profile": "override the auth profile",
"agent_id": "run completions against a non-default agent id", "profile": "override the auth profile",
}
@@ -242,9 +192,8 @@ def _check_overrides(
policy: _TrustPolicy, *, requested_provider: Optional[str], requested_model: Optional[str],
requested_agent_id: Optional[str], requested_profile: Optional[str],
) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
"""Apply the trust gate; each override is gated independently, in the order
provider, model, agent_id, profile. Returns ``(provider, model, agent_id,
profile)`` (agent_id unstripped) or raises :class:`PluginLlmTrustError`."""
"""Gate each override independently, in the order provider, model, agent_id,
profile. Returns ``(provider, model, agent_id, profile)`` (agent_id unstripped)."""
final_provider = _gate_ref_override(policy, "provider", requested_provider) if requested_provider else None
final_model = _gate_ref_override(policy, "model", requested_model) if requested_model else None
for kind, requested in (("agent_id", requested_agent_id), ("profile", requested_profile)):
@@ -255,17 +204,16 @@ def _check_overrides(
def _resolve_task_ownership(plugin_id: str) -> tuple[frozenset, frozenset]:
"""Return ``(owned_keys, builtin_keys)`` for the task trust gate.
"""``(owned_keys, builtin_keys)`` for the task trust gate.
Imports are lazy (circular import at plugin discovery). An unreadable
registry yields empty sets, failing the gate closed. Ownership matches on
the canonical id ``ctx.llm`` is bound to (``manifest.key or manifest.name``),
which is what ``register_auxiliary_task`` stores as the entry's ``plugin``."""
Imports are lazy (circular import at plugin discovery); an unreadable registry
yields empty sets, failing the gate closed. Ownership matches on the canonical id
``ctx.llm`` is bound to (``manifest.key or manifest.name``), which is what
``register_auxiliary_task`` stores as the entry's ``plugin``."""
owned: set = set()
builtin: set = set()
try:
from hermes_cli.plugins import get_plugin_auxiliary_tasks
owned = {
e.get("key") for e in get_plugin_auxiliary_tasks()
if e.get("plugin") == plugin_id and isinstance(e.get("key"), str) and e.get("key")
@@ -274,30 +222,21 @@ def _resolve_task_ownership(plugin_id: str) -> tuple[frozenset, frozenset]:
pass
try:
from hermes_cli.main import _AUX_TASKS
builtin = {k for k, _name, _desc in _AUX_TASKS}
except Exception: # pragma: no cover — main import failure
pass
return frozenset(owned), frozenset(builtin)
def _check_task(
policy: _TrustPolicy,
*,
plugin_id: str,
requested_task: Optional[str],
) -> Optional[str]:
def _check_task(policy: _TrustPolicy, *, plugin_id: str, requested_task: Optional[str]) -> Optional[str]:
"""Validate a plugin's requested auxiliary ``task`` key.
* unset / ``""`` / ``"auto"`` → ``None`` (main-model path).
* a key the plugin registered itself → allowed.
* a built-in key → allowed only with ``allow_task_override``.
* anything else → raises + logs a warning. Never silently downgraded to
``auto``: that would mask the misconfiguration and could route to a main
model the user steered elsewhere on purpose."""
if not requested_task:
return None
task = requested_task.strip()
unset / ``""`` / ``"auto"`` → ``None`` (main-model path); a key the plugin
registered itself → allowed; a built-in key → only with ``allow_task_override``;
anything else raises + logs. Never silently downgraded to ``auto``: that would
mask the misconfiguration and could route to a main model the user steered
elsewhere on purpose."""
task = (requested_task or "").strip()
if not task or task.lower() == "auto":
return None
@@ -323,9 +262,7 @@ def _check_task(
)
# ---------------------------------------------------------------------------
# Input normalization
# ---------------------------------------------------------------------------
# -- input normalization ------------------------------------------------------
def _normalize_input_block(block: PluginLlmInput) -> Dict[str, Any]:
@@ -370,18 +307,12 @@ def _image_part(norm: Dict[str, Any]) -> Dict[str, Any]:
def _build_structured_messages(
*,
instructions: str,
inputs: Sequence[PluginLlmInput],
json_mode: bool,
json_schema: Optional[Any],
schema_name: Optional[str],
system_prompt: Optional[str],
*, instructions: str, inputs: Sequence[PluginLlmInput], json_mode: bool,
json_schema: Optional[Any], schema_name: Optional[str], system_prompt: Optional[str],
) -> List[Dict[str, Any]]:
"""Build OpenAI-style messages for a structured call: optional system
message (prompt + JSON-only directive), then a user message whose first
text part is the instructions (+ schema name / JSON schema) followed by
the input blocks."""
"""OpenAI-style messages for a structured call: optional system message (prompt +
JSON-only directive), then a user message whose first text part is the
instructions (+ schema name / JSON schema) followed by the input blocks."""
messages: List[Dict[str, Any]] = []
sys_parts: List[str] = [system_prompt.strip()] if system_prompt else []
if json_mode or json_schema is not None:
@@ -402,7 +333,6 @@ def _build_structured_messages(
schema_text = str(json_schema)
header = f"{header}\n\nJSON schema:\n{schema_text}"
user_parts: List[Dict[str, Any]] = [{"type": "text", "text": header}]
for block in inputs:
norm = _normalize_input_block(block) # always "text" or "image"
user_parts.append({"type": "text", "text": norm["text"]} if norm["type"] == "text" else _image_part(norm))
@@ -410,16 +340,14 @@ def _build_structured_messages(
return messages
# ---------------------------------------------------------------------------
# JSON parsing
# ---------------------------------------------------------------------------
# -- JSON parsing / response extraction --------------------------------------
_FENCE_RE = re.compile(r"```(?:json)?\s*(.+?)```", re.DOTALL | re.IGNORECASE)
def _strip_code_fences(text: str) -> str:
"""Return the first fenced code block's body, or the stripped text when unfenced."""
"""The first fenced code block's body, or the stripped text when unfenced."""
match = _FENCE_RE.search(text)
return match.group(1).strip() if match else text.strip()
@@ -427,10 +355,9 @@ def _strip_code_fences(text: str) -> str:
def _parse_structured_text(
*, text: str, json_mode: bool, json_schema: Optional[Any]
) -> tuple[Optional[Any], str]:
"""Return ``(parsed, content_type)``: ``"json"`` when parsing (and schema
validation, if a schema was given) succeeded, ``"text"`` otherwise.
Schema violations raise ``ValueError``; a missing ``jsonschema`` package
skips validation with a debug log."""
"""``(parsed, content_type)``: ``"json"`` when parsing (and schema validation, if
given) succeeded, ``"text"`` otherwise. Schema violations raise ``ValueError``;
a missing ``jsonschema`` package skips validation with a debug log."""
if not (json_mode or json_schema is not None) or not text:
return None, "text"
@@ -446,46 +373,41 @@ def _parse_structured_text(
except ImportError:
logger.debug("jsonschema unavailable; skipping schema validation")
except jsonschema.ValidationError as exc: # type: ignore[attr-defined]
raise ValueError(
f"Plugin LLM structured output did not match schema: {exc.message}"
) from exc
raise ValueError(f"Plugin LLM structured output did not match schema: {exc.message}") from exc
return parsed, "json"
# ---------------------------------------------------------------------------
# Response extraction
# ---------------------------------------------------------------------------
def _extract_usage(response: Any) -> PluginLlmUsage:
"""Pull token usage out of an OpenAI-shaped response, tolerating provider
naming differences (Anthropic via the aux adapter: ``prompt_tokens`` /
``completion_tokens``; direct OpenAI adds ``cache_read_input_tokens``)."""
usage = PluginLlmUsage()
"""Token usage from an OpenAI-shaped response, tolerating provider naming
(``prompt_tokens``/``completion_tokens`` vs ``input_tokens``/``output_tokens``,
``cache_read_input_tokens`` vs ``cache_read_tokens``)."""
raw = getattr(response, "usage", None)
if raw is None:
return usage
return PluginLlmUsage()
def _g(name: str) -> int:
v = getattr(raw, name, None)
if v is None and isinstance(raw, dict):
v = raw.get(name)
try:
return int(v) if v is not None else 0
except (TypeError, ValueError):
return 0
def _g(*names: str) -> int:
for name in names:
v = getattr(raw, name, None)
if v is None and isinstance(raw, dict):
v = raw.get(name)
try:
if v is not None and int(v):
return int(v)
except (TypeError, ValueError):
pass
return 0
usage.input_tokens = _g("prompt_tokens") or _g("input_tokens")
usage.output_tokens = _g("completion_tokens") or _g("output_tokens")
usage.total_tokens = _g("total_tokens") or (usage.input_tokens + usage.output_tokens)
usage.cache_read_tokens = _g("cache_read_input_tokens") or _g("cache_read_tokens")
usage.cache_write_tokens = _g("cache_creation_input_tokens") or _g("cache_write_tokens")
return usage
inp, out = _g("prompt_tokens", "input_tokens"), _g("completion_tokens", "output_tokens")
return PluginLlmUsage(
input_tokens=inp, output_tokens=out, total_tokens=_g("total_tokens") or (inp + out),
cache_read_tokens=_g("cache_read_input_tokens", "cache_read_tokens"),
cache_write_tokens=_g("cache_creation_input_tokens", "cache_write_tokens"),
)
def _extract_text(response: Any) -> str:
"""Pull the assistant text out of an OpenAI-shaped response object."""
"""Assistant text of an OpenAI-shaped response (string or text-part list content)."""
try:
content = getattr(response.choices[0].message, "content", None)
if isinstance(content, str):
@@ -512,18 +434,14 @@ def _main_config_value(reader: str, default: str) -> str:
def _resolve_attribution(
*,
provider_override: Optional[str],
model_override: Optional[str],
response: Any,
*, provider_override: Optional[str], model_override: Optional[str], response: Any,
route_info: Optional[Dict[str, str]] = None,
) -> tuple[str, str]:
"""Decide what to record as ``result.provider`` / ``result.model``.
"""``(provider, model)`` to record on the result.
Provider: route selected by ``auxiliary_client`` > explicit override >
current main provider > ``"auto"``. Model: ``response.model`` (providers
return the canonical id that actually ran, e.g. ``gpt-4o-2024-08-06``) >
route > override > current main model > ``"default"``."""
Provider: route selected by ``auxiliary_client`` > explicit override > current
main provider > ``"auto"``. Model: ``response.model`` (the canonical id that
actually ran) > route > override > current main model > ``"default"``."""
route_info = route_info or {}
provider = route_info.get("provider") or provider_override or _main_config_value("_read_main_provider", "auto")
response_model = getattr(response, "model", None)
@@ -532,14 +450,12 @@ def _resolve_attribution(
return provider, route_info.get("model") or model_override or _main_config_value("_read_main_model", "default")
# ---------------------------------------------------------------------------
# PluginLlm facade
# ---------------------------------------------------------------------------
# -- PluginLlm facade ---------------------------------------------------------
def _json_response_format(*, json_mode: bool, json_schema: Optional[Any]) -> Optional[Dict[str, Any]]:
"""``extra_body.response_format`` for the request; falls back to
``json_object`` without a schema so schema-blind providers still get a hint."""
"""``extra_body.response_format``; falls back to ``json_object`` without a
schema so schema-blind providers still get a hint."""
if json_schema is not None:
schema = {"name": "plugin_structured_output", "schema": json_schema, "strict": False}
return {"response_format": {"type": "json_schema", "json_schema": schema}}
@@ -552,8 +468,7 @@ def _structured_spec(
name: str, instructions: str, input: Sequence[PluginLlmInput], system_prompt: Optional[str],
json_mode: bool, json_schema: Optional[Any], schema_name: Optional[str],
) -> Dict[str, Any]:
"""Argument check for the structured methods (runs before the trust gate);
returns the spec ``_gate`` / ``_finish`` consume."""
"""Argument check for the structured methods (runs before the trust gate)."""
if not instructions or not instructions.strip():
raise ValueError(f"{name} requires non-empty instructions")
if not input:
@@ -568,19 +483,13 @@ class PluginLlm:
"""Host-owned LLM access for one trusted plugin.
Constructed by :class:`hermes_cli.plugins.PluginContext` and exposed as
``ctx.llm``; the constructor binds plugin identity for trust enforcement,
so plugins should not instantiate it directly.
Every public method is ``_gate`` (trust checks → call kwargs) →
``_invoke_*`` (host ``call_llm`` or injected caller) → ``_finish``
(result + audit log); the sync/async and plain/structured variants differ
only in which pieces they pass through."""
``ctx.llm``; the constructor binds plugin identity for trust enforcement, so
plugins should not instantiate it directly. Every public method is ``_gate``
(trust checks → call kwargs) → ``_invoke_*`` (host ``call_llm`` or injected
caller) → ``_finish`` (result + audit log)."""
def __init__(
self,
*,
plugin_id: str,
policy_loader: Optional[Callable[[str], _TrustPolicy]] = None,
self, *, plugin_id: str, policy_loader: Optional[Callable[[str], _TrustPolicy]] = None,
sync_caller: Optional[Callable[..., Any]] = None,
async_caller: Optional[Callable[..., Awaitable[Any]]] = None,
) -> None:
@@ -589,126 +498,72 @@ class PluginLlm:
self._sync_caller = sync_caller
self._async_caller = async_caller
# -- public sync API ----------------------------------------------------
# -- public API -----------------------------------------------------------
def complete(
self,
messages: List[Dict[str, Any]],
*,
provider: Optional[str] = None,
model: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
timeout: Optional[float] = None,
agent_id: Optional[str] = None,
profile: Optional[str] = None,
purpose: Optional[str] = None,
task: Optional[str] = None,
self, messages: List[Dict[str, Any]], *, provider: Optional[str] = None, model: Optional[str] = None,
temperature: Optional[float] = None, max_tokens: Optional[int] = None,
timeout: Optional[float] = None, agent_id: Optional[str] = None, profile: Optional[str] = None,
purpose: Optional[str] = None, task: Optional[str] = None,
) -> PluginLlmCompleteResult:
"""Run a host-owned chat completion against the user's active model.
``messages`` is the standard OpenAI shape. ``provider``/``model``/
``agent_id``/``profile`` are each gated by
``plugins.entries.<id>.llm.allow_*_override``. ``task`` routes through
a plugin-registered auxiliary slot (see :func:`_check_task`)."""
``provider``/``model``/``agent_id``/``profile`` are each gated by
``plugins.entries.<id>.llm.allow_*_override``. ``task`` routes through a
plugin-registered auxiliary slot (see :func:`_check_task`)."""
agent, kw = self._gate(provider, model, agent_id, profile, task, messages, temperature, max_tokens, timeout)
return self._finish("complete", agent, kw, self._invoke_sync(kw), purpose)
def complete_structured(
self,
*,
instructions: str,
input: Sequence[PluginLlmInput],
json_schema: Optional[Any] = None,
json_mode: bool = False,
schema_name: Optional[str] = None,
system_prompt: Optional[str] = None,
provider: Optional[str] = None,
model: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
timeout: Optional[float] = None,
agent_id: Optional[str] = None,
profile: Optional[str] = None,
purpose: Optional[str] = None,
task: Optional[str] = None,
self, *, instructions: str, input: Sequence[PluginLlmInput], json_schema: Optional[Any] = None,
json_mode: bool = False, schema_name: Optional[str] = None, system_prompt: Optional[str] = None,
provider: Optional[str] = None, model: Optional[str] = None, temperature: Optional[float] = None,
max_tokens: Optional[int] = None, timeout: Optional[float] = None, agent_id: Optional[str] = None,
profile: Optional[str] = None, purpose: Optional[str] = None, task: Optional[str] = None,
) -> PluginLlmStructuredResult:
"""Run a bounded host-owned structured completion.
``input`` accepts text and image blocks. With ``json_mode=True`` or a
``json_schema`` the response is parsed (and validated when the optional
``jsonschema`` package is installed) into ``result.parsed``.
``task`` routes as in :meth:`complete`."""
``jsonschema`` package is installed) into ``result.parsed``."""
spec = _structured_spec("complete_structured", instructions, input, system_prompt, json_mode, json_schema, schema_name)
agent, kw = self._gate(provider, model, agent_id, profile, task, None, temperature, max_tokens, timeout, spec)
return self._finish("complete_structured", agent, kw, self._invoke_sync(kw), purpose, spec)
# -- public async API ---------------------------------------------------
async def acomplete(
self,
messages: List[Dict[str, Any]],
*,
provider: Optional[str] = None,
model: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
timeout: Optional[float] = None,
agent_id: Optional[str] = None,
profile: Optional[str] = None,
purpose: Optional[str] = None,
task: Optional[str] = None,
self, messages: List[Dict[str, Any]], *, provider: Optional[str] = None, model: Optional[str] = None,
temperature: Optional[float] = None, max_tokens: Optional[int] = None,
timeout: Optional[float] = None, agent_id: Optional[str] = None, profile: Optional[str] = None,
purpose: Optional[str] = None, task: Optional[str] = None,
) -> PluginLlmCompleteResult:
"""Async sibling of :meth:`complete`."""
agent, kw = self._gate(provider, model, agent_id, profile, task, messages, temperature, max_tokens, timeout)
return self._finish("acomplete", agent, kw, await self._invoke_async(kw), purpose)
async def acomplete_structured(
self,
*,
instructions: str,
input: Sequence[PluginLlmInput],
json_schema: Optional[Any] = None,
json_mode: bool = False,
schema_name: Optional[str] = None,
system_prompt: Optional[str] = None,
provider: Optional[str] = None,
model: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
timeout: Optional[float] = None,
agent_id: Optional[str] = None,
profile: Optional[str] = None,
purpose: Optional[str] = None,
task: Optional[str] = None,
self, *, instructions: str, input: Sequence[PluginLlmInput], json_schema: Optional[Any] = None,
json_mode: bool = False, schema_name: Optional[str] = None, system_prompt: Optional[str] = None,
provider: Optional[str] = None, model: Optional[str] = None, temperature: Optional[float] = None,
max_tokens: Optional[int] = None, timeout: Optional[float] = None, agent_id: Optional[str] = None,
profile: Optional[str] = None, purpose: Optional[str] = None, task: Optional[str] = None,
) -> PluginLlmStructuredResult:
"""Async sibling of :meth:`complete_structured`."""
spec = _structured_spec("acomplete_structured", instructions, input, system_prompt, json_mode, json_schema, schema_name)
agent, kw = self._gate(provider, model, agent_id, profile, task, None, temperature, max_tokens, timeout, spec)
return self._finish("acomplete_structured", agent, kw, await self._invoke_async(kw), purpose, spec)
# -- shared core --------------------------------------------------------
# -- shared core ----------------------------------------------------------
def _gate(
self,
provider: Optional[str],
model: Optional[str],
agent_id: Optional[str],
profile: Optional[str],
task: Optional[str],
messages: Optional[List[Dict[str, Any]]],
temperature: Optional[float],
max_tokens: Optional[int],
timeout: Optional[float],
spec: Optional[Dict[str, Any]] = None,
self, provider: Optional[str], model: Optional[str], agent_id: Optional[str], profile: Optional[str],
task: Optional[str], messages: Optional[List[Dict[str, Any]]], temperature: Optional[float],
max_tokens: Optional[int], timeout: Optional[float], spec: Optional[Dict[str, Any]] = None,
) -> tuple[Optional[str], Dict[str, Any]]:
"""Run the trust gate (task first, then overrides), then — for a
structured ``spec`` — build messages/response_format (input-shape errors
surface only after trust passes). Returns the effective agent id
(result-only) and the call kwargs handed to ``_invoke_*`` / an injected
caller, in the documented order: messages, provider_override,
model_override, profile_override, temperature, max_tokens, timeout,
extra_body, task."""
"""Trust gate (task first, then overrides), then — for a structured ``spec`` —
build messages/response_format (input-shape errors surface only after trust
passes). Returns the effective agent id and the call kwargs, in the documented
order: messages, provider_override, model_override, profile_override,
temperature, max_tokens, timeout, extra_body, task."""
policy = self._policy_loader(self._plugin_id)
eff_task = _check_task(policy, plugin_id=self._plugin_id, requested_task=task)
eff_provider, eff_model, eff_agent, eff_profile = _check_overrides(
@@ -720,25 +575,14 @@ class PluginLlm:
messages = _build_structured_messages(**spec)
extra_body = _json_response_format(json_mode=spec["json_mode"], json_schema=spec["json_schema"])
return eff_agent, dict(
messages=messages,
provider_override=eff_provider,
model_override=eff_model,
profile_override=eff_profile,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
extra_body=extra_body,
task=eff_task,
messages=messages, provider_override=eff_provider, model_override=eff_model,
profile_override=eff_profile, temperature=temperature, max_tokens=max_tokens,
timeout=timeout, extra_body=extra_body, task=eff_task,
)
def _finish(
self,
name: str,
agent_id: Optional[str],
kw: Dict[str, Any],
invoked: tuple[str, str, Any],
purpose: Optional[str],
spec: Optional[Dict[str, Any]] = None,
self, name: str, agent_id: Optional[str], kw: Dict[str, Any], invoked: tuple[str, str, Any],
purpose: Optional[str], spec: Optional[Dict[str, Any]] = None,
) -> Any:
"""Build the result object + audit dict and emit the INFO audit line."""
real_provider, real_model, response = invoked
@@ -761,27 +605,21 @@ class PluginLlm:
logger.info(fmt + "tokens=%d", *log_args, usage.total_tokens)
return cls(**fields, audit=audit)
# -- host invocation ---------------------------------------------------
# -- host invocation ------------------------------------------------------
@staticmethod
def _host_kwargs(kw: Dict[str, Any]) -> tuple[Dict[str, Any], Optional[Dict[str, str]]]:
"""Translate call kwargs into ``call_llm`` kwargs. The auth profile
rides in ``extra_body.metadata.auth_profile``; ``route_info`` is only
requested when routing through a task slot."""
"""Call kwargs → ``call_llm`` kwargs. The auth profile rides in
``extra_body.metadata.auth_profile``; ``route_info`` is only requested when
routing through a task slot."""
merged_extra = dict(kw["extra_body"] or {})
if kw["profile_override"]:
merged_extra.setdefault("metadata", {})["auth_profile"] = kw["profile_override"]
route_info: Optional[Dict[str, str]] = {} if kw["task"] else None
return dict(
task=kw["task"],
provider=kw["provider_override"],
model=kw["model_override"],
messages=kw["messages"],
temperature=kw["temperature"],
max_tokens=kw["max_tokens"],
timeout=kw["timeout"],
extra_body=merged_extra or None,
route_info=route_info,
task=kw["task"], provider=kw["provider_override"], model=kw["model_override"],
messages=kw["messages"], temperature=kw["temperature"], max_tokens=kw["max_tokens"],
timeout=kw["timeout"], extra_body=merged_extra or None, route_info=route_info,
), route_info
@staticmethod
@@ -793,10 +631,9 @@ class PluginLlm:
return provider, model, response
def _invoke_sync(self, kw: Dict[str, Any]) -> tuple[str, str, Any]:
"""Invoke the host's ``call_llm`` (lazy import: circular deps at plugin
discovery) and return ``(provider, model, response)``. ``task`` is
already trust-checked; ``None`` keeps the main model. An injected
``sync_caller`` replaces the whole path and receives the call kwargs."""
"""Host ``call_llm`` (lazy import: circular deps at plugin discovery) →
``(provider, model, response)``. An injected ``sync_caller`` replaces the
whole path and receives the call kwargs."""
if self._sync_caller is not None:
return self._sync_caller(**kw)
from agent.auxiliary_client import call_llm
@@ -812,31 +649,16 @@ class PluginLlm:
return self._attributed(kw, await async_call_llm(**call_kw), route_info)
# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------
def make_plugin_llm_for_test(
*,
plugin_id: str,
policy: _TrustPolicy,
sync_caller: Optional[Callable[..., Any]] = None,
*, plugin_id: str, policy: _TrustPolicy, sync_caller: Optional[Callable[..., Any]] = None,
async_caller: Optional[Callable[..., Awaitable[Any]]] = None,
) -> PluginLlm:
""":class:`PluginLlm` with an injected policy and caller (no config.yaml,
no provider). Not part of the public plugin API."""
""":class:`PluginLlm` with an injected policy and caller (no config.yaml, no
provider). Not part of the public plugin API."""
return PluginLlm(plugin_id=plugin_id, policy_loader=lambda _pid: policy, sync_caller=sync_caller, async_caller=async_caller)
__all__ = [
"PluginLlm",
"PluginLlmTextInput",
"PluginLlmImageInput",
"PluginLlmInput",
"PluginLlmUsage",
"PluginLlmCompleteResult",
"PluginLlmStructuredResult",
"PluginLlmTrustError",
"make_plugin_llm_for_test",
"PluginLlm", "PluginLlmTextInput", "PluginLlmImageInput", "PluginLlmInput", "PluginLlmUsage",
"PluginLlmCompleteResult", "PluginLlmStructuredResult", "PluginLlmTrustError", "make_plugin_llm_for_test",
]
-2
View File
@@ -77,7 +77,6 @@ def _worker(dispatcher: _ConsumerDispatcher) -> None:
def _registered_callbacks(hook_name: str) -> tuple[Callable[..., Any], ...]:
try:
from hermes_cli import plugins
return plugins.iter_hook_callbacks(hook_name)
except Exception:
logger.debug("plugin stream hook callback lookup failed: %s", hook_name, exc_info=True)
@@ -149,7 +148,6 @@ def stream_reasoning_deltas_enabled() -> bool:
"""Return True only when the user opted plugins into reasoning deltas."""
try:
from hermes_cli import config as config_mod
config = config_mod.load_config()
return bool(config_mod.cfg_get(config, "plugins", "stream_reasoning_deltas", default=False))
except Exception:
+31 -52
View File
@@ -2,15 +2,10 @@
Every Hermes request to the Nous Portal (main loop, auxiliary client, fallback
paths) must carry the same product-attribution tags, sent in OpenAI-compatible
``extra_body['tags']``::
["product=hermes-agent", "client=hermes-client-v<__version__>"]
One helper instead of inlined literals: the call sites drifted apart before,
and tests can assert one tag list everywhere. The version is read live from
``hermes_cli.__version__`` (the release script bumps that single string) — do
NOT pre-compute it as a module constant in consumers; it can change at runtime
(editable installs, hot reload).
``extra_body['tags']``: ``["product=hermes-agent", "client=hermes-client-v<__version__>"]``.
The version is read live from ``hermes_cli.__version__`` — do NOT pre-compute it
as a module constant in consumers; it can change at runtime (editable installs,
hot reload).
"""
from __future__ import annotations
@@ -19,32 +14,25 @@ from contextvars import ContextVar
from typing import List, Optional
# Ambient conversation id (ATTRIBUTION value, sent as ``conversation=<id>``).
# The agent loop publishes it at turn entry; the dozens of auxiliary call
# sites funnelling through ``auxiliary_client.call_llm`` (no session handle)
# pick it up via ``nous_portal_tags()`` instead of threading a session_id
# parameter everywhere. A ContextVar, not a module global, so concurrent agents
# in one process (gateway sessions, delegate subagents) never see each other's
# id; ``tools.thread_context.propagate_context_to_thread`` workers inherit it,
# bare threads capture it at spawn time.
_conversation_id: ContextVar[Optional[str]] = ContextVar(
"nous_portal_conversation_id", default=None
)
# The agent loop publishes it at turn entry; auxiliary call sites funnelling
# through ``auxiliary_client.call_llm`` (no session handle) pick it up via
# ``nous_portal_tags()``. A ContextVar so concurrent agents in one process never
# see each other's id; ``propagate_context_to_thread`` workers inherit it.
_conversation_id: ContextVar[Optional[str]] = ContextVar("nous_portal_conversation_id", default=None)
# Ambient affinity scope (ROUTING value): OpenRouter's sticky ``session_id``,
# Nous Portal's sticky key and xAI's ``x-grok-conv-id`` pin a conversation to
# one backend/prompt cache. Usually equal to the conversation id, but a host
# that mints one physical session per RESPONSE must route on the key it
# declared for the whole chat (``prompt_cache_scope.declared_conversation_scope``).
# Only that declared value is published; unset means consumers fall back to the
# conversation id, so delegate trees keep sharing their parent's sticky key.
_affinity_scope: ContextVar[Optional[str]] = ContextVar(
"hermes_affinity_scope", default=None
)
# Ambient affinity scope (ROUTING value): OpenRouter's sticky ``session_id``, Nous
# Portal's sticky key and xAI's ``x-grok-conv-id`` pin a conversation to one
# backend/prompt cache. Usually equal to the conversation id, but a host that mints
# one physical session per RESPONSE must route on the key it declared for the whole
# chat (``prompt_cache_scope.declared_conversation_scope``). Only that declared value
# is published; unset means consumers fall back to the conversation id, so delegate
# trees keep sharing their parent's sticky key.
_affinity_scope: ContextVar[Optional[str]] = ContextVar("hermes_affinity_scope", default=None)
def _reset_var(var: ContextVar, token) -> None:
"""Reset ``var``; a token from another Context (reset on a different
thread) falls back to clearing rather than raising in cleanup paths."""
"""Reset ``var``; a token from another Context (reset on a different thread)
falls back to clearing rather than raising in cleanup paths."""
try:
var.reset(token)
except Exception:
@@ -62,16 +50,14 @@ def reset_affinity_scope(token) -> None:
def get_affinity_scope() -> Optional[str]:
"""Return the declared routing/affinity scope, or ``None`` when unset."""
return _affinity_scope.get()
def set_conversation_context(conversation_id: Optional[str]):
"""Publish the active conversation id for ambient Portal tagging.
"""Publish the active conversation id for ambient Portal tagging; returns the token.
Called by the agent loop at turn entry with the session-lineage ROOT id
(so the tag survives context-compression rotation). ``None`` clears.
Returns the ContextVar token for ``reset_conversation_context``.
Called by the agent loop at turn entry with the session-lineage ROOT id (so
the tag survives context-compression rotation). ``None`` clears.
"""
return _conversation_id.set(conversation_id or None)
@@ -82,36 +68,29 @@ def reset_conversation_context(token) -> None:
def get_conversation_context() -> Optional[str]:
"""Return the ambient conversation id, or ``None`` when unset."""
return _conversation_id.get()
def _hermes_version() -> str:
"""Current Hermes release version; ``"unknown"`` if hermes_cli is unimportable."""
def hermes_client_tag() -> str:
"""``client=hermes-client-v<MAJOR>.<MINOR>.<PATCH>`` ("unknown" if hermes_cli is unimportable)."""
try:
from hermes_cli import __version__
return __version__
except Exception:
return "unknown"
def hermes_client_tag() -> str:
"""``client=hermes-client-v<MAJOR>.<MINOR>.<PATCH>``."""
return f"client=hermes-client-v{_hermes_version()}"
__version__ = "unknown"
return f"client=hermes-client-v{__version__}"
def conversation_tag(session_id: str) -> str:
"""``conversation=<session_id>`` — high-cardinality, so only appended when
a session id is actually available, never in the always-on base set."""
"""``conversation=<session_id>`` — high-cardinality, so only appended when a
session id is actually available, never in the always-on base set."""
return f"conversation={session_id}"
def nous_portal_tags(session_id: str | None = None) -> List[str]:
"""Return a fresh list of the canonical Nous Portal tags.
"""Fresh list of the canonical Nous Portal tags.
The ambient conversation context (lineage ROOT id published by the agent
loop) wins over the explicit ``session_id``, which remains a fallback for
callers outside any agent turn; with neither, the two-tag base set.
The ambient conversation context (lineage ROOT id) wins over the explicit
``session_id``, a fallback for callers outside any agent turn.
"""
tags = ["product=hermes-agent", hermes_client_tag()]
effective = get_conversation_context() or session_id
+49 -91
View File
@@ -1,9 +1,9 @@
"""Process-level bootstrap helpers for ``run_agent``.
Lazy OpenAI SDK import (``_load_openai_cls`` / ``_OpenAIProxy``, preserving
``isinstance`` and ``patch("run_agent.OpenAI")`` patterns), crash-resistant
stdio (``_SafeWriter``), env-only HTTP proxy resolution, and Codex dual-stack
(Happy Eyeballs) connection racing. ``run_agent`` re-exports every name.
Lazy OpenAI SDK import (``_OpenAIProxy`` keeps ``isinstance`` and
``patch("run_agent.OpenAI")`` working), crash-resistant stdio (``_SafeWriter``),
env-only HTTP proxy resolution, and Codex dual-stack (Happy Eyeballs)
connection racing. ``run_agent`` re-exports every name.
"""
from __future__ import annotations
@@ -25,29 +25,30 @@ _HAPPY_EYEBALLS_DELAY_SECONDS = 0.25
def _interleave_addrinfos(addrinfos: list[tuple]) -> list[tuple]:
"""Interleave resolved address families while preserving resolver order."""
"""Round-robin the resolved address families (deduped), preserving resolver order within each."""
queues: dict[int, list[tuple]] = {}
family_order: list[int] = []
seen: set[tuple] = set()
for addrinfo in addrinfos:
family, socktype, proto, _canonname, sockaddr = addrinfo
marker = (family, socktype, proto, sockaddr)
if marker in seen:
continue
seen.add(marker)
if family not in queues:
queues[family] = []
family_order.append(family)
queues[family].append(addrinfo)
if marker not in seen:
seen.add(marker)
queues.setdefault(family, []).append(addrinfo)
interleaved: list[tuple] = []
while any(queues.values()):
for family in family_order:
if queues[family]:
interleaved.append(queues[family].pop(0))
for queue in queues.values():
if queue:
interleaved.append(queue.pop(0))
return interleaved
def _quiet_unregister(selector, sock) -> None:
try:
selector.unregister(sock)
except Exception:
pass
def _happy_eyeballs_create_connection(
address: tuple[str, int],
timeout: Optional[float],
@@ -71,27 +72,20 @@ def _happy_eyeballs_create_connection(
deadline = None if timeout is None else time.monotonic() + max(timeout, 0.0)
next_launch = time.monotonic()
pending = list(addrinfos)
in_progress = {
0, errno.EINPROGRESS, errno.EWOULDBLOCK, errno.EALREADY, errno.EINTR,
getattr(errno, "WSAEWOULDBLOCK", 10035),
}
in_progress = {0, errno.EINPROGRESS, errno.EWOULDBLOCK, errno.EALREADY, errno.EINTR, getattr(errno, "WSAEWOULDBLOCK", 10035)}
def start_attempt(addrinfo):
family, socktype, proto, _canonname, sockaddr = addrinfo
candidate = socket.socket(family, socktype, proto)
try:
if source_address is not None:
local_infos = socket.getaddrinfo(
source_address[0], source_address[1], family=family, type=socktype
)
local_infos = socket.getaddrinfo(source_address[0], source_address[1], family=family, type=socktype)
if not local_infos:
raise OSError(
f"getaddrinfo returned no local {family} address for {source_address[0]}"
)
raise OSError(f"getaddrinfo returned no local {family} address for {source_address[0]}")
candidate.bind(local_infos[0][4])
candidate.setblocking(False)
result = candidate.connect_ex(sockaddr)
if result == 0 or result == errno.EISCONN:
if result in (0, errno.EISCONN):
return candidate
if result not in in_progress:
raise OSError(result, os.strerror(result))
@@ -109,9 +103,8 @@ def _happy_eyeballs_create_connection(
raise socket.timeout("timed out")
if pending and now >= next_launch:
addrinfo = pending.pop(0)
try:
winner = start_attempt(addrinfo)
winner = start_attempt(pending.pop(0))
except OSError as exc:
last_error = exc
if not active:
@@ -126,8 +119,7 @@ def _happy_eyeballs_create_connection(
until_launch = max(0.0, next_launch - now)
wait_timeout = until_launch if wait_timeout is None else min(wait_timeout, until_launch)
events = selector.select(wait_timeout)
for key, _mask in events:
for key, _mask in selector.select(wait_timeout):
candidate = key.fileobj
error_code = candidate.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
selector.unregister(candidate)
@@ -143,14 +135,9 @@ def _happy_eyeballs_create_connection(
next_launch = time.monotonic()
if winner is None:
if last_error is not None:
raise last_error
raise OSError(f"Could not connect to {host}:{port}")
raise last_error if last_error is not None else OSError(f"Could not connect to {host}:{port}")
try:
selector.unregister(winner)
except Exception:
pass
_quiet_unregister(selector, winner)
active.discard(winner)
winner.settimeout(timeout)
for option in socket_options or ():
@@ -159,10 +146,7 @@ def _happy_eyeballs_create_connection(
return winner
finally:
for candidate in active:
try:
selector.unregister(candidate)
except Exception:
pass
_quiet_unregister(selector, candidate)
candidate.close()
selector.close()
@@ -206,19 +190,15 @@ class _HappyEyeballsSyncBackend:
def _uses_codex_cloud_transport(base_url: str) -> bool:
return (
base_url_hostname(base_url).lower() == "chatgpt.com"
and "/backend-api/codex" in str(base_url).lower()
)
return base_url_hostname(base_url).lower() == "chatgpt.com" and "/backend-api/codex" in str(base_url).lower()
def _enable_happy_eyeballs(transport, skip_pool_types: tuple = ()) -> None:
"""Install the racing backend on one httpx transport.
Reaches into private ``transport._pool._network_backend`` (httpcore is
pinned 1.0.x); hasattr-guarded so an incompatible httpcore degrades to the
default serial backend instead of crashing. Pools of ``skip_pool_types``
(proxies) are left alone.
Reaches into private ``transport._pool._network_backend`` (httpcore pinned
1.0.x); hasattr-guarded so an incompatible httpcore degrades to the default
serial backend. Pools of ``skip_pool_types`` (proxies) are left alone.
"""
pool = getattr(transport, "_pool", None)
if pool is None or not hasattr(pool, "_network_backend"):
@@ -229,11 +209,11 @@ def _enable_happy_eyeballs(transport, skip_pool_types: tuple = ()) -> None:
def enable_happy_eyeballs_on_client(client) -> None:
"""Install the racing backend on every direct transport of a ready-built httpx.Client
(for callers that build clients inline, e.g. Codex OAuth/device-login in hermes_cli.auth).
"""Install the racing backend on every direct transport of a ready-built httpx.Client.
Proxy-backed pools are skipped (TCP connect goes to the proxy host) and
async clients need nothing (anyio already races per RFC 8305). Best-effort.
For callers that build clients inline (Codex OAuth/device-login). Proxy-backed
pools are skipped (TCP connect goes to the proxy host); async clients need
nothing (anyio already races per RFC 8305). Best-effort.
"""
try:
import httpcore
@@ -243,9 +223,7 @@ def enable_happy_eyeballs_on_client(client) -> None:
)
except Exception:
return
transports = [getattr(client, "_transport", None)]
transports.extend((getattr(client, "_mounts", None) or {}).values())
transports = [getattr(client, "_transport", None), *(getattr(client, "_mounts", None) or {}).values()]
for transport in transports:
_enable_happy_eyeballs(transport, proxy_pool_types)
@@ -314,8 +292,7 @@ class _SafeWriter:
def _get_proxy_from_env() -> Optional[str]:
"""First configured proxy URL from HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (any case), or None."""
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
"https_proxy", "http_proxy", "all_proxy"):
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"):
value = os.environ.get(key, "").strip()
if value:
return normalize_proxy_url(value)
@@ -323,56 +300,44 @@ def _get_proxy_from_env() -> Optional[str]:
def _get_proxy_for_base_url(base_url: Optional[str]) -> Optional[str]:
"""Return an env-configured proxy unless NO_PROXY excludes this base URL."""
"""Env-configured proxy unless NO_PROXY excludes this base URL."""
proxy = _get_proxy_from_env()
if not proxy or not base_url:
return proxy
host = base_url_hostname(base_url)
host = base_url_hostname(base_url) if proxy and base_url else ""
if not host:
return proxy
try:
if urllib.request.proxy_bypass_environment(host):
return None
except Exception:
pass
return proxy
def build_keepalive_http_client(base_url: str = "", *, async_mode: bool = False, verify: Any = True) -> Optional[Any]:
"""Build an httpx client for OpenAI SDK calls with env-only proxy policy.
"""httpx client for OpenAI SDK calls with env-only proxy policy (None on failure).
Explicit no-proxy mounts disable httpx's ``trust_env`` path so macOS system
proxies (which omit the ExceptionsList) are never applied. ``keepalive_expiry``
reaps idle connections before reverse proxies' 30-60 s timeouts (a custom
socket_options transport broke streaming and stripped TCP_NODELAY). ``verify``
lets auxiliary calls honor the same ``ssl_ca_cert``/``ssl_verify``/``HERMES_CA_BUNDLE``
as the main client; it goes on the client AND the mounts, since a mounted
transport owns its SSL context.
goes on the client AND the mounts, since a mounted transport owns its SSL context.
"""
try:
import httpx
proxy = _get_proxy_for_base_url(base_url)
limits = httpx.Limits(max_keepalive_connections=20, max_connections=100, keepalive_expiry=20.0)
# Generous read=None for SSE streaming endpoints.
timeout = httpx.Timeout(connect=15.0, read=None, write=15.0, pool=10.0)
timeout = httpx.Timeout(connect=15.0, read=None, write=15.0, pool=10.0) # read=None for SSE streaming
transport_cls = httpx.AsyncHTTPTransport if async_mode else httpx.HTTPTransport
client_cls = httpx.AsyncClient if async_mode else httpx.Client
mounts = {}
mounts = None
if proxy is None:
http_transport = transport_cls(verify=verify)
https_transport = transport_cls(verify=verify)
mounts = {"http://": transport_cls(verify=verify), "https://": transport_cls(verify=verify)}
# Async transports race natively (anyio happy_eyeballs_delay=0.25).
if not async_mode and _uses_codex_cloud_transport(base_url):
_enable_happy_eyeballs(http_transport)
_enable_happy_eyeballs(https_transport)
mounts = {"http://": http_transport, "https://": https_transport}
return client_cls(limits=limits, timeout=timeout, proxy=proxy, mounts=mounts or None, verify=verify)
for transport in mounts.values():
_enable_happy_eyeballs(transport)
return client_cls(limits=limits, timeout=timeout, proxy=proxy, mounts=mounts, verify=verify)
except Exception:
return None
@@ -390,13 +355,6 @@ OpenAI = _OpenAIProxy()
__all__ = [
"OpenAI",
"_OpenAIProxy",
"_load_openai_cls",
"_SafeWriter",
"_install_safe_stdio",
"_get_proxy_from_env",
"_get_proxy_for_base_url",
"build_keepalive_http_client",
"enable_happy_eyeballs_on_client",
"OpenAI", "_OpenAIProxy", "_load_openai_cls", "_SafeWriter", "_install_safe_stdio", "_get_proxy_from_env",
"_get_proxy_for_base_url", "build_keepalive_http_client", "enable_happy_eyeballs_on_client",
]
+648 -1260
View File
File diff suppressed because it is too large Load Diff
+9 -17
View File
@@ -1,19 +1,12 @@
"""Builder-declared stable prefixes for Anthropic prompt caching.
Skill/webhook/cron builders concatenate a large static scaffold with a small
volatile invocation tail into one user-message string. Only the builder knows
where the tail begins, so it registers the stable prefix here and the cache
planner places a breakpoint at that boundary instead of caching the whole
message. Re-parsing marker strings out of the message at request time is
deliberately avoided: markers can legitimately appear inside skill bodies or
event payloads, and any delimiter heuristic then shrinks the cached prefix or
silently absorbs volatile bytes into it.
Process-local by design: a webhook/cron fire is built and sent by the same
process, and any miss (restart, eviction, historic message) falls back to the
whole-message policy. The split only applies while the message is one of the
plan's marked endpoints; once it rotates out it ships as one block again
(one-time re-ingest in long interactive sessions, never for webhook/cron).
Skill/webhook/cron builders concatenate a large static scaffold with a small volatile
tail into one user-message string. Only the builder knows where the tail begins, so it
registers the stable prefix here and the cache planner places a breakpoint at that
boundary. Re-parsing marker strings at request time is deliberately avoided: markers
can legitimately appear inside skill bodies, and any delimiter heuristic then shrinks
the cached prefix or absorbs volatile bytes. Process-local by design: any miss falls
back to the whole-message policy.
"""
import threading
@@ -22,9 +15,8 @@ from typing import Optional
# A couple dozen active scaffolds is generous for one gateway process.
_MAX_ENTRIES = 32
# Entries hold whole expanded skill bodies, so also bound total retained chars
# (1-4x bytes). The newest entry is always kept so one oversized scaffold still
# gets a boundary instead of silently disabling the split.
# Entries hold whole expanded skill bodies, so also bound total retained chars.
# The newest entry is always kept so one oversized scaffold still gets a boundary.
_MAX_CHARS = 4 * 1024 * 1024
_lock = threading.Lock()
+31 -78
View File
@@ -1,30 +1,12 @@
"""Rotation-stable logical cache scope for prompt_cache_key derivation.
Legacy compression rotation (``compression.in_place: false``) mints a new
physical ``session_id`` mid-conversation, which moved the conversation into a
fresh cache bucket each time. ``resolve_prompt_cache_scope()`` instead maps
the physical id to the ROOT of its compression lineage via
``SessionDB.get_compression_lineage()`` — NOT ``get_conversation_root`` /
``_conversation_root_id`` (the Portal-attribution walk), which follows
``parent_session_id`` blindly and would collapse /branch children and delegate
trees into one id. The two resolvers are intentionally different.
Scope boundaries: rotation children walk back to the original segment; ``/new``
starts a fresh scope; ``/branch`` children, delegate subagents, and tool-tagged
children are explicit fork children with their own isolated scope; cron fires
keep their physical id (the per-fire timestamp is stripped later).
Hosts that mint one physical id per RESPONSE (Studio group chat, ``/v1/responses``
with client-managed history) carry no lineage, so the walk returns the physical
id and the scope moves every reply. Hermes must not infer the conversation from
id SYNTAX (that collides client-supplied ids); the host declares it via
``gateway_session_key`` (``X-Hermes-Session-Key`` / ``build_session_key``),
consumed by ``declared_conversation_scope()``, which wins over the lineage walk.
The declared key is hashed to ``gwk_<sha256[:24]>`` because it embeds
platform/chat/user identifiers and leaves the process as a provider routing key.
Resolution is memoized per (agent, session_id, db-present): the lineage walk
runs once per transcript segment, never per API call.
Legacy compression rotation mints a new physical ``session_id`` mid-conversation, moving it
into a fresh cache bucket. ``resolve_prompt_cache_scope()`` maps the physical id to the ROOT
of its compression lineage — NOT ``get_conversation_root`` (the Portal-attribution walk),
which would collapse /branch children and delegate trees into one id. ``/new`` starts a
fresh scope; fork children (branch, delegate, tool-tagged) are isolated. Hosts minting one id
per RESPONSE declare the conversation via ``gateway_session_key``, which wins over the lineage
walk and is hashed to ``gwk_<sha256[:24]>`` (it embeds platform/chat/user identifiers).
"""
import hashlib
@@ -38,10 +20,7 @@ _DECLARED_SCOPE_PREFIX = "gwk_"
def _lineage_root(session_id: str, session_db: Any) -> Optional[str]:
"""Compression-lineage root of *session_id*, or None.
Tolerates non-list results from test doubles / partially built agents.
"""
"""Compression-lineage root of *session_id*, or None (tolerates test-double results)."""
if session_db is None:
return None
try:
@@ -61,13 +40,10 @@ def _agent_source(
) -> str:
"""The ``sessions.source`` this agent's conversation is recorded under.
``row_source`` is the row's value when the caller already read it (``""``
= read, no source; ``None`` = not read yet, do the lookup). Before the row
lands, use the SAME resolver persistence uses
(``run_agent._session_source_for_agent``), not ``agent.platform``: they
diverge under ``HERMES_SESSION_SOURCE``, and the declared scope is memoized
immediately, so both sides of a ``/new`` would otherwise miss the boundary
recorded under the override and hash the same scope.
``row_source``: the row's value if already read (``""`` = read, none; ``None`` = look it
up). Before the row lands, use the SAME resolver persistence uses, not ``agent.platform``:
they diverge under ``HERMES_SESSION_SOURCE`` and the declared scope is memoized at once,
so both sides of a ``/new`` would otherwise hash the same scope.
"""
if row_source is None and session_id and session_db is not None:
try:
@@ -94,31 +70,24 @@ def _agent_source(
def _conversation_generation(session_key: str, source: str, session_db: Any) -> str:
"""Durable generation for *session_key*'s current conversation (``""`` if none).
The declared key names a chat and survives ``/new`` and policy resets, so
hashing it alone would reuse one scope across distinct conversations. The
``conversation_generations`` counter advances in the same transaction that
records a reset boundary and is independent of prunable rows and
wall-clock, so pruning or clock rollback cannot reissue a generation.
Compression does not advance it.
The declared key survives ``/new``, so hashing it alone would reuse one scope across
conversations. The counter advances with each reset boundary, independent of prunable
rows and wall-clock; compression does not advance it.
"""
reader = getattr(session_db, "latest_conversation_boundary", None)
if not callable(reader):
return ""
generation = reader(session_key, source)
if generation is None:
return ""
return str(int(generation))
return "" if generation is None else str(int(generation))
def declared_conversation_scope(agent: Any) -> Optional[str]:
"""Host-declared logical conversation scope (``gwk_<sha256[:24]>``), or None.
Hashes ``(source, gateway_session_key, generation)`` so no platform/chat/
user identifier reaches a provider. None — fall back to the physical-id
scope — when no key is declared, when the agent is a background-review
fork (``_persist_disabled`` clones the live runtime incl. the key), when
the row is an explicit fork child, and on any DB error (fail closed rather
than merge a fork onto its parent's key).
Hashes ``(source, gateway_session_key, generation)``. None (fall back to the physical id)
when no key is declared, for a background-review fork (``_persist_disabled``), for an
explicit fork child, and on any DB error (fail closed rather than merge a fork onto its
parent's key).
"""
key = str(getattr(agent, "_gateway_session_key", "") or "").strip()
if not key or getattr(agent, "_persist_disabled", False):
@@ -129,9 +98,7 @@ def declared_conversation_scope(agent: Any) -> Optional[str]:
row_source: Optional[str] = None
if sid and db is not None:
try:
# One read for both halves of the row identity (fork verdict +
# source). A SessionDB without the combined view keeps the
# original call.
# One read for both halves of the row identity (fork verdict + source).
identity = getattr(db, "declared_scope_identity", None)
if callable(identity):
is_fork, row_source = identity(sid)
@@ -149,39 +116,29 @@ def declared_conversation_scope(agent: Any) -> Optional[str]:
except Exception:
logger.debug("declared-scope generation read failed", exc_info=True)
return None
# Same identity tuple the peer queries use: two hosts may declare the
# same key under different sources and must not collapse.
# Same identity tuple the peer queries use: same key under different sources must not collapse.
carrier = f"{source}|{key}|{generation}"
digest = hashlib.sha256(carrier.encode("utf-8", errors="replace")).hexdigest()[:24]
return f"{_DECLARED_SCOPE_PREFIX}{digest}"
def resolve_prompt_cache_scope(agent: Any) -> str:
"""Rotation-stable cache-scope id for *agent*'s conversation.
Declared scope when one applies, else the compression-lineage root of
``agent.session_id`` (the physical id when there is no ancestry, no DB, or
the walk fails). Memoized on the agent keyed by session id.
"""
"""Rotation-stable cache-scope id: declared scope, else the compression-lineage root of
``agent.session_id`` (the physical id without ancestry/DB). Memoized on the agent."""
sid = str(getattr(agent, "session_id", None) or "")
if not sid:
return ""
db = getattr(agent, "_session_db", None)
# DB presence is part of the key: an agent that gains a DB handle later
# must re-resolve instead of staying pinned to the physical id.
# DB presence is part of the key: an agent that gains a DB handle later must re-resolve.
key = (sid, db is not None)
memo = getattr(agent, _MEMO_ATTR, None)
if isinstance(memo, tuple) and len(memo) == 2 and memo[0] == key:
return memo[1]
root = declared_conversation_scope(agent) or (
_lineage_root(sid, db) if db is not None else None
)
root = declared_conversation_scope(agent) or _lineage_root(sid, db)
scope = root or sid
# Memoize on success, with no DB, or when the agent never persists a row
# (background-review forks hold a DB handle but set _persist_disabled).
# A failed/empty walk on a persisting agent is NOT memoized: the physical
# id is right for now (row not yet persisted, transient error) but would
# stay wrong for the whole segment once the row lands.
# Memoize on success, with no DB, or when the agent never persists a row. A failed/empty
# walk on a persisting agent is NOT memoized: the physical id is right for now (row not
# yet persisted) but would stay wrong for the whole segment once it lands.
if root is not None or db is None or getattr(agent, "_persist_disabled", False):
try:
setattr(agent, _MEMO_ATTR, (key, scope))
@@ -200,12 +157,8 @@ def declared_conversation_scope_safe(agent: Any) -> Optional[str]:
def resolve_prompt_cache_scope_safe(agent: Any) -> Optional[str]:
"""Never-raising variant of :func:`resolve_prompt_cache_scope` (None on failure/empty).
Consumers treat None as "use the physical session_id"; at turn_context's
call site an exception inside the ``set_runtime_main(...)`` argument list
would skip the whole runtime binding, not just the cache scope.
"""
"""Never-raising variant of :func:`resolve_prompt_cache_scope` (None = use the physical id).
At turn_context an exception inside ``set_runtime_main(...)`` would skip the whole binding."""
try:
return resolve_prompt_cache_scope(agent) or None
except Exception:
+146 -323
View File
@@ -1,10 +1,8 @@
"""Anthropic prompt caching strategy — pure functions, no AIAgent dependency.
Default layout: 4 cache_control breakpoints — the static system prefix, the end
of the system prompt, and the last 2 non-system messages. Without a static
prefix: one system breakpoint plus the last 3 messages. All markers share one
TTL (5m or 1h). This keeps intra-session caching while letting new sessions
reuse the stable system-prompt prefix.
Default layout: 4 cache_control breakpoints — the static system prefix, the end of the
system prompt, and the last 2 non-system messages (without a static prefix: one system
breakpoint plus the last 3 messages). All markers share one TTL (5m or 1h).
"""
import copy
@@ -22,140 +20,88 @@ class PromptCachePlan:
tools: List[Dict[str, Any]]
def envelope_tool_part_cache_markers_supported(
provider: str | None, base_url: str | None
) -> bool:
def envelope_tool_part_cache_markers_supported(provider: str | None, base_url: str | None) -> bool:
"""Whether the envelope-layout route honors part-level markers on role:tool.
OpenRouter (and Nous Portal, which proxies to it) relocate a part-level
``cache_control`` onto the ``tool_result`` block during OpenAI→Anthropic
translation. LiteLLM-style proxies copy parts verbatim, so the marker lands
at ``tool_result.content[0]`` — forbidden by the Anthropic schema, a
non-retryable 400. On those routes tool messages carry no part markers and
the breakpoint budget reallocates to the nearest eligible message.
OpenRouter (and Nous Portal) relocate a part-level ``cache_control`` onto the
``tool_result`` block; LiteLLM-style proxies copy parts verbatim, so the marker lands at
``tool_result.content[0]`` — a non-retryable 400. There, tool messages carry no part
markers and the breakpoint budget reallocates to the nearest eligible message.
"""
from agent.agent_runtime_helpers import _is_litellm_route
return not _is_litellm_route((provider or "").strip().lower(), base_url or "")
def _text_part(text: str, cache_marker: dict | None = None) -> dict:
part: dict = {"type": "text", "text": text}
if cache_marker is not None:
part["cache_control"] = cache_marker
return part
def _apply_cache_marker(
msg: dict,
cache_marker: dict,
native_anthropic: bool = False,
tool_part_markers: bool = True,
msg: dict, cache_marker: dict, native_anthropic: bool = False, tool_part_markers: bool = True,
) -> None:
"""Add cache_control to a single message, handling all format variations."""
role = msg.get("role", "")
content = msg.get("content")
if role == "tool" and native_anthropic:
# Top-level marker; the native adapter moves it inside tool_result.
msg["cache_control"] = cache_marker
if role == "tool" and not native_anthropic and not tool_part_markers:
# LiteLLM-style envelope: a part marker → tool_result.content[0] → non-retryable 400.
return
if role == "tool" and not tool_part_markers:
# LiteLLM-style envelope: a part marker becomes
# tool_result.content[0].cache_control → non-retryable 400.
return
if content is None or content == "":
# Envelope layout: OpenRouter rejects top-level cache_control on
# role:tool (silent hang), and ignores it on empty assistant turns
# (pure tool_calls) — neither has a content part to carry it.
if role in ("tool", "assistant") and not native_anthropic:
return
msg["cache_control"] = cache_marker
return
if isinstance(content, str):
if role == "user":
stable_prefix = find_stable_prefix(content)
if stable_prefix is not None:
suffix = content[len(stable_prefix):]
if suffix.strip():
# Builder-declared boundary: the scaffold carries the
# breakpoint and the volatile tail rides unmarked, so a
# changed ticket ID/timestamp no longer invalidates the
# skill body. Request-local only — the stored message
# stays a plain string.
msg["content"] = [
{"type": "text", "text": stable_prefix, "cache_control": cache_marker},
{"type": "text", "text": suffix},
]
return
msg["content"] = [
{"type": "text", "text": content, "cache_control": cache_marker}
]
return
if isinstance(content, list) and content:
last = content[-1]
if isinstance(last, dict):
last["cache_control"] = cache_marker
if (role == "tool" and native_anthropic) or content is None or content == "":
# Native role:tool: top-level marker, the adapter moves it inside tool_result. Empty
# content: no part can carry it, and OpenRouter rejects a top-level marker on role:tool
# (silent hang) and ignores it on empty assistant turns — skip those on the envelope.
if not (role in ("tool", "assistant") and not native_anthropic):
msg["cache_control"] = cache_marker
elif isinstance(content, str):
stable_prefix = find_stable_prefix(content) if role == "user" else None
if stable_prefix is not None and content[len(stable_prefix):].strip():
# Builder-declared boundary: the scaffold carries the breakpoint and the volatile
# tail rides unmarked. Request-local only — the stored message stays a string.
msg["content"] = [_text_part(stable_prefix, cache_marker), _text_part(content[len(stable_prefix):])]
else:
msg["content"] = [_text_part(content, cache_marker)]
elif isinstance(content, list) and content and isinstance(content[-1], dict):
content[-1]["cache_control"] = cache_marker
def _can_carry_marker(
msg: dict, native_anthropic: bool, tool_part_markers: bool = True
) -> bool:
def _can_carry_marker(msg: dict, native_anthropic: bool, tool_part_markers: bool = True) -> bool:
"""True if a marker on this message is actually honored by the provider.
Native Anthropic honors every message (the adapter relocates top-level
markers). The envelope layout only honors markers inside content parts, so
empty-content messages would waste one of the four breakpoints; with
``tool_part_markers=False`` (LiteLLM-style routes) every role:tool message
is excluded too, since its part marker would be rejected with a 400.
Must agree with :func:`_apply_cache_marker`, which marks only the LAST part.
Native Anthropic honors every message. The envelope layout only honors markers inside
content parts, so empty-content messages would waste a breakpoint; with
``tool_part_markers=False`` every role:tool message is excluded too (400). Must agree
with :func:`_apply_cache_marker`, which marks only the LAST part.
"""
if native_anthropic:
return True
if msg.get("role") == "tool" and not tool_part_markers:
return False
content = msg.get("content")
if content is None or content == "":
return False
if isinstance(content, list):
# Mirrors _apply_cache_marker (marks only the LAST part): a list whose
# last element isn't a dict cannot receive a marker.
return bool(content) and isinstance(content[-1], dict)
return isinstance(content, str)
return isinstance(content[-1], dict) if isinstance(content, list) and content else isinstance(content, str) and content != ""
def _build_marker(ttl: str) -> Dict[str, str]:
"""Build a cache_control marker dict for the given TTL ('5m' or '1h')."""
marker: Dict[str, str] = {"type": "ephemeral"}
if ttl == "1h":
marker["ttl"] = "1h"
return marker
return {"type": "ephemeral", "ttl": "1h"} if ttl == "1h" else {"type": "ephemeral"}
# Alibaba-family providers (Qwen routes): documented five-minute context cache,
# Anthropic 1h tier rejected. Shared with
# agent_runtime_helpers.anthropic_prompt_cache_policy so the cache-policy
# opt-in and the TTL clamp never desync. Do NOT narrow this set to extend a
# TTL — it also drives the marker-layout opt-in, so narrowing DISABLES caching.
ALIBABA_FAMILY_PROVIDERS = frozenset({
"opencode",
"opencode-go",
"opencode-zen",
"alibaba",
})
# Alibaba-family providers (Qwen routes): five-minute context cache, 1h tier rejected. Shared
# with agent_runtime_helpers.anthropic_prompt_cache_policy so the opt-in and the TTL clamp
# never desync. Do NOT narrow this set to extend a TTL — narrowing DISABLES caching.
ALIBABA_FAMILY_PROVIDERS = frozenset({"opencode", "opencode-go", "opencode-zen", "alibaba"})
# 1h-tier ALLOW-list: only routes wire-measured to retain a 1h marker (delayed
# read past 5 minutes with no intervening call — an intervening read renews the
# window and masks expiry). Other opencode routes stay clamped because they are
# UNMEASURED, not known-bad. Note opencode-go labels every write
# `ephemeral_5m_input_tokens` regardless of requested ttl; that label is not
# evidence of the retention window.
MEASURED_1H_PROVIDERS = frozenset({
"opencode-go",
})
# 1h-tier ALLOW-list: only routes wire-measured to retain a 1h marker. Other opencode routes
# are UNMEASURED, not known-bad (opencode-go's `ephemeral_5m_input_tokens` label is not
# evidence of the retention window).
MEASURED_1H_PROVIDERS = frozenset({"opencode-go"})
# Models measured to ignore the 1h tier on a MEASURED_1H_PROVIDERS route.
# Consulted only there: the same model on its own Anthropic-compatible endpoint
# is a separate cache-eligible route and must not inherit this clamp.
NO_1H_TIER_MODELS = frozenset({
"minimax-m2.5",
})
# Models measured to ignore the 1h tier on a MEASURED_1H_PROVIDERS route; consulted only
# there (the same model on its own endpoint is a separate route).
NO_1H_TIER_MODELS = frozenset({"minimax-m2.5"})
def _flat_model(model: str) -> str:
@@ -164,104 +110,68 @@ def _flat_model(model: str) -> str:
def is_qwen_model(model: str) -> bool:
"""True when ``model`` names a Qwen-family model (case-insensitive).
Shared with ``agent_runtime_helpers.anthropic_prompt_cache_policy`` so the
cache-policy opt-in and the TTL clamp never desync.
"""
"""True when ``model`` names a Qwen-family model (shared with anthropic_prompt_cache_policy)."""
return "qwen" in (model or "").lower()
def effective_cache_ttl(
ttl: str | None,
*,
model: str = "",
provider: str = "",
) -> str:
"""Clamp a requested cache TTL to what the destination route supports.
def effective_cache_ttl(ttl: str | None, *, model: str = "", provider: str = "") -> str:
"""Clamp a requested cache TTL to what the destination route supports (``None`` → ``5m``).
Qwen/Alibaba routes document a five-minute window and drop the ``1h``
tier, so a configured ``1h`` regresses to ``5m`` there instead of creating
a false 1h-cache expectation — except on ``MEASURED_1H_PROVIDERS``, which
keep ``1h`` minus any ``NO_1H_TIER_MODELS`` model. The measured-route check
runs BEFORE the generic Qwen clamp, which would otherwise swallow every
Qwen model on it. ``None`` resolves to ``5m``.
Qwen/Alibaba routes drop the ``1h`` tier, so ``1h`` regresses to ``5m`` there — except
on ``MEASURED_1H_PROVIDERS`` (minus ``NO_1H_TIER_MODELS``). The measured-route check
runs BEFORE the generic Qwen clamp, which would otherwise swallow every Qwen model on it.
"""
if ttl != "1h":
return ttl or "5m"
if (provider or "").lower() in MEASURED_1H_PROVIDERS:
# Checked BEFORE the generic Qwen clamp (which would swallow every Qwen
# model on this route); the per-model denial stays nested so an
# opencode-go observation cannot reclamp the same model on another route.
provider_lower = (provider or "").lower()
if provider_lower in MEASURED_1H_PROVIDERS:
return "5m" if _flat_model(model) in NO_1H_TIER_MODELS else "1h"
if is_qwen_model(model) or (provider or "").lower() in ALIBABA_FAMILY_PROVIDERS:
if is_qwen_model(model) or provider_lower in ALIBABA_FAMILY_PROVIDERS:
return "5m"
return "1h"
def _apply_system_cache_markers(
message: dict,
cache_marker: dict,
static_system_prefix: str | None,
*,
native_anthropic: bool,
mark_suffix: bool = True,
fallback_to_whole: bool = True,
message: dict, cache_marker: dict, static_system_prefix: str | None, *,
native_anthropic: bool, mark_suffix: bool = True, fallback_to_whole: bool = True,
) -> int:
"""Mark the static system prefix (and optionally the full prompt).
"""Mark the static system prefix (and optionally the full prompt); returns markers applied.
The system prompt stays one stored string; it is split only in the
outgoing request so persistence and non-Anthropic transports are
unchanged. ``mark_suffix=False`` is the tool-cache-plan layout (suffix
unmarked, its budget spent on the tools array). ``fallback_to_whole=False``
marks nothing when the prefix split is impossible. When the prompt IS the
prefix (empty/whitespace suffix) the whole message is marked as one block —
never a split with an empty text block, which Anthropic rejects.
Returns the number of markers applied (0, 1, or 2).
The system prompt stays one stored string, split only in the outgoing request.
``mark_suffix=False`` is the tool-cache-plan layout (suffix budget spent on the tools
array). ``fallback_to_whole=False`` marks nothing when the split is impossible. When the
prompt IS the prefix the whole message is one block — never an empty text block (400).
"""
content = message.get("content")
if (
isinstance(static_system_prefix, str)
and static_system_prefix
and isinstance(content, str)
and content.startswith(static_system_prefix)
):
if isinstance(static_system_prefix, str) and static_system_prefix and isinstance(content, str) and content.startswith(static_system_prefix):
suffix = content[len(static_system_prefix):]
if suffix.strip():
suffix_part: dict = {"type": "text", "text": suffix}
if mark_suffix:
suffix_part["cache_control"] = cache_marker
message["content"] = [
{"type": "text", "text": static_system_prefix, "cache_control": cache_marker},
suffix_part,
_text_part(static_system_prefix, cache_marker),
_text_part(suffix, cache_marker if mark_suffix else None),
]
return 2 if mark_suffix else 1
_apply_cache_marker(message, cache_marker, native_anthropic=native_anthropic)
return 1
if not fallback_to_whole:
elif not fallback_to_whole:
return 0
_apply_cache_marker(message, cache_marker, native_anthropic=native_anthropic)
return 1
def strip_anthropic_cache_control(
api_messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Remove ``cache_control`` markers and undo decoration-produced list shapes.
def _has_part_marker(content: Any) -> bool:
return isinstance(content, list) and any(
isinstance(part, dict) and "cache_control" in part for part in content
)
Used before re-decorating after a mid-turn provider failover, so the
mutated undecorated shape is preserved while markers match the new
provider's policy. Flattening back to a plain string is restricted to the
exact shapes :func:`apply_anthropic_cache_control` produces from string
content — a single text part, the two-part ``[static, volatile]`` system
split, or the two-part skill split — so the ``""``-join is provably
byte-exact; organic multi-part text and parts with extra keys keep their
structure. Marker removal is copy-on-write on part dicts: parts can alias
caller-held lists and stripping must never rewrite the stored transcript.
Mutates the top-level message dicts in place and returns the same list.
def strip_anthropic_cache_control(api_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Remove ``cache_control`` markers and undo decoration-produced list shapes (in place).
Used before re-decorating after a mid-turn provider failover. Flattening back to a
string is restricted to the exact shapes :func:`apply_anthropic_cache_control` produces
from string content — a single text part, the two-part ``[static, volatile]`` system
split, or the two-part skill split — so the ``""``-join is provably byte-exact. Marker
removal is copy-on-write on part dicts: parts can alias caller-held lists and stripping
must never rewrite the stored transcript.
"""
for msg in api_messages:
if not isinstance(msg, dict):
@@ -270,39 +180,26 @@ def strip_anthropic_cache_control(
content = msg.get("content")
if not isinstance(content, list):
continue
# The builder-declared skill split is the only decoration that marks
# the FIRST part of a user message (list content is otherwise marked
# on the last part; the [static, volatile] split is system-only), so
# the shape alone identifies it even after the prefix registry has
# evicted the entry.
role = msg.get("role")
# The skill split is the only decoration marking the FIRST part of a user message,
# so the shape alone identifies it even after the prefix registry evicted the entry.
skill_split_shape = (
msg.get("role") == "user"
and len(content) == 2
and isinstance(content[0], dict)
and isinstance(content[1], dict)
and "cache_control" in content[0]
and "cache_control" not in content[1]
role == "user" and len(content) == 2
and all(isinstance(p, dict) for p in content)
and "cache_control" in content[0] and "cache_control" not in content[1]
)
if any(isinstance(part, dict) and "cache_control" in part for part in content):
content = [
if _has_part_marker(content):
content = msg["content"] = [
{k: v for k, v in part.items() if k != "cache_control"}
if isinstance(part, dict) and "cache_control" in part
else part
if isinstance(part, dict) and "cache_control" in part else part
for part in content
]
msg["content"] = content
decoration_shape = content and all(
isinstance(part, dict)
and part.get("type", "text") == "text"
and isinstance(part.get("text"), str)
and set(part.keys()) <= {"type", "text"}
plain_text_parts = content and all(
isinstance(part, dict) and part.get("type", "text") == "text"
and isinstance(part.get("text"), str) and set(part.keys()) <= {"type", "text"}
for part in content
) and (
len(content) == 1
or (msg.get("role") == "system" and len(content) == 2)
or skill_split_shape
)
if decoration_shape:
if plain_text_parts and (len(content) == 1 or (role == "system" and len(content) == 2) or skill_split_shape):
msg["content"] = "".join(part["text"] for part in content)
return api_messages
@@ -318,27 +215,22 @@ def strip_anthropic_tool_cache_control(tools: List[Dict[str, Any]] | None) -> Li
def _count_cache_markers(messages: List[Dict[str, Any]], tools: List[Dict[str, Any]]) -> int:
"""Count the wire-visible cache markers in a request-local plan."""
count = sum(
1
for message in messages
if isinstance(message, dict) and "cache_control" in message
)
count += sum(
1
for message in messages
if isinstance(message, dict) and isinstance(message.get("content"), list)
for part in message["content"]
if isinstance(part, dict) and "cache_control" in part
)
return count + sum(
1 for tool in tools if isinstance(tool, dict) and "cache_control" in tool
)
def _marked(items: Any) -> int:
return sum(1 for item in items if isinstance(item, dict) and "cache_control" in item)
parts = [p for m in messages if isinstance(m, dict) and isinstance(m.get("content"), list) for p in m["content"]]
return _marked(messages) + _marked(parts) + _marked(tools)
def _completed_transaction_endpoint_indexes(
messages: List[Dict[str, Any]], *, native_anthropic: bool,
) -> List[int]:
def _completed_transaction_endpoint_indexes(messages: List[Dict[str, Any]], *, native_anthropic: bool) -> List[int]:
"""Select legal ends of completed tool runs and ordinary turns."""
def _tool_run_end(start: int) -> int:
end = start
while end < len(messages) and isinstance(messages[end], dict) and messages[end].get("role") == "tool":
end += 1
return end
endpoints: List[int] = []
index = 0
while index < len(messages):
@@ -346,37 +238,21 @@ def _completed_transaction_endpoint_indexes(
if not isinstance(message, dict) or message.get("role") == "system":
index += 1
continue
role = message.get("role")
if message.get("role") == "assistant" and message.get("tool_calls"):
result_start = index + 1
result_end = result_start
while result_end < len(messages):
result = messages[result_end]
if not isinstance(result, dict) or result.get("role") != "tool":
break
result_end += 1
if result_end > result_start:
endpoint = result_end - 1
if _can_carry_marker(messages[endpoint], native_anthropic):
endpoints.append(endpoint)
if role == "assistant" and message.get("tool_calls"):
result_end = _tool_run_end(index + 1)
if result_end > index + 1 and _can_carry_marker(messages[result_end - 1], native_anthropic):
endpoints.append(result_end - 1)
index = result_end
continue
if message.get("role") == "tool":
while index < len(messages):
result = messages[index]
if not isinstance(result, dict) or result.get("role") != "tool":
break
index += 1
if role == "tool":
index = _tool_run_end(index)
continue
if message.get("role") == "user" and index + 1 < len(messages):
index += 1
continue
if (
message.get("role") == "assistant"
and message.get("content") in (None, "")
if (role == "user" and index + 1 < len(messages)) or (
role == "assistant" and message.get("content") in (None, "")
):
index += 1
continue
@@ -388,80 +264,49 @@ def _completed_transaction_endpoint_indexes(
def build_prompt_cache_plan(
api_messages: List[Dict[str, Any]],
tools: List[Dict[str, Any]] | None,
*,
cache_ttl: str = "5m",
native_anthropic: bool = False,
static_system_prefix: str | None = None,
direct_native_tool_cache: bool = False,
tool_part_markers: bool = True,
api_messages: List[Dict[str, Any]], tools: List[Dict[str, Any]] | None, *,
cache_ttl: str = "5m", native_anthropic: bool = False, static_system_prefix: str | None = None,
direct_native_tool_cache: bool = False, tool_part_markers: bool = True,
) -> PromptCachePlan:
"""Build isolated cache sections for one resolved request destination.
``tool_part_markers=False`` (LiteLLM-style envelope routes) keeps
``cache_control`` off role:tool content parts; breakpoints reallocate to
the nearest eligible non-tool message.
"""
"""Build isolated cache sections for one resolved request destination
(``tool_part_markers=False`` keeps markers off role:tool parts on LiteLLM-style routes)."""
messages = copy.deepcopy(api_messages or [])
strip_anthropic_cache_control(messages)
planned_tools = strip_anthropic_tool_cache_control(tools)
if not direct_native_tool_cache or not planned_tools:
planned_messages = apply_anthropic_cache_control(
messages,
cache_ttl=cache_ttl,
native_anthropic=native_anthropic,
static_system_prefix=static_system_prefix,
tool_part_markers=tool_part_markers,
messages, cache_ttl=cache_ttl, native_anthropic=native_anthropic,
static_system_prefix=static_system_prefix, tool_part_markers=tool_part_markers,
)
return PromptCachePlan(messages=planned_messages, tools=planned_tools)
marker = _build_marker(cache_ttl)
if (
messages
and isinstance(messages[0], dict)
and messages[0].get("role") == "system"
):
# Tool-cache layout: only the static prefix carries a system-side
# marker; the volatile suffix's budget is spent on the tools array.
if messages and isinstance(messages[0], dict) and messages[0].get("role") == "system":
# Tool-cache layout: only the static prefix carries a system-side marker; the
# volatile suffix's budget is spent on the tools array.
_apply_system_cache_markers(
messages[0],
marker,
static_system_prefix,
native_anthropic=True,
mark_suffix=False,
fallback_to_whole=False,
messages[0], marker, static_system_prefix,
native_anthropic=True, mark_suffix=False, fallback_to_whole=False,
)
planned_tools[-1]["cache_control"] = dict(marker)
for endpoint in _completed_transaction_endpoint_indexes(
messages,
native_anthropic=True,
)[-2:]:
for endpoint in _completed_transaction_endpoint_indexes(messages, native_anthropic=True)[-2:]:
_apply_cache_marker(messages[endpoint], marker, native_anthropic=True)
return PromptCachePlan(messages=messages, tools=planned_tools)
def apply_anthropic_cache_control(
api_messages: List[Dict[str, Any]],
cache_ttl: str = "5m",
native_anthropic: bool = False,
static_system_prefix: str | None = None,
tool_part_markers: bool = True,
api_messages: List[Dict[str, Any]], cache_ttl: str = "5m", native_anthropic: bool = False,
static_system_prefix: str | None = None, tool_part_markers: bool = True,
) -> List[Dict[str, Any]]:
"""Apply Anthropic cache-control markers to API messages.
With a matching ``static_system_prefix`` the prefix gets an early marker
and the full system prompt a trailing one; the remaining two markers go to
the latest cacheable non-system messages. Without it, the legacy
system-and-3 layout applies. Idempotent: pre-existing markers are stripped
from a per-message copy first, so repeated calls never accumulate past 4
markers; a shallow top-level copy suffices because
:func:`strip_anthropic_cache_control` is copy-on-write on content parts.
Returns:
Shallow copy of message list with selective deep copies of modified messages.
With a matching ``static_system_prefix`` the prefix and the full system prompt each get
a marker and the remaining two go to the latest cacheable non-system messages; without
it, the legacy system-and-3 layout applies. Idempotent: pre-existing markers are
stripped from a per-message copy first (shallow copy suffices — stripping is
copy-on-write on parts). Returns a shallow list copy with deep copies of modified messages.
"""
if not api_messages:
return api_messages
@@ -470,45 +315,23 @@ def apply_anthropic_cache_control(
marker = _build_marker(cache_ttl)
for i, msg in enumerate(messages):
if not isinstance(msg, dict):
continue
content = msg.get("content")
has_marker = "cache_control" in msg or (
isinstance(content, list)
and any(isinstance(part, dict) and "cache_control" in part for part in content)
)
if has_marker:
if isinstance(msg, dict) and ("cache_control" in msg or _has_part_marker(msg.get("content"))):
messages[i] = strip_anthropic_cache_control([dict(msg)])[0]
breakpoints_used = 0
if messages[0].get("role") == "system":
messages[0] = copy.deepcopy(messages[0])
breakpoints_used = _apply_system_cache_markers(
messages[0],
marker,
static_system_prefix,
native_anthropic=native_anthropic,
messages[0], marker, static_system_prefix, native_anthropic=native_anthropic,
)
remaining = 4 - breakpoints_used
non_sys = [
i
for i in range(len(messages))
i for i in range(len(messages))
if messages[i].get("role") != "system"
and _can_carry_marker(
messages[i],
native_anthropic=native_anthropic,
tool_part_markers=tool_part_markers,
)
and _can_carry_marker(messages[i], native_anthropic=native_anthropic, tool_part_markers=tool_part_markers)
]
for idx in non_sys[-remaining:]:
for idx in non_sys[-(4 - breakpoints_used):]:
messages[idx] = copy.deepcopy(messages[idx])
_apply_cache_marker(
messages[idx],
marker,
native_anthropic=native_anthropic,
tool_part_markers=tool_part_markers,
)
_apply_cache_marker(messages[idx], marker, native_anthropic=native_anthropic, tool_part_markers=tool_part_markers)
return messages
+12 -23
View File
@@ -1,12 +1,10 @@
"""Shared base classes for the pluggable-backend provider ABCs.
Every tool-provider ABC (browser, TTS, image/video gen, transcription, web
search, terminal env) shares the same identity + ``hermes tools`` picker
surface; the previous per-ABC copies of these defaults were byte-identical.
Concrete ABCs subclass :class:`ProviderBase` (or :class:`CatalogProviderBase`
when the backend also exposes a model catalog and is available by default) and
add only their domain methods. Plugins keep subclassing the concrete ABC, so
``isinstance`` checks and abstract-method sets are unchanged.
Every tool-provider ABC shares the same identity + ``hermes tools`` picker surface.
Concrete ABCs subclass :class:`ProviderBase` (or :class:`CatalogProviderBase` when
the backend also exposes a model catalog and is available by default) and add only
their domain methods. Plugins keep subclassing the concrete ABC, so ``isinstance``
checks and abstract-method sets are unchanged.
"""
from __future__ import annotations
@@ -36,16 +34,10 @@ class ProviderBase(abc.ABC):
"""Provider row for the ``hermes tools`` picker.
Shape: ``{"name", "badge", "tag", "env_vars": [{"key", "prompt", "url"}, ...]}``
(browser providers may add ``"post_setup"``). Default: a minimal entry
derived from ``display_name`` with no env vars — override to expose API
key prompts and badges.
(browser providers may add ``"post_setup"``). Override to expose API key
prompts and badges.
"""
return {
"name": self.display_name,
"badge": "",
"tag": "",
"env_vars": [],
}
return {"name": self.display_name, "badge": "", "tag": "", "env_vars": []}
class CatalogProviderBase(ProviderBase):
@@ -59,19 +51,16 @@ class CatalogProviderBase(ProviderBase):
def is_available(self) -> bool:
"""True when this provider can service calls (API key present, SDK importable).
Default True. Must NOT raise and must NOT make network calls — the picker
and ``hermes setup`` call it on every paint.
Must NOT raise and must NOT make network calls — the picker and
``hermes setup`` call it on every paint.
"""
return True
def list_models(self) -> List[Dict[str, Any]]:
"""Model catalog entries (``{"id": ..., "display": ...}`` plus optional
provider-specific keys). Default: empty (no user-selectable models)."""
"""Model catalog entries (``{"id": ..., "display": ...}`` + provider-specific keys)."""
return []
def default_model(self) -> Optional[str]:
"""Id of the first catalog entry, or None when the catalog is empty."""
models = self.list_models()
if models:
return models[0].get("id")
return None
return models[0].get("id") if models else None
+18 -34
View File
@@ -1,10 +1,10 @@
"""Shared ``$HERMES_HOME/cache/<kind>/`` materialisation helpers for the
image/video generation provider ABCs.
"""``$HERMES_HOME/cache/<kind>/`` materialisation helpers for the image/video
generation provider ABCs.
Several backends (xAI, OpenAI, DeepInfra, FAL) return *ephemeral* delivery URLs
that expire before a downstream consumer (Telegram ``send_photo``, browser
fetch) can resolve them, so providers materialise the bytes locally at
tool-completion time. Filenames are ``<prefix>_<YYYYMMDD_HHMMSS>_<uuid8>.<ext>``.
Several backends return *ephemeral* delivery URLs that expire before a downstream
consumer (Telegram ``send_photo``, browser fetch) can resolve them, so providers
materialise the bytes locally at tool-completion time. Filenames are
``<prefix>_<YYYYMMDD_HHMMSS>_<uuid8>.<ext>``.
"""
from __future__ import annotations
@@ -19,7 +19,6 @@ from typing import Dict, Tuple
def cache_dir(kind: str) -> Path:
"""Return ``$HERMES_HOME/cache/<kind>/``, creating parents as needed."""
from hermes_constants import get_hermes_home
path = get_hermes_home() / "cache" / kind
path.mkdir(parents=True, exist_ok=True)
return path
@@ -44,30 +43,19 @@ def save_b64(kind: str, b64_data: str, *, prefix: str, extension: str) -> Path:
def save_url(
kind: str,
url: str,
*,
prefix: str,
timeout: float,
max_bytes: int,
chunk_size: int,
content_types: Dict[str, str],
url_extensions: Tuple[str, ...],
default_extension: str,
label: str,
empty_error: str,
kind: str, url: str, *, prefix: str, timeout: float, max_bytes: int, chunk_size: int,
content_types: Dict[str, str], url_extensions: Tuple[str, ...], default_extension: str,
label: str, empty_error: str,
) -> Path:
"""Stream-download *url* into the cache with a size cap.
The extension comes from the response ``Content-Type`` (a small explicit
table — never inherit a type that points at HTML/JSON from a degenerate
response), then the URL suffix (some CDNs return
``application/octet-stream``), then *default_extension*. Raises on any
network / HTTP / oversize / empty error so callers can fall back to the bare
URL; a partial file is never left behind.
The extension comes from the response ``Content-Type`` (an explicit table —
never inherit a type pointing at HTML/JSON from a degenerate response), then
the URL suffix (some CDNs return ``application/octet-stream``), then
*default_extension*. Raises on any network / HTTP / oversize / empty error so
callers can fall back to the bare URL; a partial file is never left behind.
"""
import requests
response = requests.get(url, timeout=timeout, stream=True)
response.raise_for_status()
@@ -75,15 +63,11 @@ def save_url(
extension = content_types.get(content_type)
if extension is None:
url_path = url.split("?", 1)[0].lower()
for ext in url_extensions:
if url_path.endswith(f".{ext}"):
extension = "jpg" if ext == "jpeg" else ext
break
if extension is None:
extension = default_extension
extension = next(
("jpg" if ext == "jpeg" else ext for ext in url_extensions if url_path.endswith(f".{ext}")),
default_extension,
)
path = cache_path(kind, prefix, extension)
bytes_written = 0
with path.open("wb") as fh:
for chunk in response.iter_content(chunk_size=chunk_size):
+10 -24
View File
@@ -1,23 +1,13 @@
"""Fold an agent-as-provider's own activity back into Hermes' turn state.
Some providers are *agents* (an ACP CLI behind a client shim; the codex
app-server takes an analogous path in ``agent/codex_runtime.py``): they run
their own tools inside their own session, so by the time Hermes sees the
response that work is done. Those calls must never come back as pending
``tool_calls`` (Hermes would re-run finished work), but two subsystems go blind
if they are merely summarised into ``reasoning``:
* the **self-improvement loop**, which replays ``messages`` to distil memories
and skills;
* the **skill-review nudge**, whose ``_iters_since_skill`` counter only moves on
Hermes tool iterations.
So the client hands both back on the completion object —
``hermes_projected_messages`` (completed ``assistant(tool_calls=[…])`` +
``tool(result)`` rows) and ``hermes_provider_tool_iterations`` — and this helper
applies them. Ordinary OpenAI-compatible clients set neither and are unaffected.
The splice is append-only through ``append_message`` so rows carry a timestamp
and persist like any other live-transcript append.
Agent providers (ACP CLI shims, the codex app-server) run their own tools, so that
work must never come back as pending ``tool_calls`` (Hermes would re-run it) — but
the self-improvement loop (replays ``messages``) and the skill-review nudge
(``_iters_since_skill`` counter) go blind if it is merely summarised into
``reasoning``. The client hands back ``hermes_projected_messages`` (completed
assistant/tool rows) and ``hermes_provider_tool_iterations`` on the completion
object; this helper applies them append-only via ``append_message`` (timestamped,
persisted). Ordinary OpenAI-compatible clients set neither and are unaffected.
"""
from __future__ import annotations
@@ -32,9 +22,7 @@ logger = logging.getLogger(__name__)
__all__ = ["splice_provider_projection"]
def splice_provider_projection(
agent: Any, response: Any, messages: list[dict[str, Any]]
) -> int:
def splice_provider_projection(agent: Any, response: Any, messages: list[dict[str, Any]]) -> int:
"""Append the provider's projected history rows and tick the nudge counter.
Returns the number of rows spliced. Tolerates absent/garbage attributes so a
@@ -46,9 +34,7 @@ def splice_provider_projection(
append_message(messages, row)
if rows:
logger.debug(
"spliced %d provider-projected transcript row(s) from %s",
len(rows),
getattr(agent, "provider", "?"),
"spliced %d provider-projected transcript row(s) from %s", len(rows), getattr(agent, "provider", "?"),
)
try:
+26 -69
View File
@@ -1,14 +1,12 @@
"""Shared engine behind the ``agent.*_registry`` provider registries.
Every pluggable-backend registry (browser, TTS, image/video gen, transcription,
web search, terminal env) has the same shape: a global name->provider map plus
per-profile *scoped* maps (multiplexed gateways), a lock, registration with
re-registration logging, and the snapshot/restore pair that
:mod:`hermes_cli.plugins` uses to unwind a plugin's registrations. Each
``*_registry`` module instantiates one :class:`ProviderRegistry` and re-exports
its bound methods under the historical module-level names via
:meth:`ProviderRegistry.export`, so call sites, ``patch("agent.x_registry.get_provider")``
targets, and the ``_providers`` / ``_scoped_providers`` / ``_lock`` test hooks are unchanged.
Every pluggable-backend registry has the same shape: a global name->provider map
plus per-profile *scoped* maps (multiplexed gateways), a lock, registration with
re-registration logging, and the snapshot/restore pair :mod:`hermes_cli.plugins`
uses to unwind a plugin. Each ``*_registry`` module instantiates one
:class:`ProviderRegistry` and re-exports its bound methods under the historical
module-level names via :meth:`ProviderRegistry.export`, so ``patch("agent.x_registry.get_provider")``
targets and the ``_providers`` / ``_scoped_providers`` / ``_lock`` test hooks are unchanged.
"""
from __future__ import annotations
@@ -22,10 +20,6 @@ from hermes_constants import hermes_home_key
P = TypeVar("P")
def strip_key(name: str) -> str:
return name.strip()
def lower_key(name: str) -> str:
return name.strip().lower()
@@ -33,24 +27,16 @@ def lower_key(name: str) -> str:
class ProviderRegistry(Generic[P]):
"""Global + per-scope provider map with plugin snapshot/restore support.
Args:
label: Human label used in log/error strings (``"Browser"``, ``"TTS"``).
provider_cls: ABC every registered instance must satisfy (TypeError otherwise).
logger: The owning module's logger, so record names stay per-registry.
normalize: Key normalizer — ``strip_key`` or ``lower_key`` (case-insensitive
registries mirror how their dispatcher normalizes the configured name).
builtin_names: Reserved names owned by in-tree implementations; a collision
calls ``on_builtin_collision(key)`` and, if that returns, skips registration.
``normalize`` is ``str.strip`` or ``lower_key`` (case-insensitive registries mirror
how their dispatcher normalizes the configured name). ``builtin_names`` are reserved
for in-tree implementations; a collision calls ``on_builtin_collision(key)`` and, if
that returns, skips registration. ``logger`` is the owning module's so record names
stay per-registry.
"""
def __init__(
self,
*,
label: str,
provider_cls: type,
logger: logging.Logger,
normalize: Callable[[str], str] = strip_key,
builtin_names: FrozenSet[str] = frozenset(),
self, *, label: str, provider_cls: type, logger: logging.Logger,
normalize: Callable[[str], str] = str.strip, builtin_names: FrozenSet[str] = frozenset(),
on_builtin_collision: Optional[Callable[[str], None]] = None,
) -> None:
self.label = label
@@ -67,8 +53,6 @@ class ProviderRegistry(Generic[P]):
# "TTS provider" but "Registered browser provider": acronyms keep their case.
self._log_label = label if label.isupper() else label[0].lower() + label[1:]
# -- internal helpers (caller holds the lock) ---------------------------
def _target(self, scope: Optional[str], *, create: bool) -> Dict[str, P]:
if scope is None:
return self._providers
@@ -82,8 +66,6 @@ class ProviderRegistry(Generic[P]):
else:
self._scoped_generations[scope] = self._scoped_generations.get(scope, 0) + 1
# -- registration -------------------------------------------------------
def register(self, provider: P, *, scope: Optional[str] = None) -> None:
"""Register a provider; same-name re-registration overwrites (hot reload)."""
if not isinstance(provider, self.provider_cls):
@@ -107,17 +89,13 @@ class ProviderRegistry(Generic[P]):
self._bump(scope)
if existing is not None:
self.logger.debug(
f"{self.label} provider '%s' re-registered (was %r)",
key, type(existing).__name__,
f"{self.label} provider '%s' re-registered (was %r)", key, type(existing).__name__,
)
else:
self.logger.debug(
f"Registered {self._log_label} provider '%s' (%s)",
key, type(provider).__name__,
f"Registered {self._log_label} provider '%s' (%s)", key, type(provider).__name__,
)
# -- lookup ---------------------------------------------------------------
def merged(self, scope: Optional[str] = None) -> Dict[str, P]:
"""Global map overlaid with the active profile's scoped map (a copy)."""
with self._lock:
@@ -146,8 +124,6 @@ class ProviderRegistry(Generic[P]):
with self._lock:
return self._generation, self._scoped_generations.get(active_scope, 0)
# -- plugin unload support (hermes_cli.plugins) -----------------------------
def snapshot_registration(self, name: str, *, scope: Optional[str] = None) -> Optional[P]:
"""Exact-slot lookup (no global fallback) used to detect plugin ownership."""
with self._lock:
@@ -180,35 +156,19 @@ class ProviderRegistry(Generic[P]):
self._generation += 1
def export(self, namespace: Dict[str, Any]) -> None:
"""Bind the historical module-level API into a ``*_registry`` module.
Installs ``register_provider``/``list_providers``/``get_provider``/
``snapshot_registration``/``restore_registration``/``registry_generation``/
``_reset_for_tests`` plus the ``_providers``/``_scoped_providers``/``_lock``
test hooks, so ``patch("agent.x_registry.get_provider")`` and direct
``_providers`` manipulation in tests keep working unchanged.
"""
"""Bind the historical module-level API (+ ``_providers``/``_scoped_providers``/
``_lock`` test hooks) into a ``*_registry`` module namespace."""
namespace.update(
_providers=self._providers,
_scoped_providers=self._scoped_providers,
_lock=self._lock,
register_provider=self.register,
list_providers=self.list_providers,
get_provider=self.get_provider,
snapshot_registration=self.snapshot_registration,
_providers=self._providers, _scoped_providers=self._scoped_providers, _lock=self._lock,
register_provider=self.register, list_providers=self.list_providers,
get_provider=self.get_provider, snapshot_registration=self.snapshot_registration,
restore_registration=self.restore_registration,
registry_generation=self.registry_generation,
_reset_for_tests=self.reset_for_tests,
registry_generation=self.registry_generation, _reset_for_tests=self.reset_for_tests,
)
def is_available_safe(
provider: Any,
logger: logging.Logger,
fmt: str,
*,
level: int = logging.DEBUG,
exc_info: bool = False,
provider: Any, logger: logging.Logger, fmt: str, *, level: int = logging.DEBUG, exc_info: bool = False,
) -> bool:
"""``bool(provider.is_available())`` that treats a raising provider as unavailable."""
try:
@@ -224,19 +184,16 @@ def configured_provider_name(section: str, logger: logging.Logger) -> Optional[s
configured: Optional[str] = None
try:
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly()
block = cfg.get(section) if isinstance(cfg, dict) else None
if isinstance(block, dict):
raw = block.get("provider")
if isinstance(raw, str) and raw.strip():
configured = raw.strip()
raw = block.get("provider") if isinstance(block, dict) else None
if isinstance(raw, str) and raw.strip():
configured = raw.strip()
except Exception as exc:
logger.debug("Could not read %s.provider from config: %s", section, exc)
if configured:
try:
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER
if configured.lower() == NOUS_MANAGED_PROVIDER:
configured = "fal"
except Exception: # pragma: no cover — helpers are in-repo
File diff suppressed because it is too large Load Diff