hermes-agent/docs/observability
ethernet 5aa121ecfd refactor(deps): read the lazy-install specs from the pyproject extras
tools/lazy_deps.py held a table of about 40 features, each with its own
literal pip specs. pyproject.toml declares the same packages as extras,
so every pin existed twice and the two copies drifted.

Each feature now names an extra, and the specs come from pyproject at
run time. The table is 218 lines shorter. A test asserts that each
feature names an extra that exists and resolves to at least one spec, so
a typo cannot ship.

A wheel install, such as Nix, has no pyproject.toml beside the code.
There the same table comes from the dist metadata: each spec of an
extra is one Requires-Dist line, and its marker names the extra.
Without this fallback, each entry point raised on a Nix install, and
ensure() raised even for a feature whose packages the build baked in
through extraDependencyGroups. That call must be a no-op.
is_available() and feature_install_command() catch the failure as well
now. Their callers sit in status paths with no try/except, and their
contracts are bool and Optional[str].

The security overrides already come from pyproject (the previous
commit). This commit moves the reader onto the shared _pyproject()
cache and the shared temp-file writer.

The tier-0 installer, `uv sync --extra <name>`, names the project with
--project. uv reads the project from its working directory, and the
agent runs from the user's working directory, not from the install
tree. Without the flag the sync failed outside a checkout, and the pip
ladder always ran instead.

install_specs gets the same managed-install guard as ensure(). A Nix
venv is in the read-only store, so the pip ladder could only fail with
EROFS after a 15s ensurepip attempt. It reports the Nix remedy instead.
A durable install target overrides the guard, as it does in ensure(),
because the NixOS container module sets HERMES_MANAGED=true with a
writable target.

Spec parsing goes to packaging.requirements.Requirement, which is
already a core dependency. The hand-written version kept the
environment marker attached to the version. SpecifierSet raised on it,
so _is_satisfied answered True for every installed version of a marked
package. Such a package can never upgrade.

Reading the specs from an extra exposed a second fault, in the record of
which features are active. active_features read specs[0] as the anchor
package, and extra composition put sounddevice there for [voice] and for
each wake extra. One local STT install then marked every audio feature
active, and `hermes update` installed the wake engines that the user
never enabled.

ensure() records each feature it serves in
$HERMES_HOME/lazy-features.json, and active_features reads that record.
A recorded feature still needs its anchor package installed, so an
uninstalled backend does not come back. The anchor is the first pin
written directly in the extra, not the first spec after expansion. A
test asserts that no two extras share an anchor.

There is no seeding for an install that predates the record. Its first
`hermes update` refreshes nothing. ensure() then repairs a stale pin at
each backend's start and records the feature, and the next update covers
it.

[stt-whisper] splits out of [voice]. faster-whisper transcribes audio
files and needs no microphone and no PortAudio, so the Docker image can
bake it. [voice] composes [stt-whisper] and [audio-io] and stays the
microphone stack. stt.faster_whisper maps to the new extra.

Removed with the table:

- The literal pin list in plugins/platforms/google_chat/oauth.py. Its
  pip path targeted /nix/store on a Nix install, which is read-only.
- The bare honcho-ai fallback in the honcho setup. An unpinned install
  accepts whatever PyPI serves, which is the hole this branch closes.
  Both call sites report the remedy for the deployment instead, through
  the now-public managed_install_reason.
- install_deps() in the google-workspace skill. The SDKs ship in the
  [google] extra, so a stripped environment is a broken install. The
  repair is `hermes update`. A pip run from the script writes to
  whichever interpreter it runs under, which is not always the one
  Hermes uses.
- tests/test_runtime_pins_are_locked.py, which scanned first-party
  source for pin literals. There are none left to find.
- The spec shape check in install_specs. The same plugin.yaml hands
  external_dependencies[].install to bash with shell=True, and the
  plugin's __init__.py is imported. Anyone who can write that file
  already runs code as the user.
2026-08-09 17:33:14 -04:00
..
README.md
monitoring.md
relay-shared-metrics.md refactor(deps): read the lazy-install specs from the pyproject extras 2026-08-09 17:33:14 -04:00

README.md

Hermes Observer Hooks

Hermes observer hooks are the read-only telemetry contract for plugins that need to reconstruct agent execution without changing runtime behavior. This contract supports trace, metrics, audit, replay, and export integrations such as Langfuse, OpenTelemetry-style collectors, and NeMo Relay.

Observer hooks are intentionally backend-neutral. They expose stable lifecycle events, correlation IDs, sanitized payloads, timing, status, and error fields. They do not replace Hermes' planner, model providers, memory, tool registry, approval UX, CLI, gateway behavior, or execution semantics.

Behavior-changing request or execution wrappers are outside this observer contract. Observer hooks should report what happened; they should not replace provider requests, tool arguments, or execution callbacks.

