Skip to content

feat(agent,web): Trajectory pane with per-request traces, injected context texts, and compaction rows [stack 2/2] - #1157

Draft
ChrAlpha wants to merge 52 commits into
felinics:feat/context-usage-vizfrom
ChrAlpha:feat/trajectory-view
Draft

feat(agent,web): Trajectory pane with per-request traces, injected context texts, and compaction rows [stack 2/2]#1157
ChrAlpha wants to merge 52 commits into
felinics:feat/context-usage-vizfrom
ChrAlpha:feat/trajectory-view

Conversation

@ChrAlpha

@ChrAlpha ChrAlpha commented Sep 5, 2026

Copy link
Copy Markdown
Member

Summary

Adds a DeepSeek-Harness-style Trajectory pane to the chat workspace: one dock tab per session that shows, for every turn, what the model was given (system prompt, injected context, tool definitions), every model request with its server-observed timing and usage, every tool execution with its wall time, every compaction, and the per-fragment selection audit. The reference is deepseek-ai/deepseek-harness's ui-trajectory; the differences are deliberate: Memoh persists bounded facts on the rows it already writes instead of an append-only event log, and it can also show what was not injected and why.

Stacked on #1118 (the context usage display and the lifecycle storage boundary); this PR's diff is only the trajectory work. It replaces the Context Lifecycle Inspector dialog from #1118 with the Trajectory pane; the session info panel keeps Compact Now and gains Open Trajectory.

What is persisted

  • Step traces (internal/agent/runtime/native/step_clock.go, internal/agent/application/step_trace.go): every provider request is clocked at the provider seam (dispatch, first non-blank token, finish-step, monotonic elapsed time) and stored as step_trace metadata on the assistant row that carries the step; retries discard the failed attempt, commits fold the durable unit. Usage is normalized at the seam so input_tokens counts every prompt token the provider billed (Anthropic reports it without cache reads/writes).
  • Tool execution timing rides tool_call_end events and the persisted tool-call part (native, external runtimes from arrival, and the mid-stream retry path).
  • Run rollup (run_trace on the lifecycle snapshot): request count, LLM/tool wall time, TTFT, decode throughput inputs, token totals; O(1) in steps.
  • Injected context texts (context_fragment_texts, migration 0147): system prompt sections, workspace rules, tool usage, skills, identity, hook context, recalled memory, and every tool definition a run sends, stored once per bot per distinct text (TextHash(kind, text)), written off the turn path by a bounded writer (4 in flight, 30 s deadline, drop-and-retry-next-run). Rows belong to the bot and cascade with it. The lifecycle snapshot carries only text_hash refs.
  • Context injection markers on user rows the runtime injected (steering, prepared), stamped at the source so filtering cannot lose them.
  • Turn linkage: the lifecycle list joins session_runs.turn_id, so trajectory rows join history turns exactly.

API

  • GET …/context-lifecycle gains a keyset before cursor (next_cursor, dedicated continuation query), turn_id, run_trace, and fragment_previews (240-char heads, only for callers with workspace_read).
  • GET …/context-lifecycle/{run_id}/decisions: the per-fragment selection audit of one run, on demand.
  • GET …/context-lifecycle/{run_id}/fragments: the injected texts of one run (requires workspace_read; they include workspace files and hook output).
  • GET …/sessions/{session_id}/compactions: the session's compaction runs with covered span, rollup level, supersession, summarizer usage, and timing.
  • CurrentRunView.step_traces / RuntimeDelta.step_trace_appends publish finished requests live; UITurn.step_traces, UIMessage.execution_timing, UITurn.context_injection project the persisted facts.

