Unified · Split
| @@ -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. | ||
| @@ -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`"); |
| @@ -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.", |
| @@ -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 | }); |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { Effect, Match, Option, Schema } from "effect"; | 1 | import { Effect, Match, Option, Schema } from "effect"; |
| 2 | import * as Cause from "effect/Cause"; | 2 | import * as Cause from "effect/Cause"; |
| 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 3 | 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"; |
| 5 | import type { | 5 | import type { |
| 6 | jsonSchemaValidator, | 6 | jsonSchemaValidator, |
| 7 | JsonSchemaType, | 7 | JsonSchemaType, |
| @@ -10,7 +10,7 @@ import type { · 2 unmodified lines | |||
| 10 | import { Validator } from "@cfworker/json-schema"; | 10 | import { Validator } from "@cfworker/json-schema"; |
| 11 | import * as z from "zod/v4"; | 11 | import * as z from "zod/v4"; |
| 12 | 12 | ||
| 13 | import { isToolFile, isToolResult } from "@executor-js/sdk"; | 13 | import { isToolFile } from "@executor-js/sdk"; |
| 14 | import type { | 14 | import type { |
| 15 | ElicitationResponse, | 15 | ElicitationResponse, |
| 16 | ElicitationHandler, | 16 | ElicitationHandler, |
| @@ -272,109 +272,13 @@ type McpToolResult = { · 255 unmodified lines | |||
| 272 | }; | 272 | }; |
| 273 | 273 | ||
| 274 | type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0]; | 274 | type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0]; |
| 275 | type ExecuteOutputItem = NonNullable<FormattedExecuteInput["output"]>[number]; | ||
| 275 | 276 | ||
| 276 | const TEXT_FILE_CONTENT_MAX_CHARS = 64_000; | 277 | const TEXT_FILE_CONTENT_MAX_CHARS = 64_000; |
| 277 | 278 | ||
| 278 | const isRecord = (value: unknown): value is Record<string, unknown> => | 279 | const 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 | ||
| 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 | |||
| 378 | const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output"; | 282 | const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output"; |
| 379 | 283 | ||
| 380 | const fileResourceUri = (file: ToolFileValue): string => | 284 | const fileResourceUri = (file: ToolFileValue): string => |
| @@ -383,9 +287,10 @@ const fileResourceUri = (file: ToolFileValue): string => · 2 unmodified lines | |||
| 383 | const normalizedMimeType = (file: ToolFileValue): string => | 287 | const normalizedMimeType = (file: ToolFileValue): string => |
| 384 | file.mimeType.split(";")[0]?.trim().toLowerCase() ?? ""; | 288 | file.mimeType.split(";")[0]?.trim().toLowerCase() ?? ""; |
| 385 | 289 | ||
| 386 | const toolFileKind = (file: ToolFileValue): "image" | "text" | "resource" => { | 290 | const 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 | ||
| 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 = ( | ||
| 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 === 1 | 398 | 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 | ||
| 488 | const toMcpResult = (result: FormattedExecuteInput): McpToolResult => { | 407 | const 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 }], |
| @@ -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. */ | ||
| 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 | |||
| 29 | /** Result of executing code in a sandbox */ | 40 | /** Result of executing code in a sandbox */ |
| 30 | export type ExecuteResult = { | 41 | export 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 | }; |
| @@ -11,6 +11,8 @@ let ipcNonce = ""; · 10 unmodified lines | |||
| 11 | 11 | ||
| 12 | /** @type {string[]} */ | 12 | /** @type {string[]} */ |
| 13 | const logs = []; | 13 | const logs = []; |
| 14 | /** @type {Array<Record<string, unknown>>} */ | ||
| 15 | let outputs = []; | ||
| 14 | 16 | ||
| 15 | const writeIpcMessage = (message) => { | 17 | const 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 | ||
| 74 | const formatLogLine = (args) => args.map(formatLogArg).join(" "); | 76 | const formatLogLine = (args) => args.map(formatLogArg).join(" "); |
| 75 | 77 | ||
| 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 | |||
| 76 | const sandboxConsole = { | 158 | const 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 | ||
| 94 | const runUserCode = async (code) => { | 176 | const 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 | ||
| 106 | const handleStart = (message) => { | 191 | const 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 | }); |
| @@ -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(); |
| @@ -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; |
| @@ -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", () => { |
| @@ -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 | |||
| 413 | type DynamicWorkerEntrypoint = { | 414 | type 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 | }); |
| @@ -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 }); |
| @@ -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, so | 12 | * Tool args cross the dispatcher boundary via Workers RPC, not JSON, so |
| 12 | * `Uint8Array` / `ArrayBuffer` survive intact. `Blob` / `File` aren't on | 13 | * `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 | "}", |
| @@ -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( |
| @@ -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 | ||
| 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 | |||
| 181 | const readResultState = ( | 217 | const 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 { |