Hermes also has a first-party NeMo Relay shared-metrics path. It uses these lifecycle boundaries directly and does not require enabling an observability plugin. See Relay shared metrics.

Contract

Plugins register observer callbacks from register(ctx):

def register(ctx):
    ctx.register_hook("pre_api_request", on_pre_api_request)
    ctx.register_hook("post_api_request", on_post_api_request)
    ctx.register_hook("pre_tool_call", on_pre_tool_call)
    ctx.register_hook("post_tool_call", on_post_tool_call)

Every hook callback receives keyword arguments. Plugins should accept **kwargs so additive fields remain backward-compatible:

def on_post_tool_call(**kwargs):
    tool_name = kwargs.get("tool_name")
    status = kwargs.get("status")
    result = kwargs.get("result")

The plugin manager injects this field into every hook payload:

telemetry_schema_version = "hermes.observer.v1"

Hook callbacks are fail-open. Hermes catches callback exceptions, logs a warning, and keeps the agent loop running.

Most observer hook return values are ignored. The exceptions are older behavior-affecting hooks:

Hook Return behavior
pre_llm_call May return a string or {"context": "..."} to inject ephemeral context into the current user message.
pre_tool_call May return {"action": "block", "message": "..."} to block a tool before execution.
transform_tool_result May return a replacement tool result string after post_tool_call.
transform_llm_output May return a replacement final assistant text string.

Telemetry plugins should treat these behavior-affecting returns as optional compatibility features, not as observability requirements.

Correlation IDs

Observer payloads use stable IDs so plugins can join events without relying on callback order alone.

Field Meaning
session_id Conversation/session identity.
task_id Task identity, especially useful for subagents and isolated execution.
turn_id User-turn identity shared by API attempts and tool calls in a turn.
api_request_id Opaque provider-attempt identity. Do not parse its string format.
api_call_count Numeric API attempt count within the agent loop.
tool_call_id Provider-supplied tool call ID when available.
parent_session_id / child_session_id Session link for delegated subagents.
parent_subagent_id / child_subagent_id Subagent link when available.
parent_turn_id Parent turn that spawned delegated work.

Consumers should prefer explicit fields over parsing compound IDs. In particular, api_request_id is an opaque correlation value.

Event Families

Session Lifecycle

Session hooks describe conversation boundaries and resets:

Hook When it fires
on_session_start A brand-new session starts after the system prompt is built.
on_session_end A run_conversation call ends, including interrupted or incomplete turns.
on_session_finalize CLI or gateway tears down an active session identity.
on_session_reset CLI or gateway moves from an old session identity to a new one.

Common fields include session_id, completed, interrupted, reason, old_session_id, and new_session_id where available.

on_session_end is turn/run scoped. It is not necessarily the final lifetime boundary for a chat identity. Use on_session_finalize and on_session_reset for lifecycle cleanup that must happen once per session identity.

Turn-Scoped LLM Hooks

These hooks frame the user turn, not individual provider API attempts:

Hook When it fires
pre_llm_call Before the tool loop begins for a user turn.
post_llm_call After the turn completes with final assistant output.

Common pre_llm_call fields include session_id, turn_id, user_message, conversation_history, is_first_turn, model, platform, and sender_id.

Common post_llm_call fields include session_id, turn_id, user_message, assistant_response, conversation_history, model, and platform.

Use request-scoped API hooks for LLM span telemetry. Use pre_llm_call and post_llm_call for turn-level context, compatibility, and final turn summary.

Request-Scoped API Hooks

API hooks describe provider attempts inside the agent loop:

Hook When it fires
pre_api_request Immediately before a provider API request.
post_api_request After a successful provider response.
api_request_error After a failed provider request or retryable error path.

pre_api_request includes:

  • identity: session_id, task_id, turn_id, api_request_id
  • runtime: platform, model, provider, base_url, api_mode
  • attempt metadata: api_call_count, message_count, tool_count, approx_input_tokens, request_char_count, max_tokens
  • timing: started_at
  • sanitized request payload: request

post_api_request includes the same identity/runtime fields plus:

  • api_duration, started_at, ended_at
  • finish_reason, message_count, response_model
  • usage
  • assistant_content_chars, assistant_tool_call_count
  • sanitized response payload: response
  • compatibility object: assistant_message

api_request_error includes the same identity/runtime fields plus:

  • api_duration, started_at, ended_at
  • status_code, retry_count, max_retries, retryable, reason
  • structured error = {"type": ..., "message": ...}
  • sanitized failed request payload: request

The sanitized request, response, and error fields are the canonical observer inputs for new consumers.

Tool Lifecycle

Tool hooks describe individual tool calls:

