Merge branch 'simp/r3-06' into simp/integration3
This commit is contained in:
+36
-62
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+48
-105
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+16
-33
@@ -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
File diff suppressed because it is too large
Load Diff
+297
-626
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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), ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+231
-481
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user