Unrolling the Agent Loop — Hermes Agent Sessions as Reproducible Programs

2026-09-05 · 8 min read

Unrolling the Agent Loop — Hermes Agent Sessions as Reproducible Programs

Every agent harness has the same fundamental loop at its core:

while True:
    take user input
    build context (system prompt + history + tools)
    call the LLM
    if text → deliver, done
    if tool calls → execute them, collect results, append to context
    go back to the LLM

Every decision in that loop is ephemeral. The reasoning, the false starts, the exact tool arguments, the model’s chain-of-thought — all gone the moment the turn ends. What you’re left with is a log transcript you read and guess through.

“What did the agent do on Tuesday at 3pm?” — you read a wall of text. “Was it lying or did I mis-remember?” — you re-run and get a different answer.

There’s a gap between observing that a conversation happened and being able to reproduce it as a first-class artifact.

The core idea: unroll the loop

What if the trace is the program? Instead of treating the agent loop as an opaque runtime that logs to a database, what if we captured every decision as a structured event and then compiled the event stream into a self-contained, executable Python file?

Loop form:                          Unrolled form:

User prompt ──────────              run_20260905.py:
    ↓                                ──────────────
  LLM call ──→ tool call             # Step-by-step replay
    ↓              ↓                 # Timing per event
  LLM call ──→ tool call             # Structured JSON output
    ↓                                # Run: python run_20260905.py
  LLM call ──→ final text
    ↓
  response delivered

Not a log format you have to parse. Not a JSONL dump you need a reader for. A Python file. Import it. Run it. Pipe it into an evaluation pipeline.

This is the idea behind hermes-unroll — a Hermes Agent plugin I released today at github.com/dark5un/hermes-unroll.

Why this is possible at all (the Hook System)

Hermes Agent has a well-designed plugin hook system. Twenty-six lifecycle events, consistent callback signatures, clean separation from core logic. The hooks that matter here:

HookFires whenData you get
on_session_startNew session createdSession ID, model, platform
post_llm_callAfter each LLM call turnResponse text, tool calls, conversation history snapshot, model, platform
post_tool_callAfter each tool returnsTool name, arguments, result, duration
on_session_endEnd of every run_conversation callSession ID, completion status, model, platform
on_session_finalizeCLI/gateway teardownSession ID, platform

Five hooks, each with a consistent signature that doesn’t require touching run_agent.py or monkeypatching anything. The entire plugin — tracer, accumulator, code generator — weighs under 800 lines. Zero dependencies beyond the Python standard library.

The plugin system is the enabler here. If Hermes hadn’t exposed these lifecycle points with clean signatures, this plugin wouldn’t exist. The fact that it was trivial to build is a direct reflection of the architecture’s quality.

How the plugin works

The pipeline has three stages:

1. Accumulation (tracer.py)

A TraceRecorder sits on the module level and accumulates TraceEvent objects. Each event has a kind (llm_call, tool_call, system_prompt, user_message, error, final_response), a timestamp, and a data dict holding whatever the hook provided.

@dataclass
class TraceEvent:
    kind: str
    timestamp: float = field(default_factory=time.time)
    data: dict = field(default_factory=dict)

on_session_start creates a fresh recorder for every session. post_llm_call and post_tool_call append events. on_session_end writes the accumulated trace. Events accumulate across the whole session — each query extends the same trace file rather than overwriting it.

2. Reconstruction (generator.py)

When on_session_end fires, the generator walks the event list and reconstructs the full OpenAI-format message sequence:

  • system_prompt events → system role messages (deduplicated — only the first one)
  • user_message events → user role messages
  • llm_call events → assistant messages with text, tool_calls, or both
  • tool_call events → tool role messages with tool_call_id and content
  • final_response events → trailing assistant messages

The generator also builds a timeline — every event gets an offset_ms from the session start, and tool calls carry their duration_ms. This is critical for comparing original vs replay timing.

3. Emission

The generator emits a .py file to ~/.hermes/traces/unrolled/<session_id>.py. The file is Hermes-independent — it runs with just Python stdlib. It walks through the conversation step by step and outputs structured JSON for evaluation pipelines.

#!/usr/bin/env python3
"""
Reproducible Agent Run - session demo_20260905
Generated by hermes-unroll v0.1.0 at 2026-09-05 11:39:34

This file replays the agent conversation step by step.
Run it standalone - no Hermes dependency needed.
Output is structured JSON for evaluation pipelines.
"""

# ... (metadata, TIMELINE, EXPECTED constants)

TIMELINE = [
  {"kind": "user_message", "offset_ms": 10},
  {"kind": "llm_call", "offset_ms": 30},
  {"kind": "tool_call", "offset_ms": 40, "duration_ms": 450},
  {"kind": "llm_call", "offset_ms": 55},
]

