acp-hermes 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/.gitignore +12 -0
  2. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/LICENSE +21 -0
  3. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/README.md +137 -0
  4. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/docs/assets/repo-social.png +0 -0
  5. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/docs/assets/report-hero.svg +26 -0
  6. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/pyproject.toml +35 -0
  7. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/src/acp_hermes/__init__.py +310 -0
  8. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/src/acp_hermes/cli.py +509 -0
  9. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/src/acp_hermes/local_store.py +127 -0
  10. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/src/acp_hermes/plugin.yaml +11 -0
  11. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/src/acp_hermes/pricing.py +103 -0
  12. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/src/acp_hermes/report.py +288 -0
  13. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/tests/__init__.py +0 -0
  14. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/tests/test_cli.py +320 -0
  15. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/tests/test_hooks.py +173 -0
  16. acp_hermes-0.2.5/.claude/worktrees/coverage-cmd/tests/test_local_mode.py +185 -0
  17. acp_hermes-0.2.5/.gitignore +12 -0
  18. acp_hermes-0.2.5/LICENSE +21 -0
  19. acp_hermes-0.2.5/PKG-INFO +153 -0
  20. acp_hermes-0.2.5/README.md +137 -0
  21. acp_hermes-0.2.5/docs/assets/repo-social.png +0 -0
  22. acp_hermes-0.2.5/docs/assets/report-hero.svg +26 -0
  23. acp_hermes-0.2.5/pyproject.toml +38 -0
  24. acp_hermes-0.2.5/src/acp_hermes/__init__.py +310 -0
  25. acp_hermes-0.2.5/src/acp_hermes/cli.py +573 -0
  26. acp_hermes-0.2.5/src/acp_hermes/local_store.py +127 -0
  27. acp_hermes-0.2.5/src/acp_hermes/plugin.yaml +11 -0
  28. acp_hermes-0.2.5/src/acp_hermes/pricing.py +103 -0
  29. acp_hermes-0.2.5/src/acp_hermes/report.py +288 -0
  30. acp_hermes-0.2.5/tests/__init__.py +0 -0
  31. acp_hermes-0.2.5/tests/test_cli.py +367 -0
  32. acp_hermes-0.2.5/tests/test_hooks.py +173 -0
  33. acp_hermes-0.2.5/tests/test_local_mode.py +193 -0
