You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug]: V2 stores tool-result image bytes twice per item, and no client reads them #16647
I searched existing issues and did not find a duplicate.
I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
Start a Claude thread on main and ask it to Read a PNG (a 114 KB file is enough).
Measure the stored item: SELECT length(payload_json) FROM orchestration_v2_projection_turn_items WHERE type = 'dynamic_tool', and the matching turn-item.updated row in orchestration_events.
Expected behavior
The image bytes are not stored, or are stored only where something reads them back. A Read's preview loads from viewedImagePath. Context handoff skips tool items. The only image bytes a client fetches are the blocks toolOutputImages finds, served as tool-output-image assets (#16199).
Actual behavior
The full base64 body is stored in both tables. The Read above stores 153,028 bytes in orchestration_events and 153,028 bytes in orchestration_v2_projection_turn_items, for an image no client loads from there. Claude stores it as output.file.base64, Grok as rawOutput.ImageContent.data and Cursor as content[].image.data.
In one real 9 GB database, image-bearing tool rows total 2,313 MiB. 2,271 MiB of that are images no client reads; 42 MiB are screenshots the asset route serves.
The database grows by tens of MB a day for anyone whose agents read screenshots or images, and the bytes are never reclaimed.
I checked this by reading the code, not by running it. Nothing between the provider adapter and SQLite removes tool-result image bytes, and the only thing that serves image bytes back out doesn't recognize these shapes.
Where the bytes come in, unchanged
Claude: claudeToolResultEntriesFromMessage passes message.tool_use_result through as the structured output (apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts L2217, claudeNativeToolOutputFromStructuredResult L1906–1915). That value then lands on the dynamic_tool item as output (L3934, L3989). The Read preview uses viewedImagePath (L3920–3926), not these bytes.
ACP (Grok): rawOutput ?? content goes to output as-is (AcpAdapterV2.ts L3253, L3329 via acpMcpToolCallOutput L797–810, and L3355 for read).
Cursor: cursorToolOutput returns result.value unchanged (CursorAdapterV2.ts L362–368, used at L1245–1247 and L1294–1296).
dynamic_tool.output is Schema.Unknown in both the domain schema and the JSON schema (packages/contracts/src/orchestrationV2.ts L1570, L2345).
Where it gets written twice
ProviderEventIngestor.ts L463–471 wraps the turn item into a turn-item.updated domain event without changing it.
orchestration_events: appendAgentEvents writes the encoded payload to payload_json (apps/server/src/persistence/OrchestrationEventStore.ts L433–473).
orchestration_v2_projection_turn_items: the turn-item.updated projector writes encodeTurnItemPayload(event.payload) to payload_json (ProjectionStore.ts L2360–2405, encoder L991–993).
compactEventStore deliberately skips turn-item.updated (ProjectionMaintenance.ts L239, L297–308), so the event-log copy is never cleaned up.
Why nothing reads them
readToolOutputImage only matches a top-level { type: "image", data, mimeType } or { type: "image", source: { type: "base64", … } } block (packages/shared/src/toolOutput.ts L203–214). Claude's output.file.base64, Grok's ImageContent.data and Cursor's content[].image.data don't fit either shape.
Because of that, the tool-output-image asset route never returns them (apps/server/src/assets/AssetAccess.ts L226–238 → toolOutputImages, toolOutput.ts L230–239). The client asset list behaves the same way (packages/client-runtime/src/work-log/itemDetail.ts L208–219).
The timeline projection drops output (WireProjection.ts L113–121).
The on-demand detail read (projectTurnItemForDetail → boundToolOutput, WireProjection.ts L155–166, L181–186) uses omitToolOutputImageData, which doesn't recognize these shapes either. So up to 256 KB of the base64 still goes over the wire on a detail read. For the Claude shape, the client then shows it as [image] (itemDetail.ts L28). That's transport, not use.
Suggested fix
Remove or replace unrecognized inline image payloads once, when the item is ingested, before both writes. The adapters or ProviderEventIngestor are good places for this. Keep a small marker such as { type: "image", mimeType, omitted: true } so the stored rows stay tiny.
Optional: convert the provider shapes to the recognized block shape, but only if those images should appear as assets. That's a separate choice.
Reclaiming bytes that are already stored (a rewrite or migration plus VACUUM) is a separate storage decision for maintainers. The fix going forward doesn't depend on it.
Activity
juliusmarminge commentedon Oct 7, 2026
Note
Grok responding on behalf of Julius.
Triage: confirmed on current main (
517188b)I checked this by reading the code, not by running it. Nothing between the provider adapter and SQLite removes tool-result image bytes, and the only thing that serves image bytes back out doesn't recognize these shapes.
Where the bytes come in, unchanged
claudeToolResultEntriesFromMessagepassesmessage.tool_use_resultthrough as the structured output (apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.tsL2217,claudeNativeToolOutputFromStructuredResultL1906–1915). That value then lands on thedynamic_toolitem asoutput(L3934, L3989). The Read preview usesviewedImagePath(L3920–3926), not these bytes.rawOutput ?? contentgoes tooutputas-is (AcpAdapterV2.tsL3253, L3329 viaacpMcpToolCallOutputL797–810, and L3355 forread).cursorToolOutputreturnsresult.valueunchanged (CursorAdapterV2.tsL362–368, used at L1245–1247 and L1294–1296).dynamic_tool.outputisSchema.Unknownin both the domain schema and the JSON schema (packages/contracts/src/orchestrationV2.tsL1570, L2345).Where it gets written twice
ProviderEventIngestor.tsL463–471 wraps the turn item into aturn-item.updateddomain event without changing it.orchestration_events:appendAgentEventswrites the encoded payload topayload_json(apps/server/src/persistence/OrchestrationEventStore.tsL433–473).orchestration_v2_projection_turn_items: theturn-item.updatedprojector writesencodeTurnItemPayload(event.payload)topayload_json(ProjectionStore.tsL2360–2405, encoder L991–993).compactEventStoredeliberately skipsturn-item.updated(ProjectionMaintenance.tsL239, L297–308), so the event-log copy is never cleaned up.Why nothing reads them
readToolOutputImageonly matches a top-level{ type: "image", data, mimeType }or{ type: "image", source: { type: "base64", … } }block (packages/shared/src/toolOutput.tsL203–214). Claude'soutput.file.base64, Grok'sImageContent.dataand Cursor'scontent[].image.datadon't fit either shape.tool-output-imageasset route never returns them (apps/server/src/assets/AssetAccess.tsL226–238 →toolOutputImages,toolOutput.tsL230–239). The client asset list behaves the same way (packages/client-runtime/src/work-log/itemDetail.tsL208–219).output(WireProjection.tsL113–121).projectTurnItemForDetail→boundToolOutput,WireProjection.tsL155–166, L181–186) usesomitToolOutputImageData, which doesn't recognize these shapes either. So up to 256 KB of the base64 still goes over the wire on a detail read. For the Claude shape, the client then shows it as[image](itemDetail.tsL28). That's transport, not use.Suggested fix
ProviderEventIngestorare good places for this. Keep a small marker such as{ type: "image", mimeType, omitted: true }so the stored rows stay tiny.toolOutputImagesalready recognizes, because thetool-output-imageasset route (fix: tool screenshots show as images, not base64 text #16199) needs them.Related
tool-output-imageasset path for recognized blocks. It doesn't cover these shapes.preview_snapshotscreenshots, which are the images the asset route does serve. Neither covers this.