History

Use emit for code mode outputs

Rhys Sullivan committed 4 months ago

Commit 5d4f1121 parent 5cd7a7715 files · +817 −222Browse files

Unified · Split

15 files
  1. .changeset/emit-code-mode-outputs.md +7 −0
  2. packages/core/execution/src/description.test.ts +7 −0
  3. packages/core/execution/src/description.ts +6 −0
  4. packages/hosts/mcp/src/tool-server.test.ts +310 −74
  5. packages/hosts/mcp/src/tool-server.ts +53 −139
  6. packages/kernel/core/src/types.ts +12 −0
  7. packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs +89 −2
  8. packages/kernel/runtime-deno-subprocess/src/index.test.ts +77 −0
  9. packages/kernel/runtime-deno-subprocess/src/index.ts +10 −0
  10. packages/kernel/runtime-dynamic-worker/src/executor.test.ts +3 −2
  11. packages/kernel/runtime-dynamic-worker/src/executor.ts +4 −0
  12. packages/kernel/runtime-dynamic-worker/src/invocation.test.ts +84 −0
  13. packages/kernel/runtime-dynamic-worker/src/module-template.ts +35 −5
  14. packages/kernel/runtime-quickjs/src/index.test.ts +81 −0
  15. packages/kernel/runtime-quickjs/src/index.ts +39 −0
