Six opt-in, bucketed shared-metrics counters (schema + contract + docs in `v5 efficiency` blocks):
- hermes.task_cost.count {provider, model, tokens_bucket, tool_calls_bucket, api_calls_bucket,
outcome}: one row per user turn the user saw end (pre_llm_call-started, attended; forks, delegated
children and session-close aborts excluded). Tokens = prompt+completion over the turn's primary
calls. Tool/API call buckets reach gte_101 (TURN_ACTIVITY_BUCKETS; COUNT_BUCKETS untouched).
- hermes.wasted_tokens.count {provider, model, reason in undo/retry/interrupt, tokens_bucket}:
emitted from the v4 friction sites (no re-detection). /undo N counts N turns; an interrupted turn
later undone counts once; turns this process never saw read unknown.
- hermes.tool_output_truncation.count {tool, truncated, original_size_bucket}: one row per tool
result, judged after the per-turn budget; covers the per-result spill, the turn budget, and the
shared head/tail notice tools write when they cut their own output (original size reported).
- hermes.tool_overhead.count {enabled_tool_count_bucket, tool_schema_tokens_bucket,
execution_surface} + hermes.tool_enabled_unused.count {toolset (shipped TOOLSETS else custom),
used}: once per closed interactive conversation (merged across compression lineage).
- hermes.cache_break.count {provider, model, cause}: compression (committed), model_switch /
system_prompt_rebuild (continuing conversation rebuilt its prompt), toolset_change (tool array
changed mid-conversation, Bot Chat capability rebuild), provider_reported_miss (cold read after a
warm read on the same route with no Hermes-known cause), cache_expired (same after >=5 min idle).
A Hermes-known break suppresses the miss it causes. No prompt hashing.
Live-proven against a fake OpenAI-compatible server (chat -q, --resume -m, TUI gateway JSON-RPC
undo/retry/interrupt); disabled => zero telemetry files.
364 lines
16 KiB
Python
364 lines
16 KiB
Python
"""Efficiency shared metrics: what one user turn costs, what users throw away, and where Hermes
|
|
spends tokens it did not have to (spilled tool output, idle tool schemas, prompt-cache breaks).
|
|
|
|
Per-turn state lives on the relay runtime's task/session objects (see ``TurnCost`` and
|
|
``SessionEfficiency``); the agent-side entry points below run on the agent thread and are no-ops
|
|
unless shared metrics are enabled. Provider/model always go through the catalog helpers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from collections import deque
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Iterable
|
|
|
|
from . import shared_metrics_contract as contract
|
|
from .shared_metrics_contract import _bucket, _non_negative_number
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TOKEN_THRESHOLDS = (
|
|
(2_000, "lt_2k"), (10_000, "2k_to_10k"), (50_000, "10k_to_50k"), (200_000, "50k_to_200k"),
|
|
(1_000_000, "200k_to_1m"),
|
|
)
|
|
_ACTIVITY_THRESHOLDS = (
|
|
(1, "0"), (2, "1"), (3, "2"), (6, "3_to_5"), (11, "6_to_10"), (26, "11_to_25"), (51, "26_to_50"),
|
|
(101, "51_to_100"),
|
|
)
|
|
_OUTPUT_SIZE_THRESHOLDS = (
|
|
(1_000, "lt_1k"), (10_000, "1k_to_10k"), (50_000, "10k_to_50k"), (100_000, "50k_to_100k"),
|
|
(500_000, "100k_to_500k"),
|
|
)
|
|
_SCHEMA_TOKEN_THRESHOLDS = (
|
|
(1, "0"), (2_000, "lt_2k"), (5_000, "2k_to_5k"), (10_000, "5k_to_10k"), (20_000, "10k_to_20k"),
|
|
(40_000, "20k_to_40k"),
|
|
)
|
|
_TASK_COST_OUTCOMES = {"success": "completed", "cancelled": "interrupted"}
|
|
# Anthropic's default prompt-cache TTL: a cold read after this idle gap is expiry, not a bug.
|
|
CACHE_TTL_S = 300.0
|
|
# Per-session memory of spent turns an /undo or /retry can still reach.
|
|
_SPENT_TURNS_KEPT = 64
|
|
_PENDING_OUTPUTS_ATTR = "_shared_metrics_tool_outputs"
|
|
_PENDING_OUTPUTS_MAX = 256
|
|
# The one notice tools/tool_output_truncate.py writes when terminal, execute_code or MCP output was
|
|
# cut down inside the tool, before Hermes ever saw the full text.
|
|
_TOOL_TRUNCATION_NOTICE = re.compile(r"\[[A-Z_ ]{1,32} TRUNCATED - [\d,]{1,20} chars omitted out of ([\d,]{1,20}) total\]")
|
|
_NOTICE_MIN_CHARS = 1_000
|
|
|
|
|
|
def tokens_bucket(tokens: int | None) -> str:
|
|
return "unknown" if tokens is None else _bucket(max(0, tokens), _TOKEN_THRESHOLDS, "gte_1m")
|
|
|
|
|
|
def activity_bucket(count: int) -> str:
|
|
return _bucket(max(0, int(count)), _ACTIVITY_THRESHOLDS, "gte_101")
|
|
|
|
|
|
def output_size_bucket(size: int | None) -> str:
|
|
return "unknown" if size is None else _bucket(max(0, size), _OUTPUT_SIZE_THRESHOLDS, "gte_500k")
|
|
|
|
|
|
def schema_tokens_bucket(tokens: int) -> str:
|
|
return _bucket(max(0, int(tokens)), _SCHEMA_TOKEN_THRESHOLDS, "gte_40k")
|
|
|
|
|
|
def _int(value: Any) -> int | None:
|
|
number = _non_negative_number(value)
|
|
return None if number is None else int(number)
|
|
|
|
|
|
def call_tokens(usage: Any) -> int | None:
|
|
"""Prompt (cache reads/writes included) plus completion tokens of one call; None without usage."""
|
|
if not isinstance(usage, dict):
|
|
return None
|
|
total = _int(usage.get("total_tokens"))
|
|
if total is not None:
|
|
return total
|
|
parts = [_int(usage.get(key)) for key in (
|
|
"input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens")]
|
|
return sum(p for p in parts if p is not None) if any(p is not None for p in parts) else None
|
|
|
|
|
|
def toolset_metric_name(toolset: Any) -> str:
|
|
"""A shipped toolset's own name; MCP servers, plugins and user toolsets read ``custom``."""
|
|
return toolset if isinstance(toolset, str) and toolset in contract.BUILTIN_TOOLSET_NAMES else "custom"
|
|
|
|
|
|
def _route(route: dict[str, str] | None) -> dict[str, str]:
|
|
from .shared_metrics_model import model_route
|
|
|
|
return dict(route) if route else model_route(None, None)
|
|
|
|
|
|
# ---- per-turn cost and waste (runtime state; caller holds session.lock) ------------------------
|
|
|
|
@dataclass
|
|
class TurnCost:
|
|
"""Token spend of one task; ``user_turn`` marks a turn a user message started (pre_llm_call),
|
|
which Hermes-owned forks sharing the session id never fire."""
|
|
|
|
user_turn: bool = False
|
|
tokens: int = 0
|
|
known: bool = False
|
|
route: dict[str, str] | None = None
|
|
|
|
def add_call(self, route: dict[str, str], usage: Any) -> None:
|
|
self.route = route
|
|
tokens = call_tokens(usage)
|
|
if tokens is not None:
|
|
self.tokens += tokens
|
|
self.known = True
|
|
|
|
def bucket(self) -> str:
|
|
return tokens_bucket(self.tokens if self.known else None)
|
|
|
|
|
|
@dataclass
|
|
class _SpentTurn:
|
|
route: dict[str, str]
|
|
tokens_bucket: str
|
|
wasted: bool = False
|
|
|
|
|
|
def _wasted_row(reason: str, turn: _SpentTurn) -> tuple[str, dict[str, str]]:
|
|
return contract.WASTED_TOKENS_MARK, {**turn.route, "reason": reason, "tokens_bucket": turn.tokens_bucket}
|
|
|
|
|
|
@dataclass
|
|
class ToolUsage:
|
|
"""What the session paid to carry tool definitions, and which shipped toolsets it touched."""
|
|
|
|
observed: bool = False
|
|
surface: str = "unknown"
|
|
enabled_toolsets: frozenset[str] = frozenset()
|
|
enabled_count: int = 0
|
|
schema_tokens: int = 0
|
|
names: tuple[str, ...] | None = None
|
|
used: set[str] = field(default_factory=set)
|
|
|
|
def observe(self, names: tuple[str, ...], toolsets: frozenset[str], enabled_count: int, schema_tokens: int) -> bool:
|
|
"""Record the tools one request sent; True when a later request changed the array."""
|
|
changed = self.names is not None and names != self.names
|
|
self.observed, self.names = True, names
|
|
self.enabled_toolsets = self.enabled_toolsets | toolsets
|
|
self.enabled_count, self.schema_tokens = max(self.enabled_count, enabled_count), schema_tokens
|
|
return changed
|
|
|
|
def absorb(self, other: ToolUsage) -> None:
|
|
if not other.observed:
|
|
return
|
|
self.observed, self.surface = True, other.surface
|
|
self.enabled_toolsets = self.enabled_toolsets | other.enabled_toolsets
|
|
self.enabled_count = max(self.enabled_count, other.enabled_count)
|
|
self.schema_tokens = max(self.schema_tokens, other.schema_tokens)
|
|
self.used |= other.used
|
|
|
|
def rows(self) -> list[tuple[str, dict[str, str]]]:
|
|
if not self.observed:
|
|
return []
|
|
rows = [(contract.TOOL_OVERHEAD_MARK, {
|
|
"enabled_tool_count_bucket": contract.size_bucket(self.enabled_count),
|
|
"execution_surface": self.surface,
|
|
"tool_schema_tokens_bucket": schema_tokens_bucket(self.schema_tokens),
|
|
})]
|
|
rows.extend(
|
|
(contract.TOOL_ENABLED_UNUSED_MARK, {"toolset": name, "used": "yes" if name in self.used else "no"})
|
|
for name in sorted(self.enabled_toolsets)
|
|
)
|
|
return rows
|
|
|
|
|
|
@dataclass
|
|
class SessionEfficiency:
|
|
spent: deque[_SpentTurn] = field(default_factory=lambda: deque(maxlen=_SPENT_TURNS_KEPT))
|
|
tools: ToolUsage = field(default_factory=ToolUsage)
|
|
tools_key: tuple[int, int, int] | None = None
|
|
cache_read: int = 0
|
|
cache_route: dict[str, str] | None = None
|
|
cache_ended_ns: int = 0
|
|
|
|
def finish_turn(
|
|
self, cost: TurnCost, terminal: dict[str, str], *, route: dict[str, str] | None,
|
|
model_calls: int, tool_calls: int,
|
|
) -> list[tuple[str, dict[str, str]]]:
|
|
"""task_cost for one user turn the user saw end; an interrupt also counts its tokens wasted."""
|
|
turn = _SpentTurn(_route(cost.route or route), cost.bucket())
|
|
rows = [(contract.TASK_COST_MARK, {
|
|
**turn.route,
|
|
"api_calls_bucket": activity_bucket(model_calls),
|
|
"outcome": _TASK_COST_OUTCOMES.get(terminal.get("outcome", ""), "failed"),
|
|
"tokens_bucket": turn.tokens_bucket,
|
|
"tool_calls_bucket": activity_bucket(tool_calls),
|
|
})]
|
|
if terminal.get("end_reason") == "user_cancelled":
|
|
turn.wasted = True
|
|
rows.append(_wasted_row("interrupt", turn))
|
|
self.spent.append(turn)
|
|
return rows
|
|
|
|
def discard_turns(self, reason: str, turns: int, fallback_route: dict[str, str]) -> list[tuple[str, dict[str, str]]]:
|
|
"""An /undo or /retry threw away the newest ``turns`` turns: one row per turn not already
|
|
counted (an interrupted turn the user then undoes was wasted once, at the interrupt)."""
|
|
rows = []
|
|
for _ in range(max(1, min(int(turns), _SPENT_TURNS_KEPT))):
|
|
turn = self.spent.pop() if self.spent else _SpentTurn(_route(fallback_route), "unknown")
|
|
if not turn.wasted:
|
|
rows.append(_wasted_row(reason, turn))
|
|
return rows
|
|
|
|
def observe_cache(
|
|
self, route: dict[str, str], usage: Any, started_ns: int, ended_ns: int, *, expected: bool,
|
|
) -> str | None:
|
|
"""A cold primary read after a warm one on the same route is a break Hermes did not announce
|
|
(``expected``: Hermes already counted the cause); a gap past the cache TTL is plain expiry."""
|
|
read = _int(usage.get("cache_read_tokens")) if isinstance(usage, dict) else None
|
|
if read is None:
|
|
return None
|
|
prev_read, prev_route, prev_ended = self.cache_read, self.cache_route, self.cache_ended_ns
|
|
self.cache_read, self.cache_route, self.cache_ended_ns = read, route, ended_ns
|
|
if expected or read or not prev_read or prev_route != route:
|
|
return None
|
|
idle_s = (started_ns - prev_ended) / 1e9
|
|
return "cache_expired" if idle_s >= CACHE_TTL_S else "provider_reported_miss"
|
|
|
|
|
|
def cache_break_row(cause: str, route: dict[str, str] | None) -> tuple[str, dict[str, str]]:
|
|
return contract.CACHE_BREAK_MARK, {**_route(route), "cause": cause}
|
|
|
|
|
|
# ---- agent-side entry points (agent thread; never raise) ---------------------------------------
|
|
|
|
def _internal_agent(agent: Any) -> bool:
|
|
"""Hermes-owned forks (background review, curator) share the user's session id."""
|
|
return getattr(agent, "_memory_write_origin", None) == "background_review"
|
|
|
|
|
|
def _agent_route(agent: Any) -> dict[str, str]:
|
|
from .shared_metrics_model import model_route
|
|
|
|
return model_route(getattr(agent, "provider", None), getattr(agent, "model", None))
|
|
|
|
|
|
def _enabled() -> bool:
|
|
from .relay_shared_metrics import enabled
|
|
|
|
return enabled()
|
|
|
|
|
|
def note_tool_result(agent: Any, tool_name: Any, tool_call_id: Any, raw: Any, persisted: Any) -> None:
|
|
"""Remember one committed tool result until its batch's turn budget has run."""
|
|
try:
|
|
if _internal_agent(agent) or not _enabled():
|
|
return
|
|
pending = agent.__dict__.setdefault(_PENDING_OUTPUTS_ATTR, {})
|
|
if len(pending) >= _PENDING_OUTPUTS_MAX:
|
|
return
|
|
size, cut_in_tool = _result_size(raw)
|
|
pending[str(tool_call_id or id(persisted))] = [
|
|
contract.tool_metric_name({"tool_name": tool_name}), size, cut_in_tool or persisted is not raw,
|
|
]
|
|
except Exception:
|
|
logger.debug("Shared-metrics tool output not noted", exc_info=True)
|
|
|
|
|
|
def _result_size(result: Any) -> tuple[int | None, bool]:
|
|
"""(characters the tool produced, whether the tool itself already truncated them)."""
|
|
if isinstance(result, dict): # multimodal envelope: its text parts are what can spill
|
|
parts = result.get("content") or []
|
|
return sum(len(p.get("text") or "") for p in parts if isinstance(p, dict) and p.get("type") == "text"), False
|
|
if not isinstance(result, str):
|
|
return None, False
|
|
notice = _TOOL_TRUNCATION_NOTICE.search(result) if len(result) >= _NOTICE_MIN_CHARS else None
|
|
if notice is None:
|
|
return len(result), False
|
|
return max(len(result), int(notice.group(1).replace(",", ""))), True
|
|
|
|
|
|
def record_tool_batch(agent: Any, batch: list[dict], contents_before: list[Any]) -> None:
|
|
"""One row per tool result of the batch: truncated when the per-result cap or the per-turn
|
|
budget replaced it (the budget runs after the cap, so a result is judged once, here)."""
|
|
try:
|
|
pending = agent.__dict__.pop(_PENDING_OUTPUTS_ATTR, None)
|
|
if not pending or not _enabled():
|
|
return
|
|
for message, before in zip(batch, contents_before):
|
|
entry = pending.get(str(message.get("tool_call_id") or ""))
|
|
if entry is not None and message.get("content") is not before:
|
|
entry[2] = True
|
|
from .relay_shared_metrics import record_process_mark
|
|
|
|
for tool, size, truncated in pending.values():
|
|
record_process_mark(contract.TOOL_OUTPUT_TRUNCATION_MARK, {
|
|
"original_size_bucket": output_size_bucket(size), "tool": tool, "truncated": "yes" if truncated else "no",
|
|
})
|
|
except Exception:
|
|
logger.debug("Shared-metrics tool output truncation not recorded", exc_info=True)
|
|
|
|
|
|
def observe_request_tools(agent: Any, tools_for_api: Any) -> None:
|
|
"""The tool definitions one primary request carries (evidence for trimming default toolsets)."""
|
|
try:
|
|
if getattr(agent, "_persist_disabled", False) or _internal_agent(agent) or not _enabled():
|
|
return
|
|
from .relay_shared_metrics import record_session_tools
|
|
|
|
record_session_tools(str(getattr(agent, "session_id", "") or ""), agent, tools_for_api or [])
|
|
except Exception:
|
|
logger.debug("Shared-metrics tool overhead not observed", exc_info=True)
|
|
|
|
|
|
def tool_snapshot(agent: Any, tools_for_api: list) -> tuple[tuple[str, ...], frozenset[str], int, int]:
|
|
"""(sent tool names, enabled shipped toolsets, enabled tool count, estimated schema tokens)."""
|
|
from agent.model_metadata import _estimate_tools_tokens_rough
|
|
from model_tools import get_toolset_for_tool
|
|
|
|
names = tuple(_tool_names(tools_for_api))
|
|
enabled = list(_tool_names(getattr(agent, "tools", None) or tools_for_api))
|
|
extra = getattr(agent, "valid_tool_names", None) or ()
|
|
enabled_names = set(enabled) | {n for n in extra if isinstance(n, str)}
|
|
toolsets = frozenset(toolset_metric_name(get_toolset_for_tool(n)) for n in enabled_names)
|
|
return names, toolsets, len(enabled_names), _estimate_tools_tokens_rough(tools_for_api) if tools_for_api else 0
|
|
|
|
|
|
def _tool_names(tools: Iterable[Any]) -> Iterable[str]:
|
|
for tool in tools or ():
|
|
fn = tool.get("function") if isinstance(tool, dict) else None
|
|
name = fn.get("name") if isinstance(fn, dict) else (tool.get("name") if isinstance(tool, dict) else None)
|
|
if isinstance(name, str):
|
|
yield name
|
|
|
|
|
|
def record_cache_break(agent: Any, cause: str) -> None:
|
|
"""Hermes itself invalidated the conversation's cached prefix (compression, a rebuilt prompt)."""
|
|
try:
|
|
if cause not in contract.CACHE_BREAK_CAUSES or getattr(agent, "_persist_disabled", False):
|
|
return
|
|
if _internal_agent(agent) or not _enabled():
|
|
return
|
|
from .relay_shared_metrics import record_known_cache_break
|
|
|
|
record_known_cache_break(cause, _agent_route(agent), str(getattr(agent, "session_id", "") or ""))
|
|
except Exception:
|
|
logger.debug("Shared-metrics cache break not recorded", exc_info=True)
|
|
|
|
|
|
def record_prompt_rebuild(agent: Any, stored_prompt: Any, stored_state: str, rebuilt: Any) -> None:
|
|
"""A continuing conversation rebuilt its system prompt instead of replaying the stored bytes."""
|
|
if stored_state in ("null", "empty"):
|
|
record_cache_break(agent, "system_prompt_rebuild")
|
|
return
|
|
if stored_state != "stale_runtime" or not stored_prompt or rebuilt == stored_prompt:
|
|
return
|
|
try:
|
|
from agent.surface_switch import identity_line_value
|
|
|
|
switched = any(
|
|
(stored := identity_line_value(stored_prompt, label)) and stored != str(getattr(agent, attr, "") or "").strip()
|
|
for label, attr in (("Model", "model"), ("Provider", "provider"))
|
|
)
|
|
except Exception:
|
|
switched = False
|
|
record_cache_break(agent, "model_switch" if switched else "system_prompt_rebuild")
|