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
Draft
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'sui-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
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 asstep_tracemetadata on the assistant row that carries the step; retries discard the failed attempt, commits fold the durable unit. Usage is normalized at the seam soinput_tokenscounts every prompt token the provider billed (Anthropic reports it without cache reads/writes).tool_call_endevents and the persisted tool-call part (native, external runtimes from arrival, and the mid-stream retry path).run_traceon the lifecycle snapshot): request count, LLM/tool wall time, TTFT, decode throughput inputs, token totals; O(1) in steps.context_fragment_texts, migration0147): 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 onlytext_hashrefs.steering,prepared), stamped at the source so filtering cannot lose them.session_runs.turn_id, so trajectory rows join history turns exactly.API
GET …/context-lifecyclegains a keysetbeforecursor (next_cursor, dedicated continuation query),turn_id,run_trace, andfragment_previews(240-char heads, only for callers withworkspace_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 (requiresworkspace_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_appendspublish finished requests live;UITurn.step_traces,UIMessage.execution_timing,UITurn.context_injectionproject the persisted facts.Web
Bounds for long sessions
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.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
step_indexrepeats.Test plan
go test -tags integration): keyset cursor query, per-bot text rows and cascade on bot deletion, schema/RLS/composite-key guards.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
mise run dev, open a session with a few turns, then Open Trajectory from the session info panel./compact): a COMPACTION row appears on the model lane with its summary in the inspector.chaton 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 tomainand rebase.