Appearance
Agent Full Transcript
Every hosted-agent turn records four things: what the agent knew, the prompt it sent, the response it got, and the decision that reached the table.
Before this, we stored the middle two. That is enough to see what an agent did and not enough to see why it went wrong. An owner looking at a bad decision could not separate three explanations that look identical from outside:
- the strategy is wrong — it knew the right things and chose badly
- it knew something unexpected — the prompt was built from a memory you didn't anticipate
- it decided X and the table got Y — the choice was changed on the way out
Those need different fixes. Now the trace tells them apart.
The four things, taken at two chokepoints
| # | What | Taken at |
|---|---|---|
| 1 | What the agent knew | buildTurnContext's arguments |
| 2 | The prompt | the assembler (existed) |
| 3 | The response | the LLM call (existed) |
| 4 | The decision that reached the table | sendToTable |
One record per turn, on the existing 24h capture buffer; flagged hands promote to Postgres; /agents renders the loop.
#1 is the agent's memory, not the wire
The decision that shaped everything else.
There is no message that says here's the hand so far, what do you want to do? We assumed there was. Tracing the live code found the agent assembles its knowledge:
game_stateframes overwritelatestState— last one wins, no historyyour_turncarries only{legalActions, chipRange, turnEndsAt}— no table stateplayer_actionframes append tocurrentHandActions— the only way it knows the player before it raised to 40; the state shows the result (chips down, pot up), never the verbhand_resultframes accumulatehandHistory, capped at 20
So the composite exists only inside the agent process, only at that instant.
Store the memory, not the frames. Frames record what we sent; memory records what the agent had. Every interesting defect lives in the gap: a stale state, a state that never arrived, an action log emptied by a reconnect. Store frames and a perfectly good frame appears in the log while the bad decision stays unexplained. Store memory and the cause is on the page.
Store it raw, next to the prompt. The prompt is a rendering of the memory; keeping both makes "the memory said X, the prompt showed Y" visible — a renderer bug, invisible today.
Capture at the door, never a curated list
buildTurnContext's argument list is the definition of what the agent knows — it is what the prompt is built from, by construction. We capture that object.
This matters for the future, not the present: a memory source added later (a new skill, a configurable history depth) must pass through that function to reach the prompt, so it lands in traces automatically. A hand-listed set would go stale on the first addition, and no trace would show the omission.
sendToTable is the same shape at the other door. Every outbound message goes through it, enforced by scripts/check-agent-outbound-chokepoint.sh (chained into pnpm typecheck). A raw ws.send(...) would be a message that never appears in any trace — the trace would look complete and be wrong.
What we deliberately did not build
A log of every inbound frame. The brief originally said "every websocket message it received," and an earlier ADR built an append-only log with sequence numbers to solve "which turn does this frame belong to."
That machinery served a literal reading. Exactly one game_state ever feeds a prompt — the most recent one. On a ~40-frame hand, ~39 are overwritten and influence no decision. The sequence numbers, the association rule, and most of the buffer-volume concern were all scaffolding for storing those 39.
Attributing a frame to a turn at write time also requires knowing which turn comes next — knowing the future. The options are to guess or to wait, and both corrupt the artifact: a guess makes the trace assert a causal link that never existed. A trace that lies is worse than a trace that omits, because the whole value is that an owner can trust it against their own intuition.
Compression. Frames dedup across viewers (~N× on an N-agent table), not across time. We measured it and then declined it: the only thing a split buys is bytes, and what it costs is a second copy of the privacy answer — reveal-policy.ts already owns which fields are viewer-private, and a duplicated privacy answer drifting from the real one is exactly the bug hosted-agent-information-boundary existed to fix. Eat the bytes; revisit on measured pain.
Both ends are scoped, not just captured
Capturing at a chokepoint is necessary and not sufficient — both ends needed a lifetime, and falsification found out why by breaking them.
#4's buffer is scoped to a turn (capture/turn-outbound.ts). It resets when your_turn arrives and drains when the record is written. Without the reset:
- a turn whose inference failed sent its safe action, was never captured, and so never drained — the next turn's decision read
["fold","raise 200"], a trace claiming two actions on one turn entercarries the agent's Ed25519agentToken, and nothing drained before the first turn, so turn 1's decision shipped a live credential into the 24h buffer and, on flag, Postgres
Credentials are now stripped before buffering, never before sending — the wire needs the token, the transcript never does — and redacted rather than deleted, because a vanished field reads as "the agent never sent one".
#1 is frozen at decision time. actionLog/handHistory are the connection's live arrays, mutated in place, while a MIN_TURN_MS sleep of up to 3 seconds sits between building #1 and capturing it. The agent's own action even echoes back as a player_action and appends — so without a copy, the trace showed the agent knowing the action it had not yet taken. state stays by reference on purpose: it is replaced per frame, never mutated.
Both bugs had one root cause: an unnamed lifetime. private outboundThisTurn: string[] has no boundary you can point at, so nobody asked which turn it belonged to. Naming it (startTurn / take) made the question answerable.
Absent is not empty
knowledge/decision are nullable, and the read maps null → undefined, never [].
undefined= not captured (a turn from before this shipped)[]= captured, and it sent nothing
A reader that collapses the two tells an owner their agent sent no messages when we simply never recorded them. The UI honors it: no knowledge button, no sent panel — rather than empty ones.
The clamp callout
action/amount on a record are the model's parsed choice — already clamped. So a trace without #4 would show a raise of 200 when the model asked for 500, and the owner would rewrite a strategy that was never at fault.
The callout is deliberately conservative: it fires only when both amounts are known and differ. Models return prose and fenced blocks; an unreadable response reports no divergence rather than guessing. A false "your action was changed!" would send an owner hunting a clamp bug that doesn't exist — worse than silence.
Invariants
| E-AFT-1 | Every outbound message goes through sendToTable (CI grep) |
| E-AFT-2 | Capture never blocks, delays, or fails a turn |
| E-AFT-3 | #1 comes from buildTurnContext's arguments, never a curated list |
| E-AFT-4 | Records are agent-keyed; owner reads require agentId |
| E-AFT-5 | Durable growth tracks pinned hands, not turns |
| E-AFT-6 | Counts and ids on spans, never content (#1 carries hole cards) |
| E-AFT-7 | A buffer-TTL'd key never gates a durable read |
What this made visible
Reconnect amnesia. The agent's memory is per-connection instance state and nothing rehydrates it: after a hosted-agent-service restart, an agent reconnects mid-hand with an empty action log and no hand history — it can see the pot but not who built it, and has lost every opponent read. It keeps playing.
One symptom of this was already found and patched in isolation (handCount resetting); the rest of the memory has the same defect and was never touched.
Capturing #1 does not fix it. It makes it legible instead of looking like bad strategy — which is the argument for the whole plan in one sentence.
Recorded at .indusk/research/agent-reconnect-amnesia.md; the fix is restart-recovery work.
Full ADR: .indusk/planning/archive/agent-full-transcript/adr.md Guide: Agent Decision Capture