Перейти к основному содержимому

Status: BUILT 2026-09-12 (Phases 1, 3 and 5 of the AI context + trace refactor), validate-green; the turn graph and the recorded ancestry landed 2026-09-13; the ai.conversation_stepai.model_call rename DDL has been applied. Phase 2 (profiles, profiles.md) sits under it; Phase 4 (the pointer-layer retirement) landed the same day; Phase 5 gave the deskbot page the same inspector.

What this is

Every chatbot and deskbot turn now leaves one recorded account of itself: what was available to it, what each grounding source considered, what entered each model call's request, which tools ran with what result, how the provider attempts went, and what the answer cited. It is recorded from the actual execution — never a second run — streamed to the client while the turn runs, written once when it finishes, and read back by the turn's owner.

Before this, the chatbot streamed pipeline:* events as message-metadata and dropped them; conversation_step held tokens/provider/model/duration per SDK step and nothing else; chatbot tool calls were never persisted; a reloaded thread had no citations; the full prompt text was gated to dev builds and admins; the showcase's "Context orchestration" was a separate run.

The contract

$lib/types/turn-trace.ts — framework-free, imported by the server recorder, the chat components and the showcase's turn inspector.

TurnTrace
├─ messageId · conversationId · surface · requestId · profileVersion · outcome · errorKind
├─ timings        preStreamMs · embedMs · generateMs · firstTokenMs[] · finalize{catalog,persist,budget}
├─ awareness      locale · authCeiling · page (chatbot) · scopes · workspace · layout · panels (deskbot)
├─ activations    { id: CapabilityId, active, reason }   every capability's verdict for the turn
├─ blocks         { id, capability, section, text, chars, stable }   the system prompt, block by block, WITH text
├─ grounding      per source: ran · skippedReason · error · pool · cutoff · retrievers · items[]
│                 item: id · kind (chunk|catalog|map) · title · score · rank · state · blockId · omittedReason · path · catalog
│                       chunk place: parentId · level · position · contentHash · retriever   tool-surfaced: toolCallId
├─ history        the windowed history as sent — parts sized (text, tool_call, tool_call_response, compaction), never quoted
├─ toolset        the tool definitions as the model received them (description + JSON schema)
├─ modelCalls     one per provider request: attempt × step · usage · request outline (blockIds · historyCount · toolResultIds · toolsOffered) · response outline
├─ toolExecutions one per tool execute: input · model-facing output (post-compaction, ≤ 8 KB) · status · duration · compaction ref
├─ attempts       the provider rotation: started | ok | rotated | failed | cancelled, with the error kind
├─ citations      { itemId, source, match: path | quote | provider_source | unsurfaced }
├─ proposalId     the proposal a deskbot turn stopped on (read side: `proposal`, resolved by id — see Reading)
└─ createdAt · bodies (inline | persisted | redacted)

