Skip to content

Agent Intelligence (efficiency · smells · forecast)

Three analytical capabilities layered on top of the telemetry that is already captured. None of them collect new data and none of them change the schema — they are pure SELECT-side reads over runs, llm_calls, and tool_calls, so there is no _migrate_vN for this feature. All three inject a now/injectable parameter where time matters so tests stay deterministic.

db.efficiency_runs() (behind /stats efficiency and the CLI) scores each completed session. dashboard/plugin_api._compute_efficiency() mirrors the same formula for the plugin dashboard’s /efficiency endpoint.

output_contribution = min(60, (tokens_out / max(tokens_in, 1)) * 40)
error_penalty = 30 if status == 'error'
10 if status == 'interrupted'
0 otherwise
turn_penalty = min(30, api_calls * 1.5)
score = clamp(0, 100, 40 + output_contribution
- error_penalty - turn_penalty)
  • Status semantics are source-verified. The only non-running run statuses the plugin ever writes are ok, error, and interrupted (__init__.py session end). error is the real failure status and carries the heavy 30-point penalty. failed/cancelled are subagent child_status values, not run statuses, so they never reach this query — do not key the penalty on them.
  • The query reads the 100 most recent completed (status != 'running') sessions in the window, scores them in Python, then ranks best-first. With more than 100 sessions the ranking is over the recent slice, not the global best.
  • Calibration caveat: real multi-turn sessions resend context each turn, so tokens_in >> tokens_out and output_contribution usually lands well under 40. Read the score as a relative signal within a window, not an absolute grade. The same sessions flagged as context_rotation smells will always score low here — that overlap is expected.
  • Maintenance caveat: the formula lives in two places (db.efficiency_runs and plugin_api._compute_efficiency) because the plugin dashboard surface shares no Python with the core package (see § Dashboard Plugin Surface). Keep them in lockstep; a single source of truth is a wanted follow-up.

smell_detector.py (behind /stats smells) runs five independent heuristics. Each returns a list of {smell, severity, session_id, detail, …} dicts.

Smell Severity Threshold
context_rotation high tokens_in > 1,000 AND tokens_out / tokens_in < 0.10
loop_trap medium > 10 tool calls AND one tool name is > 80% of them
tool_thrashing high > 20 tool calls AND failure rate > 30% (tool_calls.ok = 0)
high_error_rate warning / high sessions with status = 'error'; high when > 30% of the window’s sessions errored
massive_session warning (tokens_in + tokens_out) > 100,000 OR api_calls > 50
  • detect_all() concatenates the detectors and sorts by severity (high > medium > warning) then smell type. detect_by_session() regroups the same findings under each session_id.
  • Detection is best-effort: a detector that raises is logged at debug (hermes_telemetry logger) and skipped, so one broken query never takes down the whole /stats smells command.
  • loop_trap aggregates per session in Python because SQLite cannot reference a SELECT alias for the top-tool-ratio check inside the same query.
  • high_error_rate matches only status = 'error' — the sole failure status a run ever carries (see the efficiency note; failed is a subagent child_status, never a run status).

budget.burn_rate_projection() (behind /budget forecast, the CLI, and the standalone dashboard route /api/budget/forecast in serve.py) projects whether a scope is on track to breach its configured limit.

  • db.daily_spend_series() returns one row per calendar day (UTC) for the last lookback_days (default 14), bucketing runs.cost_usd by substr(started_at, 1, 10) and zero-filling days with no spend so gaps do not bias the average.
  • avg_daily = mean(series), then projected_total = spent_so_far + avg_daily * remaining_days_in_window, where spent_so_far comes from db.spend_by_scope() since the window start.
  • status uses the same thresholds as /budget: hard (≥ 100%), soft (≥ 80%), else ok. A scope with no configured limit for the window returns {"enabled": False}.
  • It is a read-only projection — no network calls, no state mutation.
  • Scopes exposed: global / cron_job / sender / profile (the CLI and slash command validate all four; _resolve_limits maps profile → the per_profile budget config key).

All three features are wired into both dashboards. Because neither dashboard surface may import the package (serve.py runs standalone via python serve.py; plugin_api.py is loaded by the Hermes shell via spec_from_file_location), each reimplements the scoring/detection/projection logic inline against its own read-only connection — the same forced-duplication tradeoff already accepted for the efficiency score.

Surface Efficiency Smells Forecast
Standalone (serve.py + index.html) GET /api/efficiency → Breakdown-tab table GET /api/smells → Error-tab table GET /api/budget/forecast → Home-tab panel
Plugin (plugin_api.py + dist/index.js) GET /efficiency → Efficiency sub-tab GET /smells → top-of-page alert widget GET /forecast → inside the Budgets panel

The plugin smells/forecast widgets render nothing on the happy path (no smells / no configured limit), matching the ModelUnavailableWidget convention. Note: the standalone /api/budget/forecast maps the config-key scopes (per_cron_job/per_sender/per_profile) to the engine scopes before projecting — the previous from . import budget version raised ImportError under the standalone loader and never resolved a non-global limit.

The plugin dashboard is legible about scope: every surface either respects a selected profile or is explicitly labeled Global.

  • Profile-scoped endpoints accept an optional ?profile=<name> query param (empty = all profiles, unchanged): /summary, /runs, /requests, /providers, /cron, /token-breakdown, and — added here — /efficiency and /smells. All filter on runs.profile (smells qualifies it as r.profile in its tool_calls JOIN runs queries).
  • /budget emits per-profile scope entries alongside the global ones when budgets.per_profile is configured — one per profile present in runs, per window, with scope: "profile:<name>/<window>" and a scope_id field. Limits resolve override-else-default; this is display only (creating per-profile limits stays a budget.yaml/CLI concern). The engine already modeled per_profile; this wired it into the read endpoint.
  • Frontend (dist/index.js): in-page tabs (Efficiency, Smells, …) share the Telemetry tab’s single profile <select>. The two off-page slot widgets — sessions:top (last-run) and cron:top (cron-7d) — cannot see that selector (separate shell pages), so each carries its own MiniProfileSelect fed by /profiles. A ScopeBadge labels each surface: the profile name / “All profiles” on scoped surfaces, “Global” on genuinely-global ones.
  • Stays Global (labeled, not scoped): the burn-rate forecast (the plugin /forecast has no scope param), tier-change and model-unavailable widgets. model_unavailable_alerts has no profile/session column, so scoping it would need a schema migration — out of scope.

hermes-telemetry is an independent community plugin for Hermes Agent. It is not affiliated with or endorsed by Nous Research.