.changeset/emit-code-mode-outputs.md+7−0
@@ -0,0 +1,7 @@
1+---
2+"executor": major
3+---
4+
5+Replace the code-mode output helpers with a single `emit(value)` primitive.
6+`emit(...)` accepts plain values, `ToolFile` attachments, and MCP content blocks,
7+while `return` remains reserved for ordinary structured data.
packages/core/execution/src/description.test.ts+7−0
@@ -95,6 +95,13 @@ describe("buildExecuteDescription", () => { · 94 unmodified lines
9595
9696 // Stable anchor from the workflow preamble.
9797 expect(description).toContain("Execute TypeScript in a sandboxed runtime");
98+ expect(description).toContain("Use `emit(value)` to append user-visible output");
99+ expect(description).toContain("Emit any attachment with `emit(result.data)`");
100+ expect(description).toContain("pass an MCP content block to `emit(...)`");
101+ expect(description).toContain("`emit(ToolFile)` is MIME-based");
102+ expect(description).toContain(
103+ "Returning a `ToolFile`, a `ToolResult`, an MCP content block, or a bare base64 string does not emit content",
104+ );
98105 expect(description).toContain("## Available connection prefixes");
99106 expect(description).toContain("- `github.org.prod`");
100107 expect(description).toContain("- `github.user.personal`");
packages/core/execution/src/description.ts+6−0
@@ -115,6 +115,12 @@ const formatDescription = (connectionEntries: readonly ConnectionInventoryEntry[ · 114 unmodified lines
115115 "- `tools.executor.coreTools.connections.list({})` returns saved connections with `{ address, integration, owner, name, ... }`. The `address` field includes the leading `tools.` root.",
116116 "- 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`.",
117117 "- `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.",
118+ "- Use `emit(value)` to append user-visible output and return `undefined`. Plain values become MCP text content. MCP content blocks are forwarded as-is. `ToolFile` values are rendered by MIME.",
119+ '- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. Emit any attachment with `emit(result.data)`.',
120+ '- 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, ... }`.',
121+ "- `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.",
122+ "- `return` is only for ordinary structured data. Returning a `ToolFile`, a `ToolResult`, an MCP content block, or a bare base64 string does not emit content to the MCP client.",
123+ "- Some providers, including Gmail, return attachment bytes without a public URL. To send that attachment to another API from code, decode `ToolFile.data` from base64 and pass the bytes to that API's upload/file input.",
118124 "- If `tools.search()` returns `hasMore: true` and you didn't find what you need, fetch the next page: `tools.search({ query, offset: nextOffset, limit })`.",
119125 "- Always use the full address when calling tools: `tools.<integration>.<owner>.<connection>.<tool>(args)`. The `path` returned by `tools.search()` / `tools.describe.tool()` is already the exact path under `tools` — call `tools[path]` rather than guessing segments.",
120126 "- The `tools` object is a lazy proxy — `Object.keys(tools)` won't work. Use `tools.search()` or `tools.executor.coreTools.connections.list({})` instead.",
packages/hosts/mcp/src/tool-server.test.ts+310−74
@@ -148,7 +148,201 @@ describe("MCP host server — native elicitation mode", () => { · 147 unmodified lines
148148 });
149149 });
150150
151− it("execute tool renders image ToolFile results as MCP images", async () => {
151+ it("execute tool renders emitted file image output as MCP images", async () => {
152+ const engine = makeStubEngine({
153+ execute: () =>
154+ Effect.succeed({
155+ result: { keptReturn: true },
156+ output: [
157+ {
158+ type: "file",
159+ file: toolFile({
160+ name: "photo.png",
161+ mimeType: "image/png",
162+ data: "iVBORw0KGgo=",
163+ byteLength: 8,
164+ }),
165+ },
166+ ],
167+ }),
168+ });
169+
170+ await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
171+ const result = await client.callTool({
172+ name: "execute",
173+ arguments: { code: "emit(attachment.data);" },
174+ });
175+
176+ const content = result.content as Array<Record<string, unknown>>;
177+ expect(content[0]).toMatchObject({
178+ type: "text",
179+ text: "File output: photo.png (image/png, 8 bytes)",
180+ });
181+ expect(content[1]).toMatchObject({
182+ type: "image",
183+ data: "iVBORw0KGgo=",
184+ mimeType: "image/png",
185+ });
186+ expect(result.structuredContent).toMatchObject({
187+ status: "completed",
188+ result: { keptReturn: true },
189+ });
190+ expect(result.isError).toBeFalsy();
191+ });
192+ });
193+
194+ it("execute tool renders emitted MCP image content as MCP images", async () => {
195+ const engine = makeStubEngine({
196+ execute: () =>
197+ Effect.succeed({
198+ result: null,
199+ output: [
200+ {
201+ type: "content",
202+ content: {
203+ type: "text",
204+ text: "Deterministic image fixture",
205+ },
206+ },
207+ {
208+ type: "content",
209+ content: {
210+ type: "image",
211+ data: TEST_IMAGE_PNG_BASE64,
212+ mimeType: TEST_IMAGE_MIME_TYPE,
213+ },
214+ },
215+ ],
216+ }),
217+ });
218+
219+ await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
220+ const result = await client.callTool({
221+ name: "execute",
222+ arguments: {
223+ code: "emit(result.content[1]);",
224+ },
225+ });
226+
227+ expect(result.content).toEqual([
228+ {
229+ type: "text",
230+ text: "Deterministic image fixture",
231+ },
232+ {
233+ type: "image",
234+ data: TEST_IMAGE_PNG_BASE64,
235+ mimeType: TEST_IMAGE_MIME_TYPE,
236+ },
237+ ]);
238+ expect(result.structuredContent).toMatchObject({
239+ status: "completed",
240+ result: null,
241+ });
242+ expect(result.isError).toBeFalsy();
243+ });
244+ });
245+
246+ it("execute tool renders explicit upstream MCP content output unchanged", async () => {
247+ const engine = makeStubEngine({
248+ execute: () =>
249+ Effect.succeed({
250+ result: { forwarded: true },
251+ output: [
252+ {
253+ type: "content",
254+ content: {
255+ type: "text",
256+ text: "forwarded text",
257+ },
258+ },
259+ {
260+ type: "content",
261+ content: {
262+ type: "image",
263+ data: TEST_IMAGE_PNG_BASE64,
264+ mimeType: TEST_IMAGE_MIME_TYPE,
265+ },
266+ },
267+ {
268+ type: "content",
269+ content: {
270+ type: "audio",
271+ data: "SUQz",
272+ mimeType: "audio/mpeg",
273+ },
274+ },
275+ {
276+ type: "content",
277+ content: {
278+ type: "resource",
279+ resource: {
280+ uri: "executor-file:///report.pdf",
281+ mimeType: "application/pdf",
282+ blob: "JVBERg==",
283+ },
284+ },
285+ },
286+ {
287+ type: "content",
288+ content: {
289+ type: "resource_link",
290+ uri: "executor-file:///remote.pdf",
291+ name: "remote.pdf",
292+ mimeType: "application/pdf",
293+ },
294+ },
295+ ],
296+ }),
297+ });
298+
299+ await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
300+ const result = await client.callTool({
301+ name: "execute",
302+ arguments: {
303+ code: "for (const block of result.data.content) emit(block);",
304+ },
305+ });
306+
307+ expect(result.content).toEqual([
308+ {
309+ type: "text",
310+ text: "forwarded text",
311+ },
312+ {
313+ type: "image",
314+ data: TEST_IMAGE_PNG_BASE64,
315+ mimeType: TEST_IMAGE_MIME_TYPE,
316+ },
317+ {
318+ type: "audio",
319+ data: "SUQz",
320+ mimeType: "audio/mpeg",
321+ },
322+ {
323+ type: "resource",
324+ resource: {
325+ uri: "executor-file:///report.pdf",
326+ mimeType: "application/pdf",
327+ blob: "JVBERg==",
328+ },
329+ },
330+ {
331+ type: "resource_link",
332+ uri: "executor-file:///remote.pdf",
333+ name: "remote.pdf",
334+ mimeType: "application/pdf",
335+ },
336+ ]);
337+ expect(result.structuredContent).toMatchObject({
338+ status: "completed",
339+ result: { forwarded: true },
340+ });
341+ expect(result.isError).toBeFalsy();
342+ });
343+ });
344+
345+ it("execute tool does not render returned ToolFile values as files", async () => {
152346 const engine = makeStubEngine({
153347 execute: () =>
154348 Effect.succeed({
@@ -170,21 +364,17 @@ describe("MCP host server — native elicitation mode", () => { · 15 unmodified lines
170364 });
171365
172366 const content = result.content as Array<Record<string, unknown>>;
173− expect(content[0]).toMatchObject({
174− type: "text",
175− text: "File output: photo.png\nimage/png, 8 bytes",
176− });
177− expect(content[1]).toMatchObject({
178− type: "image",
179− data: "iVBORw0KGgo=",
180− mimeType: "image/png",
367+ expect(content).toHaveLength(1);
368+ expect(String(content[0]?.text ?? "")).toContain('"_tag": "ToolFile"');
369+ expect(String(content[0]?.text ?? "")).toContain("iVBORw0KGgo=");
370+ expect(result.structuredContent).toMatchObject({
371+ status: "completed",
181372 });
182− expect(result.structuredContent).toBeUndefined();
183373 expect(result.isError).toBeFalsy();
184374 });
185375 });
186376
187− it("execute tool preserves upstream MCP image content as native content", async () => {
377+ it("execute tool does not render returned upstream MCP image results as images", async () => {
188378 const engine = makeStubEngine({
189379 execute: () =>
190380 Effect.succeed({
@@ -217,126 +407,182 @@ describe("MCP host server — native elicitation mode", () => { · 26 unmodified lines
217407 },
218408 });
219409
220− expect(result.content).toEqual([
221− {
222− type: "text",
223− text: "Deterministic image fixture: mcp-image-fixture.png (image/png, 70 bytes)",
224− },
225− {
226− type: "image",
227− data: TEST_IMAGE_PNG_BASE64,
228− mimeType: TEST_IMAGE_MIME_TYPE,
229− },
230− ]);
231− expect(result.structuredContent).toEqual({
232− name: "mcp-image-fixture.png",
233− mimeType: TEST_IMAGE_MIME_TYPE,
234− byteLength: 70,
410+ const content = result.content as Array<Record<string, unknown>>;
411+ expect(content).toHaveLength(1);
412+ expect(String(content[0]?.text ?? "")).toContain('"type": "image"');
413+ expect(String(content[0]?.text ?? "")).toContain(TEST_IMAGE_PNG_BASE64);
414+ expect(result.structuredContent).toMatchObject({
415+ status: "completed",
235416 });
236417 expect(result.isError).toBeFalsy();
237418 });
238419 });
239420
240− it("execute tool strips nested ToolFile bytes while preserving result metadata", async () => {
421+ it("execute tool renders mixed emitted text and file output in order", async () => {
241422 const engine = makeStubEngine({
242423 execute: () =>
243424 Effect.succeed({
244− result: {
245− subject: "Flight receipt",
246− attachment: ToolResult.ok(
247− toolFile({
425+ result: { subject: "Flight receipt" },
426+ output: [
427+ {
428+ type: "content",
429+ content: {
430+ type: "text",
431+ text: "Flight receipt",
432+ },
433+ },
434+ {
435+ type: "file",
436+ file: toolFile({
248437 name: "boarding-pass.png",
249438 mimeType: "image/png",
250439 data: "iVBORw0KGgo=",
251440 byteLength: 8,
252441 }),
253− ),
254− },
442+ },
443+ ],
255444 }),
256445 });
257446
258447 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
259448 const result = await client.callTool({
260449 name: "execute",
261− arguments: { code: "return { subject, attachment };" },
450+ arguments: { code: "emit(subject); emit(attachment.data);" },
262451 });
263452
264453 const content = result.content as Array<Record<string, unknown>>;
265− const text = String(content[0]?.text ?? "");
266− expect(text).toContain("File outputs:\n1. boarding-pass.png (image/png, 8 bytes)");
267− expect(text).toContain("Result metadata:");
268− expect(text).toContain("Flight receipt");
269− expect(text).not.toContain("iVBORw0KGgo=");
454+ expect(content[0]).toMatchObject({ type: "text", text: "Flight receipt" });
270455 expect(content[1]).toMatchObject({
456+ type: "text",
457+ text: "File output: boarding-pass.png (image/png, 8 bytes)",
458+ });
459+ expect(content[2]).toMatchObject({
271460 type: "image",
272461 data: "iVBORw0KGgo=",
273462 mimeType: "image/png",
274463 });
275− expect(result.structuredContent).toBeUndefined();
464+ expect(result.structuredContent).toMatchObject({
465+ status: "completed",
466+ result: { subject: "Flight receipt" },
467+ });
276468 expect(result.isError).toBeFalsy();
277469 });
278470 });
279471
280− it("execute tool renders text-like ToolFile results as MCP text without structured content", async () => {
472+ it("execute tool renders text-like emitted file output as MCP text", async () => {
281473 const engine = makeStubEngine({
282474 execute: () =>
283475 Effect.succeed({
284− result: ToolResult.ok(
285− toolFile({
286− name: "rows.csv",
287− mimeType: "text/csv",
288− data: "YSxiCjEsMgo=",
289− byteLength: 8,
290− }),
291− ),
476+ result: null,
477+ output: [
478+ {
479+ type: "file",
480+ file: toolFile({
481+ name: "rows.csv",
482+ mimeType: "text/csv",
483+ data: "YSxiCjEsMgo=",
484+ byteLength: 8,
485+ }),
486+ },
487+ ],
292488 }),
293489 });
294490
295491 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
296492 const result = await client.callTool({
297493 name: "execute",
298− arguments: { code: "return await tools.files.org.main.getCsv({});" },
494+ arguments: { code: "emit(csv.data);" },
299495 });
300496
301497 const content = result.content as Array<Record<string, unknown>>;
302498 expect(content[0]).toMatchObject({
303499 type: "text",
304− text: "File output: rows.csv\ntext/csv, 8 bytes",
500+ text: "File output: rows.csv (text/csv, 8 bytes)",
305501 });
306502 expect(content[1]).toMatchObject({
307503 type: "text",
308504 text: "a,b\n1,2\n",
309505 });
310− expect(result.structuredContent).toBeUndefined();
506+ expect(result.structuredContent).toMatchObject({
507+ status: "completed",
508+ result: null,
509+ });
311510 expect(result.isError).toBeFalsy();
312511 });
313512 });
314513
315− it("execute tool renders opaque binary ToolFile results as embedded MCP resources", async () => {
514+ it("execute tool renders emitted audio file output as MCP audio", async () => {
316515 const engine = makeStubEngine({
317516 execute: () =>
318517 Effect.succeed({
319− result: ToolResult.ok(
320− toolFile({
321− name: "report.pdf",
322− mimeType: "application/pdf",
323− data: "JVBERg==",
324− byteLength: 4,
325− }),
326− ),
518+ result: null,
519+ output: [
520+ {
521+ type: "file",
522+ file: toolFile({
523+ name: "clip.mp3",
524+ mimeType: "audio/mpeg",
525+ data: "SUQzBAAAAAAA",
526+ byteLength: 9,
527+ }),
528+ },
529+ ],
327530 }),
328531 });
329532
330533 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
331534 const result = await client.callTool({
332535 name: "execute",
333− arguments: { code: "return await tools.gmail.org.main.getAttachment({});" },
536+ arguments: { code: "emit(audio.data);" },
334537 });
335538
336539 const content = result.content as Array<Record<string, unknown>>;
337540 expect(content[0]).toMatchObject({
338541 type: "text",
339− text: "File output: report.pdf\napplication/pdf, 4 bytes",
542+ text: "File output: clip.mp3 (audio/mpeg, 9 bytes)",
543+ });
544+ expect(content[1]).toMatchObject({
545+ type: "audio",
546+ data: "SUQzBAAAAAAA",
547+ mimeType: "audio/mpeg",
548+ });
549+ expect(result.structuredContent).toMatchObject({
550+ status: "completed",
551+ result: null,
552+ });
553+ expect(result.isError).toBeFalsy();
554+ });
555+ });
556+
557+ it("execute tool renders opaque binary emitted file output as embedded MCP resources", async () => {
558+ const engine = makeStubEngine({
559+ execute: () =>
560+ Effect.succeed({
561+ result: null,
562+ output: [
563+ {
564+ type: "file",
565+ file: toolFile({
566+ name: "report.pdf",
567+ mimeType: "application/pdf",
568+ data: "JVBERg==",
569+ byteLength: 4,
570+ }),
571+ },
572+ ],
573+ }),
574+ });
575+
576+ await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
577+ const result = await client.callTool({
578+ name: "execute",
579+ arguments: { code: "emit(attachment.data);" },
580+ });
581+
582+ const content = result.content as Array<Record<string, unknown>>;
583+ expect(content[0]).toMatchObject({
584+ type: "text",
585+ text: "File output: report.pdf (application/pdf, 4 bytes)",
340586 });
341587 expect(content[1]).toMatchObject({
342588 type: "resource",
@@ -348,18 +594,8 @@ describe("MCP host server — native elicitation mode", () => { · 5 unmodified lines
348594 });
349595 expect(result.structuredContent).toMatchObject({
350596 status: "completed",
351− result: {
352− ok: true,
353− data: {
354− _tag: "ToolFile",
355− name: "report.pdf",
356− mimeType: "application/pdf",
357− encoding: "base64",
358− byteLength: 4,
359− },
360− },
597+ result: null,
361598 });
362− expect(JSON.stringify(result.structuredContent)).not.toContain("JVBERg==");
363599 expect(result.isError).toBeFalsy();
364600 });
365601 });
packages/hosts/mcp/src/tool-server.ts+53−139
@@ -1,7 +1,7 @@
11 import { Effect, Match, Option, Schema } from "effect";
22 import * as Cause from "effect/Cause";
33 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4−import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
4+import { ContentBlockSchema, type ContentBlock } from "@modelcontextprotocol/sdk/types.js";
55 import type {
66 jsonSchemaValidator,
77 JsonSchemaType,
@@ -10,7 +10,7 @@ import type { · 2 unmodified lines
1010 import { Validator } from "@cfworker/json-schema";
1111 import * as z from "zod/v4";
1212
13−import { isToolFile, isToolResult } from "@executor-js/sdk";
13+import { isToolFile } from "@executor-js/sdk";
1414 import type {
1515 ElicitationResponse,
1616 ElicitationHandler,
@@ -272,109 +272,13 @@ type McpToolResult = { · 255 unmodified lines
272272 };
273273
274274 type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0];
275+type ExecuteOutputItem = NonNullable<FormattedExecuteInput["output"]>[number];
275276
276277 const TEXT_FILE_CONTENT_MAX_CHARS = 64_000;
277278
278279 const isRecord = (value: unknown): value is Record<string, unknown> =>
279280 typeof value === "object" && value !== null && !Array.isArray(value);
280281
281−const isTextContentBlock = (value: unknown): value is Extract<ContentBlock, { type: "text" }> =>
282− isRecord(value) && value.type === "text" && typeof value.text === "string";
283−
284−const isImageContentBlock = (value: unknown): value is Extract<ContentBlock, { type: "image" }> =>
285− isRecord(value) &&
286− value.type === "image" &&
287− typeof value.data === "string" &&
288− typeof value.mimeType === "string";
289−
290−const isResourceContentBlock = (
291− value: unknown,
292−): value is Extract<ContentBlock, { type: "resource" }> =>
293− isRecord(value) && value.type === "resource" && isRecord(value.resource);
294−
295−const isNativeMcpContentBlock = (value: unknown): value is ContentBlock =>
296− isTextContentBlock(value) || isImageContentBlock(value) || isResourceContentBlock(value);
297−
298−const nativeMcpContentResult = (value: unknown): McpToolResult | null => {
299− if (!isToolResult(value) || !value.ok || !isRecord(value.data)) return null;
300− const content = value.data.content;
301− if (!Array.isArray(content) || content.length === 0 || !content.every(isNativeMcpContentBlock)) {
302− return null;
303− }
304− if (!content.some((block) => block.type !== "text")) return null;
305− return {
306− content,
307− ...(isRecord(value.data.structuredContent)
308− ? { structuredContent: value.data.structuredContent }
309− : {}),
310− isError: value.data.isError === true || undefined,
311− };
312−};
313−
314−const redactToolFileBytes = (file: ToolFileValue): Record<string, unknown> => ({
315− _tag: "ToolFile",
316− ...(file.name ? { name: file.name } : {}),
317− mimeType: file.mimeType,
318− encoding: file.encoding,
319− byteLength: file.byteLength,
320−});
321−
322−type ToolFileExtraction = {
323− readonly value: unknown;
324− readonly files: readonly ToolFileValue[];
325−};
326−
327−const extractToolFiles = (
328− value: unknown,
329− seen: WeakSet<object> = new WeakSet(),
330−): ToolFileExtraction => {
331− if (isToolFile(value)) {
332− return { value: redactToolFileBytes(value), files: [value] };
333− }
334−
335− if (isToolResult(value) && value.ok) {
336− const data = extractToolFiles(value.data, seen);
337− if (data.files.length === 0) return { value, files: [] };
338− return {
339− value: {
340− ...value,
341− data: data.value,
342− },
343− files: data.files,
344− };
345− }
346−
347− if (Array.isArray(value)) {
348− const files: ToolFileValue[] = [];
349− const items = value.map((item) => {
350− const extracted = extractToolFiles(item, seen);
351− files.push(...extracted.files);
352− return extracted.value;
353− });
354− return { value: files.length > 0 ? items : value, files };
355− }
356−
357− if (!isRecord(value)) return { value, files: [] };
358− if (seen.has(value)) return { value: "[Circular]", files: [] };
359− seen.add(value);
360−
361− const files: ToolFileValue[] = [];
362− const entries = Object.entries(value).map(([key, item]) => {
363− const extracted = extractToolFiles(item, seen);
364− files.push(...extracted.files);
365− return [key, extracted.value] as const;
366− });
367− seen.delete(value);
368−
369− return { value: files.length > 0 ? Object.fromEntries(entries) : value, files };
370−};
371−
372−const directToolFile = (value: unknown): ToolFileValue | null => {
373− if (isToolFile(value)) return value;
374− if (isToolResult(value) && value.ok && isToolFile(value.data)) return value.data;
375− return null;
376−};
377−
378282 const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output";
379283
380284 const fileResourceUri = (file: ToolFileValue): string =>
@@ -383,9 +287,10 @@ const fileResourceUri = (file: ToolFileValue): string => · 2 unmodified lines
383287 const normalizedMimeType = (file: ToolFileValue): string =>
384288 file.mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
385289
386−const toolFileKind = (file: ToolFileValue): "image" | "text" | "resource" => {
290+const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resource" => {
387291 const mimeType = normalizedMimeType(file);
388292 if (mimeType.startsWith("image/")) return "image";
293+ if (mimeType.startsWith("audio/")) return "audio";
389294 if (
390295 mimeType.startsWith("text/") ||
391296 mimeType === "application/json" ||
@@ -424,6 +329,9 @@ const toolFileContent = (file: ToolFileValue): ContentBlock[] => { · 32 unmodified lines
424329 if (kind === "image") {
425330 return [{ type: "image", data: file.data, mimeType: file.mimeType }];
426331 }
332+ if (kind === "audio") {
333+ return [{ type: "audio", data: file.data, mimeType: file.mimeType }];
334+ }
427335 if (kind === "text") {
428336 return [{ type: "text", text: decodeTextFile(file) }];
429337 }
@@ -444,54 +352,60 @@ const toolFileSummaryLine = (file: ToolFileValue, index?: number): string => { · 14 unmodified lines
444352 return `${prefix}${toolFileName(file)} (${file.mimeType}, ${file.byteLength} bytes)`;
445353 };
446354
447−const toMcpFileResult = (
355+const outputFileContent = (file: ToolFileValue): ContentBlock[] => [
356+ {
357+ type: "text",
358+ text: `File output: ${toolFileSummaryLine(file)}`,
359+ },
360+ ...toolFileContent(file),
361+];
362+
363+const isFileOutputItem = (
364+ item: ExecuteOutputItem,
365+): item is { readonly type: "file"; readonly file: ToolFileValue } =>
366+ isRecord(item) && item.type === "file" && isToolFile(item.file);
367+
368+const isMcpContentBlock = (value: unknown): value is ContentBlock =>
369+ ContentBlockSchema.safeParse(value).success;
370+
371+const isContentOutputItem = (
372+ item: ExecuteOutputItem,
373+): item is { readonly type: "content"; readonly content: ContentBlock } =>
374+ isRecord(item) && item.type === "content" && isMcpContentBlock(item.content);
375+
376+const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => {
377+ if (isFileOutputItem(item)) {
378+ return outputFileContent(item.file);
379+ }
380+ if (isContentOutputItem(item)) {
381+ return [item.content];
382+ }
383+ return [{ type: "text", text: "Invalid execution output item omitted." }];
384+};
385+
386+const toMcpOutputResult = (
448387 result: FormattedExecuteInput,
449− extracted: ToolFileExtraction,
388+ output: readonly ExecuteOutputItem[],
450389 ): McpToolResult => {
451− const files = extracted.files;
452− const hasModelNativeFile = files.some((file) => toolFileKind(file) !== "resource");
453− const redactedResult = { ...result, result: extracted.value };
454− const formatted = formatExecuteResult(redactedResult);
455− const directFile = directToolFile(result.result);
456− const logText =
457− result.logs && result.logs.length > 0 ? `\n\nLogs:\n${result.logs.join("\n")}` : "";
458− const summary =
459− directFile && files.length === 1
460− ? `File output: ${toolFileName(directFile)}\n${directFile.mimeType}, ${
461− directFile.byteLength
462− } bytes${logText}`
463− : [
464− "File outputs:",
465− ...files.map((file, index) => toolFileSummaryLine(file, index)),
466− "",
467− "Result metadata:",
468− formatted.text,
469− ].join("\n");
390+ const formatted = formatExecuteResult(result);
391+ const content = output.flatMap(outputItemContent);
392+ const extraText: string[] = [];
393+ if (result.error) {
394+ extraText.push(formatted.text);
395+ } else if (result.logs && result.logs.length > 0) {
396+ extraText.push(`Logs:\n${result.logs.join("\n")}`);
397+ }
398+ content.push(...extraText.map((text): ContentBlock => ({ type: "text", text })));
470399
471400 return {
472− content: [
473− {
474− type: "text",
475− text: summary,
476− },
477− ...files.flatMap(toolFileContent),
478− ],
479− ...(hasModelNativeFile
480− ? {}
481− : {
482− structuredContent: formatted.structured,
483− }),
401+ content,
402+ structuredContent: formatted.structured,
484403 isError: formatted.isError || undefined,
485404 };
486405 };
487406
488407 const toMcpResult = (result: FormattedExecuteInput): McpToolResult => {
489− const nativeMcpResult = result.error ? null : nativeMcpContentResult(result.result);
490− if (nativeMcpResult) return nativeMcpResult;
491− const extracted = result.error
492− ? { value: result.result, files: [] }
493− : extractToolFiles(result.result);
494− if (extracted.files.length > 0) return toMcpFileResult(result, extracted);
408+ if (result.output && result.output.length > 0) return toMcpOutputResult(result, result.output);
495409 const formatted = formatExecuteResult(result);
496410 return {
497411 content: [{ type: "text", text: formatted.text }],
packages/kernel/core/src/types.ts+12−0
@@ -26,9 +26,21 @@ export interface SandboxToolInvoker { · 25 unmodified lines
2626 invoke(input: { path: string; args: unknown }): Effect.Effect<unknown, unknown, never>;
2727 }
2828
29+/** User-visible output accumulated by sandbox helpers. */
30+export type ExecuteOutputItem =
31+ | {
32+ readonly type: "file";
33+ readonly file: unknown;
34+ }
35+ | {
36+ readonly type: "content";
37+ readonly content: unknown;
38+ };
39+
2940 /** Result of executing code in a sandbox */
3041 export type ExecuteResult = {
3142 result: unknown;
43+ output?: ExecuteOutputItem[];
3244 error?: string;
3345 logs?: string[];
3446 };
packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs+89−2
@@ -11,6 +11,8 @@ let ipcNonce = ""; · 10 unmodified lines
1111
1212 /** @type {string[]} */
1313 const logs = [];
14+/** @type {Array<Record<string, unknown>>} */
15+let outputs = [];
1416
1517 const writeIpcMessage = (message) => {
1618 const payload = `${IPC_PREFIX}${JSON.stringify(message)}\n`;
@@ -73,6 +75,86 @@ const formatLogArg = (value) => { · 56 unmodified lines
7375
7476 const formatLogLine = (args) => args.map(formatLogArg).join(" ");
7577
78+const formatOutputText = (value) => {
79+ if (typeof value === "undefined") {
80+ return "undefined";
81+ }
82+ if (value === null) {
83+ return "null";
84+ }
85+ if (typeof value === "string") {
86+ return value;
87+ }
88+
89+ try {
90+ return JSON.stringify(value);
91+ } catch {
92+ return String(value);
93+ }
94+};
95+
96+const isToolFile = (value) =>
97+ value &&
98+ typeof value === "object" &&
99+ // oxlint-disable-next-line executor/no-manual-tag-check -- boundary: Deno worker validates serialized ToolFile values before host schema normalization
100+ value._tag === "ToolFile" &&
101+ typeof value.mimeType === "string" &&
102+ value.encoding === "base64" &&
103+ typeof value.data === "string" &&
104+ typeof value.byteLength === "number";
105+
106+const isMcpTextContentBlock = (value) =>
107+ value && typeof value === "object" && value.type === "text" && typeof value.text === "string";
108+
109+const isMcpImageContentBlock = (value) =>
110+ value &&
111+ typeof value === "object" &&
112+ value.type === "image" &&
113+ typeof value.data === "string" &&
114+ typeof value.mimeType === "string";
115+
116+const isMcpAudioContentBlock = (value) =>
117+ value &&
118+ typeof value === "object" &&
119+ value.type === "audio" &&
120+ typeof value.data === "string" &&
121+ typeof value.mimeType === "string";
122+
123+const isMcpResourceContentBlock = (value) =>
124+ value &&
125+ typeof value === "object" &&
126+ value.type === "resource" &&
127+ value.resource &&
128+ typeof value.resource === "object" &&
129+ typeof value.resource.uri === "string" &&
130+ (typeof value.resource.text === "string" || typeof value.resource.blob === "string");
131+
132+const isMcpResourceLinkContentBlock = (value) =>
133+ value &&
134+ typeof value === "object" &&
135+ value.type === "resource_link" &&
136+ typeof value.uri === "string" &&
137+ typeof value.name === "string";
138+
139+const isMcpContentBlock = (value) =>
140+ isMcpTextContentBlock(value) ||
141+ isMcpImageContentBlock(value) ||
142+ isMcpAudioContentBlock(value) ||
143+ isMcpResourceContentBlock(value) ||
144+ isMcpResourceLinkContentBlock(value);
145+
146+const emit = (value) => {
147+ if (isToolFile(value)) {
148+ outputs.push({ type: "file", file: value });
149+ return;
150+ }
151+ if (isMcpContentBlock(value)) {
152+ outputs.push({ type: "content", content: value });
153+ return;
154+ }
155+ outputs.push({ type: "content", content: { type: "text", text: formatOutputText(value) } });
156+};
157+
76158 const sandboxConsole = {
77159 log: (...args) => {
78160 logs.push(`[log] ${formatLogLine(args)}`);
@@ -92,15 +174,18 @@ const sandboxConsole = { · 13 unmodified lines
92174 };
93175
94176 const runUserCode = async (code) => {
177+ outputs = [];
95178 const tools = createToolsProxy();
96179
97180 const execute = new Function(
98181 "tools",
99182 "console",
183+ "emit",
100184 `"use strict"; return (async () => {\n${code}\n})();`,
101185 );
102186
103− return await execute(tools, sandboxConsole);
187+ const result = await execute(tools, sandboxConsole, emit);
188+ return { result, output: outputs.length > 0 ? outputs : undefined };
104189 };
105190
106191 const handleStart = (message) => {
@@ -118,11 +203,12 @@ const handleStart = (message) => { · 11 unmodified lines
118203 ipcNonce = typeof message.nonce === "string" ? message.nonce : "";
119204
120205 runUserCode(message.code)
121− .then((result) => {
206+ .then(({ result, output }) => {
122207 writeIpcMessage({
123208 type: "completed",
124209 nonce: ipcNonce,
125210 result,
211+ output,
126212 logs,
127213 });
128214 })
@@ -131,6 +217,7 @@ const handleStart = (message) => { · 2 unmodified lines
131217 type: "failed",
132218 nonce: ipcNonce,
133219 error: toErrorMessage(error),
220+ output: outputs.length > 0 ? outputs : undefined,
134221 logs,
135222 });
136223 });
packages/kernel/runtime-deno-subprocess/src/index.test.ts+77−0
@@ -52,6 +52,83 @@ describe.skipIf(!isDenoAvailable())("runtime-deno-subprocess", () => { · 51 unmodified lines
5252 }),
5353 );
5454
55+ it.effect("accumulates helper output separately from returned data", () =>
56+ Effect.gen(function* () {
57+ const executor = makeDenoSubprocessExecutor();
58+ const toolInvoker = makeTestInvoker({
59+ "files.get": () => ({
60+ _tag: "ToolFile",
61+ name: "photo.png",
62+ mimeType: "image/png",
63+ encoding: "base64",
64+ data: "iVBORw0KGgo=",
65+ byteLength: 8,
66+ }),
67+ });
68+
69+ const output = yield* executor.execute(
70+ [
71+ "const attachment = await tools.files.get({});",
72+ [
73+ 'const values = [emit("hello"), emit(attachment),',
74+ 'emit({ type: "text", text: "mcp hello" }),',
75+ 'emit({ type: "image", data: "Zm9v", mimeType: "image/png" }),',
76+ 'emit({ type: "audio", data: "SUQz", mimeType: "audio/mpeg" }),',
77+ 'emit({ type: "resource", resource: { uri: "executor-file:///report.pdf", mimeType: "application/pdf", blob: "JVBERg==" } }),',
78+ 'emit({ type: "resource_link", uri: "executor-file:///remote.pdf", name: "remote.pdf", mimeType: "application/pdf" }),',
79+ "emit({ arbitrary: true })];",
80+ ].join(" "),
81+ "return { values: values.map((value) => value === undefined), keptReturn: true };",
82+ ].join("\n"),
83+ toolInvoker,
84+ );
85+
86+ expect(output.error).toBeUndefined();
87+ expect(output.result).toEqual({
88+ values: [true, true, true, true, true, true, true, true],
89+ keptReturn: true,
90+ });
91+ expect(output.output).toEqual([
92+ { type: "content", content: { type: "text", text: "hello" } },
93+ {
94+ type: "file",
95+ file: {
96+ _tag: "ToolFile",
97+ name: "photo.png",
98+ mimeType: "image/png",
99+ encoding: "base64",
100+ data: "iVBORw0KGgo=",
101+ byteLength: 8,
102+ },
103+ },
104+ { type: "content", content: { type: "text", text: "mcp hello" } },
105+ { type: "content", content: { type: "image", data: "Zm9v", mimeType: "image/png" } },
106+ { type: "content", content: { type: "audio", data: "SUQz", mimeType: "audio/mpeg" } },
107+ {
108+ type: "content",
109+ content: {
110+ type: "resource",
111+ resource: {
112+ uri: "executor-file:///report.pdf",
113+ mimeType: "application/pdf",
114+ blob: "JVBERg==",
115+ },
116+ },
117+ },
118+ {
119+ type: "content",
120+ content: {
121+ type: "resource_link",
122+ uri: "executor-file:///remote.pdf",
123+ name: "remote.pdf",
124+ mimeType: "application/pdf",
125+ },
126+ },
127+ { type: "content", content: { type: "text", text: '{"arbitrary":true}' } },
128+ ]);
129+ }),
130+ );
131+
55132 it.effect("recovers prose-wrapped fenced async arrow input", () =>
56133 Effect.gen(function* () {
57134 const executor = makeDenoSubprocessExecutor();
packages/kernel/runtime-deno-subprocess/src/index.ts+10−0
@@ -65,6 +65,7 @@ const WorkerCompletedMessage = Schema.Struct({ · 64 unmodified lines
6565 type: Schema.Literal("completed"),
6666 nonce: Schema.String,
6767 result: Schema.Unknown,
68+ output: Schema.optional(Schema.Array(Schema.Unknown)),
6869 logs: Schema.optional(Schema.Array(Schema.String)),
6970 });
7071
@@ -72,6 +73,7 @@ const WorkerFailedMessage = Schema.Struct({ · 1 unmodified line
7273 type: Schema.Literal("failed"),
7374 nonce: Schema.String,
7475 error: Schema.String,
76+ output: Schema.optional(Schema.Array(Schema.Unknown)),
7577 logs: Schema.optional(Schema.Array(Schema.String)),
7678 });
7779
@@ -283,6 +285,10 @@ const executeInDeno = ( · 205 unmodified lines
283285 case "completed": {
284286 yield* completeWith({
285287 result: msg.result,
288+ output:
289+ Array.isArray(msg.output) && msg.output.length > 0
290+ ? (msg.output as ExecuteResult["output"])
291+ : undefined,
286292 logs: msg.logs as string[] | undefined,
287293 });
288294 return;
@@ -292,6 +298,10 @@ const executeInDeno = ( · 3 unmodified lines
292298 yield* completeWith({
293299 result: null,
294300 error: msg.error,
301+ output:
302+ Array.isArray(msg.output) && msg.output.length > 0
303+ ? (msg.output as ExecuteResult["output"])
304+ : undefined,
295305 logs: msg.logs as string[] | undefined,
296306 });
297307 return;
packages/kernel/runtime-dynamic-worker/src/executor.test.ts+3−2
@@ -37,9 +37,10 @@ describe("buildExecutorModule", () => { · 36 unmodified lines
3737 expect(module).toContain("console.error =");
3838 });
3939
40− it("returns result and logs on success", () => {
40+ it("returns result, output, and logs on success", () => {
4141 const module = buildExecutorModule("return 42;", 5000);
42− expect(module).toContain("return { result, logs: __logs }");
42+ expect(module).toContain("return { result, output:");
43+ expect(module).toContain("logs: __logs }");
4344 });
4445
4546 it("catches errors and returns them", () => {
packages/kernel/runtime-dynamic-worker/src/executor.ts+4−0
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
1717 recoverExecutionBody,
1818 stripTypeScript,
1919 type CodeExecutor,
20+ type ExecuteOutputItem,
2021 type ExecuteResult,
2122 type SandboxToolInvoker,
2223 } from "@executor-js/codemode-core";
@@ -413,6 +414,7 @@ export class ToolDispatcher extends RpcTarget { · 390 unmodified lines
413414 type DynamicWorkerEntrypoint = {
414415 evaluate(dispatcher: ToolDispatcher): Promise<{
415416 result: unknown;
417+ output?: ExecuteOutputItem[];
416418 error?: SerializedWorkerError;
417419 logs?: string[];
418420 }>;
@@ -498,6 +500,8 @@ const evaluate = ( · 79 unmodified lines
498500 return {
499501 result: error ? null : response.result,
500502 error,
503+ output:
504+ Array.isArray(response.output) && response.output.length > 0 ? response.output : undefined,
501505 logs: response.logs,
502506 } satisfies ExecuteResult;
503507 });
packages/kernel/runtime-dynamic-worker/src/invocation.test.ts+84−0
@@ -552,6 +552,90 @@ describe("makeDynamicWorkerExecutor", () => { · 551 unmodified lines
552552 }),
553553 );
554554
555+ it.effect("accumulates helper output separately from returned data", () =>
556+ Effect.gen(function* () {
557+ const executor = makeDynamicWorkerExecutor({ loader });
558+ const invoker = makeInvoker(() => ({
559+ _tag: "ToolFile",
560+ name: "photo.png",
561+ mimeType: "image/png",
562+ encoding: "base64",
563+ data: "iVBORw0KGgo=",
564+ byteLength: 8,
565+ }));
566+
567+ const result = yield* executor.execute(
568+ `async () => {
569+ const attachment = await tools.download.fetch({});
570+ const values = [
571+ emit("hello"),
572+ emit(attachment),
573+ emit({ type: "text", text: "mcp hello" }),
574+ emit({ type: "image", data: "Zm9v", mimeType: "image/png" }),
575+ emit({ type: "audio", data: "SUQz", mimeType: "audio/mpeg" }),
576+ emit({
577+ type: "resource",
578+ resource: { uri: "executor-file:///report.pdf", mimeType: "application/pdf", blob: "JVBERg==" },
579+ }),
580+ emit({
581+ type: "resource_link",
582+ uri: "executor-file:///remote.pdf",
583+ name: "remote.pdf",
584+ mimeType: "application/pdf",
585+ }),
586+ emit({ arbitrary: true }),
587+ ];
588+ return { values: values.map((value) => value === undefined), keptReturn: true };
589+ }`,
590+ invoker,
591+ );
592+
593+ expect(result.error).toBeUndefined();
594+ expect(result.result).toEqual({
595+ values: [true, true, true, true, true, true, true, true],
596+ keptReturn: true,
597+ });
598+ expect(result.output).toEqual([
599+ { type: "content", content: { type: "text", text: "hello" } },
600+ {
601+ type: "file",
602+ file: {
603+ _tag: "ToolFile",
604+ name: "photo.png",
605+ mimeType: "image/png",
606+ encoding: "base64",
607+ data: "iVBORw0KGgo=",
608+ byteLength: 8,
609+ },
610+ },
611+ { type: "content", content: { type: "text", text: "mcp hello" } },
612+ { type: "content", content: { type: "image", data: "Zm9v", mimeType: "image/png" } },
613+ { type: "content", content: { type: "audio", data: "SUQz", mimeType: "audio/mpeg" } },
614+ {
615+ type: "content",
616+ content: {
617+ type: "resource",
618+ resource: {
619+ uri: "executor-file:///report.pdf",
620+ mimeType: "application/pdf",
621+ blob: "JVBERg==",
622+ },
623+ },
624+ },
625+ {
626+ type: "content",
627+ content: {
628+ type: "resource_link",
629+ uri: "executor-file:///remote.pdf",
630+ name: "remote.pdf",
631+ mimeType: "application/pdf",
632+ },
633+ },
634+ { type: "content", content: { type: "text", text: '{"arbitrary":true}' } },
635+ ]);
636+ }),
637+ );
638+
555639 it.effect("preserves File tool args (name + lastModified survive)", () =>
556640 Effect.gen(function* () {
557641 const executor = makeDynamicWorkerExecutor({ loader });
packages/kernel/runtime-dynamic-worker/src/module-template.ts+35−5
@@ -4,9 +4,10 @@ · 3 unmodified lines
44 * The module exports a `WorkerEntrypoint` subclass with an `evaluate`
55 * method that:
66 * 1. Captures console output into `__logs`.
7− * 2. Creates a recursive `tools` Proxy that dispatches calls via RPC.
8− * 3. Executes the normalised user code with a `Promise.race` timeout.
9− * 4. Returns `{ result, error?, logs }`.
7+ * 2. Accumulates user-visible output from `text`, `file`, and `image`.
8+ * 3. Creates a recursive `tools` Proxy that dispatches calls via RPC.
9+ * 4. Executes the normalised user code with a `Promise.race` timeout.
10+ * 5. Returns `{ result, output?, error?, logs }`.
1011 *
1112 * Tool args cross the dispatcher boundary via Workers RPC, not JSON, so
1213 * `Uint8Array` / `ArrayBuffer` survive intact. `Blob` / `File` aren't on
@@ -22,6 +23,7 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 9 unmodified lines
2223 "export default class CodeExecutor extends WorkerEntrypoint {",
2324 " async evaluate(__dispatcher) {",
2425 " const __logs = [];",
26+ " const __outputs = [];",
2527 ' console.log = (...a) => { __logs.push(a.map(String).join(" ")); };',
2628 ' console.warn = (...a) => { __logs.push("[warn] " + a.map(String).join(" ")); };',
2729 ' console.error = (...a) => { __logs.push("[error] " + a.map(String).join(" ")); };',
@@ -59,6 +61,34 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 31 unmodified lines
5961 " }",
6062 " return String(value);",
6163 " };",
64+ " const __formatOutputText = (value) => {",
65+ " if (typeof value === 'undefined') return 'undefined';",
66+ " if (value === null) return 'null';",
67+ " if (typeof value === 'string') return value;",
68+ " try {",
69+ " return JSON.stringify(value);",
70+ " } catch {",
71+ " return String(value);",
72+ " }",
73+ " };",
74+ " const __isToolFile = (value) => value && typeof value === 'object' && value._tag === 'ToolFile' && typeof value.mimeType === 'string' && value.encoding === 'base64' && typeof value.data === 'string' && typeof value.byteLength === 'number';",
75+ " const __isMcpTextContentBlock = (value) => value && typeof value === 'object' && value.type === 'text' && typeof value.text === 'string';",
76+ " const __isMcpImageContentBlock = (value) => value && typeof value === 'object' && value.type === 'image' && typeof value.data === 'string' && typeof value.mimeType === 'string';",
77+ " const __isMcpAudioContentBlock = (value) => value && typeof value === 'object' && value.type === 'audio' && typeof value.data === 'string' && typeof value.mimeType === 'string';",
78+ " const __isMcpResourceContentBlock = (value) => value && typeof value === 'object' && value.type === 'resource' && value.resource && typeof value.resource === 'object' && typeof value.resource.uri === 'string' && (typeof value.resource.text === 'string' || typeof value.resource.blob === 'string');",
79+ " const __isMcpResourceLinkContentBlock = (value) => value && typeof value === 'object' && value.type === 'resource_link' && typeof value.uri === 'string' && typeof value.name === 'string';",
80+ " const __isMcpContentBlock = (value) => __isMcpTextContentBlock(value) || __isMcpImageContentBlock(value) || __isMcpAudioContentBlock(value) || __isMcpResourceContentBlock(value) || __isMcpResourceLinkContentBlock(value);",
81+ " const emit = (value) => {",
82+ " if (__isToolFile(value)) {",
83+ " __outputs.push({ type: 'file', file: value });",
84+ " return;",
85+ " }",
86+ " if (__isMcpContentBlock(value)) {",
87+ " __outputs.push({ type: 'content', content: value });",
88+ " return;",
89+ " }",
90+ " __outputs.push({ type: 'content', content: { type: 'text', text: __formatOutputText(value) } });",
91+ " };",
6292 " const __serializeThrownError = (err) => {",
6393 " const primary = __serializeErrorValue(err);",
6494 " return {",
@@ -173,9 +203,9 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 108 unmodified lines
173203 ` setTimeout(() => reject(new Error("Execution timed out after ${timeoutMs}ms")), ${timeoutMs})`,
174204 " ),",
175205 " ]);",
176− " return { result, logs: __logs };",
206+ " return { result, output: __outputs.length > 0 ? __outputs : undefined, logs: __logs };",
177207 " } catch (err) {",
178− " return { result: undefined, error: __serializeThrownError(err), logs: __logs };",
208+ " return { result: undefined, output: __outputs.length > 0 ? __outputs : undefined, error: __serializeThrownError(err), logs: __logs };",
179209 " }",
180210 " }",
181211 "}",
packages/kernel/runtime-quickjs/src/index.test.ts+81−0
@@ -35,6 +35,87 @@ describe("quickjs executor", () => { · 34 unmodified lines
3535 }),
3636 );
3737
38+ it.effect("accumulates helper output separately from returned data", () =>
39+ Effect.gen(function* () {
40+ const result = yield* executor.execute(
41+ `
42+ const attachment = {
43+ _tag: "ToolFile",
44+ name: "photo.png",
45+ mimeType: "image/png",
46+ encoding: "base64",
47+ data: "iVBORw0KGgo=",
48+ byteLength: 8,
49+ };
50+ const values = [
51+ emit("hello"),
52+ emit(attachment),
53+ emit({ type: "text", text: "mcp hello" }),
54+ emit({ type: "image", data: "Zm9v", mimeType: "image/png" }),
55+ emit({ type: "audio", data: "SUQz", mimeType: "audio/mpeg" }),
56+ emit({
57+ type: "resource",
58+ resource: { uri: "executor-file:///report.pdf", mimeType: "application/pdf", blob: "JVBERg==" },
59+ }),
60+ emit({
61+ type: "resource_link",
62+ uri: "executor-file:///remote.pdf",
63+ name: "remote.pdf",
64+ mimeType: "application/pdf",
65+ }),
66+ emit({ arbitrary: true }),
67+ ];
68+ return { values: values.map((value) => value === undefined), keptReturn: true };
69+ `,
70+ makeTestInvoker({}),
71+ );
72+
73+ expect(result.error).toBeUndefined();
74+ expect(result.result).toEqual({
75+ values: [true, true, true, true, true, true, true, true],
76+ keptReturn: true,
77+ });
78+ expect(result.output).toEqual([
79+ { type: "content", content: { type: "text", text: "hello" } },
80+ {
81+ type: "file",
82+ file: {
83+ _tag: "ToolFile",
84+ name: "photo.png",
85+ mimeType: "image/png",
86+ encoding: "base64",
87+ data: "iVBORw0KGgo=",
88+ byteLength: 8,
89+ },
90+ },
91+ { type: "content", content: { type: "text", text: "mcp hello" } },
92+ { type: "content", content: { type: "image", data: "Zm9v", mimeType: "image/png" } },
93+ { type: "content", content: { type: "audio", data: "SUQz", mimeType: "audio/mpeg" } },
94+ {
95+ type: "content",
96+ content: {
97+ type: "resource",
98+ resource: {
99+ uri: "executor-file:///report.pdf",
100+ mimeType: "application/pdf",
101+ blob: "JVBERg==",
102+ },
103+ },
104+ },
105+ {
106+ type: "content",
107+ content: {
108+ type: "resource_link",
109+ uri: "executor-file:///remote.pdf",
110+ name: "remote.pdf",
111+ mimeType: "application/pdf",
112+ },
113+ },
114+ { type: "content", content: { type: "text", text: '{"arbitrary":true}' } },
115+ ]);
116+ }),
117+ );
118+
38119 it.effect("recovers prose-wrapped fenced async arrow input", () =>
39120 Effect.gen(function* () {
40121 const result = yield* executor.execute(
packages/kernel/runtime-quickjs/src/index.ts+39−0
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 recoverExecutionBody,
33 stripTypeScript,
44 type CodeExecutor,
5+ type ExecuteOutputItem,
56 type ExecuteResult,
67 type SandboxToolInvoker,
78 } from "@executor-js/codemode-core";
@@ -137,6 +138,36 @@ const buildExecutionSource = (code: string): string => { · 129 unmodified lines
137138 " }",
138139 "};",
139140 "const __formatLogLine = (args) => args.map(__formatLogArg).join(' ');",
141+ "const __outputs = [];",
142+ "globalThis.__executor_outputs = __outputs;",
143+ "const __formatOutputText = (value) => {",
144+ " if (typeof value === 'undefined') return 'undefined';",
145+ " if (value === null) return 'null';",
146+ " if (typeof value === 'string') return value;",
147+ " try {",
148+ " return JSON.stringify(value);",
149+ " } catch {",
150+ " return String(value);",
151+ " }",
152+ "};",
153+ "const __isToolFile = (value) => value && typeof value === 'object' && value._tag === 'ToolFile' && typeof value.mimeType === 'string' && value.encoding === 'base64' && typeof value.data === 'string' && typeof value.byteLength === 'number';",
154+ "const __isMcpTextContentBlock = (value) => value && typeof value === 'object' && value.type === 'text' && typeof value.text === 'string';",
155+ "const __isMcpImageContentBlock = (value) => value && typeof value === 'object' && value.type === 'image' && typeof value.data === 'string' && typeof value.mimeType === 'string';",
156+ "const __isMcpAudioContentBlock = (value) => value && typeof value === 'object' && value.type === 'audio' && typeof value.data === 'string' && typeof value.mimeType === 'string';",
157+ "const __isMcpResourceContentBlock = (value) => value && typeof value === 'object' && value.type === 'resource' && value.resource && typeof value.resource === 'object' && typeof value.resource.uri === 'string' && (typeof value.resource.text === 'string' || typeof value.resource.blob === 'string');",
158+ "const __isMcpResourceLinkContentBlock = (value) => value && typeof value === 'object' && value.type === 'resource_link' && typeof value.uri === 'string' && typeof value.name === 'string';",
159+ "const __isMcpContentBlock = (value) => __isMcpTextContentBlock(value) || __isMcpImageContentBlock(value) || __isMcpAudioContentBlock(value) || __isMcpResourceContentBlock(value) || __isMcpResourceLinkContentBlock(value);",
160+ "const emit = (value) => {",
161+ " if (__isToolFile(value)) {",
162+ " __outputs.push({ type: 'file', file: value });",
163+ " return;",
164+ " }",
165+ " if (__isMcpContentBlock(value)) {",
166+ " __outputs.push({ type: 'content', content: value });",
167+ " return;",
168+ " }",
169+ " __outputs.push({ type: 'content', content: { type: 'text', text: __formatOutputText(value) } });",
170+ "};",
140171 "const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {",
141172 " get(_target, prop) {",
142173 " if (prop === 'then' || typeof prop === 'symbol') {",
@@ -178,6 +209,11 @@ const readPropDump = (context: QuickJSContext, handle: QuickJSHandle, key: strin · 35 unmodified lines
178209 }
179210 };
180211
212+const readOutputItems = (context: QuickJSContext): ExecuteOutputItem[] | undefined => {
213+ const output = readPropDump(context, context.global, "__executor_outputs");
214+ return Array.isArray(output) && output.length > 0 ? (output as ExecuteOutputItem[]) : undefined;
215+};
216+
181217 const readResultState = (
182218 context: QuickJSContext,
183219 handle: QuickJSHandle,
@@ -385,6 +421,7 @@ const evaluateInQuickJs = async ( · 201 unmodified lines
385421 return {
386422 result: null,
387423 error: timeoutMessage(timeoutMs),
424+ output: readOutputItems(context),
388425 logs,
389426 } satisfies ExecuteResult;
390427 }
@@ -393,12 +430,14 @@ const evaluateInQuickJs = async ( · 2 unmodified lines
393430 return {
394431 result: null,
395432 error: normalizeExecutionError(state.error, deadlineMs, timeoutMs),
433+ output: readOutputItems(context),
396434 logs,
397435 } satisfies ExecuteResult;
398436 }
399437
400438 return {
401439 result: state.value,
440+ output: readOutputItems(context),
402441 logs,
403442 } satisfies ExecuteResult;
404443 } finally {