Skip to content

Instrument prompt composition so the trace can answer "full of what" #2323

Closed

Description

@Astro-Han

Edited after @ARE404's review: the original body missed that /context already ships this breakdown, and claimed a retention cost that does not exist. Both are corrected below; the thread holds the reasoning.

Problem

The session trace's context bar answers "how full is the context" but not "full of what". Today it can only split the prompt into cacheRead / fresh — a billing dimension, because that is the only breakdown SessionTrace carries. A reader who sees 84% full has no way to learn which tools they could trim.

This is a data gap, not a styling gap. #2289 took the panel as far as the current data allows; every further visual iteration there redistributes the same two numbers.

What already exists

More than the first draft of this issue credited.

  • PreparedRequestSegment (request-shape.ts:58) is captured per item at the provider seam — one segment per tool, per system instruction, per message — each with kind, index, bytes, cacheable, hash and (for messages) role.
  • Those segments are persisted whole on provider_request_attempt_recorded (agent-run.ts:412).
  • readLatestContextDiagnostics (context-diagnostics.ts) reads them back, folds the four kinds into system_instructions / tool_definitions / messages / other, and estimates ceil(bytes / 4).
  • formatContextDiagnostics (pi-tui-runner.ts:2732) already renders that to a user, under "Estimated breakdown", every figure prefixed ≈.

So the kind-fold and the estimate policy are settled and shipped in the CLI. What is missing for the desktop panel is a per-tool label and a read.

Settled

  1. Segment kinds stay four. The actionable resolution is not a fifth kind, it is inside tool_schema: "tool definitions ≈ 40%" is a fact nobody can act on, "Bash ≈ 8k, WebFetch ≈ 6k" is a decision. The tool objects at the seam are ai-sdk's and already carry name (provider-request-telemetry.ts:617); preparedSegment discards it. One optional field recovers it.
  2. message is not split. Messages reach the seam already serialized, so skill / memory / subagent attribution would have to be carried down from assembly. Note the asymmetry: system_prompt is cheap to split — system-prompt/ has five named contributors upstream — if that split ever earns itself. Separate question, not this one.
  3. Composition is never drawn inside the bar. It is an estimate over serialized JSON bytes summing to requestBytes; cacheRead/fresh are provider-reported and sum to inputTokens. They are not two views of one total, and stacking them would make the estimate read as a decomposition of the reported number — exactly what [RFC] Canonical model-call accounting: one ModelCallAttempt record per real provider request #1679 forbids. Inherit the CLI's structure: sourced figures, then a separate ≈-prefixed block.
  4. Absent composition renders as a gap, never as zero. provider_request_attempt_recorded is appended best-effort (agent-run.ts:414) while ModelCallAttempt is deliberately durable, so "metered, but no composition on record" is reachable — as is the pi backend, which emits neither. This is the rule the coverage notice already implements one layer up.
  5. The trace is the right home. Retention: a segment list per attempt multiplies it. It does not. SessionTrace is not persisted — readSessionTrace (inspector-ipc-main.ts:83) projects it on every read from the run event ledger and already walks every event of every run filtering by type. Recognizing a second event type on that walk stores nothing new and costs approximately nothing, so no second read path is needed.

Gate

If the per-tool label is not added, this does not ship: the four-slice breakdown alone is not actionable, and #2289's bar is already the honest answer to "how full".

Path

  1. label?: string on tool_schema segments, from the tool's own name at capture — @ARE404.
  2. Composition on the trace projection, read from the segments already on the ledger — @ARE404.
  3. Renderer: a ≈-labelled block below the bar, per-tool rows under the tool total.

(3) is stacked on (2) and cannot be built or reviewed without it.