Web

  • Overview strip: DSH's row map — one segment per ledger row on Input/Model/Tools lanes over the whole loaded window, duration-scaled (model bars split at first token) or equal width, turn gaps; click focuses the row. Windows beyond 400 segments fold consecutive turns into one bar per lane, so the strip stays bounded and proportional.
  • Ledger: virtualized rows in transcript order — SYSTEM, USER, CONTEXT (one per injected fragment kind, previewing the stored text), tool definitions, selection, mutations, per-step reselection, reasoning, tool (input → output, wall time), assistant, error, notice, and COMPACTION rows placed on the model lane between the turns they ran between.
  • Inspector: per-row facts; SYSTEM rows compare the prompt with the previous run (change label, per-fragment added/removed/changed list, bounded line diff of rewritten fragments) and show the audit of the system slot; history rows show which fragments were kept, trimmed or dropped and why; compaction rows show covered span, message count, timing, usage, and the summary.
  • Stats line with DSH's honesty rules: a reading renders only with exact evidence; unsampled groups disappear.
  • Reads the session's shared transcript window (no second copy of the conversation), pages lifecycle summaries by keyset cursor, keeps loaded older pages when a finished turn moves the first page and fills the one-run gap, and stops recomputing while the tab is hidden.

Bounds for long sessions

  • Per assistant row: one step_trace (~150 B) and a lifecycle copy without run trace, fragment refs, or tool hashes. Per run: one bounded snapshot row. Texts: once per bot per distinct text, not per turn or per session.
  • Web: only viewport + overscan rows are mounted; the strip mounts ≤ 400 bars; full rebuild measured at 5.1 / 18.8 / 89.5 ms per streamed frame for 300 / 1000 / 3000 loaded turns (rows are cached per unchanged turn), and nothing while the tab is hidden. Lifecycle pages are ~10 KB per run; the per-fragment audit and full texts load only for the selected row.

Security

The stored texts include workspace instruction files and hook output, which the file endpoints only show to workspace readers; previews and full texts are therefore gated on workspace_read (manage and workspace_write imply it), and the inspector says so instead of reporting a failure. Decisions and compactions need only session access.

Known gaps

  • A turn continued by a later run (tool approval, answered question) counts its steps from zero again; the web keeps the rows apart but the persisted step_index repeats.
  • Tools executed after an approval decision are not clocked.
  • No search or fold-by-turn in the ledger yet.

Test plan

  • Unit (Go): step clock and boundary pairing, tracker retry/checkpoint/take, observer relay terminal delivery under a stalled consumer, usage normalization, text hashing and per-run text hash refs, writer bounds and timeouts, per-bot text scoping, permission gating, lifecycle cursor, decisions/fragments/compactions handlers, subagent holder wiring, view projection of traces and injections.
  • Postgres integration (go test -tags integration): keyset cursor query, per-bot text rows and cascade on bot deletion, schema/RLS/composite-key guards.
  • Unit (web): row model (context rows, per-step rows, compaction placement, repeated step indexes, previous-run resolution), strip geometry and folding, prompt change detection and line diff, lifecycle page merging and gap filling, duration formatting, KeepAlive gating, ledger, i18n coverage of every fragment kind.
  • Live e2e on the dev stack (grok-4.6 via the gateway, workspace image trajectory-v3): per-step traces, tool timing, run trace, keyset paging, panel overview/ledger/inspector, live WS updates in a split dock, load-older, session deletion closing the panel.

QA

  1. mise run dev, open a session with a few turns, then Open Trajectory from the session info panel.
  2. The strip shows Input/Model/Tools bars over the loaded window; clicking a bar selects its ledger row.
  3. Select the SYSTEM row: the injected texts are listed with their labels, the prompt change against the previous run is labelled, and the system-slot audit is shown. Select a history CONTEXT row: kept/trimmed/dropped fragments with reasons.
  4. Send a message and watch the live turn: reasoning/text/tool rows appear with timing as each request finishes.
  5. Trigger a compaction (/compact): a COMPACTION row appears on the model lane with its summary in the inspector.
  6. As a user with only chat on the bot, the SYSTEM row's texts show the workspace-access notice instead of the texts.

Stack note

