| author | |
| committer | |
| log | b77108bc41506ad7ba28839d9a8e04ba79fe340b |
| tree | 2d08bd1b2ceccb9f5d7b739aa00ff4280f92f5c0 |
| parent | 453e596da7e98d973e3853a5c2728681dedf363b |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 488 insertions(+), 15 deletions(-)
apps/server/src/assets/AssetAccess.ts+1-5| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import type { AssetResource } from "@t3tools/contracts"; | 1 | import type { AssetResource } from "@t3tools/contracts"; |
| 2 | import { | 2 | import { |
| 3 | AssetAttachmentNotFoundError, | 3 | AssetAttachmentNotFoundError, |
| 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | ||
| 5 | AssetGitHubMediaUrlValidationError, | 4 | AssetGitHubMediaUrlValidationError, |
| 6 | AssetPreviewTypeValidationError, | 5 | AssetPreviewTypeValidationError, |
| 7 | AssetProjectFaviconInspectionError, | 6 | AssetProjectFaviconInspectionError, |
| ... | @@ -33,7 +32,7 @@ import { | ... | @@ -33,7 +32,7 @@ import { |
| 33 | } from "@t3tools/shared/imageDimensions"; | 32 | } from "@t3tools/shared/imageDimensions"; |
| 34 | import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; | 33 | import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; |
| 35 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; | 34 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; |
| 36 | import { toolOutputImages } from "@t3tools/shared/toolOutput"; | 35 | import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput"; |
| 37 | import * as Clock from "effect/Clock"; | 36 | import * as Clock from "effect/Clock"; |
| 38 | import * as Crypto from "effect/Crypto"; | 37 | import * as Crypto from "effect/Crypto"; |
| 39 | import * as Effect from "effect/Effect"; | 38 | import * as Effect from "effect/Effect"; |
| ... | @@ -216,9 +215,6 @@ const optionOnNotFound = <A, R>( | ... | @@ -216,9 +215,6 @@ const optionOnNotFound = <A, R>( |
| 216 | }), | 215 | }), |
| 217 | ); | 216 | ); |
| 218 | 217 | ||
| 219 | // The largest image a provider turn accepts, as base64 (4 characters per 3 bytes). | ||
| 220 | const MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH = Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4; | ||
| 221 | |||
| 222 | /** | 218 | /** |
| 223 | * Decodes one image a tool returned inline; null when the stored item has no | 219 | * Decodes one image a tool returned inline; null when the stored item has no |
| 224 | * such image, or it is larger than a provider turn accepts. | 220 | * such image, or it is larger than a provider turn accepts. |
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+92| ... | @@ -27,6 +27,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -27,6 +27,7 @@ import * as Layer from "effect/Layer"; |
| 27 | import * as Stream from "effect/Stream"; | 27 | import * as Stream from "effect/Stream"; |
| 28 | import * as TestClock from "effect/testing/TestClock"; | 28 | import * as TestClock from "effect/testing/TestClock"; |
| 29 | 29 | ||
| 30 | import { toolOutputImages } from "@t3tools/shared/toolOutput"; | ||
| 30 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 31 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 31 | import * as EventSink from "./EventSink.ts"; | 32 | import * as EventSink from "./EventSink.ts"; |
| 32 | import * as EventStore from "./EventStore.ts"; | 33 | import * as EventStore from "./EventStore.ts"; |
| ... | @@ -1169,6 +1170,97 @@ layer("ProviderEventIngestorV2", (it) => { | ... | @@ -1169,6 +1170,97 @@ layer("ProviderEventIngestorV2", (it) => { |
| 1169 | }), | 1170 | }), |
| 1170 | ); | 1171 | ); |
| 1171 | 1172 | ||
| 1173 | it.effect("stores tool image bytes only where a tool-output-image asset serves them", () => | ||
| 1174 | Effect.gen(function* () { | ||
| 1175 | const now = yield* DateTime.now; | ||
| 1176 | const eventSink = yield* EventSink.EventSinkV2; | ||
| 1177 | const eventStore = yield* EventStore.EventStoreV2; | ||
| 1178 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; | ||
| 1179 | const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; | ||
| 1180 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | ||
| 1181 | const threadEvent = yield* threadCreatedEvent(now); | ||
| 1182 | const providerSessionId = yield* idAllocator.allocate.providerSession({ | ||
| 1183 | providerInstanceId: modelSelection.instanceId, | ||
| 1184 | threadId: threadEvent.threadId, | ||
| 1185 | }); | ||
| 1186 | const readBase64 = Buffer.alloc(30_000, 7).toString("base64"); | ||
| 1187 | const screenshotBase64 = Buffer.alloc(20_000, 9).toString("base64"); | ||
| 1188 | const toolItem = ( | ||
| 1189 | id: string, | ||
| 1190 | ordinal: number, | ||
| 1191 | toolName: string, | ||
| 1192 | output: unknown, | ||
| 1193 | ): OrchestrationV2TurnItem => ({ | ||
| 1194 | id: TurnItemId.make(id), | ||
| 1195 | threadId: threadEvent.threadId, | ||
| 1196 | runId: null, | ||
| 1197 | nodeId: null, | ||
| 1198 | providerThreadId: null, | ||
| 1199 | providerTurnId: null, | ||
| 1200 | nativeItemRef: null, | ||
| 1201 | parentItemId: null, | ||
| 1202 | ordinal, | ||
| 1203 | status: "completed", | ||
| 1204 | title: toolName, | ||
| 1205 | startedAt: now, | ||
| 1206 | completedAt: now, | ||
| 1207 | updatedAt: now, | ||
| 1208 | type: "dynamic_tool", | ||
| 1209 | toolName, | ||
| 1210 | input: {}, | ||
| 1211 | output, | ||
| 1212 | }); | ||
| 1213 | const read = toolItem("turn-item:read-image", 1, "Read", { | ||
| 1214 | type: "image", | ||
| 1215 | file: { base64: readBase64, type: "image/png", originalSize: 30_000 }, | ||
| 1216 | }); | ||
| 1217 | const screenshot = toolItem("turn-item:screenshot", 2, "mcp__t3-code__device_screenshot", { | ||
| 1218 | content: [ | ||
| 1219 | { | ||
| 1220 | type: "image", | ||
| 1221 | source: { type: "base64", media_type: "image/png", data: screenshotBase64 }, | ||
| 1222 | }, | ||
| 1223 | ], | ||
| 1224 | }); | ||
| 1225 | |||
| 1226 | yield* eventSink.write({ events: [threadEvent] }); | ||
| 1227 | for (const turnItem of [read, screenshot]) { | ||
| 1228 | yield* ingestor.ingestNormalized({ | ||
| 1229 | providerSessionId, | ||
| 1230 | providerInstanceId: modelSelection.instanceId, | ||
| 1231 | threadId: threadEvent.threadId, | ||
| 1232 | event: { type: "turn_item.updated", driver: CODEX_DRIVER, turnItem }, | ||
| 1233 | }); | ||
| 1234 | } | ||
| 1235 | |||
| 1236 | const storedEvents = yield* eventStore | ||
| 1237 | .read({ threadId: threadEvent.threadId, eventType: "turn-item.updated" }) | ||
| 1238 | .pipe(Stream.runCollect); | ||
| 1239 | const storedJson = JSON.stringify(Array.from(storedEvents, (stored) => stored.event)); | ||
| 1240 | const projectedRead = yield* projectionStore.getTurnItem({ | ||
| 1241 | threadId: threadEvent.threadId, | ||
| 1242 | itemId: read.id, | ||
| 1243 | }); | ||
| 1244 | const projectedScreenshot = yield* projectionStore.getTurnItem({ | ||
| 1245 | threadId: threadEvent.threadId, | ||
| 1246 | itemId: screenshot.id, | ||
| 1247 | }); | ||
| 1248 | |||
| 1249 | assert.equal(storedJson.includes(readBase64), false); | ||
| 1250 | assert.equal(storedJson.includes(screenshotBase64), true); | ||
| 1251 | assert.deepEqual(projectedRead?.type === "dynamic_tool" ? projectedRead.output : null, { | ||
| 1252 | type: "image", | ||
| 1253 | file: { type: "image/png", originalSize: 30_000, sizeBytes: 30_000 }, | ||
| 1254 | }); | ||
| 1255 | assert.deepEqual( | ||
| 1256 | toolOutputImages( | ||
| 1257 | projectedScreenshot?.type === "dynamic_tool" ? projectedScreenshot.output : null, | ||
| 1258 | ), | ||
| 1259 | [{ mimeType: "image/png", data: screenshotBase64 }], | ||
| 1260 | ); | ||
| 1261 | }), | ||
| 1262 | ); | ||
| 1263 | |||
| 1172 | it.effect("routes provider-owned child artifacts to their child app thread", () => | 1264 | it.effect("routes provider-owned child artifacts to their child app thread", () => |
| 1173 | Effect.gen(function* () { | 1265 | Effect.gen(function* () { |
| 1174 | const now = yield* DateTime.now; | 1266 | const now = yield* DateTime.now; |
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+2-1| ... | @@ -33,6 +33,7 @@ import * as IdAllocator from "./IdAllocator.ts"; | ... | @@ -33,6 +33,7 @@ import * as IdAllocator from "./IdAllocator.ts"; |
| 33 | import { ProviderAdapterV2Event } from "./ProviderAdapter.ts"; | 33 | import { ProviderAdapterV2Event } from "./ProviderAdapter.ts"; |
| 34 | import { makeProviderFailureTurnItem } from "./ProviderFailure.ts"; | 34 | import { makeProviderFailureTurnItem } from "./ProviderFailure.ts"; |
| 35 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; | 35 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; |
| 36 | import { stripUnservedToolOutputImageBytes } from "./toolOutputImageBytes.ts"; | ||
| 36 | 37 | ||
| 37 | export class ProviderEventNormalizeError extends Schema.TaggedError<ProviderEventNormalizeError>()( | 38 | export class ProviderEventNormalizeError extends Schema.TaggedError<ProviderEventNormalizeError>()( |
| 38 | "ProviderEventNormalizeError", | 39 | "ProviderEventNormalizeError", |
| ... | @@ -465,7 +466,7 @@ export const layer: Layer.Layer< | ... | @@ -465,7 +466,7 @@ export const layer: Layer.Layer< |
| 465 | yield* makeDomainEvent(input, { | 466 | yield* makeDomainEvent(input, { |
| 466 | type: "turn-item.updated", | 467 | type: "turn-item.updated", |
| 467 | threadId: input.event.turnItem.threadId, | 468 | threadId: input.event.turnItem.threadId, |
| 468 | payload: input.event.turnItem, | 469 | payload: stripUnservedToolOutputImageBytes(input.event.turnItem), |
| 469 | runId: input.event.turnItem.runId, | 470 | runId: input.event.turnItem.runId, |
| 470 | nodeId: input.event.turnItem.nodeId, | 471 | nodeId: input.event.turnItem.nodeId, |
| 471 | }), | 472 | }), |
apps/server/src/orchestration-v2/toolOutputImageBytes.test.ts created+234| ... | @@ -0,0 +1,234 @@ | ||
| 1 | import { NodeId, ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, | ||
| 4 | MAX_TOOL_OUTPUT_IMAGES, | ||
| 5 | toolOutputImages, | ||
| 6 | } from "@t3tools/shared/toolOutput"; | ||
| 7 | import * as DateTime from "effect/DateTime"; | ||
| 8 | import { describe, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { stripUnservedToolOutputImageBytes } from "./toolOutputImageBytes.ts"; | ||
| 11 | |||
| 12 | const PNG_BYTES = 3_000; | ||
| 13 | const PNG_BASE64 = Buffer.alloc(PNG_BYTES, 7).toString("base64"); | ||
| 14 | // 3,001 bytes encode with `=` padding, which must not count as data. | ||
| 15 | const PADDED_BASE64 = Buffer.alloc(PNG_BYTES + 1, 9).toString("base64"); | ||
| 16 | |||
| 17 | const now = DateTime.makeUnsafe("2026-10-06T00:00:00.000Z"); | ||
| 18 | |||
| 19 | const itemBase = { | ||
| 20 | id: TurnItemId.make("turn-item:tool"), | ||
| 21 | threadId: ThreadId.make("thread:tool"), | ||
| 22 | runId: null, | ||
| 23 | nodeId: NodeId.make("node:tool"), | ||
| 24 | providerThreadId: null, | ||
| 25 | providerTurnId: null, | ||
| 26 | nativeItemRef: null, | ||
| 27 | parentItemId: null, | ||
| 28 | ordinal: 1, | ||
| 29 | status: "completed", | ||
| 30 | title: null, | ||
| 31 | startedAt: now, | ||
| 32 | completedAt: now, | ||
| 33 | updatedAt: now, | ||
| 34 | } as const; | ||
| 35 | |||
| 36 | function toolItem(output: unknown, toolName = "Read"): OrchestrationV2TurnItem { | ||
| 37 | return { | ||
| 38 | ...itemBase, | ||
| 39 | type: "dynamic_tool", | ||
| 40 | toolName, | ||
| 41 | input: { file_path: "/tmp/shot.png" }, | ||
| 42 | output, | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | |||
| 46 | function outputOf(item: OrchestrationV2TurnItem): unknown { | ||
| 47 | return item.type === "dynamic_tool" ? item.output : undefined; | ||
| 48 | } | ||
| 49 | |||
| 50 | describe("stripUnservedToolOutputImageBytes", () => { | ||
| 51 | it("replaces the base64 of Claude's structured Read result with its measured size", () => { | ||
| 52 | const item = toolItem({ | ||
| 53 | type: "image", | ||
| 54 | file: { | ||
| 55 | base64: PNG_BASE64, | ||
| 56 | type: "image/png", | ||
| 57 | originalSize: PNG_BYTES, | ||
| 58 | dimensions: { originalWidth: 180, originalHeight: 180 }, | ||
| 59 | }, | ||
| 60 | }); | ||
| 61 | |||
| 62 | expect(outputOf(stripUnservedToolOutputImageBytes(item))).toEqual({ | ||
| 63 | type: "image", | ||
| 64 | file: { | ||
| 65 | type: "image/png", | ||
| 66 | originalSize: PNG_BYTES, | ||
| 67 | dimensions: { originalWidth: 180, originalHeight: 180 }, | ||
| 68 | sizeBytes: PNG_BYTES, | ||
| 69 | }, | ||
| 70 | }); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("strips ACP image shapes: Grok's native ImageContent and an MCP result behind an error", () => { | ||
| 74 | const item = toolItem({ | ||
| 75 | rawOutput: { type: "ReadFile", ImageContent: { data: PNG_BASE64, mime_type: "image/png" } }, | ||
| 76 | failed: { | ||
| 77 | error: "partial result", | ||
| 78 | result: [{ type: "image", data: PADDED_BASE64, mimeType: "image/png" }], | ||
| 79 | }, | ||
| 80 | }); | ||
| 81 | |||
| 82 | expect(outputOf(stripUnservedToolOutputImageBytes(item))).toEqual({ | ||
| 83 | rawOutput: { | ||
| 84 | type: "ReadFile", | ||
| 85 | ImageContent: { mime_type: "image/png", sizeBytes: PNG_BYTES }, | ||
| 86 | }, | ||
| 87 | failed: { | ||
| 88 | error: "partial result", | ||
| 89 | result: [{ type: "image", mimeType: "image/png", sizeBytes: PNG_BYTES + 1 }], | ||
| 90 | }, | ||
| 91 | }); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("strips a Cursor image part that names no mime type", () => { | ||
| 95 | const item = toolItem({ | ||
| 96 | content: [{ text: { text: "captured" } }, { image: { data: PNG_BASE64 } }], | ||
| 97 | }); | ||
| 98 | |||
| 99 | expect(outputOf(stripUnservedToolOutputImageBytes(item))).toEqual({ | ||
| 100 | content: [{ text: { text: "captured" } }, { image: { sizeBytes: PNG_BYTES } }], | ||
| 101 | }); | ||
| 102 | }); | ||
| 103 | |||
| 104 | it("keeps the screenshots a tool-output-image asset serves", () => { | ||
| 105 | const claudeImage = { | ||
| 106 | type: "image", | ||
| 107 | source: { type: "base64", media_type: "image/png", data: PNG_BASE64 }, | ||
| 108 | }; | ||
| 109 | const mcpImage = { type: "image", data: PADDED_BASE64, mimeType: "image/jpeg" }; | ||
| 110 | const item = toolItem( | ||
| 111 | { content: [{ type: "text", text: "captured" }, claudeImage, mcpImage] }, | ||
| 112 | "mcp__t3-code__device_screenshot", | ||
| 113 | ); | ||
| 114 | |||
| 115 | const stripped = stripUnservedToolOutputImageBytes(item); | ||
| 116 | |||
| 117 | expect(stripped).toBe(item); | ||
| 118 | expect(toolOutputImages(outputOf(stripped))).toEqual([ | ||
| 119 | { mimeType: "image/png", data: PNG_BASE64 }, | ||
| 120 | { mimeType: "image/jpeg", data: PADDED_BASE64 }, | ||
| 121 | ]); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("strips only the images past the last one an asset can serve", () => { | ||
| 125 | const images = Array.from({ length: MAX_TOOL_OUTPUT_IMAGES + 2 }, () => ({ | ||
| 126 | type: "image", | ||
| 127 | data: PNG_BASE64, | ||
| 128 | mimeType: "image/png", | ||
| 129 | })); | ||
| 130 | |||
| 131 | const output = outputOf(stripUnservedToolOutputImageBytes(toolItem(images))); | ||
| 132 | |||
| 133 | expect(Array.isArray(output) ? output.slice(0, MAX_TOOL_OUTPUT_IMAGES) : null).toEqual( | ||
| 134 | images.slice(0, MAX_TOOL_OUTPUT_IMAGES), | ||
| 135 | ); | ||
| 136 | expect(Array.isArray(output) ? output.slice(MAX_TOOL_OUTPUT_IMAGES) : null).toEqual([ | ||
| 137 | { type: "image", mimeType: "image/png", sizeBytes: PNG_BYTES }, | ||
| 138 | { type: "image", mimeType: "image/png", sizeBytes: PNG_BYTES }, | ||
| 139 | ]); | ||
| 140 | }); | ||
| 141 | |||
| 142 | it("strips a served-position image larger than an asset may serve", () => { | ||
| 143 | const oversized = "A".repeat(MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH + 4); | ||
| 144 | const servable = { type: "image", data: PNG_BASE64, mimeType: "image/png" }; | ||
| 145 | const item = toolItem({ | ||
| 146 | content: [ | ||
| 147 | { type: "image", source: { type: "base64", media_type: "image/png", data: oversized } }, | ||
| 148 | servable, | ||
| 149 | ], | ||
| 150 | }); | ||
| 151 | |||
| 152 | const output = outputOf(stripUnservedToolOutputImageBytes(item)) as { | ||
| 153 | content: ReadonlyArray<unknown>; | ||
| 154 | }; | ||
| 155 | |||
| 156 | expect(output.content).toEqual([ | ||
| 157 | { | ||
| 158 | type: "image", | ||
| 159 | source: { | ||
| 160 | type: "base64", | ||
| 161 | media_type: "image/png", | ||
| 162 | sizeBytes: ((MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH + 4) / 4) * 3, | ||
| 163 | }, | ||
| 164 | }, | ||
| 165 | servable, | ||
| 166 | ]); | ||
| 167 | expect(output.content[1]).toBe(servable); | ||
| 168 | }); | ||
| 169 | |||
| 170 | it("keeps image fields whose value is not a base64 body", () => { | ||
| 171 | const item = toolItem({ | ||
| 172 | nested: { | ||
| 173 | byId: { type: "image", data: "asset-id-42" }, | ||
| 174 | svg: { | ||
| 175 | mimeType: "image/svg+xml", | ||
| 176 | data: `<svg xmlns='http://www.w3.org/2000/svg'>${"<circle r='4'/>".repeat(8)}</svg>`, | ||
| 177 | }, | ||
| 178 | dataUrl: { type: "image", data: `data:image/png;base64,${PNG_BASE64}` }, | ||
| 179 | remote: { type: "image", source: { type: "url", url: "https://example.test/shot.png" } }, | ||
| 180 | tiny: { type: "image", data: "AA==", mimeType: "image/png" }, | ||
| 181 | notImage: { mimeType: "application/pdf", data: PNG_BASE64 }, | ||
| 182 | }, | ||
| 183 | }); | ||
| 184 | |||
| 185 | expect(stripUnservedToolOutputImageBytes(item)).toBe(item); | ||
| 186 | }); | ||
| 187 | |||
| 188 | it("measures the size instead of trusting one the provider supplied", () => { | ||
| 189 | const item = toolItem({ | ||
| 190 | type: "image", | ||
| 191 | file: { base64: PNG_BASE64, type: "image/png", sizeBytes: 0 }, | ||
| 192 | }); | ||
| 193 | |||
| 194 | expect(outputOf(stripUnservedToolOutputImageBytes(item))).toEqual({ | ||
| 195 | type: "image", | ||
| 196 | file: { type: "image/png", sizeBytes: PNG_BYTES }, | ||
| 197 | }); | ||
| 198 | }); | ||
| 199 | |||
| 200 | it("returns items without image bytes by reference", () => { | ||
| 201 | const nested = { files: [{ path: "src/app.ts" }] }; | ||
| 202 | const item = toolItem({ stdout: "data: 42", data: PNG_BASE64, nested }); | ||
| 203 | const command: OrchestrationV2TurnItem = { | ||
| 204 | ...itemBase, | ||
| 205 | type: "command_execution", | ||
| 206 | input: "cat shot.png | base64", | ||
| 207 | output: PNG_BASE64, | ||
| 208 | }; | ||
| 209 | |||
| 210 | const stripped = stripUnservedToolOutputImageBytes(item); | ||
| 211 | |||
| 212 | expect(stripped).toBe(item); | ||
| 213 | expect(outputOf(stripped)).toBe(outputOf(item)); | ||
| 214 | expect(stripUnservedToolOutputImageBytes(command)).toBe(command); | ||
| 215 | expect(stripUnservedToolOutputImageBytes(toolItem("plain text output"))).toEqual( | ||
| 216 | toolItem("plain text output"), | ||
| 217 | ); | ||
| 218 | }); | ||
| 219 | |||
| 220 | it("does not mutate the provider's item and shares untouched siblings", () => { | ||
| 221 | const untouched = { type: "text", text: "captured" }; | ||
| 222 | const output = { | ||
| 223 | error: "partial result", | ||
| 224 | result: [untouched, { type: "image", data: PNG_BASE64, mimeType: "image/png" }], | ||
| 225 | }; | ||
| 226 | const item = toolItem(output); | ||
| 227 | const snapshot = structuredClone(output); | ||
| 228 | |||
| 229 | const stripped = outputOf(stripUnservedToolOutputImageBytes(item)) as typeof output; | ||
| 230 | |||
| 231 | expect(outputOf(item)).toEqual(snapshot); | ||
| 232 | expect(stripped.result[0]).toBe(untouched); | ||
| 233 | }); | ||
| 234 | }); | ||
apps/server/src/orchestration-v2/toolOutputImageBytes.ts created+135| ... | @@ -0,0 +1,135 @@ | ||
| 1 | import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, | ||
| 4 | readToolOutputImage, | ||
| 5 | toolOutputImageBlocks, | ||
| 6 | } from "@t3tools/shared/toolOutput"; | ||
| 7 | |||
| 8 | const IMAGE_MIME_KEYS = ["mimeType", "mime_type", "media_type", "type"] as const; | ||
| 9 | const IMAGE_BODY_KEYS = new Set(["data", "blob", "base64"]); | ||
| 10 | const MAX_DEPTH = 32; | ||
| 11 | // Shorter strings are identifiers or tiny fixtures, not pixels worth removing. | ||
| 12 | const MIN_BODY_LENGTH = 64; | ||
| 13 | const BASE64_BODY = /^[A-Za-z0-9+/_-]+={0,2}$/; | ||
| 14 | |||
| 15 | type UnknownRecord = Record<string, unknown>; | ||
| 16 | |||
| 17 | function hasImageMime(record: UnknownRecord): boolean { | ||
| 18 | return IMAGE_MIME_KEYS.some((key) => { | ||
| 19 | const mime = record[key]; | ||
| 20 | return typeof mime === "string" && mime.toLowerCase().startsWith("image/"); | ||
| 21 | }); | ||
| 22 | } | ||
| 23 | |||
| 24 | /** | ||
| 25 | * The record that carries an image's bytes: an MCP or ACP `type: "image"` | ||
| 26 | * block, Cursor's `image` part (its mime type is optional), or a record with | ||
| 27 | * an `image/*` mime field such as Claude's `source`, the `file` of Claude's | ||
| 28 | * structured Read result, and Grok's `ImageContent`. | ||
| 29 | */ | ||
| 30 | function isImageBody(record: UnknownRecord, key: string | undefined): boolean { | ||
| 31 | if (record.type === "image" || key === "image") return true; | ||
| 32 | if (!hasImageMime(record)) return false; | ||
| 33 | return ( | ||
| 34 | record.type === undefined || | ||
| 35 | record.type === "base64" || | ||
| 36 | (typeof record.type === "string" && record.type.toLowerCase().startsWith("image/")) | ||
| 37 | ); | ||
| 38 | } | ||
| 39 | |||
| 40 | function isBase64Body(value: unknown): value is string { | ||
| 41 | return typeof value === "string" && value.length >= MIN_BODY_LENGTH && BASE64_BODY.test(value); | ||
| 42 | } | ||
| 43 | |||
| 44 | function base64DecodedLength(encoded: string): number { | ||
| 45 | let length = encoded.length; | ||
| 46 | while (length > 0 && encoded.charCodeAt(length - 1) === 0x3d /* = */) { | ||
| 47 | length -= 1; | ||
| 48 | } | ||
| 49 | return Math.floor((length * 3) / 4); | ||
| 50 | } | ||
| 51 | |||
| 52 | interface StripContext { | ||
| 53 | readonly output: unknown; | ||
| 54 | served: ReadonlyArray<unknown> | undefined; | ||
| 55 | } | ||
| 56 | |||
| 57 | /** A block the asset route serves: in a served position and within its size limit. */ | ||
| 58 | function isServed(context: StripContext, record: UnknownRecord): boolean { | ||
| 59 | context.served ??= toolOutputImageBlocks(context.output); | ||
| 60 | if (!context.served.includes(record)) return false; | ||
| 61 | const data = readToolOutputImage(record)?.data; | ||
| 62 | return data === undefined || data.length <= MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH; | ||
| 63 | } | ||
| 64 | |||
| 65 | function strip( | ||
| 66 | value: unknown, | ||
| 67 | key: string | undefined, | ||
| 68 | context: StripContext, | ||
| 69 | depth: number, | ||
| 70 | ): unknown { | ||
| 71 | if (depth > MAX_DEPTH || typeof value !== "object" || value === null) { | ||
| 72 | return value; | ||
| 73 | } | ||
| 74 | if (Array.isArray(value)) { | ||
| 75 | let next: Array<unknown> | undefined; | ||
| 76 | for (let index = 0; index < value.length; index += 1) { | ||
| 77 | const entry = value[index]; | ||
| 78 | const stripped = strip(entry, undefined, context, depth + 1); | ||
| 79 | if (stripped !== entry) { | ||
| 80 | next ??= value.slice(); | ||
| 81 | next[index] = stripped; | ||
| 82 | } | ||
| 83 | } | ||
| 84 | return next ?? value; | ||
| 85 | } | ||
| 86 | const record = value as UnknownRecord; | ||
| 87 | // Every served block is a `type: "image"` block; keep it whole. | ||
| 88 | if (record.type === "image" && isServed(context, record)) return record; | ||
| 89 | const imageBody = isImageBody(record, key); | ||
| 90 | let next: UnknownRecord | undefined; | ||
| 91 | let removedBody: string | undefined; | ||
| 92 | for (const entryKey in record) { | ||
| 93 | if (!Object.hasOwn(record, entryKey)) continue; | ||
| 94 | const entry = record[entryKey]; | ||
| 95 | if (imageBody && IMAGE_BODY_KEYS.has(entryKey) && isBase64Body(entry)) { | ||
| 96 | next ??= { ...record }; | ||
| 97 | delete next[entryKey]; | ||
| 98 | removedBody = entry; | ||
| 99 | continue; | ||
| 100 | } | ||
| 101 | const stripped = strip(entry, entryKey, context, depth + 1); | ||
| 102 | if (stripped !== entry) { | ||
| 103 | next ??= { ...record }; | ||
| 104 | next[entryKey] = stripped; | ||
| 105 | } | ||
| 106 | } | ||
| 107 | if (next !== undefined && removedBody !== undefined) { | ||
| 108 | // Measured from the removed body, never taken from a provider's size field. | ||
| 109 | next.sizeBytes = base64DecodedLength(removedBody); | ||
| 110 | } | ||
| 111 | return next ?? record; | ||
| 112 | } | ||
| 113 | |||
| 114 | /** | ||
| 115 | * Replaces the base64 image bodies in a dynamic tool's output with their | ||
| 116 | * decoded byte size, except the blocks a `tool-output-image` asset serves. | ||
| 117 | * | ||
| 118 | * Tools that read or capture an image return the whole file as base64, and a | ||
| 119 | * turn item is stored twice: in the event log and in the projection. Only the | ||
| 120 | * blocks `toolOutputImageBlocks` finds, within the asset size limit, are ever | ||
| 121 | * read back; previews of a read file load from `viewedImagePath`, and context | ||
| 122 | * handoff skips tool items. | ||
| 123 | * Strings that are not a base64 body are kept, so identifiers and inline SVG | ||
| 124 | * under the same keys survive. Containers are copied only when a descendant | ||
| 125 | * changes; an item without such bytes is returned by reference. | ||
| 126 | */ | ||
| 127 | export function stripUnservedToolOutputImageBytes( | ||
| 128 | item: OrchestrationV2TurnItem, | ||
| 129 | ): OrchestrationV2TurnItem { | ||
| 130 | if (item.type !== "dynamic_tool" || typeof item.output !== "object" || item.output === null) { | ||
| 131 | return item; | ||
| 132 | } | ||
| 133 | const output = strip(item.output, undefined, { output: item.output, served: undefined }, 0); | ||
| 134 | return output === item.output ? item : { ...item, output }; | ||
| 135 | } | ||
packages/shared/src/toolOutput.ts+24-9| ... | @@ -1,4 +1,7 @@ | ... | @@ -1,4 +1,7 @@ |
| 1 | import { isProviderSendTurnSupportedImageMimeType } from "@t3tools/contracts"; | 1 | import { |
| 2 | isProviderSendTurnSupportedImageMimeType, | ||
| 3 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | ||
| 4 | } from "@t3tools/contracts"; | ||
| 2 | import * as Predicate from "effect/Predicate"; | 5 | import * as Predicate from "effect/Predicate"; |
| 3 | 6 | ||
| 4 | import { | 7 | import { |
| ... | @@ -223,19 +226,31 @@ function outputBlocks(value: unknown): ReadonlyArray<unknown> { | ... | @@ -223,19 +226,31 @@ function outputBlocks(value: unknown): ReadonlyArray<unknown> { |
| 223 | /** A tool returns one screenshot or a few frames; more would only flood the timeline. */ | 226 | /** A tool returns one screenshot or a few frames; more would only flood the timeline. */ |
| 224 | export const MAX_TOOL_OUTPUT_IMAGES = 8; | 227 | export const MAX_TOOL_OUTPUT_IMAGES = 8; |
| 225 | 228 | ||
| 229 | /** The largest image a `tool-output-image` asset serves: a provider turn's limit, as base64. */ | ||
| 230 | export const MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH = | ||
| 231 | Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4; | ||
| 232 | |||
| 233 | /** | ||
| 234 | * The blocks `toolOutputImages` reads, by reference. These hold the only image | ||
| 235 | * bytes in a tool output that a `tool-output-image` asset can serve, up to | ||
| 236 | * `MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH`. | ||
| 237 | */ | ||
| 238 | export function toolOutputImageBlocks(value: unknown): ReadonlyArray<unknown> { | ||
| 239 | const blocks: unknown[] = []; | ||
| 240 | for (const block of outputBlocks(value)) { | ||
| 241 | if (readToolOutputImage(block) === null) continue; | ||
| 242 | blocks.push(block); | ||
| 243 | if (blocks.length === MAX_TOOL_OUTPUT_IMAGES) break; | ||
| 244 | } | ||
| 245 | return blocks; | ||
| 246 | } | ||
| 247 | |||
| 226 | /** | 248 | /** |
| 227 | * The first images in a tool output, in order. The order is the | 249 | * The first images in a tool output, in order. The order is the |
| 228 | * `tool-output-image` asset index, so servers and clients agree on it. | 250 | * `tool-output-image` asset index, so servers and clients agree on it. |
| 229 | */ | 251 | */ |
| 230 | export function toolOutputImages(value: unknown): ReadonlyArray<ToolOutputImage> { | 252 | export function toolOutputImages(value: unknown): ReadonlyArray<ToolOutputImage> { |
| 231 | const images: ToolOutputImage[] = []; | 253 | return toolOutputImageBlocks(value).flatMap((block) => readToolOutputImage(block) ?? []); |
| 232 | for (const block of outputBlocks(value)) { | ||
| 233 | const image = readToolOutputImage(block); | ||
| 234 | if (image === null) continue; | ||
| 235 | images.push(image); | ||
| 236 | if (images.length === MAX_TOOL_OUTPUT_IMAGES) break; | ||
| 237 | } | ||
| 238 | return images; | ||
| 239 | } | 254 | } |
| 240 | 255 | ||
| 241 | /** | 256 | /** |