Skip to content

Agent Decision Capture

Every hosted-agent turn makes an LLM call — a full assembled prompt in, a completion out. Agent decision capture records that call so a developer (and, later, the bot's owner) can reconstruct the full story of any recent turn: what the agent was told, what it decided, and why.

This is the data substrate of the "agent lab" family. It's built to be cheap (deduped + TTL'd, not a firehose), byte-faithful (what you read is exactly what the model saw), and invisible to the turn (best-effort — a capture failure never blocks or fails an agent's action).

What's captured

For each real-inference turn (the check-else-fold fallback path is not captured — a failed turn isn't a decision), we store four things:

#WhatWhere it's taken
1What the agent knew — the table it held, this hand's actions, recent hands, notes/whispersbuildTurnContext's arguments
2The prompt it sent — exact bytesthe assembler
3The response it got — raw completion, before JSON-parsingthe LLM call
4The decision — the messages that actually reached the tablesendToTable

Keyed by (agent_id, table_id, hand_number, action_index) — the same grain as agent_inference_charges, so a captured turn joins its cost/charge row.

Together these answer the question the owner actually has when a decision looks wrong. Without #1 and #4 you cannot tell these apart:

  • 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 clamped or corrected on the way out

Those need different fixes, and #2/#3 alone can't distinguish them.

"What the agent knew" is its memory, not a message

There is no wire message that says here's the hand so far, what do you want to do? The agent assembles its knowledge:

  • game_state frames overwrite latestState — the last one wins, no history
  • your_turn carries only {legalActions, chipRange, turnEndsAt}no table state
  • player_action frames append to currentHandActions — this is the only way it knows the player before it raised to 40. The state shows the result (chips down, pot up), never the verb.
  • hand_result frames accumulate handHistory (capped at 20)

So the composite exists only inside the agent process, only at that instant. Capturing it is the only record of what the agent actually had — as opposed to what we think we sent it. Those differ more often than you'd like: an agent reconnected mid-hand keeps playing with an empty action log and no hand history — it can see the pot but not who built it — and from the outside that is indistinguishable from bad strategy. (Recorded as a finding at .indusk/research/agent-reconnect-amnesia.md; capture makes it legible, it does not fix it.)

#1 is taken from buildTurnContext's argument object rather than a hand-written list of fields. That function renders the prompt, so its arguments are the definition of what the agent knows. A memory source added later — a new skill, a configurable history depth — reaches the prompt through there, so it lands in the trace automatically instead of being silently absent until someone notices.

"The decision" is what left, not what was parsed

action/amount are the model's parsed choice. #4 is the raw messages that went out. When a choice is clamped or corrected between the two, they disagree — and that gap is the whole difference between "bad agent" and "bad clamp".

Every outbound message goes through one door, sendToTable, 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.

Messages sent outside a turn (enter, sit, stand, post_blind) pass through the chokepoint but create no turn record — they have no prompt and no response, so they aren't decisions. The buffer is scoped to a turn: it resets when your_turn arrives and drains when the turn's record is written. Both halves matter, and falsification found out why the hard way:

  • Without the reset, a turn whose inference failed (sent a safe action, never captured, so never drained) bled into the next turn's decision — the trace claimed two actions on one turn. And enter carries the agent's Ed25519 agentToken, so the very first turn's decision shipped a live credential into the buffer and, on flag, Postgres.
  • Credentials are stripped before buffering, never before sending — the wire needs the token, the transcript never does. Redacted, not deleted: a vanished field reads as "the agent never sent one".

"What it knew" is frozen, not a live view

#1 snapshots the memory rather than referencing it. actionLog and 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 is deliberately kept by reference: it is replaced on each frame, never mutated, so it's already frozen.

The repeated-envelope dedup

~70–80% of every prompt is byte-identical turn to turn (the base prompt + strategy + skill sections don't change within a session; only the game state and hand history move). Storing that repetition on every turn would be a firehose (~0.5 GB/day on a busy table).

So the rendered prompt is split into two parts:

  • envelope — the repeated prefix (basePrompt + [Strategy] + strategy + agent-skill sections). Content-addressed by sha256 and stored once per distinct hash. When a skill, the base prompt, or the strategy changes, the bytes change → a new hash → a new envelope is stored automatically; old captures still reconstruct against their original hash. "Repeated" ≠ "permanent".
  • remainder — the per-turn part (game state + hand history).

Reconstruction is remainder.length > 0 ? envelope + "\n" + remainder : envelopebyte-identical to what the model received. The split keeps the live prompt unchanged (a golden test proves the model still gets the exact same bytes).

Proven under load

On a local run with real bots, 1,353 captured turns were stored against just 7 envelope blobs — the dedup collapsing thousands of turns into a handful of prompt prefixes, at zero cost to fidelity.

The 24-hour hot buffer

Capture lands in a dedicated Redis (AGENT_CAPTURE_REDIS_URL), isolated from the turnstore and engine Redis so a capture spike can never evict must-keep turnstore state. Every key carries a TTL (CAPTURE_TTL_SECONDS, default 24h), so the buffer self-bounds — a day of recent history, then it evaporates. Anything worth keeping longer is pinned to durable storage (see the pin-to-durable section).

The flow

Pinning to durable storage

The 24h buffer is disposable by design. Anything worth keeping longer gets pinned: POST /internal/agent-capture/promote {tableId, handNumber, agentId} copies that agent's captured turns out of the buffer into the durable Postgres table agent_call_records, where they survive the TTL. (The admin viewer's "keep this hand", and the player-facing flag, call this.)

agentId is required and scopes the pin to one agent. It has to be: a pin used to copy every agent's turns for the hand, so one owner flagging a hand persisted their opponents' prompts — carrying those agents' strategies and reasoning — into durable storage. See "Whose turns can you read?" below.

The outcome is owner-scoped too. When nothing of yours is in the buffer, promote reports already_pinned vs nothing_to_capture by asking whether your agent has durable rows — not whether the hand does. Asking the hand-wide question told an owner their hand was saved because a rival's turns had been pinned, which is exactly the fabrication the honest-expiry discrimination exists to prevent.

  • Durable growth tracks pinned hands, not turns. Playing hands adds nothing long-term; only a pin writes durable rows.
  • Idempotent. Re-pinning the same hand inserts nothing new (unique on table_id, hand_number, action_index).
  • The envelope is stored inline per pinned hand — pinned volume is small, so no cross-hand dedup is needed durably; envelope + remainder still reconstruct the exact prompt.

getHandTurns reads the buffer and the durable store and returns the more complete set — so a pinned hand keeps returning all its turns even after its 24h buffer entry has partially or fully expired.

Pin within the window

Pinning is buffer-sourced — "Keep this hand" only works while the hand's 24h buffer is still live. Promote a hand whose buffer has expired and it reports "nothing to pin" (or "already pinned" if it was pinned earlier), never a false success. The recent-hands list is sourced from the persistent inference ledger, so it can show hands whose capture has already expired, and it marks fallback-only hands (all check-else-fold, zero real captures) — either will open to an empty turn stream.

Version attribution

Every captured turn names the published agent version that produced it (agent-decision-inspection Phase 1). The captureTurn write carries agentVersionId from the seat's AgentDeployment snapshot; promote stamps it onto the durable agent_call_records rows; the read view exposes it on every turn as agentVersionId.

Records captured before the tag existed (buffer records or durable rows with a NULL agent_version_id) resolve their version at read/promote time via a join to agent_inference_charges on (table_id, hand_number, action_index) — the charges row has carried the version since agent-versioning shipped, so no data migration was needed. A turn whose version cannot be resolved renders version-less rather than failing the read.

Reading it back

GET /internal/agent-capture/hand?tableId=&handNumber=&agentId= (service-key) returns one agent's captured turns for a hand — each with all four things (knowledge, prompt, response, decision), plus the parsed action, the model/routing, and an isFallback flag. All three params are required — the route 400s without a scope.

undefined means not captured — never render it as empty.

knowledge/decision are undefined on turns captured before agent-full-transcript shipped. That is different from [], which means "captured, and it sent nothing". A reader that collapses the two tells an owner their agent sent no messages when in fact we never recorded them — a partial loop presented as complete is the one outcome worse than a visible gap.

GET /internal/agent-capture/hand-all?tableId=&handNumber= (service-key) is the operator view: every agent's turns for the hand. The admin viewer uses this; the owner-facing path never does.

It spans both stores, and that matters: agents:{tableId}:{handNumber} is a buffer key, so it carries the 24h TTL, while pinned rows live forever. Driving the fan-out from the buffer set alone would make the viewer blind to any pinned hand older than a day — precisely the hands an operator opens the viewer to read. So the agent set is the union of the buffer's set and the distinct agent_ids on the hand's durable rows, and turns are deduped by (agentId, actionIndex) with the buffer winning.

A buffer-TTL'd key must never gate a durable read.

The agents: set is a lifetime-bounded optimisation, not the source of truth for who played a hand.

Whose turns can you read?

Operator sees all, owner sees own. That asymmetry is the boundary, and it is enforced by the key scheme rather than by a filter:

turn:{tableId}:{handNumber}:{agentId}:{actionIndex}   one captured turn
hand:{tableId}:{handNumber}:{agentId}                 that agent's action-index set
agents:{tableId}:{handNumber}                         the operator's fan-out set
env:{envHash}                                         the deduped envelope (shared)

Every per-turn key carries the agent, so an owner-scoped read touches only that agent's keys — a rival's prompt is never fetched, not merely filtered out. The read/promote functions take a required agentId, so a caller that omits the scope is a compile error rather than a leak.

env:{envHash} is deliberately not agent-scoped: it's content-addressed, dedup across agents is exactly what bounds storage, and a hash reveals nothing on its own — you need the (agent-scoped) turn record to reach it.

Durable Postgres rows are hand-scoped by nature — one table, all agents — so they can't be key-scoped; the durable read filters by agent explicitly. That also keeps rows pinned before this scoping existed unreadable.

Two agents share an actionIndex

actionIndex is the per-hand action counter across all players, so it is not unique per agent — it stays the join key to agent_inference_charges, and the agent segment narrows it. A key scheme without the agent segment (which is what shipped first) makes one agent's turns reachable while reading another's.

The admin viewer

The dev instrument lives at /agent-capture in the admin app (nav card on the home page). It's the fast way to answer "what was this agent thinking?":

  • A collapsible recent-hands history on top — the hands that had agent inference (from agent_inference_charges), each shown as a tableId:handNumber ID with a one-click copy button and a turn count. Collapse it to focus on one hand.
  • Hand-ID lookup — type a tableId + hand # (or click a history row) to load that hand.
  • The agent-turn stream below — per turn: the collapsible full prompt (the exact bytes the model saw), the raw response (including the agent's reasoning), the action taken, the model/cost, and a FALLBACK badge if the turn was a check-else-fold safe action rather than a real decision.
  • "Keep this hand" pins it to durable storage (survives the 24h TTL).

The admin app reaches HAS through the server-side /api/hosted-agent/* proxy (the x-service-key never touches the browser).