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
5Replace the code-mode output helpers with a single `emit(value)` primitive.
6`emit(...)` accepts plain values, `ToolFile` attachments, and MCP content blocks,
7while `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
95 95
96 // Stable anchor from the workflow preamble.96 // Stable anchor from the workflow preamble.
97 expect(description).toContain("Execute TypeScript in a sandboxed runtime");97 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 );
98 expect(description).toContain("## Available connection prefixes");105 expect(description).toContain("## Available connection prefixes");
99 expect(description).toContain("- `github.org.prod`");106 expect(description).toContain("- `github.org.prod`");
100 expect(description).toContain("- `github.user.personal`");107 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
115 "- `tools.executor.coreTools.connections.list({})` returns saved connections with `{ address, integration, owner, name, ... }`. The `address` field includes the leading `tools.` root.",115 "- `tools.executor.coreTools.connections.list({})` returns saved connections with `{ address, integration, owner, name, ... }`. The `address` field includes the leading `tools.` root.",
116 "- 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`.",116 "- 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`.",
117 "- `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.",117 "- `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.",
118 "- If `tools.search()` returns `hasMore: true` and you didn't find what you need, fetch the next page: `tools.search({ query, offset: nextOffset, limit })`.",124 "- If `tools.search()` returns `hasMore: true` and you didn't find what you need, fetch the next page: `tools.search({ query, offset: nextOffset, limit })`.",
119 "- 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.",125 "- 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.",
120 "- The `tools` object is a lazy proxy — `Object.keys(tools)` won't work. Use `tools.search()` or `tools.executor.coreTools.connections.list({})` instead.",126 "- 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
148 });148 });
149 });149 });
150 150
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 () => {
152 const engine = makeStubEngine({346 const engine = makeStubEngine({
153 execute: () =>347 execute: () =>
154 Effect.succeed({348 Effect.succeed({
@@ -170,21 +364,17 @@ describe("MCP host server — native elicitation mode", () => { · 15 unmodified lines
170 });364 });
171 365
172 const content = result.content as Array<Record<string, unknown>>;366 const content = result.content as Array<Record<string, unknown>>;
173 expect(content[0]).toMatchObject({367 expect(content).toHaveLength(1);
174 type: "text",368 expect(String(content[0]?.text ?? "")).toContain('"_tag": "ToolFile"');
175 text: "File output: photo.png\nimage/png, 8 bytes",369 expect(String(content[0]?.text ?? "")).toContain("iVBORw0KGgo=");
176 });370 expect(result.structuredContent).toMatchObject({
177 expect(content[1]).toMatchObject({371 status: "completed",
178 type: "image",
179 data: "iVBORw0KGgo=",
180 mimeType: "image/png",
181 });372 });
182 expect(result.structuredContent).toBeUndefined();
183 expect(result.isError).toBeFalsy();373 expect(result.isError).toBeFalsy();
184 });374 });
185 });375 });
186 376
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 () => {
188 const engine = makeStubEngine({378 const engine = makeStubEngine({
189 execute: () =>379 execute: () =>
190 Effect.succeed({380 Effect.succeed({
@@ -217,126 +407,182 @@ describe("MCP host server — native elicitation mode", () => { · 26 unmodified lines
217 },407 },
218 });408 });
219 409
220 expect(result.content).toEqual([410 const content = result.content as Array<Record<string, unknown>>;
221 {411 expect(content).toHaveLength(1);
222 type: "text",412 expect(String(content[0]?.text ?? "")).toContain('"type": "image"');
223 text: "Deterministic image fixture: mcp-image-fixture.png (image/png, 70 bytes)",413 expect(String(content[0]?.text ?? "")).toContain(TEST_IMAGE_PNG_BASE64);
224 },414 expect(result.structuredContent).toMatchObject({
225 {415 status: "completed",
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,
235 });416 });
236 expect(result.isError).toBeFalsy();417 expect(result.isError).toBeFalsy();
237 });418 });
238 });419 });
239 420
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 () => {
241 const engine = makeStubEngine({422 const engine = makeStubEngine({
242 execute: () =>423 execute: () =>
243 Effect.succeed({424 Effect.succeed({
244 result: {425 result: { subject: "Flight receipt" },
245 subject: "Flight receipt",426 output: [
246 attachment: ToolResult.ok(427 {
247 toolFile({428 type: "content",
429 content: {
430 type: "text",
431 text: "Flight receipt",
432 },
433 },
434 {
435 type: "file",
436 file: toolFile({
248 name: "boarding-pass.png",437 name: "boarding-pass.png",
249 mimeType: "image/png",438 mimeType: "image/png",
250 data: "iVBORw0KGgo=",439 data: "iVBORw0KGgo=",
251 byteLength: 8,440 byteLength: 8,
252 }),441 }),
253 ),442 },
254 },443 ],
255 }),444 }),
256 });445 });
257 446
258 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {447 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
259 const result = await client.callTool({448 const result = await client.callTool({
260 name: "execute",449 name: "execute",
261 arguments: { code: "return { subject, attachment };" },450 arguments: { code: "emit(subject); emit(attachment.data);" },
262 });451 });
263 452
264 const content = result.content as Array<Record<string, unknown>>;453 const content = result.content as Array<Record<string, unknown>>;
265 const text = String(content[0]?.text ?? "");454 expect(content[0]).toMatchObject({ type: "text", text: "Flight receipt" });
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=");
270 expect(content[1]).toMatchObject({455 expect(content[1]).toMatchObject({
456 type: "text",
457 text: "File output: boarding-pass.png (image/png, 8 bytes)",
458 });
459 expect(content[2]).toMatchObject({
271 type: "image",460 type: "image",
272 data: "iVBORw0KGgo=",461 data: "iVBORw0KGgo=",
273 mimeType: "image/png",462 mimeType: "image/png",
274 });463 });
275 expect(result.structuredContent).toBeUndefined();464 expect(result.structuredContent).toMatchObject({
465 status: "completed",
466 result: { subject: "Flight receipt" },
467 });
276 expect(result.isError).toBeFalsy();468 expect(result.isError).toBeFalsy();
277 });469 });
278 });470 });
279 471
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 () => {
281 const engine = makeStubEngine({473 const engine = makeStubEngine({
282 execute: () =>474 execute: () =>
283 Effect.succeed({475 Effect.succeed({
284 result: ToolResult.ok(476 result: null,
285 toolFile({477 output: [
286 name: "rows.csv",478 {
287 mimeType: "text/csv",479 type: "file",
288 data: "YSxiCjEsMgo=",480 file: toolFile({
289 byteLength: 8,481 name: "rows.csv",
290 }),482 mimeType: "text/csv",
291 ),483 data: "YSxiCjEsMgo=",
484 byteLength: 8,
485 }),
486 },
487 ],
292 }),488 }),
293 });489 });
294 490
295 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {491 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
296 const result = await client.callTool({492 const result = await client.callTool({
297 name: "execute",493 name: "execute",
298 arguments: { code: "return await tools.files.org.main.getCsv({});" },494 arguments: { code: "emit(csv.data);" },
299 });495 });
300 496
301 const content = result.content as Array<Record<string, unknown>>;497 const content = result.content as Array<Record<string, unknown>>;
302 expect(content[0]).toMatchObject({498 expect(content[0]).toMatchObject({
303 type: "text",499 type: "text",
304 text: "File output: rows.csv\ntext/csv, 8 bytes",500 text: "File output: rows.csv (text/csv, 8 bytes)",
305 });501 });
306 expect(content[1]).toMatchObject({502 expect(content[1]).toMatchObject({
307 type: "text",503 type: "text",
308 text: "a,b\n1,2\n",504 text: "a,b\n1,2\n",
309 });505 });
310 expect(result.structuredContent).toBeUndefined();506 expect(result.structuredContent).toMatchObject({
507 status: "completed",
508 result: null,
509 });
311 expect(result.isError).toBeFalsy();510 expect(result.isError).toBeFalsy();
312 });511 });
313 });512 });
314 513
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 () => {
316 const engine = makeStubEngine({515 const engine = makeStubEngine({
317 execute: () =>516 execute: () =>
318 Effect.succeed({517 Effect.succeed({
319 result: ToolResult.ok(518 result: null,
320 toolFile({519 output: [
321 name: "report.pdf",520 {
322 mimeType: "application/pdf",521 type: "file",
323 data: "JVBERg==",522 file: toolFile({
324 byteLength: 4,523 name: "clip.mp3",
325 }),524 mimeType: "audio/mpeg",
326 ),525 data: "SUQzBAAAAAAA",
526 byteLength: 9,
527 }),
528 },
529 ],
327 }),530 }),
328 });531 });
329 532
330 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {533 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
331 const result = await client.callTool({534 const result = await client.callTool({
332 name: "execute",535 name: "execute",
333 arguments: { code: "return await tools.gmail.org.main.getAttachment({});" },536 arguments: { code: "emit(audio.data);" },
334 });537 });
335 538
336 const content = result.content as Array<Record<string, unknown>>;539 const content = result.content as Array<Record<string, unknown>>;
337 expect(content[0]).toMatchObject({540 expect(content[0]).toMatchObject({
338 type: "text",541 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)",
340 });586 });
341 expect(content[1]).toMatchObject({587 expect(content[1]).toMatchObject({
342 type: "resource",588 type: "resource",
@@ -348,18 +594,8 @@ describe("MCP host server — native elicitation mode", () => { · 5 unmodified lines
348 });594 });
349 expect(result.structuredContent).toMatchObject({595 expect(result.structuredContent).toMatchObject({
350 status: "completed",596 status: "completed",
351 result: {597 result: null,
352 ok: true,
353 data: {
354 _tag: "ToolFile",
355 name: "report.pdf",
356 mimeType: "application/pdf",
357 encoding: "base64",
358 byteLength: 4,
359 },
360 },
361 });598 });
362 expect(JSON.stringify(result.structuredContent)).not.toContain("JVBERg==");
363 expect(result.isError).toBeFalsy();599 expect(result.isError).toBeFalsy();
364 });600 });
365 });601 });
packages/hosts/mcp/src/tool-server.ts+53−139
@@ -1,7 +1,7 @@
1import { Effect, Match, Option, Schema } from "effect";1import { Effect, Match, Option, Schema } from "effect";
2import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";4import { ContentBlockSchema, type ContentBlock } from "@modelcontextprotocol/sdk/types.js";
5import type {5import type {
6 jsonSchemaValidator,6 jsonSchemaValidator,
7 JsonSchemaType,7 JsonSchemaType,
@@ -10,7 +10,7 @@ import type { · 2 unmodified lines
10import { Validator } from "@cfworker/json-schema";10import { Validator } from "@cfworker/json-schema";
11import * as z from "zod/v4";11import * as z from "zod/v4";
12 12
13import { isToolFile, isToolResult } from "@executor-js/sdk";13import { isToolFile } from "@executor-js/sdk";
14import type {14import type {
15 ElicitationResponse,15 ElicitationResponse,
16 ElicitationHandler,16 ElicitationHandler,
@@ -272,109 +272,13 @@ type McpToolResult = { · 255 unmodified lines
272};272};
273 273
274type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0];274type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0];
275type ExecuteOutputItem = NonNullable<FormattedExecuteInput["output"]>[number];
275 276
276const TEXT_FILE_CONTENT_MAX_CHARS = 64_000;277const TEXT_FILE_CONTENT_MAX_CHARS = 64_000;
277 278
278const isRecord = (value: unknown): value is Record<string, unknown> =>279const isRecord = (value: unknown): value is Record<string, unknown> =>
279 typeof value === "object" && value !== null && !Array.isArray(value);280 typeof value === "object" && value !== null && !Array.isArray(value);
280 281
281const isTextContentBlock = (value: unknown): value is Extract<ContentBlock, { type: "text" }> =>
282 isRecord(value) && value.type === "text" && typeof value.text === "string";
283
284const 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
290const isResourceContentBlock = (
291 value: unknown,
292): value is Extract<ContentBlock, { type: "resource" }> =>
293 isRecord(value) && value.type === "resource" && isRecord(value.resource);
294
295const isNativeMcpContentBlock = (value: unknown): value is ContentBlock =>
296 isTextContentBlock(value) || isImageContentBlock(value) || isResourceContentBlock(value);
297
298const 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
314const 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
322type ToolFileExtraction = {
323 readonly value: unknown;
324 readonly files: readonly ToolFileValue[];
325};
326
327const 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
372const 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
378const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output";282const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output";
379 283
380const fileResourceUri = (file: ToolFileValue): string =>284const fileResourceUri = (file: ToolFileValue): string =>
@@ -383,9 +287,10 @@ const fileResourceUri = (file: ToolFileValue): string => · 2 unmodified lines
383const normalizedMimeType = (file: ToolFileValue): string =>287const normalizedMimeType = (file: ToolFileValue): string =>
384 file.mimeType.split(";")[0]?.trim().toLowerCase() ?? "";288 file.mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
385 289
386const toolFileKind = (file: ToolFileValue): "image" | "text" | "resource" => {290const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resource" => {
387 const mimeType = normalizedMimeType(file);291 const mimeType = normalizedMimeType(file);
388 if (mimeType.startsWith("image/")) return "image";292 if (mimeType.startsWith("image/")) return "image";
293 if (mimeType.startsWith("audio/")) return "audio";
389 if (294 if (
390 mimeType.startsWith("text/") ||295 mimeType.startsWith("text/") ||
391 mimeType === "application/json" ||296 mimeType === "application/json" ||
@@ -424,6 +329,9 @@ const toolFileContent = (file: ToolFileValue): ContentBlock[] => { · 32 unmodified lines
424 if (kind === "image") {329 if (kind === "image") {
425 return [{ type: "image", data: file.data, mimeType: file.mimeType }];330 return [{ type: "image", data: file.data, mimeType: file.mimeType }];
426 }331 }
332 if (kind === "audio") {
333 return [{ type: "audio", data: file.data, mimeType: file.mimeType }];
334 }
427 if (kind === "text") {335 if (kind === "text") {
428 return [{ type: "text", text: decodeTextFile(file) }];336 return [{ type: "text", text: decodeTextFile(file) }];
429 }337 }
@@ -444,54 +352,60 @@ const toolFileSummaryLine = (file: ToolFileValue, index?: number): string => { · 14 unmodified lines
444 return `${prefix}${toolFileName(file)} (${file.mimeType}, ${file.byteLength} bytes)`;352 return `${prefix}${toolFileName(file)} (${file.mimeType}, ${file.byteLength} bytes)`;
445};353};
446 354
447const toMcpFileResult = (355const outputFileContent = (file: ToolFileValue): ContentBlock[] => [
356 {
357 type: "text",
358 text: `File output: ${toolFileSummaryLine(file)}`,
359 },
360 ...toolFileContent(file),
361];
362
363const isFileOutputItem = (
364 item: ExecuteOutputItem,
365): item is { readonly type: "file"; readonly file: ToolFileValue } =>
366 isRecord(item) && item.type === "file" && isToolFile(item.file);
367
368const isMcpContentBlock = (value: unknown): value is ContentBlock =>
369 ContentBlockSchema.safeParse(value).success;
370
371const isContentOutputItem = (
372 item: ExecuteOutputItem,
373): item is { readonly type: "content"; readonly content: ContentBlock } =>
374 isRecord(item) && item.type === "content" && isMcpContentBlock(item.content);
375
376const 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
386const toMcpOutputResult = (
448 result: FormattedExecuteInput,387 result: FormattedExecuteInput,
449 extracted: ToolFileExtraction,388 output: readonly ExecuteOutputItem[],
450): McpToolResult => {389): McpToolResult => {
451 const files = extracted.files;390 const formatted = formatExecuteResult(result);
452 const hasModelNativeFile = files.some((file) => toolFileKind(file) !== "resource");391 const content = output.flatMap(outputItemContent);
453 const redactedResult = { ...result, result: extracted.value };392 const extraText: string[] = [];
454 const formatted = formatExecuteResult(redactedResult);393 if (result.error) {
455 const directFile = directToolFile(result.result);394 extraText.push(formatted.text);
456 const logText =395 } else if (result.logs && result.logs.length > 0) {
457 result.logs && result.logs.length > 0 ? `\n\nLogs:\n${result.logs.join("\n")}` : "";396 extraText.push(`Logs:\n${result.logs.join("\n")}`);
458 const summary =397 }
459 directFile && files.length === 1398 content.push(...extraText.map((text): ContentBlock => ({ type: "text", text })));
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");
470 399
471 return {400 return {
472 content: [401 content,
473 {402 structuredContent: formatted.structured,
474 type: "text",
475 text: summary,
476 },
477 ...files.flatMap(toolFileContent),
478 ],
479 ...(hasModelNativeFile
480 ? {}
481 : {
482 structuredContent: formatted.structured,
483 }),
484 isError: formatted.isError || undefined,403 isError: formatted.isError || undefined,
485 };404 };
486};405};
487 406
488const toMcpResult = (result: FormattedExecuteInput): McpToolResult => {407const toMcpResult = (result: FormattedExecuteInput): McpToolResult => {
489 const nativeMcpResult = result.error ? null : nativeMcpContentResult(result.result);408 if (result.output && result.output.length > 0) return toMcpOutputResult(result, result.output);
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);
495 const formatted = formatExecuteResult(result);409 const formatted = formatExecuteResult(result);
496 return {410 return {
497 content: [{ type: "text", text: formatted.text }],411 content: [{ type: "text", text: formatted.text }],
packages/kernel/core/src/types.ts+12−0
@@ -26,9 +26,21 @@ export interface SandboxToolInvoker { · 25 unmodified lines
26 invoke(input: { path: string; args: unknown }): Effect.Effect<unknown, unknown, never>;26 invoke(input: { path: string; args: unknown }): Effect.Effect<unknown, unknown, never>;
27}27}
28 28
29/** User-visible output accumulated by sandbox helpers. */
30export type ExecuteOutputItem =
31 | {
32 readonly type: "file";
33 readonly file: unknown;
34 }
35 | {
36 readonly type: "content";
37 readonly content: unknown;
38 };
39
29/** Result of executing code in a sandbox */40/** Result of executing code in a sandbox */
30export type ExecuteResult = {41export type ExecuteResult = {
31 result: unknown;42 result: unknown;
43 output?: ExecuteOutputItem[];
32 error?: string;44 error?: string;
33 logs?: string[];45 logs?: string[];
34};46};
packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs+89−2
@@ -11,6 +11,8 @@ let ipcNonce = ""; · 10 unmodified lines
11 11
12/** @type {string[]} */12/** @type {string[]} */
13const logs = [];13const logs = [];
14/** @type {Array<Record<string, unknown>>} */
15let outputs = [];
14 16
15const writeIpcMessage = (message) => {17const writeIpcMessage = (message) => {
16 const payload = `${IPC_PREFIX}${JSON.stringify(message)}\n`;18 const payload = `${IPC_PREFIX}${JSON.stringify(message)}\n`;
@@ -73,6 +75,86 @@ const formatLogArg = (value) => { · 56 unmodified lines
73 75
74const formatLogLine = (args) => args.map(formatLogArg).join(" ");76const formatLogLine = (args) => args.map(formatLogArg).join(" ");
75 77
78const 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
96const 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
106const isMcpTextContentBlock = (value) =>
107 value && typeof value === "object" && value.type === "text" && typeof value.text === "string";
108
109const isMcpImageContentBlock = (value) =>
110 value &&
111 typeof value === "object" &&
112 value.type === "image" &&
113 typeof value.data === "string" &&
114 typeof value.mimeType === "string";
115
116const isMcpAudioContentBlock = (value) =>
117 value &&
118 typeof value === "object" &&
119 value.type === "audio" &&
120 typeof value.data === "string" &&
121 typeof value.mimeType === "string";
122
123const 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
132const isMcpResourceLinkContentBlock = (value) =>
133 value &&
134 typeof value === "object" &&
135 value.type === "resource_link" &&
136 typeof value.uri === "string" &&
137 typeof value.name === "string";
138
139const isMcpContentBlock = (value) =>
140 isMcpTextContentBlock(value) ||
141 isMcpImageContentBlock(value) ||
142 isMcpAudioContentBlock(value) ||
143 isMcpResourceContentBlock(value) ||
144 isMcpResourceLinkContentBlock(value);
145
146const 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
76const sandboxConsole = {158const sandboxConsole = {
77 log: (...args) => {159 log: (...args) => {
78 logs.push(`[log] ${formatLogLine(args)}`);160 logs.push(`[log] ${formatLogLine(args)}`);
@@ -92,15 +174,18 @@ const sandboxConsole = { · 13 unmodified lines
92};174};
93 175
94const runUserCode = async (code) => {176const runUserCode = async (code) => {
177 outputs = [];
95 const tools = createToolsProxy();178 const tools = createToolsProxy();
96 179
97 const execute = new Function(180 const execute = new Function(
98 "tools",181 "tools",
99 "console",182 "console",
183 "emit",
100 `"use strict"; return (async () => {\n${code}\n})();`,184 `"use strict"; return (async () => {\n${code}\n})();`,
101 );185 );
102 186
103 return await execute(tools, sandboxConsole);187 const result = await execute(tools, sandboxConsole, emit);
188 return { result, output: outputs.length > 0 ? outputs : undefined };
104};189};
105 190
106const handleStart = (message) => {191const handleStart = (message) => {
@@ -118,11 +203,12 @@ const handleStart = (message) => { · 11 unmodified lines
118 ipcNonce = typeof message.nonce === "string" ? message.nonce : "";203 ipcNonce = typeof message.nonce === "string" ? message.nonce : "";
119 204
120 runUserCode(message.code)205 runUserCode(message.code)
121 .then((result) => {206 .then(({ result, output }) => {
122 writeIpcMessage({207 writeIpcMessage({
123 type: "completed",208 type: "completed",
124 nonce: ipcNonce,209 nonce: ipcNonce,
125 result,210 result,
211 output,
126 logs,212 logs,
127 });213 });
128 })214 })
@@ -131,6 +217,7 @@ const handleStart = (message) => { · 2 unmodified lines
131 type: "failed",217 type: "failed",
132 nonce: ipcNonce,218 nonce: ipcNonce,
133 error: toErrorMessage(error),219 error: toErrorMessage(error),
220 output: outputs.length > 0 ? outputs : undefined,
134 logs,221 logs,
135 });222 });
136 });223 });
packages/kernel/runtime-deno-subprocess/src/index.test.ts+77−0
@@ -52,6 +52,83 @@ describe.skipIf(!isDenoAvailable())("runtime-deno-subprocess", () => { · 51 unmodified lines
52 }),52 }),
53 );53 );
54 54
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
55 it.effect("recovers prose-wrapped fenced async arrow input", () =>132 it.effect("recovers prose-wrapped fenced async arrow input", () =>
56 Effect.gen(function* () {133 Effect.gen(function* () {
57 const executor = makeDenoSubprocessExecutor();134 const executor = makeDenoSubprocessExecutor();
packages/kernel/runtime-deno-subprocess/src/index.ts+10−0
@@ -65,6 +65,7 @@ const WorkerCompletedMessage = Schema.Struct({ · 64 unmodified lines
65 type: Schema.Literal("completed"),65 type: Schema.Literal("completed"),
66 nonce: Schema.String,66 nonce: Schema.String,
67 result: Schema.Unknown,67 result: Schema.Unknown,
68 output: Schema.optional(Schema.Array(Schema.Unknown)),
68 logs: Schema.optional(Schema.Array(Schema.String)),69 logs: Schema.optional(Schema.Array(Schema.String)),
69});70});
70 71
@@ -72,6 +73,7 @@ const WorkerFailedMessage = Schema.Struct({ · 1 unmodified line
72 type: Schema.Literal("failed"),73 type: Schema.Literal("failed"),
73 nonce: Schema.String,74 nonce: Schema.String,
74 error: Schema.String,75 error: Schema.String,
76 output: Schema.optional(Schema.Array(Schema.Unknown)),
75 logs: Schema.optional(Schema.Array(Schema.String)),77 logs: Schema.optional(Schema.Array(Schema.String)),
76});78});
77 79
@@ -283,6 +285,10 @@ const executeInDeno = ( · 205 unmodified lines
283 case "completed": {285 case "completed": {
284 yield* completeWith({286 yield* completeWith({
285 result: msg.result,287 result: msg.result,
288 output:
289 Array.isArray(msg.output) && msg.output.length > 0
290 ? (msg.output as ExecuteResult["output"])
291 : undefined,
286 logs: msg.logs as string[] | undefined,292 logs: msg.logs as string[] | undefined,
287 });293 });
288 return;294 return;
@@ -292,6 +298,10 @@ const executeInDeno = ( · 3 unmodified lines
292 yield* completeWith({298 yield* completeWith({
293 result: null,299 result: null,
294 error: msg.error,300 error: msg.error,
301 output:
302 Array.isArray(msg.output) && msg.output.length > 0
303 ? (msg.output as ExecuteResult["output"])
304 : undefined,
295 logs: msg.logs as string[] | undefined,305 logs: msg.logs as string[] | undefined,
296 });306 });
297 return;307 return;
packages/kernel/runtime-dynamic-worker/src/executor.test.ts+3−2
@@ -37,9 +37,10 @@ describe("buildExecutorModule", () => { · 36 unmodified lines
37 expect(module).toContain("console.error =");37 expect(module).toContain("console.error =");
38 });38 });
39 39
40 it("returns result and logs on success", () => {40 it("returns result, output, and logs on success", () => {
41 const module = buildExecutorModule("return 42;", 5000);41 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 }");
43 });44 });
44 45
45 it("catches errors and returns them", () => {46 it("catches errors and returns them", () => {
packages/kernel/runtime-dynamic-worker/src/executor.ts+4−0
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
17 recoverExecutionBody,17 recoverExecutionBody,
18 stripTypeScript,18 stripTypeScript,
19 type CodeExecutor,19 type CodeExecutor,
20 type ExecuteOutputItem,
20 type ExecuteResult,21 type ExecuteResult,
21 type SandboxToolInvoker,22 type SandboxToolInvoker,
22} from "@executor-js/codemode-core";23} from "@executor-js/codemode-core";
@@ -413,6 +414,7 @@ export class ToolDispatcher extends RpcTarget { · 390 unmodified lines
413type DynamicWorkerEntrypoint = {414type DynamicWorkerEntrypoint = {
414 evaluate(dispatcher: ToolDispatcher): Promise<{415 evaluate(dispatcher: ToolDispatcher): Promise<{
415 result: unknown;416 result: unknown;
417 output?: ExecuteOutputItem[];
416 error?: SerializedWorkerError;418 error?: SerializedWorkerError;
417 logs?: string[];419 logs?: string[];
418 }>;420 }>;
@@ -498,6 +500,8 @@ const evaluate = ( · 79 unmodified lines
498 return {500 return {
499 result: error ? null : response.result,501 result: error ? null : response.result,
500 error,502 error,
503 output:
504 Array.isArray(response.output) && response.output.length > 0 ? response.output : undefined,
501 logs: response.logs,505 logs: response.logs,
502 } satisfies ExecuteResult;506 } satisfies ExecuteResult;
503 });507 });
packages/kernel/runtime-dynamic-worker/src/invocation.test.ts+84−0
@@ -552,6 +552,90 @@ describe("makeDynamicWorkerExecutor", () => { · 551 unmodified lines
552 }),552 }),
553 );553 );
554 554
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
555 it.effect("preserves File tool args (name + lastModified survive)", () =>639 it.effect("preserves File tool args (name + lastModified survive)", () =>
556 Effect.gen(function* () {640 Effect.gen(function* () {
557 const executor = makeDynamicWorkerExecutor({ loader });641 const executor = makeDynamicWorkerExecutor({ loader });
packages/kernel/runtime-dynamic-worker/src/module-template.ts+35−5
@@ -4,9 +4,10 @@ · 3 unmodified lines
4 * The module exports a `WorkerEntrypoint` subclass with an `evaluate`4 * The module exports a `WorkerEntrypoint` subclass with an `evaluate`
5 * method that:5 * method that:
6 * 1. Captures console output into `__logs`.6 * 1. Captures console output into `__logs`.
7 * 2. Creates a recursive `tools` Proxy that dispatches calls via RPC.7 * 2. Accumulates user-visible output from `text`, `file`, and `image`.
8 * 3. Executes the normalised user code with a `Promise.race` timeout.8 * 3. Creates a recursive `tools` Proxy that dispatches calls via RPC.
9 * 4. Returns `{ result, error?, logs }`.9 * 4. Executes the normalised user code with a `Promise.race` timeout.
10 * 5. Returns `{ result, output?, error?, logs }`.
10 *11 *
11 * Tool args cross the dispatcher boundary via Workers RPC, not JSON, so12 * Tool args cross the dispatcher boundary via Workers RPC, not JSON, so
12 * `Uint8Array` / `ArrayBuffer` survive intact. `Blob` / `File` aren't on13 * `Uint8Array` / `ArrayBuffer` survive intact. `Blob` / `File` aren't on
@@ -22,6 +23,7 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 9 unmodified lines
22 "export default class CodeExecutor extends WorkerEntrypoint {",23 "export default class CodeExecutor extends WorkerEntrypoint {",
23 " async evaluate(__dispatcher) {",24 " async evaluate(__dispatcher) {",
24 " const __logs = [];",25 " const __logs = [];",
26 " const __outputs = [];",
25 ' console.log = (...a) => { __logs.push(a.map(String).join(" ")); };',27 ' console.log = (...a) => { __logs.push(a.map(String).join(" ")); };',
26 ' console.warn = (...a) => { __logs.push("[warn] " + a.map(String).join(" ")); };',28 ' console.warn = (...a) => { __logs.push("[warn] " + a.map(String).join(" ")); };',
27 ' console.error = (...a) => { __logs.push("[error] " + a.map(String).join(" ")); };',29 ' 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
59 " }",61 " }",
60 " return String(value);",62 " return String(value);",
61 " };",63 " };",
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 " };",
62 " const __serializeThrownError = (err) => {",92 " const __serializeThrownError = (err) => {",
63 " const primary = __serializeErrorValue(err);",93 " const primary = __serializeErrorValue(err);",
64 " return {",94 " return {",
@@ -173,9 +203,9 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 108 unmodified lines
173 ` setTimeout(() => reject(new Error("Execution timed out after ${timeoutMs}ms")), ${timeoutMs})`,203 ` setTimeout(() => reject(new Error("Execution timed out after ${timeoutMs}ms")), ${timeoutMs})`,
174 " ),",204 " ),",
175 " ]);",205 " ]);",
176 " return { result, logs: __logs };",206 " return { result, output: __outputs.length > 0 ? __outputs : undefined, logs: __logs };",
177 " } catch (err) {",207 " } 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 };",
179 " }",209 " }",
180 " }",210 " }",
181 "}",211 "}",
packages/kernel/runtime-quickjs/src/index.test.ts+81−0
@@ -35,6 +35,87 @@ describe("quickjs executor", () => { · 34 unmodified lines
35 }),35 }),
36 );36 );
37 37
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
38 it.effect("recovers prose-wrapped fenced async arrow input", () =>119 it.effect("recovers prose-wrapped fenced async arrow input", () =>
39 Effect.gen(function* () {120 Effect.gen(function* () {
40 const result = yield* executor.execute(121 const result = yield* executor.execute(
packages/kernel/runtime-quickjs/src/index.ts+39−0
@@ -2,6 +2,7 @@ import { · 1 unmodified line
2 recoverExecutionBody,2 recoverExecutionBody,
3 stripTypeScript,3 stripTypeScript,
4 type CodeExecutor,4 type CodeExecutor,
5 type ExecuteOutputItem,
5 type ExecuteResult,6 type ExecuteResult,
6 type SandboxToolInvoker,7 type SandboxToolInvoker,
7} from "@executor-js/codemode-core";8} from "@executor-js/codemode-core";
@@ -137,6 +138,36 @@ const buildExecutionSource = (code: string): string => { · 129 unmodified lines
137 " }",138 " }",
138 "};",139 "};",
139 "const __formatLogLine = (args) => args.map(__formatLogArg).join(' ');",140 "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 "};",
140 "const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {",171 "const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {",
141 " get(_target, prop) {",172 " get(_target, prop) {",
142 " if (prop === 'then' || typeof prop === 'symbol') {",173 " if (prop === 'then' || typeof prop === 'symbol') {",
@@ -178,6 +209,11 @@ const readPropDump = (context: QuickJSContext, handle: QuickJSHandle, key: strin · 35 unmodified lines
178 }209 }
179};210};
180 211
212const 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
181const readResultState = (217const readResultState = (
182 context: QuickJSContext,218 context: QuickJSContext,
183 handle: QuickJSHandle,219 handle: QuickJSHandle,
@@ -385,6 +421,7 @@ const evaluateInQuickJs = async ( · 201 unmodified lines
385 return {421 return {
386 result: null,422 result: null,
387 error: timeoutMessage(timeoutMs),423 error: timeoutMessage(timeoutMs),
424 output: readOutputItems(context),
388 logs,425 logs,
389 } satisfies ExecuteResult;426 } satisfies ExecuteResult;
390 }427 }
@@ -393,12 +430,14 @@ const evaluateInQuickJs = async ( · 2 unmodified lines
393 return {430 return {
394 result: null,431 result: null,
395 error: normalizeExecutionError(state.error, deadlineMs, timeoutMs),432 error: normalizeExecutionError(state.error, deadlineMs, timeoutMs),
433 output: readOutputItems(context),
396 logs,434 logs,
397 } satisfies ExecuteResult;435 } satisfies ExecuteResult;
398 }436 }
399 437
400 return {438 return {
401 result: state.value,439 result: state.value,
440 output: readOutputItems(context),
402 logs,441 logs,
403 } satisfies ExecuteResult;442 } satisfies ExecuteResult;
404 } finally {443 } finally {