Base branch is feat/context-usage-viz (#1118's head, mirrored into this repository so the stacked diff shows only this PR). After #1118 merges, retarget this PR to main and rebase.

⚠️ No human QA — this PR has not been verified by a human yet. Remove this line once a human confirms the happy path.

Provider attempts are clocked at the provider seam (dispatch, first content
part, finish-step) and published as step_start/step_end events carrying the
request's usage and timing. The observer forwards parts like Twilight ranges
them so a cancelled provider still completes its blocked send.
Each native tool execution records started/ended wall clock under its call
ID; the timing rides tool_call_end events and the persisted tool-call part
through the existing execution metadata registry.
A step trace tracker takes finished provider steps inside the commit barrier
and stores them as step_trace metadata on each assistant row, mirroring the
reasoning timing path. Tool execution timing and terminal usage are observed
on the public event wire, which every runtime shares, and the lifecycle
snapshot gains a fixed-size run_trace rollup read at terminal time.
Persisted assistant rows expose their step_trace as UITurn.step_traces
anchored to the row's first block, tool blocks carry execution_timing, and
the live run view appends the same UIStepTrace per finished request so a
subscriber sees timing and usage while the turn is still running.
Steering interleaved at the terminal snapshot and context prepared before a
later step both persist a context_injection marker, and the history
projection surfaces it so readers can tell injected context from the
person's request.
The lifecycle list accepts an opaque before cursor bound to (created_at,
run_id) and returns next_cursor while older run-keyed rows remain, so a long
session pages in fixed-size summaries instead of widening one request. The
spec and SDK pick up the cursor together with the run trace, step trace,
execution timing, and context injection fields.
The runtime projection appends step_trace_appends onto the live run view and
drops them on reset; normalized turns keep step traces, tool execution
timing, and the context injection marker so any view can read them.
The lifecycle list joins the run ledger so each summary names the turn it
wrote into, giving readers an exact join to history turns instead of
guessing from assistant message ids.
Older lifecycle pages follow the server's next_cursor and accumulate beside
the refreshable first page, replacing the 50-to-200 limit jump.
A dockview Trajectory tab reads the session's shared transcript window and
lifecycle summaries: an overview strip with input, model, and tool lanes
(duration or sequence scale, model bars split at first token), a
virtualized ledger of system, user, context, assistant, reasoning, and
tool rows in transcript order, a local inspector for the selected record,
and a window-scoped stats line that omits anything never sampled.
… filtering

- release the observed stream when the consumer leaves after cancel
- discard finished steps a retry regenerates; fold the run rollup at commit
- stamp injected user messages at the source so steering survives filtering
- align step traces by assistant ordinal across empty-row filtering
- bound each trace by its last block id; keep run_trace off per-row copies
- clock external tool calls from their arrival at the transcript boundary
The first page keeps the plain session scan; only continuations carry the (created_at, run_id) predicate, so the planner never sees a nullable cursor.
- page lifecycle summaries only when the older page joins the loaded anchor
- keep the ledger anchored while older rows prepend; memoize row building
- continue turn numbering into the live turn; TTFT from the earliest sampled step
- rem-based virtual rows, load-older gating, error toasts, aria selection
- keep the pane alive across tab switches; close it with its deleted session
A wall-clock step between two readings produced a first token 305ms before its request in a live run. Every mark after the anchor now adds the clock's monotonic elapsed time instead of re-reading the wall clock.
isPanelAllowed had no trajectory case, so every layout restore closed the panel on reload.
The lifecycle page keeps only rolled-up counts; the trajectory inspector reads the content-light audit of one run on demand, after confirming the run belongs to the authorized session.
- the strip is DSH's row map: one segment per ledger record on its lane over the whole loaded window, duration or equal width, click focuses the row
- every turn leads with its system prompt and the context the manifest injected: rules, tool usage, skills, memory recall, history with its selection, tool definitions, mutations, and per-step reselection
- the inspector explains system and history rows with the run's per-fragment audit
- the context inspector dialog folds into the trajectory
Every injected fragment (system prompt, workspace rules, tool usage,
skills, identity) and every tool definition a run sends to the provider
is now stored once per content hash in context_fragment_texts, off the
turn path. The lifecycle snapshot stays content-light: it carries only
hash-addressed refs, while the fragment's name travels with its text.
…nt texts

The context lifecycle page now joins a short stored preview to every
fragment and tool definition hash it carries, and
GET .../context-lifecycle/{run_id}/fragments returns the full texts of
one run, labelled as the assembler named them.
SYSTEM and CONTEXT rows preview the stored fragment text instead of
counts, and the inspector lists every fragment of the selected group
with its label and full text, including tool definitions. Runs recorded
before texts were stored say so instead of showing nothing.
The dock registers the trajectory panel statically, so its whole module
graph rode along with every chat workspace import; a full vitest run
pushed the workspace init test past its timeout. The pane now loads
when a trajectory tab first renders.
The canonical fragment hash covers the run scope (session, external
message id, event id), so the "content-addressed" text store received a
fresh copy of every system fragment per session on the web and per
message on channels. Texts are now keyed by a hash of the kind and the
rendered text alone; the lifecycle snapshot's fragment refs carry that
text_hash beside the fragment's own content_hash, and the API and the
trajectory join on it. A ref without a text hash stored no text.
The text store had no owner: nothing deleted a row when its bot or team
went away, and the team foreign key even blocked team deletion. Rows now
belong to the bot whose runs rendered them, keyed (team, bot, text hash),
and cascade with the bot. Reads are scoped to the session's bot, so one
bot cannot resolve another bot's texts by hash.
The fragment texts and the page previews carry workspace instruction
files, hook output, and the full prompt, which the file endpoints only
show to workspace readers; session access alone (a chat grant on a
session one created) handed them out. The previews now ride the page only
for callers with workspace_read on the bot, the full-text endpoint denies
everyone else, and the inspector says so instead of reporting a failure.
Every assistant row carried the run's tool definition list with a 64-hex
hash per tool, doubling the row's lifecycle metadata for facts the run
row already holds. RowCopy now strips the hashes together with the run
trace and fragment refs, and the subagent paths use the same copy.
…store

Subagent runs built a plain lifecycle holder, so their snapshots carried
fragment refs whose texts were never stored and the trajectory showed
every one of them as unavailable. The spawn adapter now takes a holder
factory, and the service supplies holders bound to the run's bot.
Memory recall lands in the history slot, which the text store treated as
conversation the history already keeps; but no persisted message holds
the recalled text, so the trajectory's memory row could only show counts
and refs. Recall is now recorded like any injected fragment, one row per
distinct recall text.
One goroutine per batch with no deadline let a slow or unreachable
database accumulate writers and their payloads for as long as runs kept
coming, and the record of written hashes grew for the life of the
process. Batches now share four writers with a thirty-second deadline; a
batch that finds none free is dropped and written by the next run that
renders the same text, and the remembered set starts over past a bound.
… previous one

The observer relay reads one event ahead of the consumer, so a consumer
that stalled longer than the terminal window while the relay held the
previous event lost the terminal: the run's own send into the relay timed
out, the turn was recorded as ending without a terminal event, and the
compatibility paths persisted nothing. The run now hands the terminal to
the relay without a deadline, the relay applies the window on the
consumer's side, and ordinary events drop on cancellation as they did on
the direct channel.
The public step_end took whatever request the seam had completed most
recently, so when the event loop ran a step behind the seam a live trace
carried the next request's timing while the persisted one was right.
Completed records now queue in order and each finish-step takes the
oldest.
The mid-stream retry loop published tool_call_end without the execution
metadata the main loop attaches, so every tool run after a retry had no
timing on the wire: the live view showed none and the run rollup fell
back to the gap between call and result, which includes the model's
streaming time.
Anthropic reports input_tokens without cache reads and writes while
OpenAI-style providers include cached tokens, so the same cache-hit
ratio read above 100% on Anthropic-backed bots and their input totals
undercounted. Usage is normalized once at the provider seam to a gross
input count; the persisted step trace, the live step_end event, and the
terminal usage all carry it.
The gap budget went negative past 167 turns, which gave every bar a
negative width and emptied the strip, and the 1% floor flattened
duration mode into sequence mode past a hundred segments. Gaps now share
a bounded total, the floor never exceeds an equal share, and a window
with more rows than the strip can show folds consecutive turns into one
bar per lane that focuses the group's first row.
…first page

Every finished turn shifted the first lifecycle page by one run, which
changed its cursor and made the composable drop every older page the
reader had loaded: in a session past fifty runs the older turns lost
their system and context rows after each turn and had to be paged in
again. Older pages are immutable keyset slices, so they stay; the run
that fell between the two is fetched as a small gap page and folded in.
The system-category kinds and the current message had no context-kind
label, so a run recorded before texts were stored showed raw i18n keys in
its system row's text list. Every SDK kind is labelled in all three
locales, a test keeps it that way, and two unused keys are gone.
Seconds were rounded after the minutes were floored, so 119.6 seconds
read "1m 60s" and 59.96 seconds read "60.0s". Rounding now happens on
the unit shown, and spans past an hour get an hour unit.
…ctory

The system row's inspector passed only the current run to the lifecycle
card, so its prompt-change label always read "initial". The row now
knows the previous run of the session, the card labels the change, the
inspector lists which fragments and tool definitions were added, dropped,
or rewritten from the hashes both snapshots carry, and a rewritten
fragment shows a bounded line diff once both runs' texts are loaded.
…token

KeepAlive keeps a deactivated trajectory pane's effects running, so a
hidden tab paid the full row, strip, and stats rebuild for each streamed
token of the session. The derivations now gate on the pane's KeepAlive
activation, keep their last value while hidden, and catch up when the
tab is shown again.
Compaction runs were persisted per bot with no way to read the ones of
one session, and the log shape hid when the summary's covered span
began and ended. A session-scoped endpoint returns them newest first
with the anchors, rollup level, supersession, summarizer usage, and
timing, so a trajectory can place each compaction where it ran.
A compaction is a model request the ledger never showed: the summary
that replaced part of the history, when it ran, and what it cost were
only reachable through the bot-wide log page. The trajectory now reads
the session's compactions, places each one on the model lane between the
turns it ran between, previews its summary in the ledger, and the
inspector shows the covered span, message count, timing, summarizer
usage, and the full summary. A finished turn refetches them with the
lifecycle page.
…trajectory

A turn continued by a later run, after a tool approval or an answered
question, counts its steps from zero again, so two requests of one turn
shared a step index: their per-step context rows repeated under one key
and the reselection rows were listed twice. The step's context now
belongs to its first occurrence and later blocks keep their own rows.
Picks up the budget plan window check (felinics#1118 review) so the stacked base stays current.
The per-run audit and the injected texts were fetched under new query
keys that the cache persistence did not exclude, so workspace files and
hook output landed on disk and the audit rejoined the synchronous
whole-cache serialization that felinics#1118 had moved it out of. The three
keys are excluded like the lifecycle page.
A text over the store's byte limit was cut by byte count, so a limit
falling inside a multi-byte character produced invalid UTF-8 and
Postgres rejected the whole batch. The cut now backs up to a rune
boundary, and invalid bytes anywhere in a text are replaced first.
Draining the failed stream before a retry ran its finish-step through
the provider seam, which completed a record that the retried request's
finish-step then took, so the live step_end carried the failed attempt's
usage and timing. The seam abandons the failed request before the drain
and the retry reset drops any record no finish-step consumed. Reproduced
with the mock provider and with Twilight's OpenAI completions provider
over an in-memory transport.
The row cache keyed a streaming turn on its last block only, so a tool
that finished while a later parallel tool was still running kept its
row and inspector at "running" with no output or timing. The signature
now covers every block.
Compact Now invalidated only the session status, so an open trajectory
showed the new COMPACTION row and the rewritten context only after the
next turn or a remount. The lifecycle page and the compaction list are
invalidated with it.
The gap fill fetched one page of eight runs and moved the anchor on, so
when more than eight turns finished between refetches the runs beyond
the page were never shown and no cursor could reach them. The fill now
follows its cursor until a page repeats a loaded run or nothing older
exists, up to six pages, and releases the older window when it cannot
bridge the gap.
A spawned run published timed step_end events live but never installed
a run-trace source or stamped step_trace on its persisted rows, so once
the child session was read from history every request lost its timing
and usage. The step-commit factory now returns the observers of a step
trace tracker beside its callbacks; the spawn adapter chains them into
the run config, the committer takes the finished steps inside its
barrier, and the lifecycle snapshot carries the rollup.
The session compactions endpoint returned the newest fifty with no way
to reach older ones, so a long session's trajectory could never show
its early compactions. The endpoint now takes a limit and an opaque
before cursor over (started_at, id) and reports has_more and
next_cursor like the lifecycle page.
Compactions were read as one fixed page, so the rows of a long session's
early compactions never appeared however far back the reader loaded.
The composable keeps older keyset pages beside the refreshing first
page, fills the one-page gap when the first page moves on, and the
trajectory's load-older pulls compactions together with the transcript
and the lifecycle summaries.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant