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.
Efficiency score (0-100)
Section titled “Efficiency score (0-100)”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 otherwiseturn_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-
runningrun statuses the plugin ever writes areok,error, andinterrupted(__init__.pysession end).erroris the real failure status and carries the heavy 30-point penalty.failed/cancelledare subagentchild_statusvalues, 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_outandoutput_contributionusually lands well under 40. Read the score as a relative signal within a window, not an absolute grade. The same sessions flagged ascontext_rotationsmells will always score low here — that overlap is expected. - Maintenance caveat: the formula lives in two places (
db.efficiency_runsandplugin_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.
AI smell detection
Section titled “AI smell detection”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 eachsession_id.- Detection is best-effort: a detector that raises is logged at
debug(hermes_telemetrylogger) and skipped, so one broken query never takes down the whole/stats smellscommand. loop_trapaggregates per session in Python because SQLite cannot reference aSELECTalias for the top-tool-ratio check inside the same query.high_error_ratematches onlystatus = 'error'— the sole failure status a run ever carries (see the efficiency note;failedis a subagentchild_status, never a run status).
Burn-rate forecast
Section titled “Burn-rate forecast”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 lastlookback_days(default 14), bucketingruns.cost_usdbysubstr(started_at, 1, 10)and zero-filling days with no spend so gaps do not bias the average.avg_daily = mean(series), thenprojected_total = spent_so_far + avg_daily * remaining_days_in_window, wherespent_so_farcomes fromdb.spend_by_scope()since the window start.statususes the same thresholds as/budget:hard(≥ 100%),soft(≥ 80%), elseok. 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_limitsmapsprofile→ theper_profilebudget config key).
Dashboard surfaces
Section titled “Dashboard surfaces”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.
Per-profile scoping (plugin surface)
Section titled “Per-profile scoping (plugin surface)”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 —/efficiencyand/smells. All filter onruns.profile(smellsqualifies it asr.profilein itstool_calls JOIN runsqueries). /budgetemits per-profile scope entries alongside the global ones whenbudgets.per_profileis configured — one per profile present inruns, per window, withscope: "profile:<name>/<window>"and ascope_idfield. Limits resolve override-else-default; this is display only (creating per-profile limits stays abudget.yaml/CLI concern). The engine already modeledper_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) andcron:top(cron-7d) — cannot see that selector (separate shell pages), so each carries its ownMiniProfileSelectfed by/profiles. AScopeBadgelabels 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
/forecasthas no scope param), tier-change and model-unavailable widgets.model_unavailable_alertshas 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.