Files
hermes-agent/hermes_cli/observability/shared_metrics_efficiency.py
teknium1 607dff390a feat(telemetry): v5 efficiency metrics — turn cost, wasted tokens, tool output truncation, tool overhead, prompt-cache breaks
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.
2026-09-28 12:43:03 -07:00

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")