diff --git a/agent/insights.py b/agent/insights.py index 16edddad4db01..efcc9c1fecd0a 100644 --- a/agent/insights.py +++ b/agent/insights.py @@ -196,17 +196,23 @@ class InsightsEngine: "top_sessions": top_sessions, } - def get_skill_breakdown(self, days: int = 30, source: str = None) -> Dict[str, Any]: - """Return only the skills section without running a full generate(). + def get_usage_breakdown(self, days: int = 30, source: str = None) -> Dict[str, Any]: + """Return the analytics-usage payload without running a full generate(). Uses the instr()-prefiltered _get_skill_usage query so only messages - that reference skill_view or skill_manage are loaded from SQLite, - avoiding the cost of _get_sessions, _get_tool_usage, and the other - compute passes that generate() performs. + that reference skill_view or skill_manage are loaded from SQLite, while + still preserving the per-tool breakdown used by the dashboard route. """ cutoff = time.time() - (days * 86400) + tool_usage = self._get_tool_usage(cutoff, source) skill_usage = self._get_skill_usage(cutoff, source) - return self._compute_skill_breakdown(skill_usage) + return { + "tools": self._compute_tool_breakdown(tool_usage), + "skills": self._compute_skill_breakdown(skill_usage), + } + + def get_skill_breakdown(self, days: int = 30, source: str = None) -> Dict[str, Any]: + return self.get_usage_breakdown(days=days, source=source)["skills"] # ========================================================================= # Data gathering (SQL queries) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 2d20fff3cb3da..d9296415d5a3a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -14073,7 +14073,7 @@ def _get_usage_analytics(days: int = 30, profile: Optional[str] = None): FROM sessions WHERE started_at > ? """, (cutoff,)) totals = dict(cur3.fetchone()) - skills = InsightsEngine(db).get_skill_breakdown(days=days) + usage = InsightsEngine(db).get_usage_breakdown(days=days) return { "daily": daily, @@ -14083,10 +14083,10 @@ def _get_usage_analytics(days: int = 30, profile: Optional[str] = None): "by_task": _aux_task_summary(aux_rows), "totals": totals, "period_days": days, - "skills": skills, + "skills": usage["skills"], # Per-tool-name call counts (already computed by InsightsEngine); # the desktop Capabilities page aggregates these per toolset. - "tools": insights_report.get("tools", []), + "tools": usage["tools"], } finally: db.close() diff --git a/tests/agent/test_insights.py b/tests/agent/test_insights.py index 7215d555d1c06..c9b5474b943ff 100644 --- a/tests/agent/test_insights.py +++ b/tests/agent/test_insights.py @@ -479,6 +479,13 @@ class TestInsightsPopulated: focused = engine.get_skill_breakdown(days=30) assert focused == full["skills"] + def test_get_usage_breakdown_matches_full_generate(self, populated_db): + engine = InsightsEngine(populated_db) + full = engine.generate(days=30) + focused = engine.get_usage_breakdown(days=30) + assert focused["skills"] == full["skills"] + assert focused["tools"] == full["tools"] + def test_get_skill_breakdown_respects_source_filter(self, populated_db): engine = InsightsEngine(populated_db) # Only s1 (cli) has skill_view "github-pr-workflow" diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 16dd0f58a768c..99543f994b76e 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2226,11 +2226,34 @@ class TestNewEndpoints: """get_usage_analytics must call get_skill_breakdown, not generate().""" from unittest.mock import patch from agent.insights import InsightsEngine + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session( + session_id="usage-tools-test", + source="cli", + model="anthropic/claude-sonnet-4", + ) + db.update_token_counts( + "usage-tools-test", + input_tokens=10, + output_tokens=5, + ) + db.append_message( + "usage-tools-test", + role="tool", + content="read output", + tool_name="read_file", + ) + finally: + db.close() with patch.object(InsightsEngine, "generate") as mock_generate: resp = self.client.get("/api/analytics/usage?days=7") assert resp.status_code == 200 mock_generate.assert_not_called() + assert any(tool["tool"] == "read_file" for tool in resp.json()["tools"]) # --------------------------------------------------------------------------- # Model context length: normalize/denormalize + /api/model/info