#135807 makes the empty-transcript refusal emit its attempt record through the shared-metric lifecycle (on main that exit recorded nothing), but `empty_transcript` was not in the closed `COMPRESSION_FAILURE_CLASSES` set, so the `hermes.compression.count` row read `failed / other`. Add the class to the contract, the v4 package schema enum and the metric docs so the row says what happened.
1901 lines
96 KiB
Python
1901 lines
96 KiB
Python
"""Bounded product contract for the first Hermes shared-metrics slice."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from math import isfinite
|
|
from typing import Any
|
|
|
|
from agent.error_classifier import FailoverReason
|
|
from agent.relay_runtime import (
|
|
LOGICAL_LLM_SCOPE,
|
|
RUNTIME_INSTANCE_KEY,
|
|
RUNTIME_SCHEMA_KEY,
|
|
RUNTIME_SCHEMA_VERSION,
|
|
)
|
|
from hermes_cli.platforms import PLATFORMS
|
|
from toolsets import BUILTIN_TOOL_NAMES, BUILTIN_TOOLSET_NAMES
|
|
|
|
SCHEMA_KEY = "hermes.metrics.schema_version"
|
|
# A random per-emit token (no payload) on rows whose producer waits to learn they were SAVED: facts
|
|
# recovered from a file that is deleted only then. Events persist on the Relay thread.
|
|
COMMIT_TICKET_KEY = "hermes.shared_metrics.commit_ticket"
|
|
SCHEMA_VERSION = "hermes.metrics.event.v4"
|
|
MODEL_CALL_SCOPE = "hermes.model_call"
|
|
MODEL_CALL_PROFILE_MODEL = "unknown"
|
|
TASK_SCOPE = "hermes.task_run"
|
|
TOOL_CALL_SCOPE = "hermes.tool_call"
|
|
CLIENT_ACTIVE_MARK = "hermes.client.active"
|
|
TOOL_APPROVAL_MARK = "hermes.tool_approval"
|
|
SKILL_LIFECYCLE_MARK = "hermes.skill.lifecycle"
|
|
SKILL_LOAD_MARK = "hermes.skill.load"
|
|
INSTALL_SNAPSHOT_MARK = "hermes.install.snapshot"
|
|
SESSION_MARK = "hermes.session"
|
|
SETUP_COMPLETED_MARK = "hermes.setup.completed"
|
|
MODEL_TOKENS_MARK = "hermes.model_tokens"
|
|
COMPRESSION_MARK = "hermes.compression"
|
|
MODEL_SWITCH_MARK = "hermes.model_switch"
|
|
FALLBACK_MARK = "hermes.fallback"
|
|
SLASH_COMMAND_MARK = "hermes.slash_command"
|
|
EXTENSION_INSTALL_MARK = "hermes.extension.install"
|
|
# ---- v4 install ----
|
|
STARTUP_LATENCY_MARK = "hermes.startup.latency"
|
|
SUBSCRIBER_NAME = "hermes.nemo_relay.shared_metrics"
|
|
CLIENT_ACTIVE_METRIC = "hermes.client.active"
|
|
LEGACY_MODEL_CALL_METRIC = "hermes.model_call.count"
|
|
MODEL_ROUTE_METRIC = "hermes.model_route.count"
|
|
TASK_STARTED_METRIC = "hermes.task_run.started"
|
|
TASK_FINISHED_METRIC = "hermes.task_run.finished"
|
|
TASK_DURATION_METRIC = "hermes.task_run.duration"
|
|
TOOL_CALL_METRIC = "hermes.tool_call.count"
|
|
TOOL_LATENCY_METRIC = "hermes.tool_call.latency"
|
|
TOOL_APPROVAL_METRIC = "hermes.tool_approval.count"
|
|
SKILL_LIFECYCLE_METRIC = "hermes.skill.lifecycle.count"
|
|
SKILL_LOAD_METRIC = "hermes.skill.load.count"
|
|
TOOL_USAGE_METRIC = "hermes.tool.usage.count"
|
|
INSTALL_SNAPSHOT_METRIC = "hermes.install.snapshot"
|
|
SESSION_METRIC = "hermes.session.count"
|
|
MILESTONE_METRIC = "hermes.install.milestone"
|
|
SETUP_COMPLETED_METRIC = "hermes.setup.completed"
|
|
MODEL_TOKENS_METRIC = "hermes.model_tokens.sum"
|
|
COMPRESSION_METRIC = "hermes.compression.count"
|
|
MODEL_SWITCH_METRIC = "hermes.model_switch.count"
|
|
FALLBACK_METRIC = "hermes.fallback.count"
|
|
SLASH_COMMAND_METRIC = "hermes.slash_command.count"
|
|
EXTENSION_INSTALL_METRIC = "hermes.extension.install.count"
|
|
# ---- v4 loop ----
|
|
MEMORY_OP_MARK = "hermes.memory.op"
|
|
CURATOR_RUN_MARK = "hermes.curator.run"
|
|
DELEGATION_RUN_MARK = "hermes.delegation.run"
|
|
EXECUTION_BACKEND_MARK = "hermes.execution_backend"
|
|
MEMORY_OP_METRIC = "hermes.memory.op.count"
|
|
CURATOR_RUN_METRIC = "hermes.curator.run.count"
|
|
DELEGATION_RUN_METRIC = "hermes.delegation.run.count"
|
|
EXECUTION_BACKEND_METRIC = "hermes.execution_backend.count"
|
|
# ---- end v4 loop ----
|
|
# ---- v4 model ----
|
|
MODEL_TOOL_QUALITY_MARK = "hermes.model_tool_quality"
|
|
MODEL_FRICTION_MARK = "hermes.model_friction"
|
|
CONTEXT_PEAK_MARK = "hermes.context_peak"
|
|
MODEL_TOOL_QUALITY_METRIC = "hermes.model_tool_quality.count"
|
|
MODEL_FRICTION_METRIC = "hermes.model_friction.count"
|
|
CONTEXT_PEAK_METRIC = "hermes.context_peak.count"
|
|
# ---- end v4 model ----
|
|
# ---- v4 install ----
|
|
STARTUP_LATENCY_METRIC = "hermes.startup.latency"
|
|
# ---- v5 engagement ----
|
|
# Not a counter: one attended turn's serving model, folded into the local daily engagement rollup.
|
|
ENGAGEMENT_TURN_MARK = "hermes.engagement.turn"
|
|
ENGAGEMENT_DAY_METRIC = "hermes.engagement.day.count"
|
|
ENGAGEMENT_SURFACE_METRIC = "hermes.engagement.surface_day.count"
|
|
MODEL_SWITCH_AFTER_MARK = "hermes.model_switch_after"
|
|
MODEL_SWITCH_AFTER_METRIC = "hermes.model_switch_after.count"
|
|
# ---- end v5 engagement ----
|
|
MODEL_IDENTIFIER_MAX_LENGTH = 256
|
|
PROVIDER_IDENTIFIER_MAX_LENGTH = 64
|
|
_METRIC_IDENTIFIER_CHARACTERS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789._:/@+-")
|
|
_METRIC_IDENTIFIER_START_CHARACTERS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789")
|
|
|
|
EXECUTION_SURFACES = frozenset({
|
|
"acp", "api", "batch", "cli", "desktop", "gateway", "python", "scheduled_task", "tui",
|
|
"other", "unknown",
|
|
})
|
|
TASK_OUTCOMES = frozenset({"cancelled", "failed", "success", "timed_out", "unknown"})
|
|
TASK_END_REASONS = frozenset({
|
|
"approval_denied", "completed", "failed", "guardrail_blocked", "iteration_limit",
|
|
"system_aborted", "timed_out", "unknown", "user_cancelled",
|
|
})
|
|
TASK_TERMINATIONS = frozenset({"none", "system_aborted", "timed_out", "unknown", "user_cancelled"})
|
|
# ``one_shot``: a finite CLI run (``hermes -z``, ``hermes chat -q`` off a TTY, ``-Q``, ``--oneshot``) that
|
|
# answers one prompt and exits — a person's shell line or their script, never the REPL.
|
|
TASK_ENTRYPOINTS = frozenset({
|
|
"api", "background", "batch", "delegated", "gateway_message", "interactive", "one_shot", "other",
|
|
"python", "scheduled_task", "unknown",
|
|
})
|
|
DURATION_BUCKETS = frozenset({
|
|
"1s_to_5s", "2m_to_10m", "30s_to_2m", "5s_to_30s", "gte_10m", "lt_1s",
|
|
})
|
|
COUNT_BUCKETS = frozenset({"0", "1", "2", "3_to_5", "6_to_10", "gte_11"})
|
|
TOOL_CATEGORIES = frozenset({
|
|
"browser", "code_execution", "communication", "computer_use", "delegation", "file",
|
|
"home_automation", "mcp", "media", "memory", "other", "planning", "project", "scheduler",
|
|
"skill", "terminal", "unknown", "web",
|
|
})
|
|
TOOL_OUTCOMES = frozenset({"blocked", "cancelled", "failed", "success", "timed_out", "unknown"})
|
|
TOOL_APPROVAL_OUTCOMES = frozenset({"approved", "cancelled", "denied", "not_required", "timed_out", "unknown"})
|
|
TOOL_APPROVAL_ATTRIBUTIONS = frozenset({"tool_call", "unattributed"})
|
|
TOOL_LATENCY_BUCKETS = frozenset({
|
|
"100ms_to_250ms", "10s_to_30s", "1s_to_2s", "250ms_to_500ms", "2s_to_5s", "500ms_to_1s",
|
|
"5s_to_10s", "gte_30s", "lt_100ms", "unknown",
|
|
})
|
|
TOOL_RETRY_BUCKETS = COUNT_BUCKETS | frozenset({"unknown"})
|
|
SKILL_LIFECYCLE_ACTIONS = frozenset({
|
|
"archived", "created", "edited", "installed", "patched", "restored", "stale",
|
|
})
|
|
SKILL_PROVENANCES = frozenset({"agent_created", "external", "installed", "local", "unknown"})
|
|
SKILL_REUSE_STATES = frozenset({"first_use", "reused"})
|
|
SKILL_POST_PATCH_STATES = frozenset({"no_new_patch", "not_applicable", "reused_after_patch"})
|
|
CLIENT_OS_FAMILIES = frozenset({"linux", "macos", "unknown", "windows"})
|
|
CLIENT_ARCHITECTURES = frozenset({"arm", "arm64", "unknown", "x86", "x86_64"})
|
|
CLIENT_INSTALL_METHODS = frozenset({
|
|
"apt", "docker", "git", "home-manager", "homebrew", "nixos", "pip", "unknown",
|
|
})
|
|
CLIENT_RESOURCE_KEYS = frozenset({"architecture", "hermes_version", "install_method", "os_family"})
|
|
|
|
# ---- v3 taxonomies -------------------------------------------------------------------------
|
|
MODEL_CALL_ROLES = frozenset({"auxiliary", "primary"})
|
|
MODEL_OUTCOMES = frozenset({"cancelled", "failed", "success"})
|
|
# The classifier's own closed enum, so the exported vocabulary is exactly what recovery acts on.
|
|
MODEL_ERROR_CLASSES = frozenset(reason.value for reason in FailoverReason) | {"none"}
|
|
TASK_FAILURE_CLASSES = MODEL_ERROR_CLASSES | frozenset({
|
|
"context_compression", "empty_response", "exception", "local_error", "other", "persistence",
|
|
"repeated_errors", "restart_limit", "session_busy", "shutdown",
|
|
})
|
|
# Surfaces that are not messaging platforms keep their own execution_surface value.
|
|
# ``relay``: a connector inbound whose platform neither the inbound nor a single-platform connector names.
|
|
_CORE_GATEWAY_PLATFORMS = (frozenset(PLATFORMS) - {"api_server", "cli", "cron"}) | {"none", "plugin", "relay"}
|
|
TOOL_NAMES = BUILTIN_TOOL_NAMES | {"mcp", "plugin", "unknown"}
|
|
TOOL_ERROR_CLASSES = frozenset({
|
|
"blocked", "contract_violation", "exception", "interrupted", "invalid_arguments", "none",
|
|
"timeout", "tool_error", "unknown",
|
|
})
|
|
SIZE_BUCKETS = frozenset({
|
|
"0", "1", "2", "3_to_5", "6_to_10", "11_to_25", "26_to_100", "101_to_250", "gte_251",
|
|
})
|
|
|
|
|
|
def _bundled_memory_providers() -> frozenset[str]:
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parents[2] / "plugins" / "memory"
|
|
try:
|
|
return frozenset(p.name for p in root.iterdir() if (p / "__init__.py").is_file())
|
|
except OSError:
|
|
return frozenset()
|
|
|
|
|
|
class _CatalogValues:
|
|
"""A closed enum backed by a public catalog (see shared_metrics_catalog), loaded on first use."""
|
|
|
|
def __init__(self, *loaders: str, extra: frozenset[str]) -> None:
|
|
self.loaders, self.extra = loaders, extra
|
|
|
|
def values(self) -> frozenset[str]:
|
|
from . import shared_metrics_catalog as catalog
|
|
|
|
return self.extra.union(*(catalog._safe(getattr(catalog, name)) for name in self.loaders))
|
|
|
|
def __contains__(self, value: object) -> bool:
|
|
return value in self.extra or value in self.values()
|
|
|
|
|
|
# A provider that moved from plugins/memory/ to the plugin catalog keeps its public name; any other
|
|
# third-party provider reports as "plugin".
|
|
MEMORY_PROVIDERS = _CatalogValues("plugin_catalog_names", extra=_bundled_memory_providers() | {"builtin", "plugin"})
|
|
|
|
# ---- decision-data taxonomies ----------------------------------------------------------------
|
|
SESSION_DURATION_BUCKETS = frozenset({"lt_1m", "1m_to_5m", "5m_to_30m", "30m_to_2h", "2h_to_8h", "gte_8h"})
|
|
INSTALL_AGE_BUCKETS = frozenset({
|
|
"lt_1h", "1h_to_1d", "1d_to_7d", "7d_to_30d", "30d_to_90d", "gte_90d", "unknown",
|
|
})
|
|
MILESTONES = frozenset({
|
|
"setup_completed", "first_task_started", "first_task_success", "first_tool_success",
|
|
"first_gateway_message", "first_scheduled_task", "first_delegation", "first_skill_created",
|
|
"first_skill_reused", "first_mcp_tool_success", "first_long_session",
|
|
})
|
|
SETUP_SURFACES = frozenset({"cli", "desktop", "other"})
|
|
TOKEN_TYPES = frozenset({"input", "output", "cache_read", "cache_write", "reasoning"})
|
|
TTFT_BUCKETS = frozenset({
|
|
"lt_500ms", "500ms_to_1s", "1s_to_2s", "2s_to_5s", "5s_to_15s", "gte_15s", "not_streamed", "unknown",
|
|
})
|
|
COMPRESSION_TRIGGERS = frozenset({"auto", "manual", "other", "overflow"})
|
|
COMPRESSION_OUTCOMES = frozenset({"failed", "skipped", "success"})
|
|
# Why an attempt was skipped or failed: the attempt log's own ``failure_class`` names
|
|
# (agent/conversation_compression*.py, agent/context_compressor.py). Skips are attempts that ended
|
|
# before anything could fail (another path held the lock, nothing summarizable so no model call, the
|
|
# user stopped it, a newer attempt replaced it). ``exception:<Type>`` / ``rollback:<Type>`` keep only
|
|
# their prefix; any other value reads ``other``, a failure with no class ``unknown``.
|
|
COMPRESSION_SKIP_CLASSES = frozenset({
|
|
"attempt_superseded", "empty_post_handoff_window", "explicit_interrupt", "insufficient_messages",
|
|
"lock_contended", "no_compressible_window", "snapshot_stale",
|
|
})
|
|
COMPRESSION_FAILURE_CLASSES = COMPRESSION_SKIP_CLASSES | frozenset({
|
|
"aux_model_fallback", "commit_fence_cancelled", "empty_transcript", "exception", "feasibility_skip",
|
|
"no_progress", "none",
|
|
"other", "pool_saturated", "rollback", "session_split_failed", "stall_deterministic_fallback",
|
|
"stall_interrupted", "summary_auth_failure", "summary_empty_content_failure", "summary_generation_aborted",
|
|
"summary_generation_failed", "summary_network_failure", "summary_overload_degraded",
|
|
"summary_overload_failure", "summary_truncated_failure", "unknown", "would_grow",
|
|
})
|
|
CONTEXT_FILL_BUCKETS = frozenset({"lt_50", "50_to_75", "75_to_90", "90_to_100", "gte_100", "unknown"})
|
|
EXTENSION_KINDS = frozenset({"mcp_server", "plugin", "skill"})
|
|
EXTENSION_SOURCES = frozenset({"bundled", "catalog", "hub", "local", "other", "url"})
|
|
EXTENSION_OUTCOMES = frozenset({"failed", "success"})
|
|
# Every built-in backend in tools/terminal_tool_config.py::_BUILTIN_BACKENDS.
|
|
TERMINAL_BACKENDS = frozenset({
|
|
"daytona", "docker", "local", "managed_modal", "modal", "other", "singularity", "ssh", "vercel_sandbox",
|
|
})
|
|
AUX_TASKS = _CatalogValues("aux_task_names", extra=frozenset({"none", "other"}))
|
|
SLASH_COMMANDS = _CatalogValues("slash_command_names", extra=frozenset({"plugin", "skill", "unknown"}))
|
|
SKILL_NAMES = _CatalogValues("bundled_skill_names", extra=frozenset({"custom"}))
|
|
EXTENSION_NAMES = _CatalogValues(
|
|
"bundled_skill_names", "mcp_catalog_names", "plugin_catalog_names", extra=frozenset({"custom"})
|
|
)
|
|
DISPLAY_LANGUAGES = _CatalogValues("display_languages", extra=frozenset({"other"}))
|
|
# ---- v4 model ----
|
|
TOOL_CALL_ISSUES = frozenset({
|
|
"empty_arguments", "invalid_json", "none", "repaired", "schema_mismatch", "unknown_tool",
|
|
})
|
|
FRICTION_SIGNALS = frozenset({"interrupt", "quick_abandon", "retry", "switch_away", "undo"})
|
|
CONTEXT_WINDOW_BUCKETS = frozenset({
|
|
"lt_32k", "32k_to_128k", "128k_to_256k", "256k_to_1m", "gte_1m", "unknown",
|
|
})
|
|
LIMIT_HIT_VALUES = frozenset({"no", "yes"})
|
|
# ---- end v4 model ----
|
|
|
|
# ---- v4 loop ----
|
|
MEMORY_OPS = frozenset({"add", "other", "read", "remove", "replace", "search"})
|
|
MEMORY_OP_OUTCOMES = frozenset({"failed", "rejected", "success"})
|
|
# Who asked: the user's turn, or the unattended self-improvement review fork.
|
|
MEMORY_OP_ORIGINS = frozenset({"background_review", "foreground"})
|
|
# Why a memory op was refused or failed. Built-in classes name each refusal/failure return of
|
|
# tools/memory_tool.py and tools/memory_tool_store.py; plugin providers report ``provider_error`` (the
|
|
# tool returned an error) or ``exception`` (it raised). Successes are ``none``.
|
|
MEMORY_OP_FAILURE_CLASSES = frozenset({
|
|
"ambiguous", "disabled", "drift", "exception", "gate_refused", "invalid_args", "missing_content",
|
|
"missing_old_text", "no_match", "none", "other", "over_budget", "provider_error", "read_failed",
|
|
"retry_cap", "scan_blocked", "staged", "stale_entry", "unknown", "would_empty",
|
|
})
|
|
CURATOR_OUTCOMES = frozenset({"failed", "skipped", "success"})
|
|
CURATOR_TRIGGERS = frozenset({"manual", "scheduled"})
|
|
DELEGATION_OUTCOMES = frozenset({"cancelled", "failed", "partial", "success"})
|
|
DELEGATION_DEPTHS = frozenset({"1", "2", "3", "gte_4"})
|
|
DELEGATION_MODES = frozenset({"background", "foreground"})
|
|
EXECUTION_KINDS = frozenset({"browser", "code", "terminal"})
|
|
# Shipped browser backends: tools/browser_{camofox,lightpanda,tool_cdp,tool_cloud,extension_router}.py
|
|
# plus the bundled cloud providers in plugins/browser/.
|
|
BROWSER_BACKENDS = frozenset({
|
|
"browser-use", "browserbase", "camofox", "cdp", "extension", "firecrawl", "lightpanda", "local", "other",
|
|
})
|
|
CODE_BACKENDS = frozenset({"local", "other", "remote"})
|
|
EXECUTION_BACKENDS_BY_KIND = {
|
|
"browser": BROWSER_BACKENDS, "code": CODE_BACKENDS, "terminal": TERMINAL_BACKENDS,
|
|
}
|
|
EXECUTION_BACKENDS = BROWSER_BACKENDS | CODE_BACKENDS | TERMINAL_BACKENDS
|
|
EXECUTION_OUTCOMES = frozenset({"failed", "success"})
|
|
# ---- end v4 loop ----
|
|
# ---- v4 gateway ----
|
|
PLATFORM_HEALTH_MARK = PLATFORM_HEALTH_METRIC = "hermes.platform.health"
|
|
PLATFORM_DELIVERY_MARK = PLATFORM_DELIVERY_METRIC = "hermes.platform.delivery"
|
|
REPLY_LATENCY_MARK = REPLY_LATENCY_METRIC = "hermes.gateway.reply_latency"
|
|
CRON_RUN_MARK = CRON_RUN_METRIC = "hermes.cron.run"
|
|
# Plugin platforms are named only when Nous ships them (plugins/platforms/) or the installer proved a
|
|
# plugin-catalog install (shared_metrics_catalog.platform_metric_name); every other one is ``plugin``.
|
|
_PLATFORM_CATALOGS = ("bundled_platform_names", "catalog_platform_names")
|
|
GATEWAY_PLATFORMS = _CatalogValues(*_PLATFORM_CATALOGS, extra=_CORE_GATEWAY_PLATFORMS)
|
|
|
|
|
|
def _gateway_adapter_platforms() -> frozenset[str]:
|
|
"""The gateway's own adapter vocabulary: ``Platform``'s declared members (relay, msgraph_webhook
|
|
and api_server are adapters but not setup-wizard platforms; plugin pseudo-members are not
|
|
declared). Loaded on first use: gateway.config is too heavy for this module's import."""
|
|
from gateway.config import Platform
|
|
|
|
return frozenset(member.value for member in Platform)
|
|
|
|
|
|
class _AdapterPlatforms(_CatalogValues):
|
|
def values(self) -> frozenset[str]:
|
|
return super().values() | _gateway_adapter_platforms()
|
|
|
|
|
|
ADAPTER_PLATFORMS = _AdapterPlatforms(*_PLATFORM_CATALOGS, extra=_CORE_GATEWAY_PLATFORMS | {"api_server"})
|
|
PLATFORM_HEALTH_EVENTS = frozenset({"connect_ok", "connect_failed", "reconnect", "disconnect"})
|
|
PLATFORM_ERROR_CLASSES = frozenset({"none", "auth", "network", "rate_limited", "config", "other"})
|
|
DELIVERY_OUTCOMES = frozenset({"sent", "failed"})
|
|
DELIVERY_FAILURE_CLASSES = frozenset({"none", "rate_limited", "too_long", "auth", "network", "forbidden", "other"})
|
|
REPLY_LATENCY_BUCKETS = frozenset({"lt_2s", "2s_to_5s", "5s_to_15s", "15s_to_60s", "gte_60s"})
|
|
CRON_RUN_OUTCOMES = frozenset({"success", "failed", "missed", "skipped"})
|
|
CRON_DELIVERY_KINDS = frozenset({"local", "platform", "webhook", "none", "other"})
|
|
|
|
# ---- v4 install ----
|
|
STARTUP_SURFACES = frozenset({"cli", "desktop_attach", "gateway_boot", "serve_boot", "tui"})
|
|
STARTUP_LATENCY_BUCKETS = frozenset({
|
|
"lt_500ms", "500ms_to_1s", "1s_to_2s", "2s_to_5s", "5s_to_10s", "gte_10s",
|
|
})
|
|
RELEASE_CHANNELS = frozenset({"dev", "main", "stable", "unknown"})
|
|
VERSION_AGE_BUCKETS = frozenset({"lt_7d", "7d_to_30d", "30d_to_90d", "gte_90d", "unknown"})
|
|
BEHIND_BUCKETS = COUNT_BUCKETS | {"unknown"}
|
|
RAM_BUCKETS = frozenset({
|
|
"lt_8g", "8g_to_16g", "16g_to_32g", "32g_to_64g", "64g_to_128g", "gte_128g", "unknown",
|
|
})
|
|
GPU_CLASSES = frozenset({"amd", "apple_silicon", "intel", "none", "nvidia", "unknown"})
|
|
YES_NO = frozenset({"no", "yes"})
|
|
_INSTALL_V4_SNAPSHOT_DIMENSIONS = {
|
|
"behind_bucket": BEHIND_BUCKETS, "gpu_class": GPU_CLASSES, "local_model_provider_used": YES_NO,
|
|
"ram_bucket": RAM_BUCKETS, "release_channel": RELEASE_CHANNELS, "version_age_bucket": VERSION_AGE_BUCKETS,
|
|
}
|
|
|
|
# ---- v5 engagement ----
|
|
ENGAGEMENT_SURFACES = frozenset({"acp", "cli", "cron", "desktop", "gateway", "tui"})
|
|
ACTIVE_MINUTES_BUCKETS = frozenset({"0", "lt_5m", "5m_to_30m", "30m_to_2h", "2h_to_6h", "gte_6h"})
|
|
SURFACES_USED_BUCKETS = frozenset({"0", "1", "2", "3", "gte_4"})
|
|
TURNS_BEFORE_SWITCH_BUCKETS = frozenset({"1", "2_to_3", "4_to_10", "11_to_30", "gte_31"})
|
|
# SIZE_BUCKETS plus a longer tail: agentic conversations run to thousands of messages.
|
|
LONG_SIZE_BUCKETS = SIZE_BUCKETS | frozenset({"251_to_1000", "gte_1001"})
|
|
_LONG_SIZE_THRESHOLDS = (
|
|
(1, "0"), (2, "1"), (3, "2"), (6, "3_to_5"), (11, "6_to_10"), (26, "11_to_25"),
|
|
(101, "26_to_100"), (251, "101_to_250"), (1001, "251_to_1000"),
|
|
)
|
|
|
|
|
|
def long_size_bucket(count: int) -> str:
|
|
return _bucket(max(0, int(count)), _LONG_SIZE_THRESHOLDS, "gte_1001")
|
|
# ---- end v5 engagement ----
|
|
|
|
# ---- v4 reliability ----
|
|
UPDATE_RUN_MARK = UPDATE_RUN_METRIC = "hermes.update.run"
|
|
UPDATE_STAGE_MARK = UPDATE_STAGE_METRIC = "hermes.update.stage"
|
|
PROCESS_EXIT_MARK = PROCESS_EXIT_METRIC = "hermes.process.exit"
|
|
UPDATE_KINDS = frozenset({"cli", "desktop"})
|
|
UPDATE_OUTCOMES = frozenset({"failed", "noop", "refused", "success"})
|
|
# ``partial``: committed, but interrupted after the commit point or the user's changes stay parked
|
|
# (only `hermes update` receipts; the Desktop RPC keeps UPDATE_OUTCOMES).
|
|
UPDATE_RUN_OUTCOMES = UPDATE_OUTCOMES | {"partial"}
|
|
# `hermes update` pipeline stages, in pipeline order (the receipt's stage marks use these names).
|
|
UPDATE_STAGE_ORDER = ("plan", "snapshot", "apply", "deps", "build", "restart", "verify")
|
|
UPDATE_STAGES = frozenset(UPDATE_STAGE_ORDER)
|
|
# Desktop's packaged updaters (electron-updater, App Installer, Store) fail at their own steps.
|
|
DESKTOP_UPDATE_STAGES = frozenset({"apply", "download", "restart", "verify"})
|
|
UPDATE_FAILED_STAGES = UPDATE_STAGES | DESKTOP_UPDATE_STAGES | {"none", "other"}
|
|
UPDATE_STAGE_OUTCOMES = frozenset({"failed", "skipped", "success"})
|
|
UPDATE_DURATION_BUCKETS = frozenset({"lt_30s", "30s_to_2m", "2m_to_5m", "5m_to_15m", "gte_15m"})
|
|
# package = an OS/app-store style installer applied the update (Desktop packaged builds).
|
|
UPDATE_APPLY_MODES = frozenset({"external", "git", "package", "unknown", "zip"})
|
|
PROCESS_KINDS = frozenset({"cli", "cron", "gateway", "other", "serve", "tui"})
|
|
PROCESS_EXIT_KINDS = frozenset({"clean", "crash", "killed", "watchdog"})
|
|
CRASH_CLASSES = frozenset({"import_error", "memory_error", "none", "os_error", "other", "runtime_error"})
|
|
_UPDATE_DURATION_THRESHOLDS = (
|
|
(30_000, "lt_30s"), (120_000, "30s_to_2m"), (300_000, "2m_to_5m"), (900_000, "5m_to_15m"),
|
|
)
|
|
_DAY_MS = 86_400_000
|
|
_VERSION_AGE_THRESHOLDS = ((7 * _DAY_MS, "lt_7d"), (30 * _DAY_MS, "7d_to_30d"), (90 * _DAY_MS, "30d_to_90d"))
|
|
|
|
|
|
# ---- v5 signals ----
|
|
TOOL_UNAVAILABLE_MARK = TOOL_UNAVAILABLE_METRIC = "hermes.tool_unavailable.count"
|
|
PROVIDER_SETUP_MARK = PROVIDER_SETUP_METRIC = "hermes.provider_setup.count"
|
|
FEATURE_ADOPTION_METRIC = "hermes.feature_adoption.count"
|
|
# Not a counter: a first-use fact the subscriber turns into at most one adoption row per feature.
|
|
FEATURE_USED_MARK = "hermes.feature.used"
|
|
PROVIDER_SETUP_SURFACES = frozenset({"cli_model", "cli_setup", "dashboard", "desktop", "tui"})
|
|
PROVIDER_SETUP_EVENTS = frozenset({"abandoned", "completed", "failed", "started"})
|
|
PROVIDER_SETUP_FAILURE_CLASSES = frozenset({"auth", "cancelled", "network", "no_models", "none", "other"})
|
|
FEATURES = frozenset({
|
|
"browser", "bot_mode", "cron", "curator", "delegation", "desktop", "gateway_platform", "kanban", "mcp",
|
|
"memory", "plugins", "projects", "skills_created", "tui", "voice",
|
|
})
|
|
DAYS_SINCE_INSTALL_BUCKETS = frozenset({"same_day", "1d_to_7d", "7d_to_30d", "30d_to_90d", "gte_90d", "unknown"})
|
|
FEATURE_DISABLED_MARK = FEATURE_DISABLED_METRIC = "hermes.feature_disabled.count"
|
|
FEATURE_DISABLED_KINDS = frozenset({
|
|
"compression", "curator", "memory", "platform", "plugin", "setting", "skill", "toolset",
|
|
})
|
|
FEATURE_DISABLED_SURFACES = frozenset({"cli_config", "cli_slash", "cli_tools", "dashboard", "desktop", "tui"})
|
|
FEATURE_DISABLED_EVENTS = frozenset({"disabled", "re_enabled"})
|
|
FEATURE_DISABLED_NAME_MAX_LENGTH = 64
|
|
# ---- end v5 signals ----
|
|
|
|
# ---- iuf c1 ----
|
|
# Why an extension install failed, named at each failure exit of the three emitters
|
|
# (hermes_cli/skills_hub.py::_install_skill, hermes_cli/plugins_cmd_install.py::recorded_install,
|
|
# hermes_cli/mcp_catalog.py::recorded_catalog_install / record_mcp_install); ``none`` on success.
|
|
# A raised exception with no class of its own is classified by its TYPE: network / permission /
|
|
# filesystem_error, anything else ``exception:<Type>``, which keeps only the ``exception`` prefix
|
|
# (as on compression rows). Never the error message.
|
|
EXTENSION_COMMON_FAILURE_CLASSES = frozenset({
|
|
"exception", "filesystem_error", "network", "none", "other", "permission",
|
|
})
|
|
EXTENSION_SKILL_FAILURE_CLASSES = EXTENSION_COMMON_FAILURE_CLASSES | frozenset({
|
|
"ambiguous", # a short name matched several skills in different registries
|
|
"auth_rejected", # GitHub refused every configured credential (401)
|
|
"fetch_failed", # no registry served the files (unreachable, 404, removed)
|
|
"invalid_bundle", # unsafe path, symlink or invalid skill name in the bundle
|
|
"invalid_name", # a URL skill with no usable name (non-interactive, bad --name, cancelled prompt)
|
|
"not_found", # a short name no registry knows, or a pinned registry that does not exist
|
|
"rate_limited", # the GitHub API rate limit was exhausted
|
|
"scan_blocked", # the security scan refused the bundle
|
|
"stale_index", # listed in a registry index whose files no longer exist upstream
|
|
})
|
|
EXTENSION_PLUGIN_FAILURE_CLASSES = EXTENSION_COMMON_FAILURE_CLASSES | frozenset({
|
|
"already_installed", # the plugin already exists / is pinned and needs --force or --ref
|
|
"clone_failed", # git clone / fetch / checkout of the plugin repository failed or timed out
|
|
"deps_declined", # the user declined the Python dependency prompt
|
|
"deps_failed", # PM could not prepare the plugin's dependencies
|
|
"git_missing", # git is not installed
|
|
"incompatible", # newer manifest_version, unsupported platform / GPU / runtime version
|
|
"invalid_source", # the identifier or subdirectory does not name a usable git source
|
|
"manifest_invalid", # unreadable or malformed plugin.yaml / plugin.json / dependency declaration
|
|
"non_interactive", # Python dependencies need consent and nobody could answer (no --yes-deps)
|
|
"removed_from_catalog", # on the catalog kill list
|
|
"scan_blocked", # the security scan refused the plugin
|
|
})
|
|
EXTENSION_MCP_FAILURE_CLASSES = EXTENSION_COMMON_FAILURE_CLASSES | frozenset({
|
|
"auth_required", # the server refused the sign-in (OAuth error, HTTP 401/403)
|
|
"bootstrap_failed", # a catalog entry's bootstrap command exited non-zero
|
|
"clone_failed", # git clone / checkout of a catalog entry's repository failed
|
|
"config_invalid", # no such catalog entry, undeclared env var, or a manifest that cannot be built
|
|
"config_rejected", # Hermes refused a suspicious command/args configuration
|
|
"connect_failed", # the probe could not reach or initialize the server
|
|
"git_missing", # git is not installed
|
|
"missing_credentials", # a required credential was not provided
|
|
"server_start_failed", # the stdio server command is missing, or its native module needs another Node
|
|
})
|
|
EXTENSION_KIND_FAILURE_CLASSES = {
|
|
"mcp_server": EXTENSION_MCP_FAILURE_CLASSES, "plugin": EXTENSION_PLUGIN_FAILURE_CLASSES,
|
|
"skill": EXTENSION_SKILL_FAILURE_CLASSES,
|
|
}
|
|
EXTENSION_FAILURE_CLASSES = (
|
|
EXTENSION_SKILL_FAILURE_CLASSES | EXTENSION_PLUGIN_FAILURE_CLASSES | EXTENSION_MCP_FAILURE_CLASSES
|
|
)
|
|
# The skills-hub adapter that resolved or served a skill: every ``SOURCE_ID`` the router in
|
|
# tools/skills_hub_search.py::create_source_router builds. ``none`` = no adapter was involved
|
|
# (bundled restores, plugin and MCP rows), ``unresolved`` = a hub lookup no adapter answered,
|
|
# ``other`` = an adapter id this list does not name.
|
|
EXTENSION_REGISTRY_IDS = frozenset({
|
|
"browse-sh", "clawhub", "github", "hermes-index", "lobehub", "official", "skills-sh", "url", "well-known",
|
|
})
|
|
EXTENSION_REGISTRIES = EXTENSION_REGISTRY_IDS | frozenset({"none", "other", "unresolved"})
|
|
# Why a `hermes update` run failed or was refused, derived from the FINAL receipt only
|
|
# (shared_metrics_update.update_failure_class); ``none`` unless outcome is failed/refused/partial.
|
|
UPDATE_FAILURE_CLASSES = frozenset({
|
|
"aborted_before_apply", # fallback: exited before the checkout moved with no reason recorded
|
|
"build_failed", # the build stage reported failure
|
|
"deps_failed", # PM dependency preparation failed (no deps mark, or a PM error type)
|
|
"exception", # the run ended on an uncaught exception (type name dropped)
|
|
"fleet_stale", # post-update verification found a gateway still stale or down
|
|
"fleet_unverified", # verification could not prove the fleet current (no rows, unaccounted runtime)
|
|
"git_failed", # a git/installer subprocess failed before the checkout moved (stash, pull, merge)
|
|
"interrupted", # KeyboardInterrupt / exit 130
|
|
"lock_held", # another updater held the update lock (refused, exit 2)
|
|
"managed_install", # admission refused: image/package-managed or commit-build install (refused)
|
|
"none", "other",
|
|
"os_error", # the run ended on an OSError (disk full, permission, file in use)
|
|
"restart_failed", # the gateway restart failed, or a skipped restart left the fleet owing one
|
|
"subprocess_failed", # a subprocess error (CalledProcessError, TimeoutExpired) reached the boundary
|
|
"unknown", # the reporter sent no reason (Desktop packaged updaters)
|
|
"disk_full", # out of disk space (ENOSPC, or no room to stage the ZIP)
|
|
"local_changes_parked", # committed, but the user's stashed changes could not be re-applied (partial)
|
|
"permission_denied", # the run ended on a PermissionError
|
|
})
|
|
# The exit that stopped a run before its apply stage mark, recorded at the exit itself as one of
|
|
# these tokens (update_receipt.record_stop_reason -> the receipt's ``stop_class``).
|
|
UPDATE_STOP_CLASSES = frozenset({
|
|
"branch_missing", # the target branch exists neither locally nor on origin
|
|
"branch_unsupported", # --branch on the Windows ZIP fallback
|
|
"channel_unresolved", # the update channel could not be resolved
|
|
"checkout_move_failed", # git could not move the checkout (ff refused, reset or switch failed)
|
|
"commit_point_refused", # the commit point could not be armed durably; nothing moved
|
|
"detached_head", # a detached checkout stayed put, or its commits could not be backed up
|
|
"disk_full", # (see above)
|
|
"download_failed", # the ZIP fallback download failed
|
|
"fetch_failed", # git fetch failed
|
|
"gateway_pause_failed", # Windows: the gateways could not be paused safely before the venv moves
|
|
"git_in_progress", # a merge/rebase/cherry-pick/... was already in progress
|
|
"git_index_locked", # git refused: another git process's .git/index.lock exists
|
|
"git_timeout", # a network git call hit the updater's time limit
|
|
"head_moved", # a ref moved during the update; HEAD is not the selected commit
|
|
"local_changes_blocked", # local changes git could not stash, or a dirty tree the ZIP will not overwrite
|
|
"lock_held", # (see above)
|
|
"managed_install", # (see above): HERMES_MANAGED installs refuse before any receipt
|
|
"merge_conflict", # local commits on a custom branch conflict with upstream
|
|
"not_git_checkout", # the install is not a git checkout (non-Windows)
|
|
"old_version_handoff", # an older updater's hand-off to this version could not finish
|
|
"parked_branch_blocked", # the checkout is parked on another branch that is unsafe to switch
|
|
"permission_denied", # (see above): git could not write a file it needs (e.g. index.lock's directory)
|
|
"stash_restore_rejected", # re-applied local changes broke Hermes; the update stopped
|
|
"syntax_rollback", # the pulled code failed the syntax check and was rolled back
|
|
"target_syntax_error", # the target failed the syntax check before anything moved
|
|
"target_unresolved", # the fetched target ref did not resolve to a commit
|
|
"unexpected_branch", # the checkout ended on a branch that is not the target
|
|
"venv_foreign_owner", # the venv belongs to another OS user
|
|
"zip_failed", # the ZIP fallback swap failed and was rolled back
|
|
})
|
|
UPDATE_FAILURE_CLASSES |= UPDATE_STOP_CLASSES
|
|
# ---- end iuf c1 ----
|
|
# ---- iuf c2 ----
|
|
# One fresh-install run of scripts/install.sh / install.ps1, from the local receipt the installer
|
|
# leaves (the installer itself never sends); counted by a later Hermes start while collection is on.
|
|
INSTALL_RUN_MARK = INSTALL_RUN_METRIC = "hermes.install.run"
|
|
INSTALL_RUN_INSTALLERS = frozenset({"install_ps1", "install_sh", "other"})
|
|
INSTALL_RUN_OUTCOMES = frozenset({"failed", "success"})
|
|
# The installers' stage_names / $Stages ladder (hyphens as underscores).
|
|
INSTALL_RUN_STAGES = frozenset({
|
|
"complete", "config", "gateway", "prerequisites", "products", "python_deps", "repository", "setup", "venv",
|
|
})
|
|
INSTALL_RUN_FAILED_STAGES = INSTALL_RUN_STAGES | {"none", "other"}
|
|
# The code each installer fail()/Fail call site passes; `none` on success.
|
|
INSTALL_RUN_FAILURE_CLASSES = frozenset({
|
|
"commit_not_on_branch", "curl_missing", "deps_install_failed", "dir_not_checkout", "download_digest_mismatch",
|
|
"download_failed", "filesystem_error", "gateway_failed", "git_checkout_failed", "git_clone_failed",
|
|
"git_extract_failed", "git_fetch_failed", "git_missing", "git_reset_failed", "interrupted",
|
|
"libstdcxx_missing", "local_changes_blocked", "none", "other", "products_build_failed",
|
|
"python_install_failed", "setup_failed", "unsupported_platform", "uv_unusable",
|
|
})
|
|
# ---- end iuf c2 ----
|
|
|
|
|
|
def update_duration_bucket(duration_ms: Any) -> str:
|
|
"""Bucket an update (or update stage) wall time; non-numbers count as instant."""
|
|
value = _non_negative_number(duration_ms) or 0
|
|
return _bucket(value, _UPDATE_DURATION_THRESHOLDS, "gte_15m")
|
|
|
|
|
|
def version_age_bucket(age_ms: Any) -> str:
|
|
"""Bucket the age of the version an update started from; unknown when not measurable."""
|
|
value = _non_negative_number(age_ms)
|
|
return "unknown" if value is None else _bucket(value, _VERSION_AGE_THRESHOLDS, "gte_90d")
|
|
# ---- end v4 reliability ----
|
|
|
|
# ---- v5 harness ----
|
|
FILE_EDIT_MARK = "hermes.file_edit"
|
|
LOOP_GUARD_MARK = "hermes.loop_guard"
|
|
TOOL_RECOVERY_MARK = "hermes.tool_recovery"
|
|
TERMINAL_OUTCOME_MARK = "hermes.terminal.outcome"
|
|
MODEL_REPLY_ISSUE_MARK = "hermes.model_reply_issue"
|
|
FILE_EDIT_METRIC = "hermes.file_edit.count"
|
|
LOOP_GUARD_METRIC = "hermes.loop_guard.count"
|
|
TOOL_RECOVERY_METRIC = "hermes.tool_recovery.count"
|
|
TERMINAL_OUTCOME_METRIC = "hermes.terminal.outcome.count"
|
|
MODEL_REPLY_ISSUE_METRIC = "hermes.model_reply_issue.count"
|
|
FILE_EDIT_TOOLS = frozenset({"patch", "write_file"})
|
|
FILE_EDIT_MODES = frozenset({"replace", "v4a", "whole_file"})
|
|
FILE_EDIT_OUTCOMES = frozenset({"already_applied", "ambiguous", "applied", "failed", "no_match"})
|
|
|
|
|
|
def _fuzzy_match_strategies() -> frozenset[str]:
|
|
# The patch tool's own strategy chain, so a new strategy is a new bucket, never "none".
|
|
from tools.fuzzy_match import STRATEGIES
|
|
|
|
return frozenset(name for name, _fn in STRATEGIES)
|
|
|
|
|
|
FILE_EDIT_STRATEGIES = _fuzzy_match_strategies() | {"none"}
|
|
LOOP_GUARD_SIGNALS = frozenset({"iteration_cap", "loop_detected", "repeated_tool_call"})
|
|
LOOP_GUARD_DETECTORS = frozenset({
|
|
"exact_failure", "idempotent_no_progress", "identical_call_streak", "identical_cycle", "iteration_budget",
|
|
"same_tool_failure", "subagent_cap", "web_search_cap",
|
|
})
|
|
TOOL_RECOVERY_OUTCOMES = frozenset({"error", "gave_up", "no_tool_call", "success"})
|
|
TOOL_RECOVERY_NEXT_TOOLS = frozenset({"different", "none", "same"})
|
|
TERMINAL_COMMAND_KINDS = frozenset({
|
|
"build", "container", "file_ops", "git", "network", "node", "other", "package_manager", "python",
|
|
"shell", "shell_builtin", "test_runner",
|
|
})
|
|
TERMINAL_OUTCOMES = frozenset({"killed", "nonzero", "ok", "timeout"})
|
|
MODEL_REPLY_ISSUES = frozenset({"empty", "none", "reasoning_only", "refusal", "truncated_length"})
|
|
# ---- end v5 harness ----
|
|
# ---- v5 efficiency ----
|
|
TASK_COST_MARK = TASK_COST_METRIC = "hermes.task_cost.count"
|
|
WASTED_TOKENS_MARK = WASTED_TOKENS_METRIC = "hermes.wasted_tokens.count"
|
|
TOOL_OUTPUT_TRUNCATION_MARK = TOOL_OUTPUT_TRUNCATION_METRIC = "hermes.tool_output_truncation.count"
|
|
TOOL_OVERHEAD_MARK = TOOL_OVERHEAD_METRIC = "hermes.tool_overhead.count"
|
|
TOOL_ENABLED_UNUSED_MARK = TOOL_ENABLED_UNUSED_METRIC = "hermes.tool_enabled_unused.count"
|
|
CACHE_BREAK_MARK = CACHE_BREAK_METRIC = "hermes.cache_break.count"
|
|
# Prompt + completion tokens of one user turn, summed over its primary calls.
|
|
TURN_TOKEN_BUCKETS = frozenset({
|
|
"lt_2k", "2k_to_10k", "10k_to_50k", "50k_to_200k", "200k_to_1m", "gte_1m", "unknown",
|
|
})
|
|
TASK_COST_OUTCOMES = frozenset({"completed", "failed", "interrupted"})
|
|
# Tool and API calls of one agentic turn: COUNT_BUCKETS stops at gte_11, far below real loops.
|
|
TURN_ACTIVITY_BUCKETS = frozenset({
|
|
"0", "1", "2", "3_to_5", "6_to_10", "11_to_25", "26_to_50", "51_to_100", "gte_101",
|
|
})
|
|
WASTE_REASONS = frozenset({"interrupt", "retry", "undo"})
|
|
# Characters of one raw tool result, before Hermes spills or truncates it.
|
|
TOOL_OUTPUT_SIZE_BUCKETS = frozenset({
|
|
"lt_1k", "1k_to_10k", "10k_to_50k", "50k_to_100k", "100k_to_500k", "gte_500k", "unknown",
|
|
})
|
|
TOOL_SCHEMA_TOKEN_BUCKETS = frozenset({
|
|
"0", "lt_2k", "2k_to_5k", "5k_to_10k", "10k_to_20k", "20k_to_40k", "gte_40k",
|
|
})
|
|
# Toolsets Hermes ships; MCP servers and plugin toolsets collapse to custom.
|
|
TOOLSET_NAMES = BUILTIN_TOOLSET_NAMES | {"custom"}
|
|
# compression is the expected cause; the rest are Hermes invalidating a warm prefix (bugs or
|
|
# user-driven), or the provider reporting a cold read Hermes did not cause.
|
|
CACHE_BREAK_CAUSES = frozenset({
|
|
"cache_expired", "compression", "model_switch", "provider_reported_miss", "system_prompt_rebuild",
|
|
"toolset_change",
|
|
})
|
|
# ---- end v5 efficiency ----
|
|
# ---- v5 desktop ----
|
|
# Desktop love/hate: which areas get used, what gets in the way, where first run stops. Every value
|
|
# is a code-defined Desktop surface id (apps/desktop/src/store/desktop-metrics.ts mirrors these
|
|
# sets); anything the client names outside them collapses to `other` before it is recorded.
|
|
DESKTOP_FEATURE_USE_MARK = DESKTOP_FEATURE_USE_METRIC = "hermes.desktop.feature_use"
|
|
DESKTOP_FRICTION_MARK = DESKTOP_FRICTION_METRIC = "hermes.desktop.friction"
|
|
DESKTOP_ONBOARDING_MARK = DESKTOP_ONBOARDING_METRIC = "hermes.desktop.onboarding"
|
|
# Settings views are `SETTINGS_VIEWS` in apps/desktop/src/app/settings/index.tsx (the legacy
|
|
# `connections` tab redirects to `gateway`).
|
|
DESKTOP_SETTINGS_AREAS = frozenset({
|
|
"settings_about", "settings_billing", "settings_config_advanced", "settings_config_appearance",
|
|
"settings_config_browser", "settings_config_chat", "settings_config_memory", "settings_config_model",
|
|
"settings_config_safety", "settings_config_voice", "settings_config_workspace", "settings_gateway",
|
|
"settings_keybinds", "settings_keys", "settings_notifications", "settings_other", "settings_providers",
|
|
"settings_sessions", "settings_vault",
|
|
})
|
|
DESKTOP_FEATURE_AREAS = DESKTOP_SETTINGS_AREAS | {
|
|
# panes and panels
|
|
"browser_pane", "file_pane", "review_pane", "terminal_pane",
|
|
# overlays and pickers
|
|
"command_palette", "find_in_page", "model_picker", "session_picker", "session_switcher",
|
|
# first-class features
|
|
"bot_mode", "projects", "session_search", "skins", "voice_conversation", "voice_dictation",
|
|
# full pages (APP_ROUTES in apps/desktop/src/app/routes.ts, plus contributed plugin pages)
|
|
"agents", "artifacts", "capabilities", "command_center", "cron", "extension_page", "kanban", "messaging",
|
|
"profiles", "session_import", "starmap", "webhooks",
|
|
"other",
|
|
}
|
|
# notice_dismissed: stable toast ids and dismissible strips the Desktop code defines.
|
|
DESKTOP_NOTICE_IDS = frozenset({
|
|
"artifacts_partial_load", "background_queue_stuck", "backend_skew", "billing_banner", "billing_block",
|
|
"build_discontinued", "client_behind", "composer_queue_stuck", "credits",
|
|
"free_tier_notice", "gateway_error", "gui_skew", "install_method", "mcp_health", "model_warning",
|
|
"onboarding_handoff", "restored_draft", "runtime_not_ready", "session_compress", "terminal_backend", "tip",
|
|
"update_available", "voice_live_unavailable", "voice_stop_hint", "other",
|
|
})
|
|
# error_toast: the notifyError summary rule that matched (apps/desktop/src/store/notifications.ts).
|
|
DESKTOP_ERROR_TOAST_CATEGORIES = frozenset({
|
|
"api_key_missing", "api_key_rejected", "disk_full", "gateway_auth_failed", "method_not_allowed",
|
|
"microphone_permission", "pool_slot_timeout", "restart_required", "rpc_out_of_sync", "storage_failure",
|
|
"timeout", "unclassified", "other",
|
|
})
|
|
DESKTOP_SLOW_FRAME_BUCKETS = frozenset({"100ms_to_250ms", "250ms_to_1s", "1s_to_5s", "gte_5s"})
|
|
DESKTOP_FRICTION_DETAILS: dict[str, frozenset[str]] = {
|
|
"backend_disconnect": frozenset({"backend_exit", "network", "timeout", "other"}),
|
|
"error_toast": DESKTOP_ERROR_TOAST_CATEGORIES,
|
|
"notice_dismissed": DESKTOP_NOTICE_IDS,
|
|
# Electron render-process-gone reasons: crashed -> crash; oom; killed; anything else -> other.
|
|
"renderer_crash": frozenset({"crash", "killed", "oom", "other"}),
|
|
"slow_frame": DESKTOP_SLOW_FRAME_BUCKETS,
|
|
}
|
|
DESKTOP_FRICTION_KINDS = frozenset(DESKTOP_FRICTION_DETAILS)
|
|
DESKTOP_FRICTION_DETAIL_VALUES = frozenset().union(*DESKTOP_FRICTION_DETAILS.values())
|
|
# The Desktop first-run flows: the classic provider overlay (store/onboarding.ts), the guided flow
|
|
# (store/onboarding-gate.ts phases), free-tier sign-in, then the consent answer and the first message.
|
|
DESKTOP_ONBOARDING_STEPS = frozenset({
|
|
"choose_later", "consent", "first_message", "free_tier_ready", "guide", "guide_skip", "model_pick",
|
|
"provider_api_key", "provider_local", "provider_oauth", "provider_setup", "sign_in",
|
|
})
|
|
DESKTOP_ONBOARDING_EVENTS = frozenset({"abandoned", "completed", "reached"})
|
|
# Bot Mode (a bot's canonical chat or a bot side-chat in front) vs regular Sessions mode, per day.
|
|
DESKTOP_MODE_USE_MARK = DESKTOP_MODE_USE_METRIC = "hermes.desktop.mode_use"
|
|
DESKTOP_MODES = frozenset({"bots", "sessions"})
|
|
DESKTOP_ACTIVE_MINUTES_BUCKETS = frozenset({"0", "lt_5m", "5m_to_30m", "30m_to_2h", "2h_to_6h", "gte_6h"})
|
|
_DESKTOP_ACTIVE_THRESHOLDS = ((300_000, "lt_5m"), (1_800_000, "5m_to_30m"), (7_200_000, "30m_to_2h"),
|
|
(21_600_000, "2h_to_6h"))
|
|
|
|
|
|
def desktop_active_minutes_bucket(active_ms: Any) -> str:
|
|
"""Bucket a day's active time in one Desktop mode; nothing measurable reads 0."""
|
|
value = _non_negative_number(active_ms) or 0
|
|
return "0" if value <= 0 else _bucket(value, _DESKTOP_ACTIVE_THRESHOLDS, "gte_6h")
|
|
# ---- end v5 desktop ----
|
|
|
|
# ---- v5 desktop actions ----
|
|
# Button presses per day. Action ids are the Desktop keybinding registry (KEYBIND_ACTIONS in
|
|
# apps/desktop/src/lib/keybinds/actions.ts) plus the typed button table DESKTOP_BUTTON_ACTIONS in
|
|
# apps/desktop/src/store/desktop-metrics.ts; contributed/plugin actions collapse to `other`.
|
|
DESKTOP_ACTION_USE_MARK = DESKTOP_ACTION_USE_METRIC = "hermes.desktop.action_use"
|
|
DESKTOP_KEYBIND_ACTION_IDS = frozenset({
|
|
"appearance.toggleMode", "composer.cancel", "composer.dictate", "composer.focus", "composer.help",
|
|
"composer.history", "composer.mention", "composer.modelPicker", "composer.newline", "composer.queue",
|
|
"composer.reasoningDown", "composer.reasoningUp", "composer.send", "composer.sendQueued", "composer.slash",
|
|
"composer.steer", "composer.voice", "conversation.scrollPageDown", "conversation.scrollPageUp",
|
|
"hud.snapToPointer", "keybinds.openPanel", "nav.agents", "nav.artifacts", "nav.capabilities",
|
|
"nav.commandCenter", "nav.commandPalette", "nav.cron", "nav.messaging", "nav.profiles", "nav.settings",
|
|
"profile.create", "profile.default", "profile.next", "profile.prev", "profile.toggleAll",
|
|
"session.archive", "session.focusSearch", "session.new", "session.newTab", "session.newWindow",
|
|
"session.next", "session.prev", "session.togglePin", "view.closeTab", "view.closeTerminal",
|
|
"view.cycleSidebarGrouping", "view.findInPage", "view.findNext", "view.findPrevious", "view.flipPanes",
|
|
"view.newTerminal", "view.nextTerminal", "view.prevTerminal", "view.reopenTab", "view.selectionToComposer",
|
|
"view.showBrowser", "view.showFiles", "view.showTerminal", "view.terminalCopy", "view.terminalPaste",
|
|
"view.toggleHud", "view.toggleProfileRail", "view.toggleReview", "view.toggleRightSidebar",
|
|
"view.toggleSidebar", "view.toggleSimpleMode", "view.toggleStatusbar", "view.toggleTabStrip",
|
|
"workspace.newWorktree", "workspace.openFolder",
|
|
})
|
|
DESKTOP_BUTTON_ACTION_IDS = frozenset({"composer.attach", "message.copy", "message.retry"})
|
|
DESKTOP_ACTION_IDS = DESKTOP_KEYBIND_ACTION_IDS | DESKTOP_BUTTON_ACTION_IDS | {"other"}
|
|
DESKTOP_ACTION_VIAS = frozenset({"click", "menu", "palette", "shortcut"})
|
|
# Dislike signals, each naming its target from a closed set (setting keys are DEFAULT_CONFIG leaf
|
|
# paths, checked against the live schema by shared_metrics_desktop and shape-checked here).
|
|
DESKTOP_DISLIKE_MARK = DESKTOP_DISLIKE_METRIC = "hermes.desktop.dislike"
|
|
DESKTOP_FLOW_IDS = frozenset({
|
|
"command_palette", "free_tier_sign_in", "keybind_capture", "model_picker", "project_create", "provider_oauth",
|
|
"session_picker", "session_switcher", "other",
|
|
})
|
|
DESKTOP_FEATURE_TOGGLES = frozenset({
|
|
"backdrop", "bot_activity_toasts", "composer_popout_gestures", "intro_splash", "native_notifications",
|
|
"notification_kind", "reactions", "thread_timeline", "tips", "tours", "vibe_hearts", "other",
|
|
})
|
|
DESKTOP_UNDO_TARGETS = frozenset({"closed_tab", "restored_draft", "other"})
|
|
DESKTOP_DISLIKE_TARGETS: dict[str, frozenset[str]] = {
|
|
"cancelled": DESKTOP_FLOW_IDS,
|
|
"feature_disabled": DESKTOP_FEATURE_TOGGLES,
|
|
"quick_close": DESKTOP_FEATURE_AREAS,
|
|
"rage_click": DESKTOP_ACTION_IDS,
|
|
"setting_off_default": frozenset({"setting"}),
|
|
"undo": DESKTOP_UNDO_TARGETS,
|
|
}
|
|
DESKTOP_DISLIKE_SIGNALS = frozenset(DESKTOP_DISLIKE_TARGETS)
|
|
DESKTOP_DISLIKE_TARGET_VALUES = frozenset().union(*DESKTOP_DISLIKE_TARGETS.values())
|
|
DESKTOP_DISLIKE_DIRECTIONS = frozenset({"away_from_default", "none", "to_default"})
|
|
DESKTOP_SETTING_KEY_MAX_LENGTH = 96
|
|
# ---- end v5 desktop actions ----
|
|
|
|
_ARCHITECTURE_ALIASES = {
|
|
"amd64": "x86_64", "x64": "x86_64", "x86_64": "x86_64",
|
|
"aarch64": "arm64", "arm64": "arm64",
|
|
"i386": "x86", "i486": "x86", "i586": "x86", "i686": "x86", "x86": "x86",
|
|
}
|
|
_OS_FAMILIES = {"darwin": "macos", "linux": "linux", "macos": "macos", "windows": "windows"}
|
|
|
|
|
|
def _norm(value: Any) -> str:
|
|
return str(value or "").strip().lower()
|
|
|
|
|
|
def _allowlisted(normalized: str, allowed: frozenset[str]) -> str:
|
|
return normalized if normalized in allowed else "unknown"
|
|
|
|
|
|
def client_os_family(value: Any) -> str:
|
|
"""Map a platform system name to the shared-metrics OS taxonomy."""
|
|
return _OS_FAMILIES.get(_norm(value), "unknown")
|
|
|
|
|
|
def client_architecture(value: Any) -> str:
|
|
"""Map a machine architecture to the shared-metrics taxonomy."""
|
|
normalized = _norm(value).replace("-", "_")
|
|
if normalized in _ARCHITECTURE_ALIASES:
|
|
return _ARCHITECTURE_ALIASES[normalized]
|
|
return "arm" if normalized.startswith("armv") else "unknown"
|
|
|
|
|
|
def client_install_method(value: Any) -> str:
|
|
"""Return an allowlisted Hermes installation method."""
|
|
normalized = _norm(value)
|
|
return _allowlisted("nixos" if normalized == "nix" else normalized, CLIENT_INSTALL_METHODS)
|
|
|
|
|
|
def client_resource(
|
|
hermes_version: Any, *, os_name: Any, architecture: Any, install_method: Any
|
|
) -> dict[str, str]:
|
|
"""Build the bounded client resource attached to aggregate packages."""
|
|
version = str(hermes_version or "").strip()
|
|
return {
|
|
"architecture": client_architecture(architecture),
|
|
"hermes_version": version if 0 < len(version) <= 64 else "unknown",
|
|
"install_method": client_install_method(install_method),
|
|
"os_family": client_os_family(os_name),
|
|
}
|
|
|
|
|
|
def client_resource_is_valid(resource: Any) -> bool:
|
|
"""Return whether a package resource exactly matches the bounded contract."""
|
|
if not isinstance(resource, dict) or set(resource) != CLIENT_RESOURCE_KEYS:
|
|
return False
|
|
version = resource.get("hermes_version")
|
|
return (
|
|
isinstance(version, str)
|
|
and 0 < len(version) <= 64
|
|
and resource.get("os_family") in CLIENT_OS_FAMILIES
|
|
and resource.get("architecture") in CLIENT_ARCHITECTURES
|
|
and resource.get("install_method") in CLIENT_INSTALL_METHODS
|
|
)
|
|
|
|
|
|
_LEGACY_PROVIDER_FAMILIES = frozenset({"aggregator", "custom", "direct", "local", "unknown"})
|
|
_LEGACY_MODEL_LOCALITIES = frozenset({"local", "remote", "unknown"})
|
|
_LEGACY_MODEL_OUTCOMES = frozenset({"cancelled", "failed", "success"})
|
|
_LEGACY_MODEL_FAMILIES = frozenset({
|
|
"claude", "deepseek", "gemini", "gemma", "glm", "gpt", "grok", "kimi", "llama", "minimax",
|
|
"mimo", "mistral", "nemotron", "nova", "o1", "o3", "o4", "qwen", "step", "trinity",
|
|
"unknown",
|
|
})
|
|
|
|
_COUNTER_DIMENSION_VALUES: dict[str, dict[str, frozenset[str]]] = {
|
|
CLIENT_ACTIVE_METRIC: {},
|
|
# Retained only so pre-v2 pending rows remain packageable.
|
|
LEGACY_MODEL_CALL_METRIC: {
|
|
"call_role": frozenset({"primary"}), "locality": _LEGACY_MODEL_LOCALITIES,
|
|
"model_family": _LEGACY_MODEL_FAMILIES, "outcome": _LEGACY_MODEL_OUTCOMES,
|
|
"provider_family": _LEGACY_PROVIDER_FAMILIES,
|
|
},
|
|
TASK_STARTED_METRIC: {
|
|
"entrypoint": TASK_ENTRYPOINTS, "execution_surface": EXECUTION_SURFACES,
|
|
"platform": GATEWAY_PLATFORMS,
|
|
},
|
|
TASK_FINISHED_METRIC: {
|
|
"duration_bucket": DURATION_BUCKETS, "end_reason": TASK_END_REASONS,
|
|
"entrypoint": TASK_ENTRYPOINTS, "execution_surface": EXECUTION_SURFACES,
|
|
"failure_class": TASK_FAILURE_CLASSES,
|
|
"model_call_count_bucket": COUNT_BUCKETS, "outcome": TASK_OUTCOMES,
|
|
"platform": GATEWAY_PLATFORMS,
|
|
"retry_count_bucket": COUNT_BUCKETS, "termination": TASK_TERMINATIONS,
|
|
"tool_call_count_bucket": COUNT_BUCKETS,
|
|
},
|
|
MODEL_ROUTE_METRIC: {
|
|
"call_role": MODEL_CALL_ROLES, "error_class": MODEL_ERROR_CLASSES, "outcome": MODEL_OUTCOMES,
|
|
"ttft_bucket": TTFT_BUCKETS,
|
|
},
|
|
TOOL_CALL_METRIC: {
|
|
"approval_outcome": TOOL_APPROVAL_OUTCOMES, "latency_bucket": TOOL_LATENCY_BUCKETS,
|
|
"outcome": TOOL_OUTCOMES, "retry_count_bucket": TOOL_RETRY_BUCKETS,
|
|
"tool_category": TOOL_CATEGORIES,
|
|
},
|
|
TOOL_LATENCY_METRIC: {
|
|
"latency_bucket": TOOL_LATENCY_BUCKETS, "retry_count_bucket": TOOL_RETRY_BUCKETS,
|
|
"tool_category": TOOL_CATEGORIES,
|
|
},
|
|
TASK_DURATION_METRIC: {
|
|
"duration_bucket": DURATION_BUCKETS, "execution_surface": EXECUTION_SURFACES,
|
|
"outcome": TASK_OUTCOMES, "retry_count_bucket": COUNT_BUCKETS,
|
|
},
|
|
TOOL_USAGE_METRIC: {
|
|
"error_class": TOOL_ERROR_CLASSES, "outcome": TOOL_OUTCOMES, "tool_name": TOOL_NAMES,
|
|
},
|
|
TOOL_APPROVAL_METRIC: {
|
|
"attribution": TOOL_APPROVAL_ATTRIBUTIONS,
|
|
"outcome": TOOL_APPROVAL_OUTCOMES - {"not_required"},
|
|
},
|
|
SKILL_LIFECYCLE_METRIC: {"action": SKILL_LIFECYCLE_ACTIONS, "provenance": SKILL_PROVENANCES},
|
|
SKILL_LOAD_METRIC: {
|
|
"post_patch_state": SKILL_POST_PATCH_STATES, "provenance": SKILL_PROVENANCES,
|
|
"reuse_state": SKILL_REUSE_STATES, "skill_name": SKILL_NAMES, "use_count_bucket": COUNT_BUCKETS,
|
|
},
|
|
INSTALL_SNAPSHOT_METRIC: {
|
|
"cron_job_count_bucket": SIZE_BUCKETS, "display_language": DISPLAY_LANGUAGES,
|
|
"install_age_bucket": INSTALL_AGE_BUCKETS, "mcp_server_count_bucket": SIZE_BUCKETS,
|
|
"memory_provider": MEMORY_PROVIDERS, "messaging_platform_count_bucket": SIZE_BUCKETS,
|
|
"plugin_count_bucket": SIZE_BUCKETS, "profile_count_bucket": SIZE_BUCKETS,
|
|
"skill_count_bucket": SIZE_BUCKETS, "terminal_backend": TERMINAL_BACKENDS,
|
|
**_INSTALL_V4_SNAPSHOT_DIMENSIONS,
|
|
},
|
|
SESSION_METRIC: {
|
|
"active_duration_bucket": SESSION_DURATION_BUCKETS, "entrypoint": TASK_ENTRYPOINTS,
|
|
"execution_surface": EXECUTION_SURFACES, "failed_turn_count_bucket": COUNT_BUCKETS,
|
|
"last_outcome": TASK_OUTCOMES, "platform": GATEWAY_PLATFORMS, "turn_count_bucket": SIZE_BUCKETS,
|
|
},
|
|
MILESTONE_METRIC: {"install_age_bucket": INSTALL_AGE_BUCKETS, "milestone": MILESTONES},
|
|
SETUP_COMPLETED_METRIC: {"surface": SETUP_SURFACES},
|
|
MODEL_TOKENS_METRIC: {"aux_task": AUX_TASKS, "call_role": MODEL_CALL_ROLES, "token_type": TOKEN_TYPES},
|
|
COMPRESSION_METRIC: {
|
|
"context_fill_bucket": CONTEXT_FILL_BUCKETS, "failure_class": COMPRESSION_FAILURE_CLASSES,
|
|
"outcome": COMPRESSION_OUTCOMES, "trigger": COMPRESSION_TRIGGERS,
|
|
},
|
|
MODEL_SWITCH_METRIC: {"execution_surface": EXECUTION_SURFACES},
|
|
FALLBACK_METRIC: {"error_class": MODEL_ERROR_CLASSES},
|
|
SLASH_COMMAND_METRIC: {"command": SLASH_COMMANDS, "execution_surface": EXECUTION_SURFACES},
|
|
EXTENSION_INSTALL_METRIC: {
|
|
"kind": EXTENSION_KINDS, "name": EXTENSION_NAMES, "outcome": EXTENSION_OUTCOMES,
|
|
"source": EXTENSION_SOURCES,
|
|
# ---- iuf c1 ----
|
|
"failure_class": EXTENSION_FAILURE_CLASSES, "registry": EXTENSION_REGISTRIES,
|
|
# ---- end iuf c1 ----
|
|
},
|
|
# ---- v4 loop ----
|
|
MEMORY_OP_METRIC: {
|
|
"failure_class": MEMORY_OP_FAILURE_CLASSES, "op": MEMORY_OPS, "origin": MEMORY_OP_ORIGINS,
|
|
"outcome": MEMORY_OP_OUTCOMES, "provider": MEMORY_PROVIDERS,
|
|
},
|
|
CURATOR_RUN_METRIC: {
|
|
"archived_bucket": COUNT_BUCKETS, "created_bucket": COUNT_BUCKETS, "merged_bucket": COUNT_BUCKETS,
|
|
"outcome": CURATOR_OUTCOMES, "patched_bucket": COUNT_BUCKETS, "trigger": CURATOR_TRIGGERS,
|
|
},
|
|
DELEGATION_RUN_METRIC: {
|
|
"depth": DELEGATION_DEPTHS, "mode": DELEGATION_MODES, "outcome": DELEGATION_OUTCOMES,
|
|
"subagent_count_bucket": COUNT_BUCKETS,
|
|
},
|
|
EXECUTION_BACKEND_METRIC: {
|
|
"backend": EXECUTION_BACKENDS, "error_class": TOOL_ERROR_CLASSES, "kind": EXECUTION_KINDS,
|
|
"outcome": EXECUTION_OUTCOMES,
|
|
},
|
|
# ---- end v4 loop ----
|
|
# ---- v4 gateway ----
|
|
PLATFORM_HEALTH_METRIC: {
|
|
"error_class": PLATFORM_ERROR_CLASSES, "event": PLATFORM_HEALTH_EVENTS, "platform": ADAPTER_PLATFORMS,
|
|
},
|
|
PLATFORM_DELIVERY_METRIC: {
|
|
"failure_class": DELIVERY_FAILURE_CLASSES, "outcome": DELIVERY_OUTCOMES, "platform": ADAPTER_PLATFORMS,
|
|
},
|
|
REPLY_LATENCY_METRIC: {"first_response_bucket": REPLY_LATENCY_BUCKETS, "platform": ADAPTER_PLATFORMS},
|
|
CRON_RUN_METRIC: {
|
|
"delivery_kind": CRON_DELIVERY_KINDS, "duration_bucket": DURATION_BUCKETS, "outcome": CRON_RUN_OUTCOMES,
|
|
},
|
|
# ---- v4 model ----
|
|
MODEL_TOOL_QUALITY_METRIC: {"call_role": MODEL_CALL_ROLES, "issue": TOOL_CALL_ISSUES},
|
|
MODEL_FRICTION_METRIC: {"signal": FRICTION_SIGNALS},
|
|
CONTEXT_PEAK_METRIC: {
|
|
"limit_hit": LIMIT_HIT_VALUES, "peak_fill_bucket": CONTEXT_FILL_BUCKETS,
|
|
"window_bucket": CONTEXT_WINDOW_BUCKETS,
|
|
},
|
|
# ---- end v4 model ----
|
|
# ---- v4 install ----
|
|
STARTUP_LATENCY_METRIC: {"latency_bucket": STARTUP_LATENCY_BUCKETS, "surface": STARTUP_SURFACES},
|
|
# ---- v4 reliability ----
|
|
UPDATE_RUN_METRIC: {
|
|
"apply_mode": UPDATE_APPLY_MODES, "duration_bucket": UPDATE_DURATION_BUCKETS,
|
|
"failed_stage": UPDATE_FAILED_STAGES, "from_version_age_bucket": VERSION_AGE_BUCKETS,
|
|
"kind": UPDATE_KINDS, "outcome": UPDATE_RUN_OUTCOMES,
|
|
# ---- iuf c1 ----
|
|
"failure_class": UPDATE_FAILURE_CLASSES,
|
|
# ---- end iuf c1 ----
|
|
},
|
|
UPDATE_STAGE_METRIC: {
|
|
"duration_bucket": UPDATE_DURATION_BUCKETS, "outcome": UPDATE_STAGE_OUTCOMES, "stage": UPDATE_STAGES,
|
|
},
|
|
PROCESS_EXIT_METRIC: {
|
|
"crash_class": CRASH_CLASSES, "exit_kind": PROCESS_EXIT_KINDS, "process_kind": PROCESS_KINDS,
|
|
},
|
|
# ---- end v4 reliability ----
|
|
# ---- v5 harness ----
|
|
FILE_EDIT_METRIC: {
|
|
"match_strategy": FILE_EDIT_STRATEGIES, "mode": FILE_EDIT_MODES, "outcome": FILE_EDIT_OUTCOMES,
|
|
"tool": FILE_EDIT_TOOLS,
|
|
},
|
|
LOOP_GUARD_METRIC: {"detector": LOOP_GUARD_DETECTORS, "signal": LOOP_GUARD_SIGNALS},
|
|
TOOL_RECOVERY_METRIC: {
|
|
"next_outcome": TOOL_RECOVERY_OUTCOMES, "next_tool": TOOL_RECOVERY_NEXT_TOOLS, "tool": TOOL_NAMES,
|
|
},
|
|
TERMINAL_OUTCOME_METRIC: {
|
|
"backend": TERMINAL_BACKENDS, "command_kind": TERMINAL_COMMAND_KINDS, "outcome": TERMINAL_OUTCOMES,
|
|
},
|
|
MODEL_REPLY_ISSUE_METRIC: {"issue": MODEL_REPLY_ISSUES},
|
|
# ---- end v5 harness ----
|
|
# ---- v5 efficiency ----
|
|
TASK_COST_METRIC: {
|
|
"api_calls_bucket": TURN_ACTIVITY_BUCKETS, "outcome": TASK_COST_OUTCOMES,
|
|
"tokens_bucket": TURN_TOKEN_BUCKETS, "tool_calls_bucket": TURN_ACTIVITY_BUCKETS,
|
|
},
|
|
WASTED_TOKENS_METRIC: {"reason": WASTE_REASONS, "tokens_bucket": TURN_TOKEN_BUCKETS},
|
|
TOOL_OUTPUT_TRUNCATION_METRIC: {
|
|
"original_size_bucket": TOOL_OUTPUT_SIZE_BUCKETS, "tool": TOOL_NAMES, "truncated": YES_NO,
|
|
},
|
|
TOOL_OVERHEAD_METRIC: {
|
|
"enabled_tool_count_bucket": SIZE_BUCKETS, "execution_surface": EXECUTION_SURFACES,
|
|
"tool_schema_tokens_bucket": TOOL_SCHEMA_TOKEN_BUCKETS,
|
|
},
|
|
TOOL_ENABLED_UNUSED_METRIC: {"toolset": TOOLSET_NAMES, "used": YES_NO},
|
|
CACHE_BREAK_METRIC: {"cause": CACHE_BREAK_CAUSES},
|
|
# ---- end v5 efficiency ----
|
|
# ---- v5 engagement ----
|
|
ENGAGEMENT_DAY_METRIC: {
|
|
"active_minutes_bucket": ACTIVE_MINUTES_BUCKETS, "active_profile_count_bucket": SIZE_BUCKETS,
|
|
"surfaces_used_count": SURFACES_USED_BUCKETS,
|
|
},
|
|
ENGAGEMENT_SURFACE_METRIC: {"active_minutes_bucket": ACTIVE_MINUTES_BUCKETS, "surface": ENGAGEMENT_SURFACES},
|
|
MODEL_SWITCH_AFTER_METRIC: {"turns_before_switch_bucket": TURNS_BEFORE_SWITCH_BUCKETS},
|
|
# ---- end v5 engagement ----
|
|
# ---- v5 desktop ----
|
|
DESKTOP_FEATURE_USE_METRIC: {"area": DESKTOP_FEATURE_AREAS},
|
|
DESKTOP_FRICTION_METRIC: {"detail": DESKTOP_FRICTION_DETAIL_VALUES, "kind": DESKTOP_FRICTION_KINDS},
|
|
DESKTOP_ONBOARDING_METRIC: {"event": DESKTOP_ONBOARDING_EVENTS, "step": DESKTOP_ONBOARDING_STEPS},
|
|
DESKTOP_MODE_USE_METRIC: {
|
|
"active_minutes_bucket": DESKTOP_ACTIVE_MINUTES_BUCKETS, "bot_count_bucket": SIZE_BUCKETS,
|
|
"messages_sent_bucket": SIZE_BUCKETS, "mode": DESKTOP_MODES,
|
|
},
|
|
DESKTOP_ACTION_USE_METRIC: {"action": DESKTOP_ACTION_IDS, "count_bucket": SIZE_BUCKETS, "via": DESKTOP_ACTION_VIAS},
|
|
DESKTOP_DISLIKE_METRIC: {
|
|
"direction": DESKTOP_DISLIKE_DIRECTIONS, "signal": DESKTOP_DISLIKE_SIGNALS, "target": DESKTOP_DISLIKE_TARGET_VALUES,
|
|
},
|
|
# ---- end v5 desktop ----
|
|
# ---- v5 signals ----
|
|
TOOL_UNAVAILABLE_METRIC: {"tool_name": BUILTIN_TOOL_NAMES},
|
|
PROVIDER_SETUP_METRIC: {
|
|
"event": PROVIDER_SETUP_EVENTS, "failure_class": PROVIDER_SETUP_FAILURE_CLASSES,
|
|
"surface": PROVIDER_SETUP_SURFACES,
|
|
},
|
|
FEATURE_ADOPTION_METRIC: {"days_since_install_bucket": DAYS_SINCE_INSTALL_BUCKETS, "feature": FEATURES},
|
|
FEATURE_DISABLED_METRIC: {
|
|
"event": FEATURE_DISABLED_EVENTS, "kind": FEATURE_DISABLED_KINDS, "surface": FEATURE_DISABLED_SURFACES,
|
|
},
|
|
# ---- end v5 signals ----
|
|
# ---- iuf c2 ----
|
|
INSTALL_RUN_METRIC: {
|
|
"duration_bucket": UPDATE_DURATION_BUCKETS, "failed_stage": INSTALL_RUN_FAILED_STAGES,
|
|
"failure_class": INSTALL_RUN_FAILURE_CLASSES, "installer": INSTALL_RUN_INSTALLERS,
|
|
"outcome": INSTALL_RUN_OUTCOMES,
|
|
},
|
|
# ---- end iuf c2 ----
|
|
}
|
|
_MODEL_ROUTE_MAX_LENGTHS = {
|
|
"model": MODEL_IDENTIFIER_MAX_LENGTH, "provider": PROVIDER_IDENTIFIER_MAX_LENGTH,
|
|
}
|
|
# metric -> {field: max length} for provider/model identifiers, validated by shape (no catalog).
|
|
_IDENTIFIER_FIELDS: dict[str, dict[str, int]] = {
|
|
MODEL_ROUTE_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
MODEL_TOKENS_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
SETUP_COMPLETED_METRIC: {"provider": PROVIDER_IDENTIFIER_MAX_LENGTH},
|
|
MODEL_SWITCH_METRIC: dict.fromkeys(("from_provider", "to_provider"), PROVIDER_IDENTIFIER_MAX_LENGTH),
|
|
FALLBACK_METRIC: dict.fromkeys(("from_provider", "to_provider"), PROVIDER_IDENTIFIER_MAX_LENGTH),
|
|
INSTALL_SNAPSHOT_METRIC: {"main_provider": PROVIDER_IDENTIFIER_MAX_LENGTH},
|
|
# ---- v4 model ----
|
|
MODEL_TOOL_QUALITY_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
MODEL_FRICTION_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
CONTEXT_PEAK_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
# ---- end v4 model ----
|
|
# ---- v5 harness ----
|
|
LOOP_GUARD_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
TOOL_RECOVERY_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
MODEL_REPLY_ISSUE_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
# ---- end v5 harness ----
|
|
# ---- v5 efficiency ----
|
|
TASK_COST_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
WASTED_TOKENS_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
CACHE_BREAK_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
# ---- end v5 efficiency ----
|
|
# ---- v5 engagement ----
|
|
ENGAGEMENT_DAY_METRIC: {
|
|
"primary_model": MODEL_IDENTIFIER_MAX_LENGTH, "primary_provider": PROVIDER_IDENTIFIER_MAX_LENGTH,
|
|
},
|
|
MODEL_SWITCH_AFTER_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
# ---- end v5 engagement ----
|
|
# ---- v5 desktop ----
|
|
DESKTOP_DISLIKE_METRIC: {"setting": DESKTOP_SETTING_KEY_MAX_LENGTH},
|
|
# ---- end v5 desktop ----
|
|
# ---- v5 signals ----
|
|
TOOL_UNAVAILABLE_METRIC: _MODEL_ROUTE_MAX_LENGTHS,
|
|
PROVIDER_SETUP_METRIC: {"provider": PROVIDER_IDENTIFIER_MAX_LENGTH},
|
|
FEATURE_DISABLED_METRIC: {"name": FEATURE_DISABLED_NAME_MAX_LENGTH},
|
|
# ---- end v5 signals ----
|
|
}
|
|
# ---- v5 engagement ----
|
|
# Conversation volume on the session row: new fields, so rows recorded before them still package.
|
|
SESSION_VOLUME_DIMENSIONS = dict.fromkeys(
|
|
("message_count_bucket", "model_call_count_bucket", "tool_call_count_bucket"), LONG_SIZE_BUCKETS,
|
|
)
|
|
_COUNTER_DIMENSION_VALUES[SESSION_METRIC] = {**_COUNTER_DIMENSION_VALUES[SESSION_METRIC], **SESSION_VOLUME_DIMENSIONS}
|
|
# ---- end v5 engagement ----
|
|
# metric -> closed dimension field set
|
|
_METRIC_FIELDS: dict[str, frozenset[str]] = {
|
|
name: frozenset(contract) | frozenset(_IDENTIFIER_FIELDS.get(name, ()))
|
|
for name, contract in _COUNTER_DIMENSION_VALUES.items()
|
|
}
|
|
# Fields v2 carried on the per-task and per-tool-call rows. Together they made nearly every task and
|
|
# tool call its own row; v3 moves duration/retries/latency to the small split counters above (call
|
|
# counts per task are already on hermes.task_cost.count). Their values stay in the contract so rows
|
|
# recorded before an upgrade still validate and package.
|
|
_TASK_FINISHED_V2_ONLY = frozenset(
|
|
{"duration_bucket", "model_call_count_bucket", "retry_count_bucket", "tool_call_count_bucket"}
|
|
)
|
|
_TOOL_CALL_V2_ONLY = frozenset({"latency_bucket", "retry_count_bucket"})
|
|
_METRIC_FIELDS[TASK_FINISHED_METRIC] -= _TASK_FINISHED_V2_ONLY
|
|
_METRIC_FIELDS[TOOL_CALL_METRIC] -= _TOOL_CALL_V2_ONLY
|
|
# Older field sets still accepted at packaging so counters recorded before an upgrade drain.
|
|
_LEGACY_METRIC_FIELDS: dict[str, tuple[frozenset[str], ...]] = {
|
|
MODEL_ROUTE_METRIC: (
|
|
frozenset(_MODEL_ROUTE_MAX_LENGTHS), _METRIC_FIELDS[MODEL_ROUTE_METRIC] - {"ttft_bucket"},
|
|
),
|
|
TASK_STARTED_METRIC: (_METRIC_FIELDS[TASK_STARTED_METRIC] - {"platform"},),
|
|
TASK_FINISHED_METRIC: (frozenset(_COUNTER_DIMENSION_VALUES[TASK_FINISHED_METRIC]) - {"failure_class", "platform"},),
|
|
TOOL_CALL_METRIC: (frozenset(_COUNTER_DIMENSION_VALUES[TOOL_CALL_METRIC]),),
|
|
# v3 rows, recorded before the failure_class split, drain as they were counted.
|
|
COMPRESSION_METRIC: (_METRIC_FIELDS[COMPRESSION_METRIC] - {"failure_class"},),
|
|
MEMORY_OP_METRIC: (_METRIC_FIELDS[MEMORY_OP_METRIC] - {"failure_class"},),
|
|
SKILL_LOAD_METRIC: (_METRIC_FIELDS[SKILL_LOAD_METRIC] - {"skill_name"},),
|
|
INSTALL_SNAPSHOT_METRIC: (_METRIC_FIELDS[INSTALL_SNAPSHOT_METRIC] - {
|
|
"display_language", "install_age_bucket", "main_provider", "messaging_platform_count_bucket",
|
|
"terminal_backend", *_INSTALL_V4_SNAPSHOT_DIMENSIONS,
|
|
},
|
|
# ---- v4 install ----
|
|
_METRIC_FIELDS[INSTALL_SNAPSHOT_METRIC] - set(_INSTALL_V4_SNAPSHOT_DIMENSIONS),
|
|
),
|
|
}
|
|
# ---- v5 engagement ----
|
|
_LEGACY_METRIC_FIELDS[SESSION_METRIC] = (_METRIC_FIELDS[SESSION_METRIC] - set(SESSION_VOLUME_DIMENSIONS),)
|
|
# ---- end v5 engagement ----
|
|
# ---- iuf c1 ----
|
|
# Rows recorded before the failure_class / registry split drain as they were counted.
|
|
_LEGACY_METRIC_FIELDS[EXTENSION_INSTALL_METRIC] = (
|
|
_METRIC_FIELDS[EXTENSION_INSTALL_METRIC] - {"failure_class", "registry"},
|
|
)
|
|
_LEGACY_METRIC_FIELDS[UPDATE_RUN_METRIC] = (_METRIC_FIELDS[UPDATE_RUN_METRIC] - {"failure_class"},)
|
|
# ---- end iuf c1 ----
|
|
COUNTER_METRICS = frozenset(_METRIC_FIELDS) - {LEGACY_MODEL_CALL_METRIC}
|
|
# Counters whose value is a summed quantity rather than an event count.
|
|
SUM_METRICS = frozenset({MODEL_TOKENS_METRIC})
|
|
_SKILL_MARK_METRICS = {
|
|
SKILL_LIFECYCLE_MARK: SKILL_LIFECYCLE_METRIC, SKILL_LOAD_MARK: SKILL_LOAD_METRIC,
|
|
}
|
|
# Marks projected one-to-one onto a counter of the same contract.
|
|
_DECISION_MARK_METRICS = {
|
|
SESSION_MARK: SESSION_METRIC, SETUP_COMPLETED_MARK: SETUP_COMPLETED_METRIC,
|
|
COMPRESSION_MARK: COMPRESSION_METRIC, MODEL_SWITCH_MARK: MODEL_SWITCH_METRIC,
|
|
FALLBACK_MARK: FALLBACK_METRIC, SLASH_COMMAND_MARK: SLASH_COMMAND_METRIC,
|
|
EXTENSION_INSTALL_MARK: EXTENSION_INSTALL_METRIC,
|
|
# ---- v4 loop ----
|
|
MEMORY_OP_MARK: MEMORY_OP_METRIC, CURATOR_RUN_MARK: CURATOR_RUN_METRIC,
|
|
DELEGATION_RUN_MARK: DELEGATION_RUN_METRIC, EXECUTION_BACKEND_MARK: EXECUTION_BACKEND_METRIC,
|
|
# ---- end v4 loop ----
|
|
# ---- v4 gateway ----
|
|
PLATFORM_HEALTH_MARK: PLATFORM_HEALTH_METRIC, PLATFORM_DELIVERY_MARK: PLATFORM_DELIVERY_METRIC,
|
|
REPLY_LATENCY_MARK: REPLY_LATENCY_METRIC, CRON_RUN_MARK: CRON_RUN_METRIC,
|
|
# ---- v4 model ----
|
|
MODEL_TOOL_QUALITY_MARK: MODEL_TOOL_QUALITY_METRIC, MODEL_FRICTION_MARK: MODEL_FRICTION_METRIC,
|
|
CONTEXT_PEAK_MARK: CONTEXT_PEAK_METRIC,
|
|
# ---- end v4 model ----
|
|
# ---- v4 install ----
|
|
STARTUP_LATENCY_MARK: STARTUP_LATENCY_METRIC,
|
|
# ---- v4 reliability ----
|
|
UPDATE_RUN_MARK: UPDATE_RUN_METRIC, UPDATE_STAGE_MARK: UPDATE_STAGE_METRIC,
|
|
PROCESS_EXIT_MARK: PROCESS_EXIT_METRIC,
|
|
# ---- end v4 reliability ----
|
|
# ---- v5 harness ----
|
|
FILE_EDIT_MARK: FILE_EDIT_METRIC, LOOP_GUARD_MARK: LOOP_GUARD_METRIC,
|
|
TOOL_RECOVERY_MARK: TOOL_RECOVERY_METRIC, TERMINAL_OUTCOME_MARK: TERMINAL_OUTCOME_METRIC,
|
|
MODEL_REPLY_ISSUE_MARK: MODEL_REPLY_ISSUE_METRIC,
|
|
# ---- end v5 harness ----
|
|
# ---- v5 efficiency ----
|
|
TASK_COST_MARK: TASK_COST_METRIC, WASTED_TOKENS_MARK: WASTED_TOKENS_METRIC,
|
|
TOOL_OUTPUT_TRUNCATION_MARK: TOOL_OUTPUT_TRUNCATION_METRIC, TOOL_OVERHEAD_MARK: TOOL_OVERHEAD_METRIC,
|
|
TOOL_ENABLED_UNUSED_MARK: TOOL_ENABLED_UNUSED_METRIC, CACHE_BREAK_MARK: CACHE_BREAK_METRIC,
|
|
# ---- end v5 efficiency ----
|
|
# ---- v5 engagement ----
|
|
MODEL_SWITCH_AFTER_MARK: MODEL_SWITCH_AFTER_METRIC,
|
|
# ---- end v5 engagement ----
|
|
# ---- v5 desktop ----
|
|
DESKTOP_FEATURE_USE_MARK: DESKTOP_FEATURE_USE_METRIC, DESKTOP_FRICTION_MARK: DESKTOP_FRICTION_METRIC,
|
|
DESKTOP_ONBOARDING_MARK: DESKTOP_ONBOARDING_METRIC, DESKTOP_MODE_USE_MARK: DESKTOP_MODE_USE_METRIC,
|
|
DESKTOP_ACTION_USE_MARK: DESKTOP_ACTION_USE_METRIC, DESKTOP_DISLIKE_MARK: DESKTOP_DISLIKE_METRIC,
|
|
# ---- end v5 desktop ----
|
|
# ---- v5 signals ----
|
|
TOOL_UNAVAILABLE_MARK: TOOL_UNAVAILABLE_METRIC, PROVIDER_SETUP_MARK: PROVIDER_SETUP_METRIC,
|
|
FEATURE_DISABLED_MARK: FEATURE_DISABLED_METRIC,
|
|
# ---- end v5 signals ----
|
|
# ---- iuf c2 ----
|
|
INSTALL_RUN_MARK: INSTALL_RUN_METRIC,
|
|
# ---- end iuf c2 ----
|
|
}
|
|
|
|
|
|
def counter_dimensions_are_valid(metric_name: str, dimensions: dict[str, Any]) -> bool:
|
|
"""Return whether dimensions match one closed shared-metric contract (current or legacy)."""
|
|
fields = frozenset(dimensions)
|
|
if fields != _METRIC_FIELDS.get(metric_name) and fields not in _LEGACY_METRIC_FIELDS.get(metric_name, ()):
|
|
return False
|
|
identifiers = _IDENTIFIER_FIELDS.get(metric_name, {})
|
|
contract = _COUNTER_DIMENSION_VALUES[metric_name]
|
|
return all(
|
|
isinstance(value := dimensions[field], str) and (
|
|
value == _metric_identifier(value, max_length=identifiers[field]) if field in identifiers
|
|
else value in contract[field]
|
|
)
|
|
for field in fields
|
|
) and _extension_install_fields_agree(metric_name, dimensions)
|
|
|
|
|
|
# ---- iuf c1 ----
|
|
def _extension_install_fields_agree(metric_name: str, dimensions: dict[str, Any]) -> bool:
|
|
"""Cross-field rule the per-field enums cannot state: an install row's ``failure_class`` is in
|
|
its kind's set and is ``none`` exactly on success; only skill rows name a registry."""
|
|
if metric_name != EXTENSION_INSTALL_METRIC or "failure_class" not in dimensions:
|
|
return True # other metrics, and the pre-split shape without failure_class/registry
|
|
kind, failure_class = dimensions["kind"], dimensions["failure_class"]
|
|
return (
|
|
failure_class in EXTENSION_KIND_FAILURE_CLASSES[kind]
|
|
and (failure_class == "none") == (dimensions["outcome"] == "success")
|
|
and (kind == "skill" or dimensions["registry"] == "none")
|
|
)
|
|
# ---- end iuf c1 ----
|
|
|
|
|
|
def _relay_metadata(
|
|
event: Any, schema_key: str, schema_version: str, *extra_keys: str
|
|
) -> dict | None:
|
|
"""Return the event metadata when it carries only the allowlisted Relay keys."""
|
|
metadata = getattr(event, "metadata", None)
|
|
if not isinstance(metadata, dict) or metadata.get(schema_key) != schema_version:
|
|
return None
|
|
allowed = {schema_key, RUNTIME_INSTANCE_KEY, COMMIT_TICKET_KEY, "otel.status_code", *extra_keys}
|
|
if set(metadata) - allowed or metadata.get("otel.status_code", "OK") not in {"OK", "ERROR"}:
|
|
return None
|
|
return metadata
|
|
|
|
|
|
def _event_text(event: Any, attr: str) -> str:
|
|
return str(getattr(event, attr, "") or "")
|
|
|
|
|
|
def _event_shape_matches(event: Any, **expected: Any) -> bool:
|
|
"""Match the coarse Relay event shape (``kind`` plus any of name/category/scope_category/
|
|
category_profile).
|
|
|
|
A ``str`` expectation compares against the stringified attribute; ``None`` requires the
|
|
attribute itself to be ``None``; anything else (the ``category_profile`` dict) compares
|
|
with plain equality. Unmentioned attributes are not checked.
|
|
"""
|
|
for attr, value in expected.items():
|
|
actual = _event_text(event, attr) if isinstance(value, str) else getattr(event, attr, None)
|
|
if actual != value:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _bounded_dimensions(metric_name: str, data: Any) -> dict[str, str] | None:
|
|
"""Project ``data`` onto the metric's closed field set, or None when it does not fit."""
|
|
expected_fields = _METRIC_FIELDS[metric_name]
|
|
if not isinstance(data, dict) or set(data) != expected_fields:
|
|
return None
|
|
dimensions = {field: data.get(field) for field in sorted(expected_fields)}
|
|
valid = counter_dimensions_are_valid(metric_name, dimensions) and _catalog_holds(metric_name, dimensions)
|
|
return dimensions if valid else None
|
|
|
|
|
|
def _catalog_holds(metric_name: str, dimensions: dict[str, str]) -> bool:
|
|
"""Re-run the producer's catalog pass on a mark's provider/model fields: an identifier it would
|
|
rewrite (a user-named provider, a local model id) never reaches a counter. Record-time only, so
|
|
catalog drift can never block packaging rows already stored. ``none`` is the unset-provider value."""
|
|
from .shared_metrics_catalog import model_metric_name, provider_metric_name
|
|
|
|
for field, max_length in _IDENTIFIER_FIELDS.get(metric_name, {}).items():
|
|
value = dimensions[field]
|
|
if field.endswith("provider"):
|
|
expected = value if value == "none" else provider_metric_name(value)
|
|
elif field.endswith("model"):
|
|
expected = model_metric_name(value, dimensions[field[:-5] + "provider"], max_length=max_length)
|
|
else:
|
|
continue
|
|
if value != expected:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _valid_shape(event: Any, **shape: Any) -> bool:
|
|
"""Metadata allowlist check plus :func:`_event_shape_matches` in one step."""
|
|
return (
|
|
_relay_metadata(event, SCHEMA_KEY, SCHEMA_VERSION) is not None
|
|
and _event_shape_matches(event, **shape)
|
|
)
|
|
|
|
|
|
def _bounded_counter(metric_name: str | None, event: Any) -> tuple[str, dict[str, str]] | None:
|
|
if metric_name is None:
|
|
return None
|
|
dimensions = _bounded_dimensions(metric_name, getattr(event, "data", None))
|
|
return None if dimensions is None else (metric_name, dimensions)
|
|
|
|
|
|
def _scoped_dimensions(event: Any, metric_name: str, **shape: Any) -> dict[str, str] | None:
|
|
"""Bounded ``event.data`` for a scope *end* event of the given shape, else None."""
|
|
if not _valid_shape(event, kind="scope", scope_category="end", **shape):
|
|
return None
|
|
return _bounded_dimensions(metric_name, getattr(event, "data", None))
|
|
|
|
|
|
_MARK_SHAPE = dict(kind="mark", category=None, scope_category=None, category_profile=None)
|
|
|
|
|
|
def _mark_counter(event: Any, metrics_by_mark: dict[str, str]) -> tuple[str, dict[str, str]] | None:
|
|
"""Return the bounded counter for a safe Relay mark whose name is in *metrics_by_mark*."""
|
|
if not _valid_shape(event, **_MARK_SHAPE):
|
|
return None
|
|
return _bounded_counter(metrics_by_mark.get(_event_text(event, "name")), event)
|
|
|
|
|
|
def client_active_counter(event: Any) -> tuple[str, dict[str, str]] | None:
|
|
"""Return the active-install counter for one empty allowlisted mark."""
|
|
return _mark_counter(event, {CLIENT_ACTIVE_MARK: CLIENT_ACTIVE_METRIC})
|
|
|
|
|
|
def model_call_dimensions(event: Any) -> dict[str, str] | None:
|
|
"""Return package dimensions for one valid logical model-call end event."""
|
|
auxiliary = _auxiliary_model_call_dimensions(event)
|
|
if auxiliary is not None:
|
|
return auxiliary
|
|
# The synthetic scope can span provider fallback. The accepted terminal
|
|
# route is carried in the validated payload rather than this start profile.
|
|
return _scoped_dimensions(
|
|
event, MODEL_ROUTE_METRIC, category="llm", name=MODEL_CALL_SCOPE,
|
|
category_profile={"model_name": MODEL_CALL_PROFILE_MODEL},
|
|
)
|
|
|
|
|
|
def _auxiliary_model_call_dimensions(event: Any) -> dict[str, str] | None:
|
|
"""Project a terminal auxiliary route from its Hermes logical scope."""
|
|
metadata = _relay_metadata(
|
|
event, RUNTIME_SCHEMA_KEY, RUNTIME_SCHEMA_VERSION, "hermes.call_role"
|
|
)
|
|
call_role = (metadata or {}).get("hermes.call_role")
|
|
data = getattr(event, "data", None)
|
|
if (
|
|
not isinstance(call_role, str)
|
|
or not call_role.startswith("auxiliary:")
|
|
or not _event_shape_matches(
|
|
event, kind="scope", category="function", name=LOGICAL_LLM_SCOPE,
|
|
scope_category="end", category_profile=None,
|
|
)
|
|
or not isinstance(data, dict)
|
|
or set(data) - {"response_model", "error_class"} != {"model", "outcome", "provider"}
|
|
or data.get("outcome") not in _LEGACY_MODEL_OUTCOMES
|
|
):
|
|
return None
|
|
outcome = data["outcome"]
|
|
# Same reading as a primary call: a cancelled call reports ``none``, a failure without a
|
|
# classified reason ``unknown``, a success the last error it recovered from.
|
|
error_class = "none" if outcome == "cancelled" else data.get("error_class") or "none"
|
|
if outcome == "failed" and error_class == "none":
|
|
error_class = "unknown"
|
|
dimensions = model_route_fields(data, call_role="auxiliary", outcome=outcome, error_class=error_class)
|
|
return dimensions if counter_dimensions_are_valid(MODEL_ROUTE_METRIC, dimensions) else None
|
|
|
|
|
|
def task_counter(event: Any) -> tuple[str, dict[str, str]] | None:
|
|
"""Return one validated task counter from a task scope event."""
|
|
if not _valid_shape(
|
|
event, kind="scope", category="function", name=TASK_SCOPE, category_profile=None
|
|
):
|
|
return None
|
|
phase = _event_text(event, "scope_category")
|
|
if phase == "start":
|
|
return _bounded_counter(TASK_STARTED_METRIC, event)
|
|
return _task_end_counter(event, TASK_FINISHED_METRIC) if phase == "end" else None
|
|
|
|
|
|
def task_duration_counter(event: Any) -> tuple[str, dict[str, str]] | None:
|
|
"""Return the duration/retry counter carried by the same task end event."""
|
|
if not _valid_shape(
|
|
event, kind="scope", scope_category="end", category="function", name=TASK_SCOPE,
|
|
category_profile=None,
|
|
):
|
|
return None
|
|
return _task_end_counter(event, TASK_DURATION_METRIC)
|
|
|
|
|
|
# The task end payload (task_terminal_fields) also carries per-task call counts for other Relay
|
|
# subscribers; hermes.task_cost.count already buckets them, so no counter projects them here.
|
|
_TASK_END_FIELDS = (
|
|
_METRIC_FIELDS[TASK_FINISHED_METRIC] | _METRIC_FIELDS[TASK_DURATION_METRIC]
|
|
| {"model_call_count_bucket", "tool_call_count_bucket"}
|
|
)
|
|
|
|
|
|
def _task_end_counter(event: Any, metric_name: str) -> tuple[str, dict[str, str]] | None:
|
|
"""Project the task end event onto one counter; both projections must validate first."""
|
|
data = getattr(event, "data", None)
|
|
if not isinstance(data, dict) or set(data) != _TASK_END_FIELDS:
|
|
return None
|
|
projections = {
|
|
name: _bounded_dimensions(name, {f: data[f] for f in _METRIC_FIELDS[name]})
|
|
for name in (TASK_FINISHED_METRIC, TASK_DURATION_METRIC)
|
|
}
|
|
return (metric_name, projections[metric_name]) if all(projections.values()) else None
|
|
|
|
|
|
def tool_call_dimensions(event: Any) -> dict[str, str] | None:
|
|
"""Return package dimensions for one allowlisted tool lifecycle end event."""
|
|
return _tool_end_projection(event, TOOL_CALL_METRIC)
|
|
|
|
|
|
def tool_usage_dimensions(event: Any) -> dict[str, str] | None:
|
|
"""Return the per-tool usage dimensions carried by the same tool lifecycle end event."""
|
|
return _tool_end_projection(event, TOOL_USAGE_METRIC)
|
|
|
|
|
|
def tool_latency_dimensions(event: Any) -> dict[str, str] | None:
|
|
"""Return the per-category latency/retry dimensions carried by the same tool end event."""
|
|
return _tool_end_projection(event, TOOL_LATENCY_METRIC)
|
|
|
|
|
|
_TOOL_END_METRICS = (TOOL_CALL_METRIC, TOOL_USAGE_METRIC, TOOL_LATENCY_METRIC)
|
|
_TOOL_END_FIELDS = frozenset().union(*(_METRIC_FIELDS[name] for name in _TOOL_END_METRICS))
|
|
|
|
|
|
def _tool_end_projection(event: Any, metric_name: str) -> dict[str, str] | None:
|
|
"""Project the tool end event onto one counter; both projections must validate first."""
|
|
if not _valid_shape(
|
|
event, kind="scope", scope_category="end", category="tool", name=TOOL_CALL_SCOPE,
|
|
category_profile={},
|
|
):
|
|
return None
|
|
data = getattr(event, "data", None)
|
|
if not isinstance(data, dict) or set(data) != _TOOL_END_FIELDS:
|
|
return None
|
|
projections = {
|
|
name: _bounded_dimensions(name, {f: data[f] for f in _METRIC_FIELDS[name]})
|
|
for name in _TOOL_END_METRICS
|
|
}
|
|
return projections[metric_name] if all(projections.values()) else None
|
|
|
|
|
|
def install_snapshot_counter(event: Any) -> tuple[str, dict[str, str]] | None:
|
|
"""Return the daily install-configuration snapshot from a safe mark."""
|
|
return _mark_counter(event, {INSTALL_SNAPSHOT_MARK: INSTALL_SNAPSHOT_METRIC})
|
|
|
|
|
|
def tool_approval_counter(event: Any) -> tuple[str, dict[str, str]] | None:
|
|
"""Return one validated approval counter from a safe Relay mark event."""
|
|
return _mark_counter(event, {TOOL_APPROVAL_MARK: TOOL_APPROVAL_METRIC})
|
|
|
|
|
|
def skill_counter(event: Any) -> tuple[str, dict[str, str]] | None:
|
|
"""Return one validated skill lifecycle or load counter from a safe mark."""
|
|
return _mark_counter(event, _SKILL_MARK_METRICS)
|
|
|
|
|
|
def decision_counter(event: Any) -> tuple[str, dict[str, str]] | None:
|
|
"""Return the counter for one session/setup/compression/switch/fallback/command/install mark."""
|
|
return _mark_counter(event, _DECISION_MARK_METRICS)
|
|
|
|
|
|
_TOKEN_MARK_DIMENSIONS = frozenset({"aux_task", "call_role", "model", "provider"})
|
|
|
|
|
|
def model_token_counters(event: Any) -> list[tuple[str, dict[str, str], int]]:
|
|
"""Expand one token-usage mark into ``(metric, dimensions, amount)`` sums, one per token type."""
|
|
if not _valid_shape(event, **_MARK_SHAPE) or _event_text(event, "name") != MODEL_TOKENS_MARK:
|
|
return []
|
|
data = getattr(event, "data", None)
|
|
if not isinstance(data, dict) or set(data) != _TOKEN_MARK_DIMENSIONS | TOKEN_TYPES:
|
|
return []
|
|
base = {field: data[field] for field in _TOKEN_MARK_DIMENSIONS}
|
|
counters = []
|
|
for token_type in sorted(TOKEN_TYPES):
|
|
amount = data[token_type]
|
|
if isinstance(amount, bool) or not isinstance(amount, int) or amount < 0:
|
|
return []
|
|
dimensions = {**base, "token_type": token_type}
|
|
if not counter_dimensions_are_valid(MODEL_TOKENS_METRIC, dimensions) or not _catalog_holds(
|
|
MODEL_TOKENS_METRIC, dimensions,
|
|
):
|
|
return []
|
|
if amount:
|
|
counters.append((MODEL_TOKENS_METRIC, dimensions, amount))
|
|
return counters
|
|
|
|
|
|
def skill_lifecycle_fields(kwargs: dict[str, Any]) -> dict[str, str] | None:
|
|
"""Build bounded fields for one successful non-load skill transition."""
|
|
action = _norm(kwargs.get("action"))
|
|
if action not in SKILL_LIFECYCLE_ACTIONS:
|
|
return None
|
|
return {"action": action, "provenance": skill_provenance(kwargs.get("provenance"))}
|
|
|
|
|
|
def skill_load_fields(kwargs: dict[str, Any]) -> dict[str, str] | None:
|
|
"""Build bounded skill-use fields without exporting local skill identity."""
|
|
use_count, reused = kwargs.get("use_count"), kwargs.get("reused")
|
|
reuse_after_patch = kwargs.get("reuse_after_patch")
|
|
if (
|
|
isinstance(use_count, bool) or not isinstance(use_count, int) or use_count < 1
|
|
or not isinstance(reused, bool) or not isinstance(reuse_after_patch, bool)
|
|
or (reuse_after_patch and not reused)
|
|
):
|
|
return None
|
|
return {
|
|
"post_patch_state": (
|
|
"not_applicable" if not reused
|
|
else "reused_after_patch" if reuse_after_patch
|
|
else "no_new_patch"
|
|
),
|
|
"provenance": skill_provenance(kwargs.get("provenance")),
|
|
"reuse_state": "reused" if reused else "first_use",
|
|
"skill_name": _skill_metric_name(kwargs.get("skill_name")),
|
|
"use_count_bucket": count_bucket(use_count),
|
|
}
|
|
|
|
|
|
def _skill_metric_name(value: Any) -> str:
|
|
from .shared_metrics_catalog import skill_metric_name
|
|
|
|
return skill_metric_name(value)
|
|
|
|
|
|
def skill_provenance(value: Any) -> str:
|
|
"""Normalize producer provenance to the closed shared-metrics taxonomy."""
|
|
return _allowlisted(_norm(value), SKILL_PROVENANCES)
|
|
|
|
|
|
_SURFACE_ALIASES = {
|
|
"api_server": "api",
|
|
# The relay connector's own platform: an inbound it could not stamp is still a gateway message.
|
|
"relay": "gateway",
|
|
**dict.fromkeys(("cron", "scheduler", "scheduled"), "scheduled_task"),
|
|
}
|
|
_KNOWN_GATEWAY_PLATFORMS = frozenset({"discord", "email", "slack", "telegram", "teams", "whatsapp"})
|
|
|
|
|
|
def execution_surface(kwargs: dict[str, Any]) -> str:
|
|
"""Normalize the safe session surface carried by the parent Relay scope."""
|
|
value = _norm(kwargs.get("execution_surface") or kwargs.get("platform") or "unknown")
|
|
if value in EXECUTION_SURFACES:
|
|
return value
|
|
if value in _SURFACE_ALIASES:
|
|
return _SURFACE_ALIASES[value]
|
|
try:
|
|
from hermes_cli.platforms import get_all_platforms
|
|
|
|
if value in get_all_platforms():
|
|
return "gateway"
|
|
except Exception:
|
|
pass
|
|
return "gateway" if value in _KNOWN_GATEWAY_PLATFORMS else "other"
|
|
|
|
|
|
def task_start_fields(kwargs: dict[str, Any]) -> dict[str, str]:
|
|
"""Build the bounded fields recorded on a task scope start event."""
|
|
surface = execution_surface(kwargs)
|
|
return {
|
|
"entrypoint": task_entrypoint(kwargs, surface), "execution_surface": surface,
|
|
"platform": gateway_platform(kwargs, surface),
|
|
}
|
|
|
|
|
|
def gateway_platform(kwargs: dict[str, Any], surface: str | None = None) -> str:
|
|
"""The messaging platform for a gateway task: core, bundled or proven catalog plugin platforms
|
|
by name, every other plugin platform anonymous."""
|
|
if (surface or execution_surface(kwargs)) != "gateway":
|
|
return "none"
|
|
name = adapter_platform(kwargs.get("platform"))
|
|
return name if name in GATEWAY_PLATFORMS else "plugin"
|
|
|
|
|
|
def adapter_platform(value: Any) -> str:
|
|
"""Public name of a gateway adapter's platform (Platform enum or its value)."""
|
|
from .shared_metrics_catalog import platform_metric_name
|
|
|
|
return platform_metric_name(value, _CORE_GATEWAY_PLATFORMS | _gateway_adapter_platforms())
|
|
|
|
|
|
_SURFACE_ENTRYPOINTS = {
|
|
# An ACP session is a human in an editor (VS Code / Zed / JetBrains), same
|
|
# dispatch shape as the other interactive surfaces.
|
|
**dict.fromkeys(("acp", "cli", "desktop", "tui"), "interactive"),
|
|
**{s: s for s in ("api", "batch", "python", "scheduled_task", "unknown")},
|
|
"gateway": "gateway_message",
|
|
}
|
|
|
|
|
|
def task_entrypoint(kwargs: dict[str, Any], surface: str | None = None) -> str:
|
|
"""Normalize the task dispatch owner without exporting source strings."""
|
|
declared = _norm(kwargs.get("entrypoint"))
|
|
if declared in TASK_ENTRYPOINTS:
|
|
return declared
|
|
if kwargs.get("parent_task_id") or kwargs.get("parent_session_id"):
|
|
return "delegated"
|
|
return _SURFACE_ENTRYPOINTS.get(surface or execution_surface(kwargs), "other")
|
|
|
|
|
|
def task_terminal_fields(
|
|
kwargs: dict[str, Any], *, duration_ms: int, model_call_count: int, tool_call_count: int,
|
|
retry_count: int,
|
|
) -> dict[str, str]:
|
|
"""Build the bounded terminal payload for one task scope."""
|
|
outcome, end_reason, termination = task_terminal_state(kwargs)
|
|
return {
|
|
**task_start_fields(kwargs),
|
|
"duration_bucket": duration_bucket(duration_ms),
|
|
"end_reason": end_reason,
|
|
"failure_class": task_failure_class(kwargs, outcome),
|
|
"model_call_count_bucket": count_bucket(model_call_count),
|
|
"outcome": outcome,
|
|
"retry_count_bucket": count_bucket(retry_count),
|
|
"termination": termination,
|
|
"tool_call_count_bucket": count_bucket(tool_call_count),
|
|
}
|
|
|
|
|
|
_LOCAL_FAILURE_CLASSES = {"interpreter_shutdown": "shutdown", "session_busy": "session_busy"}
|
|
# turn_exit_reason prefix -> class, for failures that never reached the provider classifier.
|
|
_EXIT_REASON_FAILURE_CLASSES = (
|
|
("empty_response", "empty_response"), ("all_retries_exhausted", "empty_response"),
|
|
("context_compression", "context_compression"), ("compaction_", "context_compression"),
|
|
("ollama_runtime_context", "context_compression"),
|
|
("local_processing_error", "local_error"), ("repeated_outer_errors", "repeated_errors"),
|
|
("error_near_max_iterations", "repeated_errors"), ("session_persistence", "persistence"),
|
|
("redirect_restart_limit", "restart_limit"), ("rebuilt_restart_limit", "restart_limit"),
|
|
)
|
|
|
|
|
|
def task_failure_class(kwargs: dict[str, Any], outcome: str | None = None) -> str:
|
|
"""Why a failed task failed, as a closed class; ``none`` for any non-failed outcome."""
|
|
if (outcome or task_terminal_state(kwargs)[0]) != "failed":
|
|
return "none"
|
|
declared = _norm(kwargs.get("failure_class"))
|
|
if declared in TASK_FAILURE_CLASSES - {"none"}:
|
|
return declared
|
|
failure_reason = _norm(kwargs.get("failure_reason"))
|
|
if failure_reason in MODEL_ERROR_CLASSES - {"none"}:
|
|
return failure_reason
|
|
if failure_reason in _LOCAL_FAILURE_CLASSES:
|
|
return _LOCAL_FAILURE_CLASSES[failure_reason]
|
|
reason = _norm(kwargs.get("turn_exit_reason"))
|
|
return next(
|
|
(label for prefix, label in _EXIT_REASON_FAILURE_CLASSES if reason.startswith(prefix)),
|
|
"other",
|
|
)
|
|
|
|
|
|
def task_terminal_state(kwargs: dict[str, Any]) -> tuple[str, str, str]:
|
|
"""Map Hermes terminal state to bounded (outcome, end_reason, termination)."""
|
|
reason = _norm(kwargs.get("turn_exit_reason"))
|
|
if kwargs.get("interrupted") or "interrupt" in reason or "cancel" in reason:
|
|
return "cancelled", "user_cancelled", "user_cancelled"
|
|
if "timeout" in reason or "timed_out" in reason:
|
|
return "timed_out", "timed_out", "timed_out"
|
|
if "max_iterations" in reason or "budget_exhausted" in reason:
|
|
return "failed", "iteration_limit", "system_aborted"
|
|
if "approval" in reason and ("denied" in reason or "rejected" in reason):
|
|
return "failed", "approval_denied", "none"
|
|
if "guardrail" in reason:
|
|
return "failed", "guardrail_blocked", "system_aborted"
|
|
if reason == "system_aborted":
|
|
return "failed", "system_aborted", "system_aborted"
|
|
if kwargs.get("completed") is True:
|
|
return "success", "completed", "none"
|
|
if kwargs.get("failed") is True or (reason and reason != "unknown"):
|
|
return "failed", "failed", "none"
|
|
return "unknown", "unknown", "unknown"
|
|
|
|
|
|
# (exclusive upper bound, label) — ascending; the trailing label catches the rest.
|
|
_DURATION_THRESHOLDS = (
|
|
(1_000, "lt_1s"), (5_000, "1s_to_5s"), (30_000, "5s_to_30s"),
|
|
(120_000, "30s_to_2m"), (600_000, "2m_to_10m"),
|
|
)
|
|
_COUNT_THRESHOLDS = ((1, "0"), (2, "1"), (3, "2"), (6, "3_to_5"), (11, "6_to_10"))
|
|
_LATENCY_THRESHOLDS = (
|
|
(100, "lt_100ms"), (250, "100ms_to_250ms"), (500, "250ms_to_500ms"), (1_000, "500ms_to_1s"),
|
|
(2_000, "1s_to_2s"), (5_000, "2s_to_5s"), (10_000, "5s_to_10s"), (30_000, "10s_to_30s"),
|
|
)
|
|
|
|
|
|
def _bucket(value: float, thresholds: tuple[tuple[float, str], ...], last: str) -> str:
|
|
return next((label for upper, label in thresholds if value < upper), last)
|
|
|
|
|
|
def duration_bucket(duration_ms: int) -> str:
|
|
"""Bucket a non-negative task duration into a fixed low-cardinality range."""
|
|
return _bucket(max(0, int(duration_ms)), _DURATION_THRESHOLDS, "gte_10m")
|
|
|
|
|
|
def count_bucket(count: int) -> str:
|
|
"""Bucket a non-negative per-task count into a fixed range."""
|
|
return _bucket(max(0, int(count)), _COUNT_THRESHOLDS, "gte_11")
|
|
|
|
|
|
_TOOL_CATEGORY_EXACT = {
|
|
**{category: category for category in TOOL_CATEGORIES},
|
|
"clarify": "planning", "kanban": "planning", "todo": "planning", "session_search": "memory",
|
|
"cronjob": "scheduler", "skills": "skill", "x_search": "web",
|
|
}
|
|
_TOOL_CATEGORY_PREFIXES = (
|
|
("mcp", "mcp"),
|
|
("browser", "browser"),
|
|
(("image", "tts", "video", "vision"), "media"),
|
|
("homeassistant", "home_automation"), # the homeassistant catalog plugin's toolset
|
|
(("discord", "email", "feishu", "hermes-yuanbao", "slack", "sms"), "communication"),
|
|
)
|
|
|
|
|
|
def tool_category(kwargs: dict[str, Any]) -> str:
|
|
"""Map Hermes registry toolset metadata to a low-cardinality category."""
|
|
toolset = _norm(kwargs.get("toolset"))
|
|
if not toolset:
|
|
return "unknown"
|
|
if toolset in _TOOL_CATEGORY_EXACT:
|
|
return _TOOL_CATEGORY_EXACT[toolset]
|
|
for prefixes, category in _TOOL_CATEGORY_PREFIXES:
|
|
if toolset.startswith(prefixes):
|
|
return category
|
|
return "other"
|
|
|
|
|
|
_TOOL_STATUS_OUTCOMES = {
|
|
**{s: s for s in ("blocked", "cancelled", "failed", "success", "timed_out")},
|
|
"error": "failed", "ok": "success", "timeout": "timed_out",
|
|
}
|
|
|
|
|
|
def tool_outcome(kwargs: dict[str, Any]) -> str:
|
|
"""Normalize the terminal Hermes tool status without inspecting its result."""
|
|
return _TOOL_STATUS_OUTCOMES.get(_norm(kwargs.get("status")), "unknown")
|
|
|
|
|
|
_APPROVAL_CHOICES = {
|
|
**dict.fromkeys(
|
|
("always", "approve", "approved", "once", "session", "smart_approve"), "approved"
|
|
),
|
|
**dict.fromkeys(("deny", "denied", "smart_deny"), "denied"),
|
|
**dict.fromkeys(("timed_out", "timeout"), "timed_out"),
|
|
"cancelled": "cancelled", # prompt withdrawn / undeliverable / unanswered — not a user decision
|
|
}
|
|
|
|
|
|
def tool_approval_outcome(kwargs: dict[str, Any]) -> str:
|
|
"""Normalize a terminal approval choice to a bounded outcome."""
|
|
return _APPROVAL_CHOICES.get(_norm(kwargs.get("choice")), "unknown")
|
|
|
|
|
|
def tool_terminal_fields(
|
|
kwargs: dict[str, Any], *, category: str | None = None, approval_outcome: str = "not_required",
|
|
fallback_duration_ms: int | None = None, tool_name: str | None = None,
|
|
) -> dict[str, str]:
|
|
"""Build one bounded tool-call terminal payload (feeds the category and per-tool counters)."""
|
|
outcome = tool_outcome(kwargs)
|
|
return {
|
|
"approval_outcome": _allowlisted(approval_outcome, TOOL_APPROVAL_OUTCOMES),
|
|
"error_class": tool_error_class(kwargs, outcome),
|
|
"latency_bucket": tool_latency_bucket(
|
|
kwargs.get("duration_ms"), fallback_duration_ms=fallback_duration_ms
|
|
),
|
|
"outcome": outcome,
|
|
"retry_count_bucket": tool_retry_bucket(kwargs.get("retry_count")),
|
|
"tool_category": category if category in TOOL_CATEGORIES else tool_category(kwargs),
|
|
"tool_name": tool_name if tool_name is not None and tool_name in TOOL_NAMES
|
|
else tool_metric_name(kwargs),
|
|
}
|
|
|
|
|
|
def tool_metric_name(kwargs: dict[str, Any]) -> str:
|
|
"""A built-in tool's own name; MCP and plugin tools collapse to their source kind."""
|
|
name = _norm(kwargs.get("tool_name"))
|
|
if name in BUILTIN_TOOL_NAMES:
|
|
return name
|
|
if not name:
|
|
return "unknown"
|
|
return "mcp" if name.startswith("mcp_") or tool_category(kwargs) == "mcp" else "plugin"
|
|
|
|
|
|
_TOOL_STATUS_ERROR_CLASSES = {
|
|
"blocked": "blocked", "cancelled": "interrupted", "success": "none", "timed_out": "timeout",
|
|
}
|
|
_TOOL_ERROR_TYPES = {
|
|
**dict.fromkeys(("keyboard_interrupt", "tool_interrupted", "user_interrupt"), "interrupted"),
|
|
"invalid_tool_arguments": "invalid_arguments", "thread_missing_result": "exception",
|
|
"tool_error": "tool_error", "tool_result_contract": "contract_violation",
|
|
"tool_timeout": "timeout",
|
|
}
|
|
|
|
|
|
def tool_error_class(kwargs: dict[str, Any], outcome: str | None = None) -> str:
|
|
"""Closed failure class from Hermes's own error_type; exception class names become
|
|
``exception`` so plugin-defined identifiers never leave the machine."""
|
|
outcome = outcome or tool_outcome(kwargs)
|
|
if outcome in _TOOL_STATUS_ERROR_CLASSES:
|
|
return _TOOL_STATUS_ERROR_CLASSES[outcome]
|
|
if outcome != "failed":
|
|
return "unknown"
|
|
error_type = _norm(kwargs.get("error_type"))
|
|
return _TOOL_ERROR_TYPES.get(error_type, "exception" if error_type else "unknown")
|
|
|
|
|
|
def tool_latency_bucket(value: Any, *, fallback_duration_ms: int | None = None) -> str:
|
|
"""Bucket a tool duration reported in milliseconds."""
|
|
duration_ms = _non_negative_number(value)
|
|
if duration_ms is None:
|
|
duration_ms = _non_negative_number(fallback_duration_ms)
|
|
if duration_ms is None:
|
|
return "unknown"
|
|
return _bucket(duration_ms, _LATENCY_THRESHOLDS, "gte_30s")
|
|
|
|
|
|
def tool_retry_bucket(value: Any) -> str:
|
|
"""Bucket only explicit tool retries; missing relationships stay unknown."""
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
return "unknown"
|
|
return count_bucket(value)
|
|
|
|
|
|
def _non_negative_number(value: Any) -> float | None:
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
return None
|
|
try:
|
|
number = float(value)
|
|
except (OverflowError, TypeError, ValueError):
|
|
return None
|
|
return number if isfinite(number) and number >= 0 else None
|
|
|
|
|
|
def model_call_fields(kwargs: dict[str, Any]) -> dict[str, str]:
|
|
"""Return the terminal model identity and provider route known to Hermes."""
|
|
from .shared_metrics_catalog import model_metric_name, provider_metric_name
|
|
|
|
provider = provider_metric_name(kwargs.get("provider"))
|
|
model = model_metric_name(kwargs.get("response_model"), provider, max_length=MODEL_IDENTIFIER_MAX_LENGTH)
|
|
if model == "unknown":
|
|
model = model_metric_name(kwargs.get("model"), provider, max_length=MODEL_IDENTIFIER_MAX_LENGTH)
|
|
return {"model": model, "provider": provider}
|
|
|
|
|
|
def model_route_fields(
|
|
kwargs: dict[str, Any], *, call_role: str, outcome: str, error_class: str,
|
|
ttft_bucket: str = "unknown",
|
|
) -> dict[str, str]:
|
|
"""The terminal route plus how the logical call ended. ``error_class`` is the last
|
|
classified attempt error, so ``success`` + ``rate_limit`` reads as "recovered from a 429"."""
|
|
return {
|
|
**model_call_fields(kwargs),
|
|
"call_role": _allowlisted(call_role, MODEL_CALL_ROLES),
|
|
"error_class": error_class if error_class in MODEL_ERROR_CLASSES else "unknown",
|
|
"outcome": outcome if outcome in MODEL_OUTCOMES else "failed",
|
|
"ttft_bucket": ttft_bucket if ttft_bucket in TTFT_BUCKETS else "unknown",
|
|
}
|
|
|
|
|
|
def model_error_class(kwargs: dict[str, Any]) -> str:
|
|
"""The classifier's FailoverReason for one failed provider attempt."""
|
|
reason = _norm(kwargs.get("reason"))
|
|
return reason if reason in MODEL_ERROR_CLASSES - {"none"} else "unknown"
|
|
|
|
|
|
# (exclusive upper bound, label) for install-scale counts (skills routinely exceed 100).
|
|
_SIZE_THRESHOLDS = (
|
|
(1, "0"), (2, "1"), (3, "2"), (6, "3_to_5"), (11, "6_to_10"), (26, "11_to_25"),
|
|
(101, "26_to_100"), (251, "101_to_250"),
|
|
)
|
|
|
|
|
|
def size_bucket(count: int) -> str:
|
|
"""Bucket a non-negative install-scale count."""
|
|
return _bucket(max(0, int(count)), _SIZE_THRESHOLDS, "gte_251")
|
|
|
|
|
|
def install_snapshot_fields(
|
|
*, memory_provider: Any, mcp_servers: int, plugins: int, skills: int, cron_jobs: int,
|
|
profiles: int, messaging_platforms: int, install_age_bucket: str, main_provider: Any,
|
|
terminal_backend: Any, display_language: Any,
|
|
) -> dict[str, str]:
|
|
"""Bounded daily configuration snapshot: counts, closed enums and public names only."""
|
|
from .shared_metrics_catalog import display_language_metric_name, provider_metric_name
|
|
|
|
provider = _norm(memory_provider)
|
|
backend = _norm(terminal_backend) or "local"
|
|
return {
|
|
"cron_job_count_bucket": size_bucket(cron_jobs),
|
|
"display_language": display_language_metric_name(display_language),
|
|
"install_age_bucket": install_age_bucket if install_age_bucket in INSTALL_AGE_BUCKETS else "unknown",
|
|
"main_provider": provider_metric_name(main_provider) if main_provider else "none",
|
|
"mcp_server_count_bucket": size_bucket(mcp_servers),
|
|
"memory_provider": "builtin" if not provider
|
|
else provider if provider in MEMORY_PROVIDERS else "plugin",
|
|
"messaging_platform_count_bucket": size_bucket(messaging_platforms),
|
|
"plugin_count_bucket": size_bucket(plugins),
|
|
"profile_count_bucket": size_bucket(profiles),
|
|
"skill_count_bucket": size_bucket(skills),
|
|
"terminal_backend": backend if backend in TERMINAL_BACKENDS else "other",
|
|
}
|
|
|
|
|
|
def _metric_identifier(value: Any, *, max_length: int) -> str:
|
|
"""Normalize one structurally safe identifier without a product catalog."""
|
|
if not isinstance(value, str):
|
|
return "unknown"
|
|
identifier = value.strip().lower()
|
|
if (
|
|
not identifier
|
|
or len(identifier) > max_length
|
|
or identifier[0] not in _METRIC_IDENTIFIER_START_CHARACTERS
|
|
or not _METRIC_IDENTIFIER_CHARACTERS.issuperset(identifier)
|
|
):
|
|
return "unknown"
|
|
return identifier
|