Hook When it fires
pre_tool_call Before guardrail-approved tool dispatch.
post_tool_call After tool dispatch, cancellation, block, or error completion.
transform_tool_result After post_tool_call, before the result is appended to model context.

pre_tool_call includes tool_name, args, task_id, session_id, tool_call_id, turn_id, and api_request_id.

post_tool_call includes the same identity fields plus result, duration_ms, status, error_type, and error_message.

status is the observer-grade lifecycle outcome. Common values include:

Status Meaning
ok Tool completed normally.
error Tool ran and returned or raised an error outcome.
blocked A pre_tool_call hook blocked execution.
cancelled Execution was cancelled before normal completion.

post_tool_call is emitted for blocked and cancelled paths so telemetry plugins can close spans cleanly.

Approval Lifecycle

Approval hooks describe dangerous-command approval prompts:

Hook When it fires
pre_approval_request Before the approval request is shown or sent.
post_approval_response After the user responds or the request times out.

Common fields include command, description, pattern_key, pattern_keys, session_key, and surface.

post_approval_response also includes choice, with values such as once, session, always, deny, and timeout.

Approval hooks are observer-only. Plugins cannot pre-answer or veto approvals from these hooks. To prevent a tool from reaching approval, use pre_tool_call blocking.

Subagent Lifecycle

Subagent hooks describe delegated child-agent work:

Hook When it fires
subagent_start A delegated child agent is created.
subagent_stop A delegated child agent returns or fails.

subagent_start fields include parent_session_id, parent_turn_id, parent_subagent_id, child_session_id, child_subagent_id, child_role, and child_goal.

subagent_stop fields include parent/child session IDs, role/status fields, child_summary, duration_ms, and a metadata-only tool_call_history. Each history entry contains the tool name, argument names, bounded side-effect targets, input/output byte counts, and outcome. URL query strings and fragments are removed; raw arguments, prompts, commands, contents, headers, and results are intentionally excluded.

Observers can use these hooks to model nested trajectories while keeping child agent execution linked to the parent turn that spawned it.

Payload Safety

Observer payloads are designed for telemetry consumers, not raw object access. New consumers should use the sanitized API payloads:

  • pre_api_request.request
  • post_api_request.response
  • api_request_error.request
  • api_request_error.error

Sanitization converts provider objects to JSON-compatible structures, bounds large payloads, redacts sensitive keys, and avoids exposing raw response objects in sanitized fields.

Legacy compatibility fields such as request_messages, conversation_history, and assistant_message may still be present for existing plugins. New observability consumers should prefer the sanitized payloads.

Performance

The default uninstrumented path should stay cheap. Expensive request/response payload construction is gated behind has_hook(...), so Hermes only builds sanitized API telemetry payloads when at least one plugin registered the relevant hook.

Plugin authors should preserve this property:

  • Register only hooks the plugin actually consumes.
  • Avoid deep-copying or re-sanitizing already sanitized payloads.
  • Keep hook callbacks fast and fail-open.
  • Offload network export or batch writes when practical.

Writing An Observer Plugin

Minimal observer plugin:

def register(ctx):
    ctx.register_hook("pre_api_request", on_pre_api_request)
    ctx.register_hook("post_api_request", on_post_api_request)
    ctx.register_hook("pre_tool_call", on_pre_tool_call)
    ctx.register_hook("post_tool_call", on_post_tool_call)


def on_pre_api_request(**kwargs):
    start_llm_span(
        request_id=kwargs.get("api_request_id"),
        turn_id=kwargs.get("turn_id"),
        request=kwargs.get("request"),
        model=kwargs.get("model"),
    )


def on_post_api_request(**kwargs):
    finish_llm_span(
        request_id=kwargs.get("api_request_id"),
        response=kwargs.get("response"),
        usage=kwargs.get("usage"),
        duration=kwargs.get("api_duration"),
    )


def on_pre_tool_call(**kwargs):
    start_tool_span(
        call_id=kwargs.get("tool_call_id"),
        name=kwargs.get("tool_name"),
        args=kwargs.get("args"),
    )


def on_post_tool_call(**kwargs):
    finish_tool_span(
        call_id=kwargs.get("tool_call_id"),
        result=kwargs.get("result"),
        status=kwargs.get("status"),
        duration_ms=kwargs.get("duration_ms"),
    )

Use session_id, turn_id, api_request_id, and tool_call_id for span correlation. Use subagent and approval hooks when the export format supports nested agent work or security lifecycle events.

Existing Consumers

The bundled Langfuse plugin demonstrates direct hook-based observability for turns, provider requests, and tool calls.

The bundled NeMo Relay plugin maps the same generic observer contract to NeMo Relay scopes, LLM spans, tool spans, marks, ATOF streams, and ATIF exports. NeMo Relay-specific configuration and examples live in plugins/observability/nemo_relay/README.md.