@@ -0,0 +1,12 @@
1
+ # Build artifacts
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ src/*.egg-info/
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ .pytest_cache/
11
+ .venv/
12
+ venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GatewayStack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,137 @@
1
+ # hermes-acp
2
+
3
+ **Cost & quality X-ray plus deterministic governance for [Hermes Agent](https://github.com/NousResearch/hermes-agent)** — via the [Agentic Control Plane](https://agenticcontrolplane.com).
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/hermes-acp)](https://pypi.org/project/hermes-acp/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Python](https://img.shields.io/pypi/pyversions/hermes-acp)](https://pypi.org/project/hermes-acp/)
6
+
7
+ <p align="center"><img src="docs/assets/report-hero.svg" alt="acp-hermes report — real output: 164 calls, 83% cache hit rate, context composition, per-tool costs" width="860"></p>
8
+
9
+ Two planes, one plugin:
10
+
11
+ - **Local metering (no account, nothing leaves your machine)** — every model call and tool call your Hermes agent makes is metered into a local SQLite DB. `acp-hermes report` shows spend by model, cache hit rate, what actually fills your context, tool error rates, and cost per task. `report --json` gives agents their own economics so they can self-optimize.
12
+ - **Cloud governance (optional, free — `acp-hermes login`)** — every tool call is checked against server-side policy (deny / ask / allow with inline approvals), audited with tenant + session attribution, and visible on a team dashboard across all your machines.
13
+
14
+ Companion to the [Claude Code ACP plugin](https://github.com/agentic-control-plane/claude-code-acp-plugin). Same backend contract, same dashboard, same policies — wired into Hermes's Python plugin system instead of Claude Code's shell hooks.
15
+
16
+ Full install guide with dashboard walkthrough: [agenticcontrolplane.com/integrations/hermes](https://agenticcontrolplane.com/integrations/hermes)
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install hermes-acp
22
+ hermes plugins enable acp
23
+ ```
24
+
25
+ That's it — local metering is on. After your next Hermes session:
26
+
27
+ ```bash
28
+ acp-hermes report
29
+ ```
30
+
31
+ ```
32
+ ACP local report — last 7 days (this machine, ~/.acp/hermes-local.db)
33
+
34
+ MODEL SPEND
35
+ model calls in-tok out-tok cache
36
+ gemini-pro-latest 164 827K 5.2K 83%
37
+
38
+ CACHE hit rate 83% (4.1M of 5.0M prompt tokens read from cache)
39
+
40
+ CONTEXT COMPOSITION (share of prompt chars, all turns)
41
+ tool results 96% · system 0% · assistant 1% · user 3%
42
+ Tool results dominate your context — truncating or summarizing large
43
+ tool outputs is usually the cheapest big win.
44
+
45
+ TOOLS (top by calls)
46
+ tool calls errors avg ms output
47
+ terminal 160 0 1201 492KB
48
+ ```
49
+
50
+ (That's real output from the autonomous agent that runs our own ops backlog — [governed by the same plugin](https://agenticcontrolplane.com/blog/setup-hermes-autonomous-agent-safely).)
51
+
52
+ Costs come from Hermes's own pricing engine (same numbers Hermes shows you), including OpenRouter and Bedrock routes. Unknown routes are reported as unpriced, never guessed.
53
+
54
+ To add policies, approvals, and the cross-machine dashboard:
55
+
56
+ ```bash
57
+ acp-hermes login
58
+ ```
59
+
60
+ `login` opens the dashboard, exchanges your one-time auth token for a workspace API key, and writes it to `~/.acp/credentials`.
61
+
62
+ ## Configure
63
+
64
+ For non-interactive setups (CI, devcontainers), skip the `login` step and provide the key directly:
65
+
66
+ ```bash
67
+ export ACP_BEARER_TOKEN="gsk_yourslug_..."
68
+ # or
69
+ mkdir -p ~/.acp && echo "gsk_yourslug_..." > ~/.acp/credentials
70
+ ```
71
+
72
+ The env var wins over the file.
73
+
74
+ Local metering knobs:
75
+
76
+ ```bash
77
+ export ACP_LOCAL_METERING=off # disable local metering entirely
78
+ export ACP_LOCAL_DB=/path/to.db # move the DB (default ~/.acp/hermes-local.db)
79
+ ```
80
+
81
+ ## CLI
82
+
83
+ ```bash
84
+ acp-hermes report # local cost & quality X-ray (no account needed)
85
+ acp-hermes report --days 30 # wider window
86
+ acp-hermes report --json # machine-readable — feed it back to your agent
87
+ acp-hermes login # browser auth + workspace provisioning (cloud plane)
88
+ acp-hermes status # check creds + gateway reachability
89
+ acp-hermes logout # remove ~/.acp/credentials
90
+ ```
91
+
92
+ Optional — point at a non-default backend:
93
+
94
+ ```bash
95
+ export ACP_API_BASE="https://api.agenticcontrolplane.com" # default
96
+ ```
97
+
98
+ ## How it works
99
+
100
+ The plugin registers four Hermes hooks:
101
+
102
+ | Hook | Plane | Behavior |
103
+ |--------------------|-------|------------------------------------------------------------|
104
+ | `pre_tool_call` | cloud | POSTs to `/govern/tool-use`. Server returns `allow` / `deny` / `ask`. `deny` blocks with a system message; `ask` escalates to Hermes's native approval prompt; `allow` passes through. |
105
+ | `post_tool_call` | both | Records duration, status, and result size locally; POSTs to `/govern/tool-output` for server-side audit, redaction logging, and DLP scanning when logged in. |
106
+ | `post_api_request` | local | Records each LLM API request — model, token buckets (input / output / cache read / cache write / reasoning), latency — and prices it with Hermes's own pricing engine. |
107
+ | `post_llm_call` | local | Records context composition by role (system / user / assistant / tool results) per turn. |
108
+
109
+ Local rows land in `~/.acp/hermes-local.db` (SQLite, WAL). Nothing is uploaded from the metering plane.
110
+
111
+ ### Fail-open
112
+
113
+ Network errors, timeouts (>4s), malformed responses, a full disk, or a locked SQLite file all **fail open** — the tool call proceeds and (for network issues) a warning is written to stderr. ACP must never block or break your work.
114
+
115
+ ### "Ask" semantic
116
+
117
+ An ACP `ask` decision escalates to **Hermes's native approval gate** — the same inline `[o]nce / [s]ession / [a]lways / [d]eny` prompt Hermes uses for dangerous shell commands. An `[a]lways` answer is scoped to the tool via the plugin's `rule_key` (`acp:<tool>`), so a standing approval never widens beyond the tool the human actually reviewed. (Versions ≤0.1.0 wrongly claimed Hermes had no approval surface and hard-blocked with a dashboard detour — upgrade.)
118
+
119
+ ### Client identity
120
+
121
+ Sends `X-GS-Client: hermes-plugin/<version>` so the dashboard, policy router, and audit log can distinguish Hermes traffic from Claude Code / Cursor / Codex / etc.
122
+
123
+ ## Troubleshooting
124
+
125
+ **`hermes-acp` opens an editor/ACP mode instead of this CLI.** Hermes itself ships a `hermes-acp` binary (ACP there = Agent Client Protocol, its editor integration) — install order decides which one owns the name. This package installs **`acp-hermes`** as the unambiguous primary command (0.2.1+); use that. `python -m acp_hermes.cli` also always works.
126
+
127
+ **`acp-hermes report` says no metered activity.** The metering hooks only run inside Hermes — confirm the plugin is enabled (`hermes plugins list`, then `hermes plugins enable acp`) and run a session. Also check `ACP_LOCAL_METERING` isn't set to `off`.
128
+
129
+ **Costs show n/a.** Pricing comes from Hermes's engine in-process; routes it can't price (unusual proxies, self-hosted endpoints) are honestly unpriced. Token counts are still exact.
130
+
131
+ **No audit events appearing on the dashboard.** Check that `ACP_BEARER_TOKEN` is set in the shell that launched `hermes`, not just your `.zshrc` after the fact. Hermes inherits the env at process start.
132
+
133
+ **Every tool call blocked.** Look at stderr. A `[ACP] gateway unreachable` warning means network failure (fail-open kicked in but something else blocked you — maybe a policy from another hook). A `[ACP] Denied by policy: …` means the server returned `deny`; check the policy in the ACP dashboard.
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,26 @@
1
+ <svg viewBox="0 0 860 560" xmlns="http://www.w3.org/2000/svg" font-family="ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-size="14">
2
+ <rect width="860" height="560" rx="12" fill="#0d1117" stroke="#30363d"/>
3
+ <circle cx="24" cy="24" r="6" fill="#ff5f57"/><circle cx="44" cy="24" r="6" fill="#febc2e"/><circle cx="64" cy="24" r="6" fill="#28c840"/>
4
+ <text x="430" y="29" fill="#8b949e" text-anchor="middle" font-size="12">acp-hermes report — live output from a governed Hermes agent</text>
5
+ <g fill="#c9d1d9">
6
+ <text x="28" y="64" fill="#7ee787">$ acp-hermes report</text>
7
+ <text x="28" y="92" fill="#8b949e">ACP local report — last 7 days (this machine, ~/.acp/hermes-local.db)</text>
8
+ <text x="28" y="126" fill="#79c0ff" font-weight="bold">MODEL SPEND</text>
9
+ <text x="28" y="148" fill="#8b949e"> model calls in-tok out-tok cache</text>
10
+ <text x="28" y="170"> gemini-pro-latest 164 827K 5.2K 83%</text>
11
+ <text x="28" y="192"> total 164 827K 5.2K</text>
12
+ <text x="28" y="226" fill="#79c0ff" font-weight="bold">CACHE</text>
13
+ <text x="28" y="248"> hit rate <tspan fill="#7ee787" font-weight="bold">83%</tspan> (4.1M of 5.0M prompt tokens read from cache)</text>
14
+ <text x="28" y="282" fill="#79c0ff" font-weight="bold">CONTEXT COMPOSITION (share of prompt chars, all turns)</text>
15
+ <text x="28" y="304"> tool results <tspan fill="#ffa657">96%</tspan> · system 0% · assistant 1% · user 3%</text>
16
+ <text x="28" y="326" fill="#8b949e"> Tool results dominate your context — truncating or summarizing</text>
17
+ <text x="28" y="344" fill="#8b949e"> large tool outputs is usually the cheapest big win.</text>
18
+ <text x="28" y="378" fill="#79c0ff" font-weight="bold">TOOLS (top by calls)</text>
19
+ <text x="28" y="400" fill="#8b949e"> tool calls errors avg ms output</text>
20
+ <text x="28" y="422"> terminal 160 0 1201 492KB</text>
21
+ <text x="28" y="444"> execute_code 1 0 110 271B</text>
22
+ <text x="28" y="466"> skills_list 1 0 0 101B</text>
23
+ <text x="28" y="500" fill="#8b949e">— metered locally by the acp plugin. zero account, nothing leaves</text>
24
+ <text x="28" y="518" fill="#8b949e"> the machine. cloud mode adds allow/ask/deny policy + a console.</text>
25
+ </g>
26
+ </svg>
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hermes-acp"
7
+ version = "0.2.4"
8
+ description = "ACP governance plugin for Hermes Agent — audit and veto every tool call via the Agentic Control Plane."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "GatewayStack" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=7.0"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://agenticcontrolplane.com"
20
+ Documentation = "https://agenticcontrolplane.com/integrations/hermes"
21
+ Source = "https://github.com/agentic-control-plane/hermes-acp-plugin"
22
+ Issues = "https://github.com/agentic-control-plane/hermes-acp-plugin/issues"
23
+
24
+ [project.entry-points."hermes_agent.plugins"]
25
+ acp = "acp_hermes"
26
+
27
+ [project.scripts]
28
+ # acp-hermes is the primary, unambiguous name. hermes-acp is kept for
29
+ # compatibility but Nous's hermes-agent ships its OWN bin of that name
30
+ # (Agent Client Protocol editor mode) — install order decides who owns it.
31
+ acp-hermes = "acp_hermes.cli:main"
32
+ hermes-acp = "acp_hermes.cli:main"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/acp_hermes"]
@@ -0,0 +1,310 @@
1
+ """ACP plugin for Hermes Agent: governance + local self-optimization data.
2
+
3
+ Two independent planes, one plugin:
4
+
5
+ Cloud governance (needs `hermes-acp login`):
6
+ - POST /govern/tool-use — pre-call policy check, can deny / ask / allow
7
+ - POST /govern/tool-output — post-call observation, fire-and-forget
8
+ - An ACP `ask` decision escalates to Hermes's NATIVE approval gate
9
+ ({"action": "approve"}): the user answers [o]nce/[s]ession/[a]lways/[d]eny
10
+ inline, same as Hermes's own dangerous-shell tier.
11
+
12
+ Local metering (works with ZERO credentials, nothing leaves the machine):
13
+ - post_api_request → model calls (tokens, cache buckets, cost via Hermes's
14
+ own pricing engine) into ~/.acp/hermes-local.db
15
+ - post_llm_call → context composition by role per turn
16
+ - post_tool_call → tool durations, status, result sizes
17
+ Read it back with `hermes-acp report` (or `report --json` for agents that
18
+ want to optimize themselves). ACP_LOCAL_METERING=off disables.
19
+
20
+ Everything fails OPEN: an ACP outage, a full disk, or a locked SQLite file
21
+ must never block a Hermes run.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import sys
29
+ import urllib.error
30
+ import urllib.request
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from . import local_store, pricing
35
+
36
+ PLUGIN_VERSION = "0.2.4"
37
+ CLIENT_ID = f"hermes-plugin/{PLUGIN_VERSION}"
38
+
39
+ DEFAULT_API_BASE = "https://api.agenticcontrolplane.com"
40
+ REQUEST_TIMEOUT_SECONDS = 4.0
41
+ POST_HOOK_PAYLOAD_CEILING = 200 * 1024 # 200 KB, matches backend scan ceiling.
42
+
43
+
44
+ def _api_base() -> str:
45
+ return os.environ.get("ACP_API_BASE", DEFAULT_API_BASE).rstrip("/")
46
+
47
+
48
+ def _resolve_token() -> str | None:
49
+ token = os.environ.get("ACP_BEARER_TOKEN")
50
+ if token:
51
+ return token.strip()
52
+ try:
53
+ path = Path.home() / ".acp" / "credentials"
54
+ return path.read_text(encoding="utf-8").strip() or None
55
+ except OSError:
56
+ return None
57
+
58
+
59
+ def _post_json(path: str, body: dict[str, Any], token: str) -> dict[str, Any] | None:
60
+ url = f"{_api_base()}{path}"
61
+ data = json.dumps(body).encode("utf-8")
62
+ req = urllib.request.Request(
63
+ url,
64
+ data=data,
65
+ method="POST",
66
+ headers={
67
+ "Authorization": f"Bearer {token}",
68
+ "Content-Type": "application/json",
69
+ "X-GS-Client": CLIENT_ID,
70
+ },
71
+ )
72
+ try:
73
+ with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SECONDS) as resp:
74
+ raw = resp.read()
75
+ if not raw:
76
+ return {}
77
+ try:
78
+ return json.loads(raw)
79
+ except json.JSONDecodeError:
80
+ return None
81
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
82
+ sys.stderr.write(f"[ACP] gateway unreachable ({exc}); failing open\n")
83
+ return None
84
+
85
+
86
+ def _block(message: str) -> dict[str, str]:
87
+ return {"action": "block", "message": message}
88
+
89
+
90
+ def _pre_tool_call(
91
+ tool_name: str,
92
+ args: dict[str, Any],
93
+ task_id: str = "",
94
+ **_: Any,
95
+ ) -> dict[str, str] | None:
96
+ token = _resolve_token()
97
+ if not token:
98
+ return None # Not configured — pass through.
99
+
100
+ body = {
101
+ "tool_name": tool_name,
102
+ "tool_input": args,
103
+ "session_id": task_id,
104
+ "hook_event_name": "PreToolUse",
105
+ "agent_tier": "interactive",
106
+ }
107
+ result = _post_json("/govern/tool-use", body, token)
108
+ if result is None:
109
+ return None # Fail-open on network / parse error.
110
+
111
+ decision = result.get("decision")
112
+ reason = result.get("reason") or "policy did not return a reason"
113
+ if decision == "deny":
114
+ return _block(f"[ACP] Denied by policy: {reason}")
115
+ if decision == "ask":
116
+ # Escalate to Hermes's native human-approval gate — the same
117
+ # once/session/always/deny prompt its dangerous-shell tier uses.
118
+ # rule_key scopes an [a]lways answer to this tool under ACP's grain.
119
+ return {
120
+ "action": "approve",
121
+ "message": f"[ACP] Approval required: {reason}",
122
+ "rule_key": f"acp:{tool_name}",
123
+ }
124
+ return None
125
+
126
+
127
+ def _post_tool_call(
128
+ tool_name: str,
129
+ args: dict[str, Any],
130
+ result: str = "",
131
+ task_id: str = "",
132
+ session_id: str = "",
133
+ turn_id: str = "",
134
+ duration_ms: int = 0,
135
+ status: str = "",
136
+ error_type: str = "",
137
+ **_: Any,
138
+ ) -> None:
139
+ output_str = result if isinstance(result, str) else json.dumps(result, default=str)
140
+
141
+ if local_store.metering_enabled():
142
+ try:
143
+ local_store.record_tool_call(
144
+ {
145
+ "session_id": session_id or "",
146
+ "task_id": task_id or "",
147
+ "turn_id": turn_id or "",
148
+ "tool_name": tool_name,
149
+ "duration_ms": int(duration_ms or 0),
150
+ "status": status or "",
151
+ "error_type": error_type or "",
152
+ "result_bytes": len(output_str.encode("utf-8", errors="replace")),
153
+ }
154
+ )
155
+ except Exception:
156
+ pass
157
+
158
+ token = _resolve_token()
159
+ if not token:
160
+ return
161
+
162
+ if len(output_str.encode("utf-8")) > POST_HOOK_PAYLOAD_CEILING:
163
+ output_str = output_str[:POST_HOOK_PAYLOAD_CEILING]
164
+
165
+ body = {
166
+ "tool_name": tool_name,
167
+ "tool_input": args,
168
+ "tool_output": output_str,
169
+ "session_id": task_id,
170
+ "duration_ms": duration_ms,
171
+ "hook_event_name": "PostToolUse",
172
+ "agent_tier": "interactive",
173
+ }
174
+ _post_json("/govern/tool-output", body, token)
175
+
176
+
177
+ def _post_api_request(
178
+ model: str = "",
179
+ provider: str = "",
180
+ base_url: str = "",
181
+ api_mode: str = "",
182
+ usage: dict[str, Any] | None = None,
183
+ session_id: str = "",
184
+ task_id: str = "",
185
+ turn_id: str = "",
186
+ api_request_id: str = "",
187
+ api_duration: float = 0.0,
188
+ finish_reason: str = "",
189
+ message_count: int = 0,
190
+ **_: Any,
191
+ ) -> None:
192
+ """Meter one LLM API request into the local store. Never raises."""
193
+ if not local_store.metering_enabled():
194
+ return
195
+ try:
196
+ u = usage or {}
197
+ cost_usd, cost_status = pricing.estimate_cost_usd(
198
+ model, u, provider=provider, base_url=base_url
199
+ )
200
+ local_store.record_model_call(
201
+ {
202
+ "session_id": session_id or "",
203
+ "task_id": task_id or "",
204
+ "turn_id": turn_id or "",
205
+ "api_request_id": api_request_id or "",
206
+ "model": model or "",
207
+ "provider": provider or "",
208
+ "api_mode": api_mode or "",
209
+ "input_tokens": int(u.get("input_tokens") or 0),
210
+ "output_tokens": int(u.get("output_tokens") or 0),
211
+ "cache_read_tokens": int(u.get("cache_read_tokens") or 0),
212
+ "cache_write_tokens": int(u.get("cache_write_tokens") or 0),
213
+ "reasoning_tokens": int(u.get("reasoning_tokens") or 0),
214
+ "request_count": int(u.get("request_count") or 1),
215
+ "api_duration_ms": int((api_duration or 0) * 1000),
216
+ "finish_reason": finish_reason or "",
217
+ "message_count": int(message_count or 0),
218
+ "cost_usd": cost_usd,
219
+ "cost_status": cost_status,
220
+ }
221
+ )
222
+ except Exception:
223
+ pass
224
+
225
+
226
+ def _bucket_for(role: str, part: Any) -> str:
227
+ if isinstance(part, dict):
228
+ ptype = str(part.get("type") or "")
229
+ if ptype in ("tool_result", "tool_response", "function_call_output"):
230
+ return "tool"
231
+ if role in ("tool", "function"):
232
+ return "tool"
233
+ if role in ("system", "developer"):
234
+ return "system"
235
+ if role in ("user", "assistant"):
236
+ return role
237
+ return "other"
238
+
239
+
240
+ def _part_chars(part: Any) -> int:
241
+ if part is None:
242
+ return 0
243
+ if isinstance(part, str):
244
+ return len(part)
245
+ if isinstance(part, dict):
246
+ for key in ("text", "content", "output"):
247
+ if key in part:
248
+ return _part_chars(part[key])
249
+ try:
250
+ return len(json.dumps(part, default=str))
251
+ except Exception:
252
+ return len(str(part))
253
+ if isinstance(part, list):
254
+ return sum(_part_chars(p) for p in part)
255
+ return len(str(part))
256
+
257
+
258
+ def _post_llm_call(
259
+ conversation_history: list[Any] | None = None,
260
+ session_id: str = "",
261
+ task_id: str = "",
262
+ turn_id: str = "",
263
+ model: str = "",
264
+ **_: Any,
265
+ ) -> None:
266
+ """Record context composition by role for one completed turn. Never raises."""
267
+ if not local_store.metering_enabled():
268
+ return
269
+ try:
270
+ buckets = {"system": 0, "user": 0, "assistant": 0, "tool": 0, "other": 0}
271
+ history = conversation_history or []
272
+ for msg in history:
273
+ role = str(
274
+ (msg.get("role") if isinstance(msg, dict) else getattr(msg, "role", "")) or ""
275
+ ).lower()
276
+ content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None)
277
+ parts = content if isinstance(content, list) else [content]
278
+ for part in parts:
279
+ buckets[_bucket_for(role, part)] += _part_chars(part)
280
+ local_store.record_turn(
281
+ {
282
+ "session_id": session_id or "",
283
+ "task_id": task_id or "",
284
+ "turn_id": turn_id or "",
285
+ "model": model or "",
286
+ "message_count": len(history),
287
+ "system_chars": buckets["system"],
288
+ "user_chars": buckets["user"],
289
+ "assistant_chars": buckets["assistant"],
290
+ "tool_chars": buckets["tool"],
291
+ "other_chars": buckets["other"],
292
+ }
293
+ )
294
+ except Exception:
295
+ pass
296
+
297
+
298
+ def register(ctx: Any) -> None:
299
+ ctx.register_hook("pre_tool_call", _pre_tool_call)
300
+ ctx.register_hook("post_tool_call", _post_tool_call)
301
+ # Metering hooks — registered individually and fail-open so an older
302
+ # Hermes without these hook names still loads the governance plane.
303
+ for hook, fn in (
304
+ ("post_api_request", _post_api_request),
305
+ ("post_llm_call", _post_llm_call),
306
+ ):
307
+ try:
308
+ ctx.register_hook(hook, fn)
309
+ except Exception:
310
+ pass