Activity

  1. ARE404 commented on Aug 6, 2026

    @ARE404
    Contributor

    Before the three-PR path starts, one correction to "what already exists" that changes the size of the first PR: this breakdown already ships, in the CLI.

    /context (#1580) renders exactly the per-category composition this issue asks for, from the same segments:

    • Segments are persisted whole on provider_request_attempt_recorded (agent-run.ts:323 — segments is a field of ProviderRequestAttemptRecord).
    • readLatestContextDiagnostics (packages/runtime/src/context-diagnostics.ts) reads them back and folds the four kinds into system_instructions / tool_definitions / messages / other, each with bytes and estimatedTokens = ceil(bytes / 4).
    • formatContextDiagnostics (packages/cli/src/pi-tui-runner.ts:2732) prints it under "Estimated breakdown".

    So the issue is right that PreparedRequestSegment the type is only referenced by provider-request-telemetry.ts and provider-request-trace.ts — but the data already reaches a user-facing surface with a settled kind-fold and a settled estimate policy. The runtime PR of the sketched path is close to empty unless we decide to split message.

    With that, on the four questions:

    1. Segment kinds. The four-kind fold has shipped precedent, and I'd keep it — but the actionable resolution isn't a fifth kind, it's inside tool_schema. Segments are already emitted per item (one per tool, one per message, each with index and role — request-shape.ts:199-219), so per-tool bytes exist at capture time; contextSegmentEstimates is what discards them, at read. Adding an optional label (the tool name) to tool_schema segments is a small runtime change and is the only thing here that answers "which tools do I trim".

    Splitting message is the expensive direction and I don't think it's knowable at the seam: messages arrives there as already-serialized unknown[], so skill / memory / subagent attribution has to be carried down from assembly. Worth noting the asymmetry — the system prompt does have named contributors upstream (packages/runtime/src/system-prompt/: personalization, project context, workspace instructions, session environment), so splitting system_prompt is materially cheaper than splitting message, if a split is wanted at all.

    2. Bytes vs tokens — one hazard the bar has that the CLI's list doesn't. Segment bytes is the byte length of each item's serialized JSON: it includes JSON syntax, and for image/attachment parts (base64) bytes / 4 is not close to a token count. More importantly the two numbers sum to different things:

    • composition sums to requestBytes
    • cacheRead / fresh sums to the provider-reported inputTokens

    A list can hold both apart; one bar cannot — stacking them invites the reader to treat the estimate as a decomposition of the reported number, which is exactly the #1679 rule. The CLI's structure is the precedent to inherit: a "Usage" block where every line names its source (provider-reported / request-model snapshot / calculated), and a separate "Estimated breakdown" block where every figure is prefixed ≈. I'd render composition as its own labelled block under the bar, not as segments of it.

    3. Where it lands. I don't think it needs to be on the SessionTrace contract at all. readLatestContextDiagnostics already demonstrates the alternative: a command-time read over the AgentRun ledger for the latest completed request only — no new field, no retention multiplication. Two caveats to state if we go that way:

    • It carries its own // ponytail: command-time O(session ledger) note; the Inspector reads on a refresh policy, not once per command, so that cost lands differently.
    • provider_request_attempt_recorded is appended best-effort, while ModelCallAttempt is deliberately durable ("Durable, unlike the diagnostic attempt append above: this is the metering source of truth"). So "metered, but no composition on record" is a reachable state, and the panel has to render it as a gap rather than zero. Same for the pi backend, which emits neither record.

    4. What the reader does with it. Only the per-tool number is actionable. "Tools are 40%" doesn't tell anyone which tool to drop; "Bash 8k / WebFetch 6k" does. I'd make that the gate rather than a follow-up: if we're not adding the tool label, the four-slice breakdown probably doesn't earn the widening, and #2289's bar is already the honest answer to "how full".

    Happy to take the instrumentation PR (tool label on tool_schema segments + a read the Inspector can call) if the shape above is what you want.

  2. Astro-Han commented on Aug 6, 2026

    @Astro-Han
    ContributorAuthor

    Verified every claim in this against the source before replying — all six hold, and two of them change the plan.

    On /context already shipping this. Confirmed: contextSegmentEstimates (context-diagnostics.ts:142) folds the four kinds and estimates ceil(bytes / 4); formatContextDiagnostics prints it under "Estimated breakdown" with a ≈ on every figure. You're right that the sketched runtime PR is close to empty. Thank you — the issue was written from the type's reference graph and missed that the data already lands on a user-facing surface.

    On the tool label. Confirmed, and it's cheaper than you stated: the tool objects reaching the seam are ai-sdk's, each already carrying name (provider-request-telemetry.ts:617). The name is present at capture and discarded by preparedSegment, which keeps only kind/index/cacheable/hash/bytes/role. So it is one optional field, not a plumbing change.

    I agree with making it the gate. "Tool definitions ≈ 40%" is a fact nobody can act on; "Bash ≈ 8k, WebFetch ≈ 6k" is a decision. If we are not adding the label, #2289's bar is already the honest answer and this issue should close.

    On not splitting message. Agreed, and the asymmetry you noted is the useful part: system-prompt/ has five named contributors upstream (personalization, project context, workspace instructions, session environment, sandbox context), so that split is available whenever it earns itself. Filing it as a separate question rather than scope here.

    On the two numbers. Agreed, and this is the constraint I'd write into the issue as non-negotiable: composition is an estimate over serialized JSON bytes and sums to requestBytes; cacheRead/fresh are provider-reported and sum to inputTokens. They are not two views of one total. Stacking them into one bar would make the estimate read as a decomposition of the reported number, which is exactly what #1679 forbids. The CLI's two-block structure — sourced figures, then a ≈-prefixed estimate block — is the precedent to inherit.

    On where it lands — one correction, and it is mine. The issue's question 3 said a segment list per attempt "multiplies retention". That is wrong, and your command-time alternative was reasoning from it. SessionTrace is not persisted: readSessionTrace (inspector-ipc-main.ts:83) projects it on every read from the run event ledger, and already walks every event of every run filtering by type. Recognizing a second event type on that same walk costs approximately nothing and stores nothing new. So there is no retention argument against putting composition on the trace, and no need for a second read path with its own O(session ledger) note.

    What does survive from your caveat is the durability asymmetry, and it is the sharper point: provider_request_attempt_recorded is appended best-effort (agent-run.ts:414) while ModelCallAttempt is deliberately durable ("a record lost to a crashed flush is spend nothing else can reconstruct"). "Metered, but no composition on record" is therefore a reachable state, as is the pi backend emitting neither. The panel must render that as a gap. That is the same rule the coverage notice already implements one layer up, so the pattern exists.

    Revised shape, if this matches your read:

    1. label?: string on tool_schema segments, set from the tool's own name at capture.
    2. Composition on the trace projection, read from the segments already on the ledger; absent when the best-effort record is not there, never zero.
    3. Renderer: a ≈-labelled block below the bar, not bands inside it; per-tool rows under the tool total.

    Yours if you want it — the shape above is what I'd review against. Happy to take the renderer half if you'd rather stop at the runtime and contract.

  3. Astro-Han commented on Aug 23, 2026

    @Astro-Han
    ContributorAuthor

    Closing as completed by #2346. The merged implementation added per-tool composition labels, exposed the shared context diagnostics through the Runtime Host, rendered the breakdown in both Desktop and CLI, and kept the canonical model-call attempt as the durable authority with an atomic rebuildable projection. The final head had all required checks green, including Desktop E2E and Storybook.\n\nTriage and wording were prepared with OpenAI Codex and reviewed by the maintainer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions