fix(history): stop shipping cumulative request history in session detail; page-scope list payload lookups - #803
fix(history): stop shipping cumulative request history in session detail; page-scope list payload lookups#803scottwofford wants to merge 1 commit into
Conversation
…ail; page-scope list payload lookups Session detail responses repeated the full conversation prefix inside every turn (agent clients re-send history each request), growing O(turns^2) with session length. The dedup the activity-monitor frontend already performed client-side (slice by previous turn's count) now runs server-side; policy- modified turns keep full arrays for diff alignment with request_delta_start marking the boundary. Exports apply the same boundary. Session list queries probed request payloads table-wide (PG: final_model + max_tokens gates detoast every stored payload; SQLite: every request payload for page sessions crossed into Python). Both backends now fetch models, previews, and user_ids via page-scoped lookups; the PG preview uses a LATERAL LIMIT 1 so only the first qualifying payload per page session is read; the SQLite preview keeps one row per session inside SQLite via ROW_NUMBER. Measured: 200-turn synthetic session detail JSON 27.2MB -> 0.56MB (49x); markdown export 23.3MB -> 0.44MB. Verified against real Postgres 16 (migrations 001-021, list/user/model filters, pagination, dedup). Addresses https://trello.com/c/vhlQyEO0 (Trajectory unblocker). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewSolid, focused fix. The problem statement (cumulative-history-as-per-turn payload), the semantics-preserving contract ( A few things worth addressing before merge, ordered by impact. 1. JSONL export leaks the fix for modified turns (bug)In "request_messages": [m.model_dump(mode="json") for m in turn.request_messages[turn.request_delta_start :]],
...
if turn.original_request_messages is not None:
record["original_request_messages"] = [m.model_dump(mode="json") for m in turn.original_request_messages]For modified turns, Suggested fix (assuming both arrays have the same delta boundary, which is the design invariant): record["original_request_messages"] = [
m.model_dump(mode="json") for m in turn.original_request_messages[turn.request_delta_start :]
]Add a JSONL test analogous to 2.
|
|
Post-review note (adversarial verification pass, Jul 7): the headline 49x / 27.2MB→0.56MB figure was a one-off local measurement, not a committed reproducible benchmark; the committed regression test ( |
|
Claude-generated merge-queue triage of all open Luthien PRs, requested by Scott (Jul 7, 2026). Advisory only; Scott has not yet acted on these recommendations. Recommendation: hold; close once #795 is merged and verified. This revises the Jul 7 run-report suggestion to merge this PR first. Reasoning:
Not closing yet on purpose: if #795 review stalls for multiple weeks, this PR is the fallback interim unblock (fix the export asymmetry first, then merge, and ask for a rebase of #795). |
Summary
Fixes the admin-dashboard slowness Sami reported (May 12-15, #trajectory-luthien): "Loading the history takes many, many seconds. Trying to load a transcript, I think, can take like a minute." Addresses Trello card Fix UI slowness on the activity monitor (Trajectory unblocker).
Per Paolo's May 15 diagnosis, the root problem is admin-client response payload bloat under realistic agent-session volumes. This PR is the minimal, semantics-preserving unblock for both slow paths:
1. Session detail (
GET /api/history/sessions/{id}) — the "transcript takes a minute" path.Agent clients (Claude Code) re-send the full conversation history on every request, so each stored
transaction.request_recordedpayload is cumulative. The endpoint returned every turn's full parsed history, growing the response O(turns²). The dedup the frontend already performed client-side (presentTurnsslicing by the previous turn's message count) now runs server-side (_dedup_cumulative_request_messages): unmodified turns carry only their new messages; policy-modified turns keep full arrays (so original-vs-final diff panels still line up index-by-index) with a newrequest_delta_startfield marking where the new messages begin. Rendered output is unchanged. Markdown/JSONL exports apply the same boundary, so they stop repeating prior turns' history too.Measured on a 200-turn synthetic session (~1KB tool results): detail JSON 27.2 MB → 0.56 MB (49x); markdown export 23.3 MB → 0.44 MB. The ratio grows linearly with session length — at the 1,771-turn scale reported in #795 this is the difference between ~69 MB and well under 1 MB per load.
Bonus fix: a turn whose request shrinks (context compaction) previously rendered as empty in the viewer (client slice past the end); it now displays its full compacted request and resets the dedup baseline.
2. Session list (
GET /api/history/sessions) — the "history takes many seconds" path.The Postgres list query probed
ce.payloadfor everytransaction.request_recordedrow in the table (thefinal_modelextraction and themax_tokensprobe-gate in thesession_models/session_first_messageCTEs), detoasting every stored cumulative payload on every page load. The SQLite path shipped every request payload for the page's sessions across into Python. Both backends now use the same 5-query shape: a metadata-only page aggregate, then models / previews / user_ids lookups keyed on the page's session_ids. The PG preview lookup usesLATERAL ... LIMIT 1(walks each page session's events increated_atorder, stops at the first non-probe payload); the SQLite preview keeps one row per session inside SQLite viaROW_NUMBER. Response fields and filter semantics are unchanged, including the user-scoped preview/models isolation.API note
ConversationTurngainsrequest_delta_start: int = 0.request_messagesnow contains only the turn's new messages for unmodified turns (full arrays, with the boundary marked, for modified turns). The bundled viewer (conversation_live.js) is updated in this PR; external consumers of the detail endpoint that relied on cumulative per-turn arrays will see deltas instead. JSONL/markdown exports likewise no longer repeat prior turns per turn.Relationship to open PRs
history_list.html(stilllimit=10000) to avoid colliding with feat(ui): cursor pagination + lazy loading for admin dashboard #752's frontend rework. With the payload probes gone, the remaining list cost is the metadata aggregation, which feat(ui): cursor pagination + lazy loading for admin dashboard #752's pagination further bounds.session_summaries-backed list read in feat(history): bound memory + O(turns) detail with turn pagination #795 likewise supersedes the list portion here if adopted. Reconciliation flagged for review rather than decided unilaterally in an overnight run.Root cause (RCA/COE)
test_payload_size_grows_linearly_not_quadraticallylocks the O(total messages) contract for the detail response into the default unit pass, and the new dedup tests pin the modified-turn / preflight / compaction edge cases. The dedup rule now lives in one server-side place with the invariant documented, instead of implicitly in a frontend comment. For the list path, both backends now share the page-scoped lookup shape, and the query comments state the "payload reads must be proportional to the page" invariant explicitly. Landing feat(perf): performance test harness, Server-Timing middleware, and baseline evidence #753's harness would add end-to-end regression detection at realistic volumes; recommended as the structural follow-up.Verification
./scripts/dev_checks.shgreen (ruff, pyright, full unit suite: 2848 passed)../scripts/run_e2e.sh sqlite(47 passed) andmocktier green.nav.js$cleanuperror is pre-existing on main).Remaining known costs (out of scope, tracked by open PRs)
limit=10000— feat(ui): cursor pagination + lazy loading for admin dashboard #752 addresses this.🤖 Generated with Claude Code