Chapter 6 of 8 · 16 min read
Reading a finished trajectory one message at a time
On this page
Describing a finished run has a wrong default, and it is the one almost everybody reaches for. Asked what happened, most engineers describe intent — first it decides, then it searches, then it answers — and that account is fluent, plausible, and compatible with several different runs, including runs in which the agent did the right things in the wrong order. The difference between the two accounts is the same as the difference between "the request failed" and a packet capture. One is a story about the system. The other is the sequence of things that actually crossed the boundary, in order, with contents.
A trajectory is the accumulated message list for one run, in the order the next request would present it: the caller's first user message, every turn the model produced, and every message the loop appended carrying tool results. The previous chapter left the loop handing that list back inside a RunResult rather than handing back a string, and that decision is what makes everything here possible, since a run whose message list was never stored is one whose questions have no answers the moment the request finishes. What the list supports is a particular kind of claim: one that names a position, and can therefore be checked against the artefact instead of agreed with.
One run, eight messages
This is a complete trajectory for the refund conversation. Blocks are abbreviated; nothing is omitted.
[0] user "I never got order 4471 and I want my money back."
[1] assistant text "Let me pull up the order and its shipment."
tool_use toolu_01Ab search_orders {customer_id: "cus_8123"}
tool_use toolu_01Cd get_shipment {order_id: "4471"}
stop_reason: tool_use
[2] user tool_result toolu_01Ab
[{"id":"4471","status":"shipped","total_cents":8900,"placed":"41d ago"}]
tool_result toolu_01Cd
{"carrier":"DHL","state":"in_transit","last_scan":"33d ago"}
[3] assistant text "No scan in over a month. Let me check the policy."
tool_use toolu_01Ef lookup_policy {topic: "lost_shipment"}
stop_reason: tool_use
[4] user tool_result toolu_01Ef
"Shipments with no carrier scan for 21+ days may be refunded in
full without return, provided order status is shipped."
[5] assistant tool_use toolu_01Gh issue_refund {order_id:"4471", amount_cents:8900}
stop_reason: tool_use
[6] user tool_result toolu_01Gh
{"refund_id":"re_9d2","amount_cents":8900,"state":"succeeded"}
[7] assistant text "Your order hasn't moved since 21 April, so I've refunded
the full £89.00 to your original payment method..."
stop_reason: end_turnFour model calls produced messages 1, 3, 5 and 7. Your loop produced messages 0, 2, 4 and 6. That alternation is the whole control flow, and it is worth naming as a rule: control moves from the model to your code exactly when a turn contains a tool_use block, and from your code back to the model exactly when you append results and send the next request. There is no third mechanism. Anything you would like to describe as the agent "deciding" is one of those two transitions.
That alternation is not a fact about this particular run, and it is worth deriving once because several later claims lean on it. The loop appends exactly one assistant turn for each model call, immediately, before any tool runs. It appends exactly one user message for each assistant turn that contained tool calls, carrying one result block per call. And the list begins with the caller's user message at index zero. Put those three together and the pattern is forced rather than observed: index zero is yours, each model call adds one message, each dispatch adds one, and the two strictly alternate. So the parity of an index names its producer. Even indices are your loop's, odd indices are the model's, for as long as the alternation is unbroken.
Three useful things fall out of that one rule, which is cheaper than arguing each of them separately. The first is the claim above that there is no third mechanism: only two events ever add a message, so only two transitions exist to describe. The second is that "what could the model see" is arithmetic rather than recall — the request that produced an assistant turn at index n contained messages zero through n minus one, every time, with no exceptions to remember. The third is that a broken parity has a name. Two assistant turns side by side means a results message was never appended; two user messages side by side means an assistant turn was dropped. Those are exactly the pairing violations from the chapter on result blocks, where an assistant turn carrying tool calls must be followed by one result per call, and here they are visible as an index pattern rather than as a rejected request from the API.
The account that answers questions
Walking the list is a discipline with four columns: what the message is, who produced it, what the model could see when the message was produced or acted on, and why control moved.
| # | Produced by | Contents | What the model could see | Why control moved |
|---|---|---|---|---|
| 0 | The caller | The user's complaint | — | Loop starts; first request is system + 4 tool schemas + this message, ~1,420 input tokens |
| 1 | Model, call 1 | Narration + two tool_use blocks | Only message 0 — no order data of any kind | stop_reason: tool_use; control to the loop |
| 2 | The loop | Two tool_result blocks, matched by id | — | Both tools dispatched concurrently, 84 ms and 121 ms; results appended, request sent |
| 3 | Model, call 2 | Narration + one tool_use | Messages 0–2: the order is shipped, the shipment has not scanned in 33 days | tool_use; control to the loop |
| 4 | The loop | One tool_result | — | Policy text appended; ~2,690 input tokens on the next request |
| 5 | Model, call 3 | One tool_use, no text | Messages 0–4: order, shipment, and the rule permitting a full refund | tool_use; control to the loop |
| 6 | The loop | One tool_result | — | Money moved here, not at message 5 |
| 7 | Model, call 4 | Text only | Messages 0–6, including the refund receipt | stop_reason: end_turn; loop returns |
Several facts fall out of the table that the intent-story cannot express. At message 1 the model had seen nothing except the customer's sentence, which is why it asked for two tools at once — it had no basis for sequencing them, and no reason to spend a round trip finding out. The 8,900 figure in message 5 came from message 2, three messages earlier, and the model has been carrying it since; if the order lookup had returned a different total, the refund amount would have changed with no other part of the run differing. The policy in message 4 is the only justification the run contains for refunding without a return, and it arrived after the shipment data and before the refund, which is the ordering the incident review was actually asking about. And the refund happened at message 6, in your code, not at message 5 — the model requested it, and the request became money because issue_refund executed.
That last one is asserted often enough to be worth walking through, because the steps are what make it more than a pedantic distinction. A tool_use block is a request expressed in text. Text has no effect on the world by itself; it sits in a JSON body until something reads it. What reads it is your dispatch, which looks the name up and calls issue_refund with the arguments the model supplied. That function calls a payments API, the payments API moves money, and the receipt it returns is what your loop writes into message 6. So the model's decision and the money are separated by exactly one function call, and the function call is yours. That is the same conclusion the chapter on tool errors reaches from the other direction, where the model's choices are advisory and the tool is the enforcement point — one property, arrived at twice, and the trajectory is where you can see it.
The same run as a sequence
The diagram makes one property visible that the table states but does not show: every arrow from the loop to the model carries the entire history, not the newest message. msgs[0..6] is a real payload of around 2,930 input tokens on the fourth call, and the model re-reads all of it each time. This is why runs get more expensive per step as they go, why prompt caching pays for itself on anything longer than three steps, and why a tool that returns 40 KB of JSON on step two is still being paid for on step nine.
The three request sizes in this run teach more as differences than as levels, because the levels are rounded and the differences are not. The first request is about 1,420 input tokens; the request after the policy arrives is about 2,690; the fourth call's is about 2,930. Subtract the last two and the gap is two hundred and forty tokens, and that gap is precisely messages 5 and 6 — a bare tool_use block with two short fields, and a JSON receipt with three. The gap below it is several times larger and covers four messages, two of which are tool results carrying order rows, carrier state and two sentences of policy text. What the comparison licenses is a rule about where a step's cost comes from: the step that moved money is one of the cheapest in the run, and the expensive steps are the ones whose tools returned a lot. The bill tracks what came back, not what was decided.
The drill
The way to get fluent at this is to retell a run out loud, from the message list, with four sentences per message: what it is, who produced it, what was visible, why control moved. It sounds mechanical because it is, and the mechanism is the point — the account either holds up against the indices or it does not.
Those four sentences are not an arbitrary number. Each one answers a question the message list can settle and narration cannot: which artefact this is, which of the two producers made it, which prefix of the list conditioned it, and which of the two transitions it caused. Nothing else about a message is recoverable from the trajectory, and anything a fifth sentence would add is inference. Keeping to four is what stops the retelling drifting back into a story, because the moment you have no fact for a slot you notice the slot is empty rather than filling it with purpose.
The version for message 3 of the trajectory above goes roughly: "Message 3 is an assistant turn, produced by the second model call. At that point the model could see the customer's complaint, its own previous turn, and both tool results — so it knew the order was marked shipped for 8,900 cents and that the carrier had not scanned the package in 33 days. It emitted one tool_use for lookup_policy with topic lost_shipment, and because the turn contained a tool call, control moved back to the loop." Four sentences, no adjectives, nothing that could be true of a different run.
Two habits make the drill easier. Number the messages before you start talking, because "then it checked the policy" and "at message 3 it asked for the policy" are the same claim with different falsifiability, and the second one is the one that survives a follow-up question. And say what was visible before saying what happened, because the interesting failures are almost always cases where the model acted on less information than the retelling assumes it had — a refund issued before the policy arrived, a total taken from a lookup that returned an empty list, an order id the model produced rather than read.
The follow-up questions to expect are all positional. Why did it call two tools at once instead of one? Because at message 1 it had seen nothing that would let it sequence them. Where did 8,900 come from? Message 2. What would have happened if lookup_policy had returned an error? The result block at message 4 would have carried the error text, and the model's next decision would have been made without a justification for a no-return refund — which is a testable claim, since you can replay the trajectory with that one block replaced and see what the model does.
The system prompt is not in messages
A trajectory is a complete record of the messages and an incomplete record of the run, and the gap is worth knowing precisely.
The system prompt and the tool schemas are not in messages. They are separate fields of the request, they were present on every one of the four model calls, and in this agent they come to roughly 1,000 tokens of instructions and declarations that shaped every decision in the list. A trajectory read without them is missing the rules the model was following, which is the usual explanation when a stored run replays differently after somebody edited a tool description.
Thinking is present but usually unreadable. On models with thinking enabled — a mode in which the model works through the problem in a private pass before it writes the turn you see — the assistant turns contain thinking blocks, and by default their text is not returned at all, so what the transcript preserves is the fact that reasoning happened and its token cost rather than its content. Treating an empty thinking block as evidence the model did not reason is a misreading of the default.
That last sentence is a claim about a default, and the condition on it matters as soon as somebody proposes keeping thinking text as a record. What comes back is decided by the provider, the model and the options on your request, and it may be a summary, a block that has to be passed through untouched, or nothing at all. The honest reading of an empty block is therefore that this request did not ask for the text or this provider did not return it, and which of the two it was is a question about your own request rather than about the model's behaviour. The step to make somebody demonstrate, before any claim about what a model did or did not consider, is the request that produced the turn.
Timing, retries and concurrency are yours, not the model's. That messages 1's two tool calls were dispatched in parallel and finished 128 ms apart is a fact about your loop; the message list records only that both results arrived in one user message. If a call was retried three times inside the tool before succeeding, the trajectory shows one result. This is exactly why the call id belongs in your log lines: the trajectory tells you what the model saw, and the logs tell you what your code did, and the id is the join between them.
And nothing in the list is a record of intent. The narration text is written for the user, the tool arguments are the decision, and the only evidence about why a decision was made is what preceded it in the list.
A narrated account survives runs that did not happen
The failure mode is consistent enough to name, and what the retelling gets wrong is the same thing every time. Asked to describe a run, people narrate purpose — it decides, it searches, it checks, it answers — and the narration is fluent, plausible, and unfalsifiable. It survives contact with a run in which the refund preceded the policy lookup, because "it checked the policy" does not commit to an ordinal position. It survives a run where the model asked for both lookups at once, because the story naturally serialises them into "first it looked up the order, then the shipment." That serialisation is the specific error the two-call turn is here to expose: message 1 contains two calls, issued together, and any account that puts them in sequence has described a run that did not happen.
Underneath that is a belief that a defence works, and it is worth stating in those terms because that is the shape it shares with the other traps in this topic. The reassuring quantity is that every assistant turn carries a fluent sentence explaining itself, and it carries one whether the run went well or badly — the sentence is present in every case you are trying to tell apart, so its presence separates nothing. What varies underneath it is whether that sentence had anything to do with the decision. The narration and the tool arguments are produced in the same turn, and only one of them acts on the world. A review that reads narration is reading the one part of the turn that nothing checks against anything, which is why it always sounds reasonable.
Two smaller mistakes travel with it. The first is treating the narration text inside an assistant turn as the model's reasoning: "No scan in over a month" in message 3 is a sentence written for the user's benefit alongside a tool request, and it is not a record of why the model chose lookup_policy. On models where thinking is enabled, the thinking blocks are also in the turn, and by default their text is not returned at all, so the transcript contains a placeholder rather than reasoning. The second is forgetting what is not in the message list: the system prompt and the tool schemas ride in the request rather than in messages, so a trajectory read in isolation omits roughly 1,000 tokens of instructions that shaped every decision in it. When you replay a stored trajectory and get different behaviour, that omission is the first place to look.
The corrective is mechanical. Read the list in order, and for each message say who produced it, what preceded it, and which of the two transitions it caused. If a sentence in your account cannot be attributed to a message index, it is a story.
A finished run does not have to be remembered, and that is the whole reason for storing it. It is a short numbered list, and every question a review can ask of it — what did the model know, when did it know it, what moved the money, what would have happened if that one result had been an error — is a question about a position in that list. The account built from indices is not more careful than the narrated one. It is answerable, and the narrated one is not, which is the difference that shows up the first time somebody disputes what happened.
This run took four model calls. Another version of the same task, where the customer gives an order number that turns out to belong to a different account, takes seven. A simple "where is my order" takes two. Read enough finished trajectories and the step counts stop being anecdotes and become a distribution — and that distribution, rather than a round number that felt safe, is what the iteration cap should be derived from.
The agent loop implemented from scratch — read the current version at https://daviddominguez.dev/learn/en/agent-loop-implemented-from-scratch/reading-a-finished-trajectory-one-message-at-a-time