36 · Seeing what happened¶
When a run does something you did not expect, you want to see it step by step: what the router read, what
each check decided and why, which call ran, what was undone. stalwart trace shows a run as a timeline, from
the storage your agent already keeps or from the spans a tracer recorded, and every decision on it names the
rule that made it.
Language support
The selector reads turns written in English and in Brazilian Portuguese (pt-BR). The language is chosen, never guessed from the turn: the console's Settings page (Language the selector reads), language on a plan request, or the settings.language of your vocabulary file; with none of them, English. A Portuguese turn may be typed with its accents or without them. European Portuguese is not a grammar the selector has, and a turn in any other language is unread.
A run as a timeline¶
stalwart trace has three subcommands:
stalwart trace show: the timeline in the terminal, one block per step, each check with its answer and its reasons, each pause for a person, each undo and the step it undoes.stalwart trace html ... -o run.html: the same timeline as one self-contained HTML file, with no script and no request to anywhere, for a ticket or an audit.stalwart trace diff a.jsonl b.jsonl: where two runs of the same input went different ways: the first step whose decisions differ, and what only each run did there.
show and html read either a spans file or, with --thread and --store, your agent's storage.
The same from storage alone¶
A run your agent kept in storage: can be read back with nothing recorded beforehand. E6's failed order
(chapter 34), with the shop's storage on disk:
desk = Agent.from_file("agent.yaml", context={"card_on_file": True}) # storage: sqlite:///runs.db
reply = desk.chat("charge order 4411 40.00, then update the crm for order 4411, "
"then provision the account for order 4411", user="u-17")
while reply.waiting:
reply = desk.confirm("u-17", "approve") # each write approved as it comes
print(reply.text)
desk.close()
This run stopped: provision_account failed (RuntimeError). Undone, latest first: update_crm (by restore_crm), charge_card (by refund_card).
The conversation is desk/u-17: the agent's name and the person's. Read back from the storage:
$ stalwart trace show --thread desk/u-17 --store sqlite:///runs.db
Run: thread desk/u-17
step -1
node input
step 0
node run
step 1
node run
step 2
node run
check charge_card -> confirm
interrupt charge_card [write] (confirm)
step 3
node run
check charge_card -> allow
step 4
node run
call charge_card
step 5
node run
step 6
node run
step 7
node run
check update_crm -> confirm
interrupt update_crm [write] (confirm)
step 8
node run
check update_crm -> allow
step 9
node run
call update_crm
step 10
node run
step 11
node run
step 12
node run
check provision_account -> confirm
interrupt provision_account [write] (confirm)
step 13
node run
check provision_account -> allow
step 14
node run
call provision_account
step 15
node run
undo restore_crm -> allow undoes step 3
step 16
node run
step 17
node run
undo refund_card -> allow undoes step 1
step 18
node run
step 19
node run
step 20
node run
Each write was checked and asked about (confirm, [write]), approved (allow), and run; provision_account
failed at step 14, and the two undos follow, each checked, latest first: restore_crm undoes the CRM update
(history step 3) and refund_card the charge (history step 1). A step with nothing under it is one that only
moved the run on.
Spans for any OpenTelemetry tool¶
A tracer records one span around each of these, generated from the package's tracer:
| Span | Opened around |
|---|---|
stalwart.node |
each step of a run |
stalwart.plan |
the router reading a turn |
stalwart.tool_check |
the tool check deciding a call |
stalwart.tool |
a tool running |
stalwart.compensate |
an undo running |
stalwart.rollback |
a failed run being undone |
stalwart.rewind |
a run rewound |
stalwart.interrupt |
a run pausing for a person |
stalwart.resume |
a run resuming on the person's answer |
A tool check's span carries its decision, as these attributes and as a stalwart.decision event:
| Attribute | Holds |
|---|---|
gen_ai.operation.name |
execute_tool, as OpenTelemetry's GenAI conventions name a tool call |
gen_ai.tool.name |
the tool |
gen_ai.tool.call.id |
the call's id |
stalwart.outcome |
the check's answer: allow, confirm or refuse |
stalwart.decided_by |
which step of the check decided |
stalwart.rule |
the rule, flow or reason behind the answer |
stalwart.reasons |
every reason the check gave |
stalwart.labels |
the marks the call carried |
stalwart.args_digest |
the first 16 hex characters of the arguments' digest, never their values |
stalwart.history_token |
a short digest of the session's history token, never the token |
gen_ai.* are OpenTelemetry's GenAI conventions, so any tool that reads them shows the calls; stalwart.* are
ours. With pip install "stalwart-agents[otel]", the spans go to OpenTelemetry's tracer provider, and from it to
whichever exporter you configured. With no exporter at all, a JSON-lines tracer writes them to a file, which
stalwart trace show and diff read.
Content is off by default¶
No argument value, message, result or turn is put on a span unless the tracer is made with
capture_content=True. Where content is off, a 16-character digest stands in for each, so two spans can be
compared without either holding the value. Turning it on adds exactly these attributes:
| Attribute | Content off (the default) | Content on (capture_content=True) |
|---|---|---|
stalwart.arguments |
stalwart.arguments_digest |
the call's arguments, as values |
stalwart.result |
stalwart.result_digest |
the tool's result |
stalwart.message |
stalwart.message_digest |
a message of the conversation |
stalwart.turn |
stalwart.turn_digest |
the person's turn, as typed |
They go where every other attribute goes: to the exporters of the tracer provider you gave it, or to the JSON-lines file you named. A tracer sends nothing to us, either way: a trace is yours.
The state validator¶
The state a run keeps -- its messages, its history, what is waiting for a person, what to undo -- must stay consistent, and a step your team writes can break it. The validator names each break:
| Violation | Means |
|---|---|
call_without_result |
a call the model asked for has no result, and the conversation went on past it |
result_without_call |
a result answers no call in the conversation |
results_out_of_order |
a step's results do not follow it in the order of its calls |
duplicate_message_id |
two messages share an id |
message_after_interrupt |
a message was added while a pause for a person was still open |
stack_step_not_in_history |
the list of what to undo names a step the history does not have |
history_broken |
the history's steps are not 0, 1, 2, ..., or it has entries and no token |
history_token_invalid |
the history's token does not verify: an entry was edited, dropped or reordered outside the run |
label_on_removed_message |
a mark names a message the conversation no longer holds |
stalwart trace show and html mark the first break in a run read from storage with <<< FIRST VIOLATION,
at the step that caused it, so the timeline shows where the state went wrong rather than where it failed later.
Following a decision to its rule¶
Each check on a timeline names its answer and who decided it, and its span names the rule:
stalwart.decided_byis the step of the tool check that decided (chapter 32);stalwart.ruleis the rule, flow or reason: a rulebook's refusal names its flow (chapter 45), an order rule names the binding it broke;stalwart.reasonslists every reason a confirmation gave.
If you already use LangGraph
The spans are opened by the run a tracer is given: desk.graph(tracer=JsonLinesTracer("run.jsonl"))
compiles the agent's graph with stalwart_agents.langchain.core.tracing.JsonLinesTracer, and
stalwart_agents.langchain.otel.OtelTracer opens the same spans in OpenTelemetry, with NodeSpans as the
callback that opens one per node. validate_state(state) and the debug wrapper validating(graph), which
stops at the first violation with the node and step, are in stalwart_agents.langchain.graph.
Chapter 27 has them in LangGraph's own terms.
The limits¶
- A timeline read from storage shows what the storage kept: the checks, the calls, the pauses and the undos, not the spans' timings, and not what happened inside your tools.
- Captured content goes wherever your exporter sends it. Turn it on only where that destination may hold your users' words and your tools' results.