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 {
13 claudeMcpToolName,
14 MCP_APP_OUTPUT_KEY,
15 readMcpAppReference,
16 type McpAppReference,
17} from "./mcpApp.ts";
18import { resolveT3McpToolId } from "./t3McpToolPresentation.ts";
19
20const MAX_PARSED_BYTES = 16_384;
21const MAX_METADATA_BYTES = 8_192;
22const MAX_ID_LENGTH = 256;
23const MAX_THREADS = 100;
24const MAX_CONTENT_BLOCKS = 32;
25const MAX_ENVELOPE_DEPTH = 4;
26const MAX_ENVELOPE_NODES = 128;
27const encoder = new TextEncoder();
28
29interface ResultReadBudget {
30 remainingBytes: number;
31 remainingNodes: number;
32 exceeded: boolean;
33}
34
35interface ResultEnvelope {
36 data?: Record<PropertyKey, unknown>;
37 failed: boolean;
38}
39
40interface CompactToolOutput {
41 isError?: true;
42 threadId?: string;
43 messageId?: string;
44 taskId?: string;
45 scheduledTaskId?: string;
46 status?: "rolled_back";
47 htmlRender?: HtmlRenderReference;
48 [MCP_APP_OUTPUT_KEY]?: McpAppReference;
49 thread?: { threadId: string };
50 threads?: Array<{ threadId?: string; status?: "rolled_back" }>;
51 linkedCount?: number;
52 unlinkedCount?: number;
53}
54
55function readResult(value: unknown, budget: ResultReadBudget, depth = 0): ResultEnvelope {
56 if (depth > MAX_ENVELOPE_DEPTH || budget.remainingNodes-- <= 0) {
57 budget.exceeded = true;
58 return { failed: false };
59 }
60 if (typeof value === "string") {
61 if (value.length > budget.remainingBytes) {
62 budget.exceeded = true;
63 return { failed: false };
64 }
65 const bytes = encoder.encode(value).byteLength;
66 if (bytes > budget.remainingBytes) {
67 budget.exceeded = true;
68 return { failed: false };
69 }
70 budget.remainingBytes -= bytes;
71 try {
72 return readResult(JSON.parse(value), budget, depth + 1);
73 } catch {
74 return { failed: false };
75 }
76 }
77 if (Array.isArray(value)) {
78 if (value.length > MAX_CONTENT_BLOCKS) {
79 budget.exceeded = true;
80 return { failed: false };
81 }
82 let data: ResultEnvelope["data"];
83 let failed = false;
84 for (const block of value) {
85 const text = Predicate.isObject(block) ? block.text : undefined;
86 const result = readResult(
87 Predicate.isObject(text) ? (text.text ?? text) : text,
88 budget,
89 depth + 1,
90 );
91 data ??= result.data;
92 failed ||= result.failed;
93 if (budget.exceeded) break;
94 }
95 return { ...(data === undefined ? {} : { data }), failed };
96 }
97 if (!Predicate.isObject(value)) return { failed: false };
98 const failed =
99 value.isError === true ||
100 value.is_error === true ||
101 value._tag === "OrchestratorMcpFailure" ||
102 value.error != null;
103 const content = value.structuredContent ?? value.content;
104 if (content !== undefined) {
105 const nested = readResult(content, budget, depth + 1);
106 return { ...nested, failed: failed || nested.failed };
107 }
108 return { data: value, failed };
109}
110
111function boundedId(value: unknown): string | undefined {
112 if (typeof value !== "string" || value.length > MAX_ID_LENGTH || value.trim() === "") {
113 return undefined;
114 }
115 return Array.from(value).join("");
116}
117
118export function compactDynamicToolOutput(value: unknown): CompactToolOutput | undefined {
119 const budget: ResultReadBudget = {
120 remainingBytes: MAX_PARSED_BYTES,
121 remainingNodes: MAX_ENVELOPE_NODES,
122 exceeded: false,
123 };
124 const result = readResult(value, budget);
125 const output: CompactToolOutput = result.failed ? { isError: true } : {};
126 const data = budget.exceeded ? undefined : result.data;
127 if (data !== undefined) {
128 for (const key of ["threadId", "messageId", "taskId", "scheduledTaskId"] as const) {
129 const id = boundedId(data[key]);
130 if (id !== undefined) output[key] = id;
131 }
132 if (data.status === "rolled_back") output.status = "rolled_back";
133 for (const key of ["linkedCount", "unlinkedCount"] as const) {
134 const count = data[key];
135 if (typeof count === "number" && Number.isSafeInteger(count)) output[key] = count;
136 }
137 const htmlRender = readHtmlRenderReference(data.htmlRender);
138 if (htmlRender !== undefined) output.htmlRender = htmlRender;
139 const mcpApp = readMcpAppReference(data[MCP_APP_OUTPUT_KEY]);
140 if (mcpApp !== undefined) output[MCP_APP_OUTPUT_KEY] = mcpApp;
141 const nestedThreadId = Predicate.isObject(data.thread)
142 ? boundedId(data.thread.threadId)
143 : undefined;
144 if (nestedThreadId !== undefined) output.thread = { threadId: nestedThreadId };
145
146 if (Array.isArray(data.threads)) {
147 let complete = data.threads.length <= MAX_THREADS;
148 const threads: NonNullable<CompactToolOutput["threads"]> = [];
149 if (complete) {
150 for (const entry of data.threads) {
151 if (!Predicate.isObject(entry)) {
152 complete = false;
153 break;
154 }
155 const threadId = boundedId(entry.threadId);
156 const rolledBack = entry.status === "rolled_back";
157 if (threadId === undefined && !rolledBack) {
158 complete = false;
159 break;
160 }
161 threads.push({
162 ...(threadId === undefined ? {} : { threadId }),
163 ...(rolledBack ? { status: "rolled_back" as const } : {}),
164 });
165 }
166 }
167 if (complete) output.threads = threads;
168 else {
169 // Partial batches or top-level ids make the summary parser report a false creation count.
170 delete output.threadId;
171 delete output.status;
172 }
173 }
174 }
175 const oversized = () => encoder.encode(JSON.stringify(output)).byteLength > MAX_METADATA_BYTES;
176 if (oversized()) {
177 delete output.threads;
178 delete output.threadId;
179 delete output.status;
180 }
181 const app = output[MCP_APP_OUTPUT_KEY];
182 if (app?.csp !== undefined && oversized()) {
183 const { csp: _csp, ...rest } = app;
184 output[MCP_APP_OUTPUT_KEY] = rest;
185 }
186 if (oversized()) delete output[MCP_APP_OUTPUT_KEY];
187 return Object.keys(output).length === 0 ? undefined : output;
188}
189
190// OpenCode 2 Code Mode reports a script's nested T3 tool calls as one item with the script's result.
191const OPENCODE_CODE_MODE_TOOL_NAME = "execute";
192
193export function htmlRenderFromToolItem(item: {
194 readonly toolName: string | null | undefined;
195 readonly output?: unknown;
196}): HtmlRenderReference | undefined {
197 if (
198 item.toolName !== OPENCODE_CODE_MODE_TOOL_NAME &&
199 resolveT3McpToolId(item.toolName) !== HTML_RENDER_TOOL_NAME
200 ) {
201 return undefined;
202 }
203 const output = compactDynamicToolOutput(item.output);
204 return output?.isError ? undefined : output?.htmlRender;
205}
206
207/** The thread-owned file `preview_recording_stop` stored for the agent. */
208export function previewRecordingIdFromToolItem(item: {
209 readonly toolName: string | null | undefined;
210 readonly output?: unknown;
211}): string | undefined {
212 if (resolveT3McpToolId(item.toolName) !== "preview_recording_stop") return undefined;
213 const budget: ResultReadBudget = {
214 remainingBytes: MAX_PARSED_BYTES,
215 remainingNodes: MAX_ENVELOPE_NODES,
216 exceeded: false,
217 };
218 const result = readResult(item.output, budget);
219 return result.failed || budget.exceeded ? undefined : boundedId(result.data?.id);
220}
221
222/** Accept app references only when server and tool match the item, so tools cannot impersonate another app source. */
223export function mcpAppFromToolItem(item: {
224 readonly toolName: string | null | undefined;
225 readonly output?: unknown;
226}): McpAppReference | undefined {
227 const app = compactDynamicToolOutput(item.output)?.[MCP_APP_OUTPUT_KEY];
228 return app !== undefined &&
229 (item.toolName === `${app.server}.${app.tool}` ||
230 item.toolName === claudeMcpToolName(app.server, app.tool))
231 ? app
232 : undefined;
233}
234
235// A superset of every needle below, so most outputs are rejected by this single scan.
236const FAILURE_HINT =
237 /enoent|no such|no files found|not found|commandnotfound|not recognized|cannot (?:find path|be found)|exit/i;
238
239/** Some providers report completion even when command output describes a failure. */
240export function toolOutputIndicatesFailure(text: string): boolean {
241 return (
242 FAILURE_HINT.test(text) &&
243 (/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(
244 text,
245 ) ||
246 (/cannot find path/i.test(text) && /because it does not exist/i.test(text)) ||
247 (/is not recognized/i.test(text) && /the term '/i.test(text)) ||
248 /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) ||
249 /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text))
250 );
251}
252
253export interface ToolOutputImage {
254 readonly mimeType: string;
255 readonly data?: string;
256}
257
258/** Raster types only, so agent SVG/HTML cannot be served inline. */
259export function readToolOutputImage(block: unknown): ToolOutputImage | null {
260 if (!Predicate.isObject(block) || block.type !== "image") return null;
261 const source = Predicate.isObject(block.source) ? block.source : undefined;
262 if (source !== undefined && source.type !== "base64") return null;
263 const mimeType = source === undefined ? block.mimeType : source.media_type;
264 const data = source === undefined ? block.data : source.data;
265 if (typeof mimeType !== "string" || !isProviderSendTurnSupportedImageMimeType(mimeType)) {
266 return null;
267 }
268 const image = { mimeType: mimeType.toLowerCase() };
269 return typeof data === "string" ? { ...image, data } : image;
270}
271
272function outputBlocks(value: unknown): ReadonlyArray<unknown> {
273 if (Array.isArray(value)) return value;
274 if (Predicate.isObject(value) && Array.isArray(value.content)) return value.content;
275 return [value];
276}
277
278export const MAX_TOOL_OUTPUT_IMAGES = 8;
279
280export const MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH =
281 Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4;
282
283export function toolOutputImageBlocks(value: unknown): ReadonlyArray<unknown> {
284 const blocks: unknown[] = [];
285 for (const block of outputBlocks(value)) {
286 if (readToolOutputImage(block) === null) continue;
287 blocks.push(block);
288 if (blocks.length === MAX_TOOL_OUTPUT_IMAGES) break;
289 }
290 return blocks;
291}
292
293/** Order defines the shared tool-output-image asset index. */
294export function toolOutputImages(value: unknown): ReadonlyArray<ToolOutputImage> {
295 return toolOutputImageBlocks(value).flatMap((block) => readToolOutputImage(block) ?? []);
296}
297
298export function mcpToolResultOutput(result: {
299 readonly content?: unknown;
300 readonly structuredContent?: unknown;
301}): unknown {
302 if (result.structuredContent != null && toolOutputImages(result.content).length > 0) {
303 return { content: result.content, structuredContent: result.structuredContent };
304 }
305 return result.structuredContent ?? result.content;
306}
307
308export function omitToolOutputImageData(value: unknown): unknown {
309 const omit = (block: unknown) => {
310 const image = readToolOutputImage(block);
311 return image?.data === undefined ? block : { type: "image", mimeType: image.mimeType };
312 };
313 if (Array.isArray(value)) return value.map(omit);
314 if (Predicate.isObject(value) && Array.isArray(value.content)) {
315 return { ...value, content: value.content.map(omit) };
316 }
317 return omit(value);
318}