def replay():
    # Step-by-step walkthrough building messages + step_log
    ...
    result = {
        "session_id": SESSION_ID,
        "model": MODEL,
        "provider": PROVIDER,
        "original_duration_ms": 55,
        "messages_count": 4,
        "steps": step_log,   # structured per-step log
        "messages": messages, # full OpenAI-format history
    }
    return result

if __name__ == "__main__":
    result = replay()
    print(json.dumps(result, indent=2, ensure_ascii=False))

What a trace file buys you

Timing and performance comparison

Every event has a timestamp. The generated file includes TIMELINE with offset_ms (from session start) and duration_ms for tool calls. Run the trace against a different model or provider and compare the timing side-by-side.

  +10ms  user_message
  +30ms  llm_call
  +40ms  tool_call  (450ms)
  +55ms  llm_call

Structured evaluation

The program outputs JSON at the end with session_id, model, provider, messages, steps, and original_duration_ms. Pipe it into evaluation tools:

python trace.py | jq '.steps | length'
python trace.py | jq '.messages[-1].content'

Audit

diff run_tuesday.py run_wednesday.py shows exactly what the agent did differently — not summaries or log aggregations, but the actual messages, tool calls, and results with timing. For regulated environments (finance, healthcare, compliance), this changes the conversation from “trust our logging” to “here’s the executable record.”

Training data

The trace is already structured Python with messages, tool calls, and reasoning. The JSON output at the end is ready to feed into a training pipeline. No scraping logs, no ad-hoc parsers, no guesswork about message ordering.

CI/CD for agent behavior

Collect a corpus of trace files from production and run them in CI against a candidate model. The structured JSON output makes comparison deterministic:

for f in traces/*.py; do
  python "$f" > /tmp/actual.json
  diff /tmp/expected.json /tmp/actual.json || echo "REGRESSION: $f"
done

What a trace file doesn’t buy you

Honest limitations:

  1. Non-deterministic LLMs — the same prompt can produce different responses. The trace is a record of what happened, not a guarantee of what will happen. Mitigation: responses replay deterministically from RESPONSE_CACHE by default; --live replays through the real model when you want the fresh answer.

  2. Side-effectful tools — re-running a write_file or terminal call would modify state. Traces are safe by default: destructive tools print [DRY-RUN] and skip unless you pass --allow-destructive.

  3. Live replay costs money — dry-run replay is free and deterministic. --live calls the real model through the OpenAI-compatible API (PydanticAI opt-in behind --engine pydantic), so each live step bills tokens. The per-trace COST ledger tells you exactly what the original run spent.

The companion: Pulse

This isn’t the only Hermes plugin I’ve built: there’s also Pulse, a session health coach that analyses conversation quality — attributing problems to human vs agent, scoring signal patterns, and providing coaching insights.

hermes-unroll and Pulse are two sides of the same coin. hermes-unroll produces the structured trace; Pulse consumes it for analysis. Together they form a pipeline: capture → structure → evaluate → improve.

How to use it

git clone https://github.com/dark5un/hermes-unroll.git \
  ~/.hermes/plugins/hermes-unroll
hermes plugins enable hermes-unroll

Restart Hermes. Every conversation produces a .py file at ~/.hermes/traces/unrolled/<session_id>.py. No configuration, no additional infrastructure.

The repository includes the full specification document — a 9,000-word proposal covering the architecture, the hook mapping, the code generator design, and four future phases.

What’s next

There’s a companion post that surveys the academic and project landscape this work sits in — The Landscape of Reproducible Agent Traces. It maps Execution Lineage, TraceCompiler, Shepherd, Hindsight, AgentReplay, DSPy, Heimdall, and more, and positions where hermes-unroll fits in the gap none of them fill.

For the plugin itself, v0.3.0 already ships the full roadmap — and v0.4.0 closes the remaining gaps:

  • Shipped in v0.3.0 — Timed replay (timing_log, replay_duration_ms), range replay, tool schemas + provider routing, --live execution, LangGraph export, counterfactual --edit, PII redaction, cost ledger, destructive-tool guard, HTML diff, Pulse auto-score, exec-free --diff.
  • New in v0.4.0ACTIVE_SKILLS capture (which skills the agent loaded, via the skill_view tool path) and --html report writer for --diff.

Next: The Landscape of Reproducible Agent Traces — A Survey of Related Work

The full source is at github.com/dark5un/hermes-unroll. MIT licensed. Pull requests welcome.

Written from the workshop — building tools that make the agent loop legible, one trace at a time.