1import {
2 isProviderSendTurnSupportedImageMimeType,
3 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
4} from "@t3tools/contracts";
5import * as Predicate from "effect/Predicate";
6
7import {
8 HTML_RENDER_TOOL_NAME,
9 readHtmlRenderReference,
10 type HtmlRenderReference,
11} from "./htmlRender.ts";
12import { MCP_APP_OUTPUT_KEY, readMcpAppReference, type McpAppReference } from "./mcpApp.ts";
13import { resolveT3McpToolId } from "./t3McpToolPresentation.ts";
14
15const MAX_PARSED_BYTES = 16_384;
16const MAX_METADATA_BYTES = 8_192;
17const MAX_ID_LENGTH = 256;
18const MAX_THREADS = 100;
19const MAX_CONTENT_BLOCKS = 32;
20const MAX_ENVELOPE_DEPTH = 4;
21const MAX_ENVELOPE_NODES = 128;
22const encoder = new TextEncoder();
23
24interface ResultReadBudget {
25 remainingBytes: number;
26 remainingNodes: number;
27 exceeded: boolean;
28}
29
30interface ResultEnvelope {
31 data?: Record<PropertyKey, unknown>;
32 failed: boolean;
33}
34
35interface 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
48function 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
104function 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. */
112export 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. */
186export 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 */
202export 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. */
211export 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. */
225export 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 */
236export 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. */
250function 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. */
257export const MAX_TOOL_OUTPUT_IMAGES = 8;
258
259/** The largest image a `tool-output-image` asset serves: a provider turn's limit, as base64. */
260export 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 */
268export 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 */
282export 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 */
290export 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}