Session Debug Workspace
One merged debug artifact per call, and a state inspector inside flow-studio — the observability payoff of the "global workspace" pattern, built as derived views over data riff already emits.
1 · Origin and framing
This started from the Transformer Circuits "global workspace" discussion — a small shared space every module can read. Auditing riff against that proposal showed riff already implements the workspace pattern in the superior, derived form: facts in session.slots decoupled from FSM position (types.py:590), missing fields derived per turn (never stored), policy vetoing LLM tool calls (the I-2 confirm-state ban), and the two main slot-write lanes — tool-handler diffs and transition sets/clears — emitted with attribution as slot_write {slot, old, new, kind, writer}. (Not every write: direct session.slots.update/.pop paths still bypass that choke point, which is exactly what UC5 makes measurable.)
What riff lacks is the workspace's observability payoff: one artifact answering "what did the system know, when, and why did it act?" — and a place in flow-studio where an operator sees it. The evidence exists but is scattered across four artifacts, two of which evaporate.
2 · What exists today (verified)
| Artifact | Contents | Live calls? | Eval runs? |
|---|---|---|---|
logs/turns.jsonlturn_logger.py:52 | One rich row per turn: utterance, assistant text, from/to state, verdict, latency, layers, slot_updates, tool_calls with results, public slots, post_turn_snapshot | ✅ | ❌ evals never call log_turn |
logs/state_trace.jsonlstate_trace_writer.py | Durable filtered events: state entered/exited, guard_evaluated (with slots_read), tool_called, transition_committed, turn_complete, … | ✅ | partial |
| Event-bus RAM ring events.py:73 | ALL events incl. slot_write and transition_verdict (winner + matched/no/shadowed candidates) | ⚠️ evaporates after ~500 events | ⚠️ same |
| Eval in-memory transcript flow_eval/driver.py:280–410 | Richest per-turn record in the codebase: per-turn state, missing_required_slots, fact_events, state_aspects | n/a | ⚠️ in-memory, gone after the run |
web/state-trace-panel.js | Real per-state inspector (guards, tools, transitions, stuck-state diagnosis) via /api/debug/state_trace — mounted in voice.html and post-call.html, not flow-studio | ✅ | ❌ |
| flow-studio replay/live flow-studio-replay.js | State-card glow, caller/agent text, tools line, judge score | ✅ | ❌ |
| flow-studio side panel flow-studio.js:1860 | Static authored required_slots chips only | no live data | no live data |
data/sessions/<sid>/bus_events.jsonl + scripts/call_replay.pyCall Replay Atlas, 2026-07-07 | Phone-teardown dump of this session's remaining bus events; static replay page with HEAR/PATH/SLOTS/VERDICTS lanes; eval transcripts render too (its R5) | ✅ phone only, truncates when the ring rolled | ✅ static page |
curl+jq) can hit mid-call or post-call. Same three artifacts underneath; A1 provides the durable event source and plan Task 1b merges it into the teardown dump (dump_session_bus_events, scripts/call_replay.py:300, today reads only the RAM ring), which is what makes the atlas's SLOTS/VERDICTS lanes lossless for long calls. The atlas's PII rule (no public slot values anywhere; <str:12>-style reprs survive rendering verbatim) is adopted unchanged.3 · The gaps
Gap 1 — scattered and lossy evidence. "Why did it ask for the address twice?" means hand-joining turns.jsonl + state_trace.jsonl + bus_events.jsonl. And the two most valuable event types — slot_write and transition_verdict — are not durable: they live in the 500-event RAM ring until teardown dumps what remains (phone calls only; web/console/sim sessions get no dump at all). A 40-turn call loses its early slot history precisely when it's the interesting call.
Gap 2 — flow-studio is blind. The editor where authors work shows a state glowing and a transcript — not the live triad (required / filled / missing at that turn), not guard evaluations, not transition verdicts. All of it already exists (get_state_snapshot returns missing_slots_in_state; the state-trace panel renders guards on voice.html) — it just isn't surfaced where authoring happens.
Gap 3 — eval/live schism. Evals compute the richest per-turn record in the codebase, then throw it away. Eval failures can't be scrubbed interactively next to the flow being edited, and there's no single canonical record format to diff an eval run against a live call.
4 · Design principles (constraints, not aspirations)
| Principle | Why | |
|---|---|---|
| 1 | Derive, don't store. Every new field is computed from ground truth at read/write time. | Stored derivations drift (this is why B-016 was fixable in one function). |
| 2 | Additive-only schema. schema_version: 2; missing = v1; readers ignore unknown fields; ONE server-side normalized reader. | turns.jsonl has existing consumers (replay UI, replay scripts). |
| 3 | Redaction mandatory for slot values in new fields via _slot_write_repr. | Deltas are higher-risk than snapshots — they highlight exactly what changed. |
| 4 | Eval rows never land in the live log. Run-scoped logs/evals/<run_id>/turns.jsonl. | Bloat, index pollution, /api/sessions/active latching. |
| 5 | No new turn-path latency. Event collection + one additional cheap get_state_snapshot() call where the caller holds a StateManager (snapshot_session is a different serializer and doesn't compute the missing list) + appends that already happen. | Live phone call; the budget is already spent on ASR/LLM/TTS. |
5 · The design — two phases
Phase A — merged debug view over existing logs (prove the shape)
A1 · Durable slot_write + transition_verdict. Two strings added to _RELEVANT_EVENT_TYPES (state_trace_writer.py:46). Written as events happen — lossless where the teardown ring-dump truncates, and covers non-phone sessions. Values are already redacted at the emit sites. The durable file alone doesn't change atlas output (call_replay.py reads only bus_events.jsonl) — plan Task 1b extends the teardown dump to merge it, deduped by the (seq, ts, type) key.
A2 · /api/debug/session_facts?session_id=X. One endpoint merging, per turn: the turns.jsonl row + required_slots_in_state and missing_required_slots (computed server-side from post_turn_snapshot against the flow's required slots — the same derivation is_state_complete uses; the triad needs the required list, not just the missing list) + slot_diff + guards_evaluated + transition_verdict, from all three event sources deduplicated by the (seq, ts, type) key the state-trace endpoint already uses. Per-state visit aggregation stays on the existing /api/debug/state_trace. The live voice path emits no turn_complete boundary today — plan Task 2b adds that emit; older live sessions group unboundaried events to the first turn (stated fallback). Pure merge function in riff/session_debug.py; thin handler.
{
"session_id": "voice-1777009875064",
"turns": [{
"turn_index": 2,
"user_utterance": "uh, two months",
"from_state": "collect_duration", "to_state": "collect_duration",
"required_slots_in_state": ["service_duration_minutes"],
"missing_required_slots": [],
"slot_diff": [{"slot": "service_duration_minutes", "old": null,
"new": "<str:8>", "kind": "tool", "writer": "update_slots"}],
"guards_evaluated": [{"guard": "all_state_slots_filled_and_valid",
"result": false, "slots_read": ["service_duration_minutes"]}],
"transition_verdict": {"winner": null,
"candidates": [{"to": "confirm", "when": "…", "result": "no"}]}
}]
}
A3 · flow-studio state inspector. Extend flow-studio's own side panel (decision: do not transplant state-trace-panel.js — it's visit-oriented and styled for voice.html; whole-panel reuse would rot). With a replay/live session attached: the scrubber's turn selects to_state; #spSlots switches from static chips to the triad (filled green, missing amber); new sections render guards (name, ✓/✗, slots_read) and the transition-verdict candidate table. One session_facts fetch on the existing 3-second polling cadence.
Phase B — canonical TurnRecord v2 + eval parity
B1 · TurnRecord v2 builder. log_turn's entry construction becomes build_turn_record(result, ctx, *, state_snapshot, turn_events, slots_before, kind, run_id). New optional fields: schema_version: 2, kind, run_id, missing_required_slots (from the post-transition snapshot), slot_diff, transition_verdict, guards_evaluated (capped at 24).
slot_write events collected during the turn (attribution: kind/writer). Reconciliation: before/after diff of session.slots; changes with no matching event are labeled kind: "unattributed_diff" — verified today, several live paths mutate slots directly, and the tool-diff lane skips dict-valued slots. An unattributed entry is a finding (a writer to move behind the choke point), not noise.B2 · Eval sink + replay. flow_eval/driver.py writes each turn through the same builder to logs/evals/<run_id>/turns.jsonl. The turns API gains a tolerant normalized reader (malformed lines skipped, v1 defaults stamped) and an opt-in way to load an eval run into the same flow-studio scrubber.
6 · Use cases — step by step
UC1 · "Why did Riff ask for the address twice?"
Today (~20 min, often inconclusive)
- Grep
logs/turns.jsonlfor the session; find both address turns. - See
slots.addressfilled at turn 3, absent at turn 6. The row doesn't say who cleared it —slot_updatesshows what the LLM proposed, not what the FSM cleared. - Grep
state_trace.jsonl, correlate timestamps by hand.slot_writeisn't there (RAM ring only); if the ring rolled before teardown: dead end. - Re-listen to the audio and guess.
After Phase A (~2 min)
- Open flow-studio
?replay=<sid>; scrub to turn 6. - Side panel
slot_diff:{"slot": "address", "old": "<str:14>", "new": null, "kind": "clears", "writer": "confirm_details->collect_address"}— the writer for transition sets/clears is the edge itself (from_state->to_state): a transition re-enteredcollect_address, whose entry clears the slot. - Root cause in three clicks: fix the re-entry clear or the transition, not the symptom.
UC2 · "Why didn't it move on?" — the call already cited in the code
Call voice-1777009875064 turn 2 (cited at types.py:632): caller answered a duration question with "2 months"; the FSM correctly refused to advance. The transcript alone can't tell an operator whether that's a bug.
After Phase A
- Open the replay, click turn 2's state card.
- Triad:
service_duration_minutesshows filled — so why re-ask? - Guards:
all_state_slots_filled_and_valid → ✗,slots_read: ["service_duration_minutes"]— the validator rejected the value. - Transition verdict:
→ confirmresultno. Nothing shadowed. Working as designed, visible without reading Python.
UC3 · Live test call while editing the flow
Today — flow-studio ?live=1 shows state glow + transcript; whether the address actually landed in the slot is unknown until post-call.
After Phase A
- Operator calls the test number, watches flow-studio.
- Caller gives name + address in one breath. Triad flips:
name✅address✅problem⏳ — early-volunteered facts visibly captured (or visibly not, which is the bug you wanted to catch live). - A garbage write shows immediately as a filled chip + failed validator guard — UC2's failure mode caught during the call instead of from a complaint.
UC4 · Eval regression triage (Phase B)
Today — CI prints a transcript; the atlas renders a static page if you have the transcript JSON. No interactive scrubber next to the flow being edited; no canonical record to diff against live calls.
After Phase B
- Run
eval-2026-07-07-a3ffails onplumbing_urgent_leak. - Load
logs/evals/eval-2026-07-07-a3f/turns.jsonlin flow-studio. - Scrub to the failing turn:
missing_required_slotsstill listsaddresswhile the persona already gave it —slot_diffshows the extractor never wrote it. - Fix, re-run, diff the two eval replays side by side.
UC5 · "Who wrote this slot?" — attribution audit (Phase B)
- Filter a day of turn records for
kind: "unattributed_diff". - Each hit names a code path that mutates
session.slotswithout the choke point. - Move it behind the choke point; the category empties. Write-discipline drift becomes a measurable burn-down list instead of silent corruption of the "canonical" record.
7 · Why this is useful (grounded)
- The pain is named in the code. Comments cite specific calls (
voice-1777009875064), specific bugs (B-181 "booked while nothing was written" is why tool_calls carry results), and buffer-aging failures (B-148: the editor lost live sessions because the ring aged outstate_entered). Every one of those investigations becomes a UC1/UC2 replay. - Eval gates get replayable evidence — every judged failure becomes a scrubbable artifact instead of CI text.
- Write discipline becomes observable (UC5) —
unattributed_diffmeasures the choke-point leak instead of letting it silently rot future records. - Zero drift risk, zero latency cost — every field derives from data already in memory or on disk.
8 · Risks and deterministic defenses
| Risk | Defense |
|---|---|
False canonical record — event capture misses writers; slot_diff looks authoritative but isn't | Dual-source reconciliation: the before/after diff catches every change, including a direct mutation of the same slot after an attributed write (final redacted shape reconciled against the last event's new); unmatched changes are labeled unattributed_diff, never dropped. Known limit: a mutation whose redacted shape exactly matches the last attributed value is indistinguishable. Test every known writer path. |
| Log growth / slow replay APIs — bulk evals × rich rows | Run-scoped eval sinks; summaries bounded (guards and candidates capped at 24, matching the emitter); rotation plan required before bulk eval logging defaults on. |
| Wrong turn/state attribution — events grouped into the wrong turn window | Group by turn_index where events carry it; else by turn_complete boundaries. Explicit tests: multi-transition turns, locked-choice short-circuit turns, delayed live turn_complete. |
9 · What we are explicitly NOT building
- A stored workspace object (
missing_fields/next_best_actionas state) — stored derivations go stale the moment the caller says "actually, it's the toilet." - A universal slot schema in the runtime — vertical knowledge lives in tree data (Tree DSL contract).
- Numeric LLM confidences — categorical outcomes only (
matched / no / shadowed, guard true/false). - Per-turn LLM reflection calls — the deterministic guards already do this in microseconds, without hallucinated "all clear"s.
- J-lens / activation-level interpretability — needs model internals hosted APIs don't expose; not a runtime debugging tool.
10 · Open questions
- Should
/api/sessions/activedrop thevoice-prefix heuristic in Phase B for akind-field filter? (Leaning yes; not blocking.) - Eval replay loading UX: query param vs picker in flow-studio — decide when B2 lands.
- Rotation for
state_trace.jsonlonceslot_writevolume lands (v1 punted rotation; A1 increases volume).