The trace database
Every session appends its events to trace.sqlite in the session state
directory. The trace is the system's source of truth for what happened:
transcripts are renderings of it, bench scorers grade against it, and any
future learn-from-usage loop mines it. The model never sees it.
Schema
One table, append-only, WAL mode (trace/log.py):
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL, -- UTC ISO-8601
kind TEXT NOT NULL,
payload_json TEXT NOT NULL
);
Payloads are JSON, so new event kinds and new fields are additive: no
migrations, and old traces stay readable. The TraceLog class is the
writer and reader API, though plain sqlite3 works too. Kind names live
in trace/schema.py; writers and readers import the constants rather
than restating the strings, and the table below is the payload contract.
Event kinds
| Kind | Written by | Payload |
|---|---|---|
session_start |
Session.create |
session_id, simulator |
session_resume |
Session.resume |
session_id, simulator |
session_title |
Session.adopt_title |
session_id, title (from the first prompt) |
session_end |
Session.finalize |
none |
message_user |
TurnRunner |
content |
message_reasoning |
recorder middleware | content (the model's reasoning text) |
message_assistant |
recorder middleware | content |
model_usage |
recorder middleware | input_tokens, output_tokens, total_tokens, provider detail fields |
context_compaction |
summarization middleware / /compact |
summarized, kept (message counts, null when unknown), offloaded, manual |
tool_call |
recorder middleware | id, name, args |
tool_result |
recorder middleware | tool_call_id, name, content, status |
hitl_request |
TurnRunner |
the pending tool call awaiting approval |
hitl_response |
TurnRunner |
interrupt_id, the decision payload |
artifact |
plot_julia / recapture_plot / write_report |
path (relative to the session output dir), mime, caption, tool_call_id, format, kind (plot/report, routes the browser viz), size_px, dpi, slot, source_code, poster (thumbnail for an interactive plot), live_url |
attempt |
record_attempt |
id, parent_id, rationale, parameters_changed, metrics, candidate_path, plot_artifact_path, notes |
upload |
web server | path of a file the user uploaded into the workspace |
ui |
a capability's user-interface tool | action and payload, forwarded to the front end as a ui message |
ui_event |
web server | payload of an interaction the front end reported (recorded for later analysis) |
host_context |
web server | session_id and the context object the embedding application had selected, recorded each time it is adopted or changes (the system prompt only ever states the current one, so this is where the earlier selections survive) |
eval_target |
bench solver | expected golden answer for an eval session |
eval_result |
jutul-agent review eval-link |
passed, task, scores (written onto the session after scoring) |
The recorder is an agent middleware (trace/recorder.py), so it observes
the same stream the model produces: every model turn and every tool
round-trip, including tool errors (a raised tool exception is recorded as
an error result, not lost).
Two kinds carry the domain structure that makes the trace more than a
chat log. artifact ties every figure to the exact code that produced it.
attempt records one step of a parameter investigation, with a
parent_id so calibration runs form a tree. The investigation report and
the bench's process scorers both read that structure.
Consumers
jutul-agent transcriptrenders the trace as HTML or markdown, with--bundlezipping the referenced artifacts alongside.- Bench scorers read tool calls, arguments, artifacts, and attempts to verify the agent did the work it claims (evaluation).
model_usageevents make token cost per turn and per workflow measurable in real sessions, not just bench runs.
Reading one
from jutul_agent.trace import TraceLog
with TraceLog.open_readonly(path_to_trace) as log:
for event in log.iter_events():
print(event.timestamp, event.kind, event.payload)
open_readonly inspects a trace without touching it (no schema creation,
no journal-mode change), which is the right way to read another session's
file, possibly one still being written.
Traces live under the state home, at
workspaces/<hash>/sessions/<id>/trace.sqlite (see
configuration), and are plain files: copy one next to a
bug report and the whole session comes with it.