| 1 | import { |
| 2 | isProviderSendTurnSupportedImageMimeType, |
| 3 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 4 | } from "@t3tools/contracts"; |
| 5 | import * as Predicate from "effect/Predicate"; |
| 6 | |
| 7 | import { |
| 8 | HTML_RENDER_TOOL_NAME, |
| 9 | readHtmlRenderReference, |
| 10 | type HtmlRenderReference, |
| 11 | } from "./htmlRender.ts"; |
| 12 | import { MCP_APP_OUTPUT_KEY, readMcpAppReference, type McpAppReference } from "./mcpApp.ts"; |
| 13 | import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; |
| 14 | |
| 15 | const MAX_PARSED_BYTES = 16_384; |
| 16 | const MAX_METADATA_BYTES = 8_192; |
| 17 | const MAX_ID_LENGTH = 256; |
| 18 | const MAX_THREADS = 100; |
| 19 | const MAX_CONTENT_BLOCKS = 32; |
| 20 | const MAX_ENVELOPE_DEPTH = 4; |
| 21 | const MAX_ENVELOPE_NODES = 128; |
| 22 | const encoder = new TextEncoder(); |
| 23 | |
| 24 | interface ResultReadBudget { |
| 25 | remainingBytes: number; |
| 26 | remainingNodes: number; |
| 27 | exceeded: boolean; |
| 28 | } |
| 29 | |
| 30 | interface ResultEnvelope { |
| 31 | data?: Record<PropertyKey, unknown>; |
| 32 | failed: boolean; |
| 33 | } |
| 34 | |
| 35 | interface CompactToolOutput { |
| 36 | isError?: true; |
| 37 | threadId?: string; |
| 38 | messageId?: string; |
| 39 | taskId?: string; |
| 40 | scheduledTaskId?: string; |
| 41 | status?: "rolled_back"; |
| 42 | htmlRender?: HtmlRenderReference; |
| 43 | [MCP_APP_OUTPUT_KEY]?: McpAppReference; |
| 44 | thread?: { threadId: string }; |
| 45 | threads?: Array<{ threadId?: string; status?: "rolled_back" }>; |
| 46 | } |
| 47 | |
| 48 | function readResult(value: unknown, budget: ResultReadBudget, depth = 0): ResultEnvelope { |
| 49 | if (depth > MAX_ENVELOPE_DEPTH || budget.remainingNodes-- <= 0) { |
| 50 | budget.exceeded = true; |
| 51 | return { failed: false }; |
| 52 | } |
| 53 | if (typeof value === "string") { |
| 54 | if (value.length > budget.remainingBytes) { |
| 55 | budget.exceeded = true; |
| 56 | return { failed: false }; |
| 57 | } |
| 58 | const bytes = encoder.encode(value).byteLength; |
| 59 | if (bytes > budget.remainingBytes) { |
| 60 | budget.exceeded = true; |
| 61 | return { failed: false }; |
| 62 | } |
| 63 | budget.remainingBytes -= bytes; |
| 64 | try { |
| 65 | return readResult(JSON.parse(value), budget, depth + 1); |
| 66 | } catch { |
| 67 | return { failed: false }; |
| 68 | } |
| 69 | } |
| 70 | if (Array.isArray(value)) { |
| 71 | if (value.length > MAX_CONTENT_BLOCKS) { |
| 72 | budget.exceeded = true; |
| 73 | return { failed: false }; |
| 74 | } |
| 75 | let data: ResultEnvelope["data"]; |
| 76 | let failed = false; |
| 77 | for (const block of value) { |
| 78 | const text = Predicate.isObject(block) ? block.text : undefined; |
| 79 | const result = readResult( |
| 80 | Predicate.isObject(text) ? (text.text ?? text) : text, |
| 81 | budget, |
| 82 | depth + 1, |
| 83 | ); |
| 84 | data ??= result.data; |
| 85 | failed ||= result.failed; |
| 86 | if (budget.exceeded) break; |
| 87 | } |
| 88 | return { ...(data === undefined ? {} : { data }), failed }; |
| 89 | } |
| 90 | if (!Predicate.isObject(value)) return { failed: false }; |
| 91 | const failed = |
| 92 | value.isError === true || |
| 93 | value.is_error === true || |
| 94 | value._tag === "OrchestratorMcpFailure" || |
| 95 | value.error != null; |
| 96 | const content = value.structuredContent ?? value.content; |
| 97 | if (content !== undefined) { |
| 98 | const nested = readResult(content, budget, depth + 1); |
| 99 | return { ...nested, failed: failed || nested.failed }; |
| 100 | } |
| 101 | return { data: value, failed }; |
| 102 | } |
| 103 | |
| 104 | function boundedId(value: unknown): string | undefined { |
| 105 | if (typeof value !== "string" || value.length > MAX_ID_LENGTH || value.trim() === "") { |
| 106 | return undefined; |
| 107 | } |
| 108 | return Array.from(value).join(""); |
| 109 | } |
| 110 | |
| 111 | /** Keeps only IDs and failure metadata used by T3's grouped tool summaries. */ |
| 112 | export function compactDynamicToolOutput(value: unknown): CompactToolOutput | undefined { |
| 113 | const budget: ResultReadBudget = { |
| 114 | remainingBytes: MAX_PARSED_BYTES, |
| 115 | remainingNodes: MAX_ENVELOPE_NODES, |
| 116 | exceeded: false, |
| 117 | }; |
| 118 | const result = readResult(value, budget); |
| 119 | const output: CompactToolOutput = result.failed ? { isError: true } : {}; |
| 120 | const data = budget.exceeded ? undefined : result.data; |
| 121 | if (data !== undefined) { |
| 122 | for (const key of ["threadId", "messageId", "taskId", "scheduledTaskId"] as const) { |
| 123 | const id = boundedId(data[key]); |
| 124 | if (id !== undefined) output[key] = id; |
| 125 | } |
| 126 | if (data.status === "rolled_back") output.status = "rolled_back"; |
| 127 | const htmlRender = readHtmlRenderReference(data.htmlRender); |
| 128 | if (htmlRender !== undefined) output.htmlRender = htmlRender; |
| 129 | const mcpApp = readMcpAppReference(data[MCP_APP_OUTPUT_KEY]); |
| 130 | // Kept under its stored key, so the compact wire output reads back the same way. |
| 131 | if (mcpApp !== undefined) output[MCP_APP_OUTPUT_KEY] = mcpApp; |
| 132 | const nestedThreadId = Predicate.isObject(data.thread) |
| 133 | ? boundedId(data.thread.threadId) |
| 134 | : undefined; |
| 135 | if (nestedThreadId !== undefined) output.thread = { threadId: nestedThreadId }; |
| 136 | |
| 137 | if (Array.isArray(data.threads)) { |
| 138 | let complete = data.threads.length <= MAX_THREADS; |
| 139 | const threads: NonNullable<CompactToolOutput["threads"]> = []; |
| 140 | if (complete) { |
| 141 | for (const entry of data.threads) { |
| 142 | if (!Predicate.isObject(entry)) { |
| 143 | complete = false; |
| 144 | break; |
| 145 | } |
| 146 | const threadId = boundedId(entry.threadId); |
| 147 | const rolledBack = entry.status === "rolled_back"; |
| 148 | if (threadId === undefined && !rolledBack) { |
| 149 | complete = false; |
| 150 | break; |
| 151 | } |
| 152 | threads.push({ |
| 153 | ...(threadId === undefined ? {} : { threadId }), |
| 154 | ...(rolledBack ? { status: "rolled_back" as const } : {}), |
| 155 | }); |
| 156 | } |
| 157 | } |
| 158 | if (complete) output.threads = threads; |
| 159 | else { |
| 160 | // A partial batch or a top-level ID would turn an unknown creation |
| 161 | // count into a confidently wrong one in the existing summary parser. |
| 162 | delete output.threadId; |
| 163 | delete output.status; |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | const oversized = () => encoder.encode(JSON.stringify(output)).byteLength > MAX_METADATA_BYTES; |
| 168 | if (oversized()) { |
| 169 | delete output.threads; |
| 170 | delete output.threadId; |
| 171 | delete output.status; |
| 172 | } |
| 173 | // An app's declared origins are the only unbounded-ish part of its |
| 174 | // reference; hosting works without them (the stored document carries its |
| 175 | // own policy), so they go before the app does. |
| 176 | const app = output[MCP_APP_OUTPUT_KEY]; |
| 177 | if (app?.csp !== undefined && oversized()) { |
| 178 | const { csp: _csp, ...rest } = app; |
| 179 | output[MCP_APP_OUTPUT_KEY] = rest; |
| 180 | } |
| 181 | if (oversized()) delete output[MCP_APP_OUTPUT_KEY]; |
| 182 | return Object.keys(output).length === 0 ? undefined : output; |
| 183 | } |
| 184 | |
| 185 | /** The page a completed `html_render` tool call published, if this item is one. */ |
| 186 | export function htmlRenderFromToolItem(item: { |
| 187 | readonly toolName: string | null | undefined; |
| 188 | readonly output?: unknown; |
| 189 | }): HtmlRenderReference | undefined { |
| 190 | if (resolveT3McpToolId(item.toolName) !== HTML_RENDER_TOOL_NAME) return undefined; |
| 191 | const output = compactDynamicToolOutput(item.output); |
| 192 | return output?.isError ? undefined : output?.htmlRender; |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * The MCP App a completed tool call carries, if any. The adapter that captured |
| 197 | * it put the reference in the output, beside the tool's own result. A tool's |
| 198 | * result can imitate that shape, so the reference only counts when it names |
| 199 | * the very server and tool the item records: a server can then only ever |
| 200 | * point at an app of its own. |
| 201 | */ |
| 202 | export function mcpAppFromToolItem(item: { |
| 203 | readonly toolName: string | null | undefined; |
| 204 | readonly output?: unknown; |
| 205 | }): McpAppReference | undefined { |
| 206 | const app = compactDynamicToolOutput(item.output)?.[MCP_APP_OUTPUT_KEY]; |
| 207 | return app !== undefined && item.toolName === `${app.server}.${app.tool}` ? app : undefined; |
| 208 | } |
| 209 | |
| 210 | /** Some providers report completion even when command output describes a failure. */ |
| 211 | export function toolOutputIndicatesFailure(text: string): boolean { |
| 212 | return ( |
| 213 | /file not found|no files found|enoent|no such file|commandnotfoundexception|command not found|is not recognized as the name of a cmdlet|a parameter cannot be found that matches parameter name/i.test( |
| 214 | text, |
| 215 | ) || |
| 216 | (/cannot find path/i.test(text) && /because it does not exist/i.test(text)) || |
| 217 | (/is not recognized/i.test(text) && /the term '/i.test(text)) || |
| 218 | /<exited with exit code\s+[1-9]\d*\s*>/i.test(text) || |
| 219 | /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || |
| 220 | /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) |
| 221 | ); |
| 222 | } |
| 223 | |
| 224 | /** An image a tool returned inline. `data` is base64; a detail read omits it. */ |
| 225 | export interface ToolOutputImage { |
| 226 | readonly mimeType: string; |
| 227 | readonly data?: string; |
| 228 | } |
| 229 | |
| 230 | /** |
| 231 | * Reads one image block in the MCP `{ data, mimeType }` shape or the Anthropic |
| 232 | * `{ source: { type: "base64", media_type, data } }` shape Claude stores. |
| 233 | * Only raster types are recognized, so the server never serves an agent's SVG |
| 234 | * or HTML inline. |
| 235 | */ |
| 236 | export function readToolOutputImage(block: unknown): ToolOutputImage | null { |
| 237 | if (!Predicate.isObject(block) || block.type !== "image") return null; |
| 238 | const source = Predicate.isObject(block.source) ? block.source : undefined; |
| 239 | if (source !== undefined && source.type !== "base64") return null; |
| 240 | const mimeType = source === undefined ? block.mimeType : source.media_type; |
| 241 | const data = source === undefined ? block.data : source.data; |
| 242 | if (typeof mimeType !== "string" || !isProviderSendTurnSupportedImageMimeType(mimeType)) { |
| 243 | return null; |
| 244 | } |
| 245 | const image = { mimeType: mimeType.toLowerCase() }; |
| 246 | return typeof data === "string" ? { ...image, data } : image; |
| 247 | } |
| 248 | |
| 249 | /** Tools return a block, a list of blocks, or an MCP result with a `content` list. */ |
| 250 | function outputBlocks(value: unknown): ReadonlyArray<unknown> { |
| 251 | if (Array.isArray(value)) return value; |
| 252 | if (Predicate.isObject(value) && Array.isArray(value.content)) return value.content; |
| 253 | return [value]; |
| 254 | } |
| 255 | |
| 256 | /** A tool returns one screenshot or a few frames; more would only flood the timeline. */ |
| 257 | export const MAX_TOOL_OUTPUT_IMAGES = 8; |
| 258 | |
| 259 | /** The largest image a `tool-output-image` asset serves: a provider turn's limit, as base64. */ |
| 260 | export const MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH = |
| 261 | Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4; |
| 262 | |
| 263 | /** |
| 264 | * The blocks `toolOutputImages` reads, by reference. These hold the only image |
| 265 | * bytes in a tool output that a `tool-output-image` asset can serve, up to |
| 266 | * `MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH`. |
| 267 | */ |
| 268 | export function toolOutputImageBlocks(value: unknown): ReadonlyArray<unknown> { |
| 269 | const blocks: unknown[] = []; |
| 270 | for (const block of outputBlocks(value)) { |
| 271 | if (readToolOutputImage(block) === null) continue; |
| 272 | blocks.push(block); |
| 273 | if (blocks.length === MAX_TOOL_OUTPUT_IMAGES) break; |
| 274 | } |
| 275 | return blocks; |
| 276 | } |
| 277 | |
| 278 | /** |
| 279 | * The first images in a tool output, in order. The order is the |
| 280 | * `tool-output-image` asset index, so servers and clients agree on it. |
| 281 | */ |
| 282 | export function toolOutputImages(value: unknown): ReadonlyArray<ToolOutputImage> { |
| 283 | return toolOutputImageBlocks(value).flatMap((block) => readToolOutputImage(block) ?? []); |
| 284 | } |
| 285 | |
| 286 | /** |
| 287 | * Replaces each image's bytes with `{ type: "image", mimeType }` and keeps its |
| 288 | * position, so detail reads stay small and clients load the bytes as assets. |
| 289 | */ |
| 290 | export function omitToolOutputImageData(value: unknown): unknown { |
| 291 | const omit = (block: unknown) => { |
| 292 | const image = readToolOutputImage(block); |
| 293 | return image?.data === undefined ? block : { type: "image", mimeType: image.mimeType }; |
| 294 | }; |
| 295 | if (Array.isArray(value)) return value.map(omit); |
| 296 | if (Predicate.isObject(value) && Array.isArray(value.content)) { |
| 297 | return { ...value, content: value.content.map(omit) }; |
| 298 | } |
| 299 | return omit(value); |
| 300 | } |