Five item states on every grounding item: available (the profile manifest's inventory — never a trace item) → considered (retrieved or ranked this turn) → included (in a model call's request) → executed (a tool execution returned it; toolCallId names which) → cited (the answer names its path or quotes it). Inclusion never claims influence. An item the prompt left out says why: below_cutoff (ranked past the top-N) or size_cap (cut by the block's size cap). An unsurfaced citation is a project path the answer named that nothing this turn produced — the answer's claim, not the turn's.

A chunk's place (2026-09-13). Tier 1 returns each hit with its parentId, level (section · paragraph), position, contentHash and the document's sourceUri (retrieval/tiers/contextual.ts); chunkPlace() (ai/capabilities/chunk-place.ts) records them on the item together with the retriever that produced it, and the source records the retrievers that ran. The read route resolves a recorded parentId to the parent as it stands now (parent: level, position, size) — current metadata, labelled so, never the turn's own account; a parent that is gone leaves it absent. The tiers the kernel has not taught yet (parent-child, graph) record no place: the inspector says "not recorded".

What each request carried. ModelCallRequest.toolResultIds names the tool calls whose results were in the request (the earlier steps' round trips, read from the prompt's tool-result parts by the middleware). Absent on a call recorded before the field existed — not recorded, which is what the graph then says — and empty when none. A tool-surfaced catalog row or desk chunk carries the toolCallId of the execution that returned it (the sinks in profile.ts take it from the tool's execute options).

TurnTraceSnapshot is the same shape with every key always present and the bodies withheld (block text, tool I/O, tool definitions): what rides the wire as message.metadata.trace. The client deep-merges metadata objects and replaces arrays, so a key that vanished would linger — hence "always all keys". TurnSummary is what a conversation's turn list carries so a reloaded thread renders its citation chips without the full trace.

Recording

One author: src/lib/server/ai/trace/recorder.ts (createTurnRecorder). Each part of the turn reports what only it sees; the recorder folds it into one trace.

Who Reports Recorder call
Orchestrator, before the branches the windowed history outline history()
Tier 1 (searchContextual), through chunkPlace() each candidate's parent, level, position, hash, path and retriever inside grounding()
composeTurn (both surfaces, profiles.md) the awareness, every capability's verdict, every block with its text and its owning capability, every grounding source with its candidates and what the prompt took (per-lane timing), the tools mounted awareness(), activation(), block(), grounding(), timing(), toolsOffered()
trace/model-call-middleware.ts (wrapLanguageModel) each provider request as it leaves: system-prompt hash, history count, the tool results it carries (toolResultIds), tools with schemas, tool choice, provider options callStart()
onStepFinish usage (incl. cache reads), finish reason, response id/model, text size, tool calls, warnings callEnd()
onChunk (first token) ms to the call's first streamed token firstToken()
experimental_onToolCallFinish + the desk step loop input, model-facing output, status (incl. requires_approval), duration tool()
wrapToolsWithCompaction a result replaced by a ref (resolve_ref is the model's route back) compacted()
streamTextIntoOpenMessage hooks attempt start, rotation, final failure, cancellation attemptStart(), attemptEnd()
afterTextcomposition.verify() (both surfaces) citations (the catalog's path check, unsurfaced paths included), tool-surfaced grounding — the catalog rows the chatbot's tools returned, the desk chunks desk_search_knowledge put in front of the deskbot (desk source) — as executed items naming their toolCallId; finalize timings, outcome citations(), grounding(), timing(), outcome()
the desk approval boundary the proposal's id, the awaiting_decision outcome proposal(), outcome()

The middleware and the step hook each open/close the same call record by order (one open call at a time); when the middleware could not wrap the model (a gateway id, a v2 provider, a test mock), callEnd() opens the record itself without the request outline. The hook names are the SDK's; the recorder's are the lifecycle's, so the v7 renames touch the orchestrator's adapter lines only.

profileVersion hashes the stable blocks' text and the tool definitions: identical across turns of one profile, so together with cacheReadTokens it shows whether the prefix is being cached.

Streaming

recorder.subscribe(flush): every change schedules one message-metadata frame per 16 ms burst (METADATA_FLUSH_MS), carrying { trace: recorder.snapshot() } — plus harness (the PlanCard) on the deskbot. Nothing else rides beside the trace. Explicit drains keep the order exact: before the first model frame, before finish. Nothing is written once the message is closed (closed), so no frame ever lands after finish or after an error frame — that would open a second, empty message on the client.

Client consumers (composites/chatbot/turn-progress.ts): turnProgress() reads the status row's stage from the snapshot (retrieving → catalog → generating); citedCatalogSources() turns cited catalog items into CitationChips; turnFinished() says when the snapshot's last attempt has ended — the moment the showcase can open the turn from its persisted row.

Persistence

Written once, in one transaction (saveTurnTrace), inside afterText after the answer row is backfilled and before finish; a turn that ended on an error frame is written from the stream's onError. Insert-only: a jsonb column updated per step would rewrite its whole TOAST value each time.

Table One row per Holds
ai.turn assistant message (PK = message_id) everything above except the calls and executions; user_id denormalized so the read route checks one column
ai.model_call (was conversation_step) provider request (attempt × step) usage, provider/model, duration, request and response outlines, outcome
ai.tool_call tool execution tool_call_id (the SDK's), model_call_id, ordinal, args, model-facing result, status, duration, compaction

ai.message.parts stores the assistant message's parts (tool parts included, rebuilt from the trace) so a reloaded thread renders the tool rows the answer was built on. The dead conversation_step.retrieval_events and message.context columns are gone. Conversation totals are summed from model_call.

Retention (retention/schedule.ts, jobs/ai-telemetry-retention.ts), the mcp.call_log pattern: redact the bodies at 30 days (ai-turn-bodies: turn.blocks, turn.history, turn.toolset, model_call.request, tool_call.result; redacted_at stamped), delete the rows at 180 days (ai-turns, ai-model-calls, ai-tool-calls). Conversation delete cascades; user delete cascades through the conversation. The privacy report counts turns.

Reading

Route Answers Guard
GET /api/ai/conversations/[id] messages (with parts) + turns[] summaries owner
GET /api/ai/conversations/[id]/turns/[messageId] the full TurnTrace, grounding bodies resolved by id for [SYSTEM_DOCS_USER_ID, viewer], drifted when a body's live hash differs from the recorded one, a recorded parentId resolved to the parent as it stands now (parent), bodies: 'redacted' after the redact pass; a deskbot turn's proposal resolved by proposalId (resolveTurnProposal: the card's steps derived from the tool as the PlanCard's were, the frozen scopes, the lifecycle status as it stands now, the receipts of what the approval ran — a proposal that names another message is left unresolved) owner (the turn row's user_id AND the path's conversation; a miss on either is 404)

The turn records the proposal's id only: the row keeps moving after the turn (approved → executing → executed | failed, or rejected, or expired), so the trace shows where it stands when read, next to the awaiting_decision outcome the turn ended on.

The owner sees their own turn entirely: prompt bodies, desk context, tool I/O, the chunk text they were shown. The dev/admin gate on prompt text is gone (decision D1). Provider internals (keys, raw errors) are never recorded.

The turn inspector (Phases 3 and 5) and the turn graph (2026-09-13)

/showcases/ai/chatbot#orchestration and /showcases/ai/deskbot#orchestration open ONE turn of their surface — a committed fixture signed-out, any of the viewer's own turns signed in — and render it three ways over one selection: the turn graph, the tree (the accessible alternative) and the timing waterfall. Selecting anywhere opens the same detail pane, which takes space only while something is selected. The turn line above them names the question, the outcome, the calls, the tools, the citations and the provider.

The turn graph

Three fixed columns ($lib/showcases/ai/turn-graph.ts, the pure projection; its geometry in turn-graph-layout.ts; rendered by _components/turn-graph/* through the shared FlowDiagram wrapper on a desktop canvas and as three stacked lists under 768 px):

Column Cards, top to bottom
Sources one per grounding source; expanded: its documents → a section (the parent chunk, read-side current metadata) → the chunks; the omitted candidates folded into one row
Context and model calls the system prompt (one card; expanded: every block as a one-line row in cache order, the stable/per-request boundary a divider between them), the conversation and question, one card per model call, the answer (or the proposal a deskbot turn stopped on)
Tools the inventory — declared · offered · called as counts, a badge on every row that ran, failed or was not offered — then one card per tool execution

Every edge is a recorded relation; the graph never infers one. containment is where a record sits; the data-flow kinds are inclusion (item → block, by blockId), request (block → call by request.blockIds; the history → every call whose request counted messages; the last ok call → the answer), execution (call → tool execution, by modelCallId), result (execution → a later call only when that call's toolResultIds names it; execution → an item only when the item's toolCallId does) and citation (answer → item, for a citation that resolved; an unsurfaced citation is a count on the answer card, never an edge). A fact the record does not carry renders as "not recorded" (flags.unknown). Folding lifts an edge onto the nearest visible ancestor and merges what coincides (×n). The model-call selector marks what the chosen request was made of and which tools it offered; declared, offered and called stay separate facts.

Geometry is a pure function of the visible graph (layoutTurnGraph, routeEdges) — fixed sizes per kind and variant, columns at fixed x, nothing measured, so a test asserts it:

  • A node with visible members is a group: a header row and its members stacked inside, each depth stepping in by the group's padding and tinted deeper (Source › Document › Section › Chunk; System prompt › Block). The nesting is the containment — a containment edge is never drawn on the canvas; the list writes nothing for it either.
  • No edge crosses a card. Between adjacent columns an edge takes a track in the gutter (its one vertical run; runs that overlap in y take different tracks, 8 px apart). Inside a column an edge runs down that column's rail (a shared line just left of it, read as a bus with taps — the prompt and the history into every call, the last call into the answer). Two columns apart (a tool's result surfacing an item) an edge takes a detour beneath the columns. Data flow carries an arrowhead; containment is a box in a box; the legend under the canvas stays in view.
  • Expanding never refits: the canvas grows with the graph (to 88 vh) and the zoom stays where the reader had it. A detail pane opening or closing refits to the highlighted subgraph, never below a readable floor (0.8). Fit everything is the reader's own button on the canvas, and the only fit allowed below that floor. Every node carries its size as measured: the flow counts a node initialized only once measured, and a node object the parent replaced would otherwise wait for a resize that never comes, leaving every queued fit unresolved.

On the chatbot page the inspector follows the thread above it (TurnInspectorState.follow): a running turn is on the page from its streamed snapshot (inspectedFromSnapshot, bodies pending), a finished one — or a resumed thread's last turn, which carries only its summary — is read from its persisted trace (a 404 is read again briefly: the row lands before the finish frame, but the client is not made to depend on it). A turn picked by hand, or opened from an "Inspect this turn" link, switches following off until the viewer asks for it again. A live turn that is loading, failed or not picked shows exactly that; the fixture never stands in for it. profileDrifted() compares the recorded identity, guidance and tool definitions with today's profile fact by fact — the two version hashes are built from different inputs and never agree.

The tree walks the five states in commitment order:

Group Source Nodes
Available GET /api/ai/profiles/[surface] (a fixture carries its own copy) identity · every capability with its activation verdict and its tool definitions (description + JSON schema) · the grounding inventories
Awareness trace.awareness locale, ceiling; chatbot: the page the question was asked from; deskbot: the granted scopes, the workspace, the panel layout, the panels handed to the prompt with their sizes
Considered trace.grounding every candidate per lane with its state chip, score, rank, the cut it fell to; a lane that did not run says why (deskbot: the desk chunks desk_search_knowledge surfaced, or scope_off)
Prompt trace.blocks, trace.history every block with its text — <desk-context> included (the tape in #prompt reads the same list); the history outline
Model calls trace.modelCalls, trace.toolExecutions, trace.attempts request outline, usage, cache reads, finish reason; tool input and model-facing output (a gated tool's requiresApproval sentinel shows as requires_approval); the rotation
Proposal (deskbot only) trace.proposal the plan the turn stopped on — status, risk tier, frozen scopes, expiry, approval and execution times — with one child per planned step: tool, risk, recovery, reviewed target, and its receipt (ok · failed · conflict with the replay's output, or "not run")
Cited trace.citations the deterministic string facts, each resolved to its item

Every node opens content, never a count. The spine (#spine), the guard chain, the prompt tape and the deskbot's approval lifecycle all render the same trace — spineOf() derives the band statuses from the recorded facts (the deskbot's gate band is the door: skipped when no proposal, active while pending, not-taken when the human rejected it or let it expire, done otherwise); a trace exists only past the guard, so the guard chain of a turn that exists has passed. The projection is pure ($lib/showcases/ai/inspector.ts): the fixtures, a live turn and the recording script share it.

Sources. recorded: scripts/ai/record-turn-fixture.ts reads a real dev turn through the same owner-guarded routes the page uses — with the session cookie (--cookie), or from the four responses saved out of a signed-in browser (--from <dir>; the cookie is httpOnly, so the page's own fetches are the door that copies no secret) — renames every id with scrubTurn() (demo_*; the leak gate refuses UUIDs, createId shapes and key shapes) and writes the fixture module (--surface deskbot --out … for the deskbot's two). authored: the hand-written stand-in a fixture is until a turn has persisted — its identity, guidance, tool definitions and the composer's own blocks (catalog map and current page; permissions, workspace, desk context and layout) are the profile's real texts; its ranking, timings, model calls, plan and receipts are not, and the provenance strip says so. The deskbot page carries two: the plan halt (proposed through desk_propose_plan, then approved and run — receipts and all) and the sentinel denial (a gated tool refused before anyone was asked; the proposal is still pending). live: the viewer's own turn, fetched by id — from the picker, from "Inspect your latest Vely turn" on the chatbot page (its ids ride on the streamed snapshot), or from the "Inspect this turn" link every finished answer carries in Vely and in the desk's chat panel (?conversation=…&turn=…#orchestration; the owner-guarded routes decide what the ids may show).

The probe (POST /api/ai/context-probe, ContextProbe.svelte, the galaxy) is gone: it was a second run reporting ids and counts. The frame scrubber (TracePlayer, reduceTurn, the SurfaceReplay frame fixtures) is gone from both pages (decision D4, then Phase 5): a recorded turn is read, not replayed.

What it closes

  • P5 of the plan: the trace was ephemeral, partial and gated.
  • Chatbot tool calls are persisted; reloaded threads show tool rows and citation chips.
  • The fabricated vectorHits/bm25Hits split and the degraded pipeline:chunks event are gone with the pipeline:* events; the recorder is the only trace author.
  • P3: the prompt tape reads a turn's recorded blocks; the PROMPT_BLOCKS mirror and its text-scan drift test are gone.
  • P6: the showcase shows real turns — recorded, or the viewer's own — never a simulation.
  • The docs candidate pool is recorded from the real turn (12 candidates, top 4 in the prompt), so "what ranked below the cutoff" no longer needs a second run.

Not recorded

Citation quote matching (a verbatim span of a grounding body in the answer) has a place in the contract and no producer yet; provider_source waits for a provider that returns grounding metadata. The approval replay (POST /api/ai/proposals/[id]/approve) is a separate request and leaves no model call — its account is the proposal's receipts. The parent-child and graph tiers return no chunk place yet (RankedChunk leaves it absent), so a deskbot turn's desk chunks carry no ancestry. A model call recorded before 2026-09-13 has no toolResultIds: the graph draws no result edge into it and says so.

← Back to Blueprint

Думаете, этот паттерн можно сделать лучше? Расскажите как.

Оставить отзыв