feat: Laminar tracing plugin for Hermes Agent (LAM-1511) - #286
feat: Laminar tracing plugin for Hermes Agent (LAM-1511)#286laminar-coding-agent[bot] wants to merge 9 commits into
Conversation
…AM-1511) Ships as examples/hermes-plugin: a standalone pip package that bridges nousresearch/hermes-agent plugin hooks (pre_llm_call, pre_tool_call, post_tool_call, post_api_request, post_llm_call, on_session_end, subagent_stop) to Laminar spans. Entry-point install exposes it to Hermes's PluginManager without needing to publish to PyPI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Avoid a TOCTOU race where a concurrent pre_llm_call could replace the session's turn state between the get() and the pop() in _close_turn, causing the newly created turn span to be ended instead of the original. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hermes drives its own HTTP client for provider calls (see run_agent.py ~L11880 _get_transport().normalize_response), not the anthropic / openai Python SDKs, so Laminar's auto-enabled raw-SDK instrumentors never see these calls. Without direct emission the Laminar UI showed tool spans but no model spans and reported $0 cost per trace. Open an LLM span (span_type="LLM") in pre_api_request parented to the turn's LaminarSpanContext, and close it in post_api_request after setting the GenAI semconv usage attributes. Map Hermes's CanonicalUsage fields (input_tokens / output_tokens / cache_read_tokens / cache_write_tokens / reasoning_tokens) onto the exact attribute keys Laminar reads for cost computation (gen_ai.usage.cache_read_input_tokens, gen_ai.usage.cache_creation_input_tokens). Sweep any dangling api spans in _close_turn / _on_session_end via _cleanup_api_spans so a provider error between pre_ and post_ can't leak a span. Also honour LMNR_HTTP_PORT / LMNR_GRPC_PORT env vars in the plugin's _ensure_initialized so a local Laminar dev instance (gRPC 8001, not the SDK default 8443) can be targeted without having to pass ports through the base URL (which the SDK strips). Fix the entry_points declaration to point at the module (`lmnr_hermes`) rather than the register callable; Hermes's loader does ep.load() then looks for a `register` attribute on the result. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Update: emit LLM spans from
|
Mirror the _cleanup_api_spans logic for _tool_spans: concurrent tool calls on Hermes's ThreadPoolExecutor can raise before post_tool_call fires, leaking the span entry forever. Sweep by session_id when the turn closes or the session ends. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
register() wires nine hooks but the manifest only listed eight. Hermes's directory-based loader validates registered hooks against this list, so pre_api_request could silently fail to fire — and without it no LLM spans get opened, breaking the cost/usage display in the Laminar UI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Use an is-None check instead of `or` when reading total_tokens so a legitimate 0 (e.g. fully-cached or rejected request) is kept rather than silently replaced by a computed sum. Also always set the total_tokens attribute even when zero, so downstream consumers see the reported value. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 30b8243. Configure here.
_cleanup_{api,tool}_spans iterated _api_spans / _tool_spans without a
lock; a concurrent post_tool_call or post_api_request on another session
could pop mid-iteration and raise "dictionary changed size during
iteration", leaking the turn span and OTel context. Introduce _spans_lock
and take it for all mutations so sweeping is safe under concurrency.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CanonicalUsage treats reasoning_tokens as a separate additive field, so when the provider omits total_tokens the computed fallback must include it — otherwise llm.usage.total_tokens is under-reported for reasoning models and skews Laminar's cost display. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Summary
examples/hermes-plugin/— a standalone pip package (lmnr-hermes) that plugs Laminar tracing into Hermes Agent via Hermes's native plugin hook system. No monkey-patching, no forked runtime.hermes.turnroot span perrun_conversationcall with session_id / user_id / model / provider attribution,tool.<name>children (span_type=TOOL) for every tool dispatch, andsubagent.<role>children for delegated subagents. Token usage, finish_reason, and toolduration_msare attached as span attributes.hermes_agent.pluginsentry-point group, sopip install -e examples/hermes-plugin && hermes plugins enable lmnr-hermesis the whole install — no PyPI publish, no Hermes fork required.Design notes
on_session_start,pre_llm_call,post_api_request,pre_tool_call,post_tool_call,post_llm_call,on_session_end,subagent_stop. Signatures are taken directly fromhermes-agent/run_agent.py(lines 9659–12899) andmodel_tools.py(lines 530–634).ThreadPoolExecutor. Rather than relying on OTel's thread-local current context, tool spans are parented by serializing the turn span'sLaminarSpanContextand passing it asparent_span_context=— so parenting survives cross-thread hook invocations._ensure_initialized()), keyed offLMNR_PROJECT_API_KEY. Missing key → plugin no-ops silently; Hermes keeps working.Laminar.initialize()and nest their GenAI spans under the current Hermes turn, giving token/message-level detail for free.examples/hermes-pluginto[tool.uv.workspace].membersin the rootpyproject.toml, mirroring howfastapi-appis wired.Test plan
invoke_hook(...)signatures against the plugin and asserts:hermes.turn/tool.terminal/subagent.researcherspans are all capturedlmnr.association.properties.session_idon every spanuv pip install -e examples/hermes-pluginbuilds the package and registers thelmnr-hermesentry point inhermes_agent.plugins(verified viaimportlib.metadata.entry_points().select(group="hermes_agent.plugins")).uv run pytest tests/) — the 28 failures on this branch all reproduce onmain(bedrock network errors + unrelatedtest_claude_agent/test_proxy_env.py) and are pre-existing.🤖 Generated with Claude Code
Note
Medium Risk
Adds a new standalone plugin that manages OpenTelemetry span lifecycle across Hermes hook threads; concurrency/state handling and token-usage mapping could lead to incorrect/duplicated spans or leaked context if hook behavior differs in production.
Overview
Adds a new standalone
examples/hermes-pluginpackage (lmnr-hermes) that registers via thehermes_agent.pluginsentry point and emits Laminar spans for Hermes hooks.The plugin creates a
hermes.turnroot span per turn, plus childtool.<name>TOOL spans,subagent.<role>spans, and explicit per-requestLLMspans frompre_api_request/post_api_request(including GenAI semantic-convention token usage/caching fields for cost attribution). It includes thread-safe span bookkeeping and cleanup to handle Hermes’s multi-threaded tool execution and missing hook pairs.Updates the root
pyproject.tomluv workspace membership to includeexamples/hermes-plugin, and documents the new example and its tracing/port-configuration caveats inCLAUDE.md.Reviewed by Cursor Bugbot for commit 3b25bce. Bugbot is set up for automated code reviews on this repo. Configure here.