Unrolling the Agent Loop — Hermes Agent Sessions as Reproducible Programs
2026-09-05 · 8 min read

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:
| Hook | Fires when | Data you get |
|---|---|---|
on_session_start | New session created | Session ID, model, platform |
post_llm_call | After each LLM call turn | Response text, tool calls, conversation history snapshot, model, platform |
post_tool_call | After each tool returns | Tool name, arguments, result, duration |
on_session_end | End of every run_conversation call | Session ID, completion status, model, platform |
on_session_finalize | CLI/gateway teardown | Session 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_promptevents → system role messages (deduplicated — only the first one)user_messageevents → user role messagesllm_callevents → assistant messages with text, tool_calls, or bothtool_callevents → tool role messages with tool_call_id and contentfinal_responseevents → 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:
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_CACHEby default;--livereplays through the real model when you want the fresh answer.Side-effectful tools — re-running a
write_fileorterminalcall would modify state. Traces are safe by default: destructive tools print[DRY-RUN]and skip unless you pass--allow-destructive.Live replay costs money — dry-run replay is free and deterministic.
--livecalls the real model through the OpenAI-compatible API (PydanticAI opt-in behind--engine pydantic), so each live step bills tokens. The per-traceCOSTledger 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,--liveexecution, LangGraph export, counterfactual--edit, PII redaction, cost ledger, destructive-tool guard, HTML diff, Pulse auto-score,exec-free--diff. - New in v0.4.0 —
ACTIVE_SKILLScapture (which skills the agent loaded, via theskill_viewtool path) and--htmlreport 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.