diff --git a/.changeset/toolfile-base64url.md b/.changeset/toolfile-base64url.md new file mode 100644 index 0000000000..7f26928638 --- /dev/null +++ b/.changeset/toolfile-base64url.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Emitted `ToolFile` output now accepts base64url data, padded or unpadded, such as Gmail message bodies. Before, this failed with "Internal tool error". File data that is not valid base64 now shows a short note in place of the file, and the rest of the result still comes back. diff --git a/packages/core/execution/src/skills.ts b/packages/core/execution/src/skills.ts index 9bf8f43ef0..2377b8ab58 100644 --- a/packages/core/execution/src/skills.ts +++ b/packages/core/execution/src/skills.ts @@ -47,7 +47,7 @@ const EXECUTE_SKILL_BODY = [ "- Tool calls return a value union: `{ ok: true, data }` for success or `{ ok: false, error: { code, message, status?, details?, retryable? } }` for expected tool/domain failures. Branch on `result.ok`.", "- `data` is the upstream payload itself. HTTP-backed tools (OpenAPI) also set `http: { status, headers }` beside `data` — read `result.http?.headers` for pagination (Link) or rate-limit headers.", "- Use `emit(value)` to append user-visible output. Plain values become MCP text content. MCP content blocks are forwarded as-is. `ToolFile` values are rendered by MIME. Emitting and returning compose: emitted items come first in the tool result, the returned value follows, and the envelope reports an `emitted` count so you can confirm the items landed.", - '- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. A "file-returning tool" includes APIs that return file bytes inside a JSON field, such as Base64-encoded `content`; wrap those payloads in a `ToolFile` and `emit()` them. Emit any attachment with `emit(result.data)`.', + '- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. A "file-returning tool" includes APIs that return file bytes inside a JSON field, such as Base64-encoded `content`; wrap those payloads in a `ToolFile` and `emit()` them. `data` may be standard base64 or base64url (e.g. Gmail `payload.body.data`), padded or not — keep `encoding: "base64"` and pass it through unchanged. Emit any attachment with `emit(result.data)`.', "- Never decode or transcode bytes yourself — the sandbox has no `Buffer`, `atob`, `btoa`, `TextDecoder`, or `TextEncoder`. For base64-encoded bytes in a JSON field, wrap the payload in a `ToolFile` with its `mimeType` and `emit()` it; to forward them to an upload tool, pass the `ToolFile`'s base64 `data` as that tool's `bodyBase64` (both sides speak base64, so nothing is decoded).", '- To emit MCP-native content directly, pass an MCP content block to `emit(...)`, such as `{ type: "image", data, mimeType }`, `{ type: "audio", data, mimeType }`, `{ type: "text", text }`, `{ type: "resource", resource }`, or `{ type: "resource_link", uri, name, ... }`.', "- `emit(ToolFile)` is MIME-based: `image/*` becomes MCP image content, `audio/*` becomes MCP audio content, text-like files become decoded text, and other binary files become embedded MCP resources.", diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index 04fd4fca28..eebda3106a 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -729,6 +729,109 @@ describe("MCP host server — native elicitation mode", () => { }); }); + it("execute tool decodes unpadded base64url text file output (Gmail message bodies)", async () => { + const engine = makeStubEngine({ + execute: () => + Effect.succeed({ + result: null, + output: [ + { + type: "file", + file: toolFile({ + name: "body.txt", + mimeType: "text/plain; charset=UTF-8", + data: "5Lu25ZCNOiDjgZPjgpPjgavjgaHjga_kuJbnlYwg4oCUIOODhuOCueODiD8-Pg", + byteLength: 46, + }), + }, + ], + }), + }); + + await withNativeClient(engine, ELICITATION_CAPS, async (client) => { + const result = await client.callTool({ + name: "execute", + arguments: { code: "emit(body);" }, + }); + + const content = result.content as Array>; + expect(content[1]).toMatchObject({ + type: "text", + text: "件名: こんにちは世界 — テスト?>>", + }); + expect(result.isError).toBeFalsy(); + }); + }); + + it("execute tool hands MCP clients standard padded base64 for base64url binary file output", async () => { + const engine = makeStubEngine({ + execute: () => + Effect.succeed({ + result: null, + output: [ + { + type: "file", + file: toolFile({ + name: "blob.bin", + mimeType: "application/octet-stream", + data: "-_8", + byteLength: 2, + }), + }, + ], + }), + }); + + await withNativeClient(engine, ELICITATION_CAPS, async (client) => { + const result = await client.callTool({ + name: "execute", + arguments: { code: "emit(blob);" }, + }); + + const content = result.content as Array>; + expect(content[1]).toMatchObject({ + type: "resource", + resource: { blob: "+/8=" }, + }); + expect(result.isError).toBeFalsy(); + }); + }); + + it("execute tool reports undecodable file output instead of failing the call", async () => { + const engine = makeStubEngine({ + execute: () => + Effect.succeed({ + result: { kept: true }, + output: [ + { + type: "file", + file: toolFile({ + name: "body.txt", + mimeType: "text/plain", + data: "not base64!", + byteLength: 11, + }), + }, + ], + }), + }); + + await withNativeClient(engine, ELICITATION_CAPS, async (client) => { + const result = await client.callTool({ + name: "execute", + arguments: { code: "emit(body); return { kept: true };" }, + }); + + const content = result.content as Array>; + expect(content[1]).toMatchObject({ + type: "text", + text: "File output omitted: body.txt data is not valid base64 or base64url.", + }); + expect(result.structuredContent).toMatchObject({ status: "completed" }); + expect(result.isError).toBeFalsy(); + }); + }); + it("execute tool surfaces failed engine effects as an opaque generic with correlation id", async () => { const engine = makeStubEngine({ execute: () => Effect.fail(new TestExecutionError({ message: "Unexpected token ':'" })), diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index f7c1a349f7..1cf2a6c2c2 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -600,6 +600,20 @@ const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resour return "resource"; }; +/** + * Agents wrap upstream payloads in a `ToolFile` verbatim, and some upstreams + * (Gmail message bodies) use unpadded base64url. Accept either alphabet and + * hand MCP clients standard padded base64. Returns null when the data is not + * base64 in either alphabet. + */ +const standardBase64 = (data: string): string | null => { + const alphabet = data.replace(/\s/g, "").replace(/-/g, "+").replace(/_/g, "/"); + const unpadded = alphabet.replace(/=+$/, ""); + if (!/^[A-Za-z0-9+/]*$/.test(unpadded) || unpadded.length % 4 === 1) return null; + const remainder = unpadded.length % 4; + return remainder === 0 ? unpadded : `${unpadded}${"=".repeat(4 - remainder)}`; +}; + const bytesFromBase64 = (base64: string): Uint8Array => { const binary = atob(base64); const bytes = new Uint8Array(binary.length); @@ -609,8 +623,8 @@ const bytesFromBase64 = (base64: string): Uint8Array => { return bytes; }; -const decodeTextFile = (file: ToolFileValue): string => { - const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(file.data)); +const decodeTextFile = (base64: string): string => { + const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(base64)); if (text.length <= TEXT_FILE_CONTENT_MAX_CHARS) return text; return `${text.slice(0, TEXT_FILE_CONTENT_MAX_CHARS)}\n\n[truncated ${ text.length - TEXT_FILE_CONTENT_MAX_CHARS @@ -618,15 +632,24 @@ const decodeTextFile = (file: ToolFileValue): string => { }; const toolFileContent = (file: ToolFileValue): ContentBlock[] => { + const data = standardBase64(file.data); + if (data === null) { + return [ + { + type: "text", + text: `File output omitted: ${toolFileName(file)} data is not valid base64 or base64url.`, + }, + ]; + } const kind = toolFileKind(file); if (kind === "image") { - return [{ type: "image", data: file.data, mimeType: file.mimeType }]; + return [{ type: "image", data, mimeType: file.mimeType }]; } if (kind === "audio") { - return [{ type: "audio", data: file.data, mimeType: file.mimeType }]; + return [{ type: "audio", data, mimeType: file.mimeType }]; } if (kind === "text") { - return [{ type: "text", text: decodeTextFile(file) }]; + return [{ type: "text", text: decodeTextFile(data) }]; } return [ { @@ -634,7 +657,7 @@ const toolFileContent = (file: ToolFileValue): ContentBlock[] => { resource: { uri: fileResourceUri(file), mimeType: file.mimeType, - blob: file.data, + blob: data, }, }, ];