Skip to main content

Turn summary

At on_session_end, the plugin enriches the session.* / cron root span with a summary of what happened during the turn. Everything a dashboard would otherwise have to JOIN across children to compute is precomputed and attached to the root:

AttributeTypeMeaning
hermes.turn.tool_countintDistinct tool names invoked
hermes.turn.toolsstringSorted CSV of distinct tool names (≤500 chars)
hermes.turn.tool_targetsstring|-joined distinct file paths / URLs
hermes.turn.tool_commandsstring|-joined distinct shell commands
hermes.turn.tool_outcomesstringSorted CSV of distinct outcome statuses
hermes.turn.skill_countintDistinct skills inferred
hermes.turn.skillsstringSorted CSV of distinct skill names
hermes.turn.api_call_countintpre_api_request hooks fired during the turn
hermes.turn.final_statusstringcompleted · interrupted · incomplete · timed_out

Empty / zero aggregators are omitted rather than emitted as empty strings — so a turn that didn't call any tools simply won't have hermes.turn.tool_count on it.

Why?

The individual tool.* spans carry the names, targets, and commands already — so why copy them up?

Because every backend dashboard you'd want to write looks like this:

"Show me the last 100 turns, how many tools each one used, and what tools they were."

Without the roll-up, that's a JOIN of 100 root spans against all their descendants across an indexed attribute, pivoted into a list. Every backend UI handles that join differently, and many handle it badly. With the roll-up, the same question is a table view of 100 rows with the tools column right there.

Same logic for:

  • "Which turns touched credentials.json?" → filter on hermes.turn.tool_targets CONTAINS "credentials.json"
  • "Which turns ran rm?" → filter on hermes.turn.tool_commands CONTAINS "rm"
  • "Which turns errored?" → filter on hermes.turn.tool_outcomes CONTAINS "error" or hermes.turn.final_status = "error"

All become single-span filters on the root instead of span-tree traversals.

The final_status values

ValueMeaning
completedTurn finished normally via on_session_end
interruptedUser cancelled mid-turn (Ctrl-C, /cancel, etc.)
incompleteTurn ended without a final assistant response (edge case)
timed_outOrphan-sweep finalized a stale session — see Orphan-span sweep

completed and timed_out both map the span StatusCode to OK. interrupted also stays OK (user interrupts aren't errors). Only actual tool/API errors inside the turn raise StatusCode.ERROR — at the child span level, not the root.

This matters because every backend has a "error rate" widget that reads StatusCode. You don't want orphan-sweeps or user interrupts polluting that number.

Char caps

hermes.turn.tools is capped at 500 characters. After a handful of distinct tool names, the CSV gets truncated with .... That rarely matters — a turn with more than 10 distinct tool names is unusual — but it prevents pathological cases from blowing up the attribute size.

hermes.turn.tool_targets and hermes.turn.tool_commands are pipe-joined and not individually capped. A single attribute value is limited by OTel's default attribute length limit (4096 chars); beyond that the SDK truncates.

Aggregation rule

Distinctness is computed by normalized lowercase string equality. Two bash tool calls with the exact same command field are collapsed; two with different commands are counted separately. This keeps the rolled-up values bounded even on long-running turns.