History

Acknowledge emitted output in the execute result envelope

emit() sends content to the user, not back to the model, so a script that only emits returns a null value. The result envelope gave no signal that emit had produced anything, so an emit-without-return looked indistinguishable from a script that did nothing. formatExecuteResult now reports an emitted count in the structured envelope and a "(no return value; N items emitted to the user)" text preview, and the execute tool docs spell out that emit is user-facing while return is what the caller reads back.

Rhys Sullivan committed 4 months ago

Commit 83f20211 parent a12d7433 files · +81 −7Browse files

Unified · Split

3 files
  1. e2e/cloud/execute-emit-envelope.test.ts +53 −0
  2. packages/core/execution/src/description.ts +1 −1
  3. packages/core/execution/src/engine.ts +27 −6
e2e/cloud/execute-emit-envelope.test.ts+53−0
@@ -0,0 +1,53 @@
1// Cloud: the `execute` sandbox reports emitted output in its result envelope.
2//
3// `emit()` sends content to the USER, not back to the model, so a script that
4// only emits returns a null value. Without a signal, an emit-without-return
5// looks to the caller like nothing happened at all (the exact confusion this
6// pins against). The envelope now carries an `emitted` count, and the text
7// preview says output went to the user instead of a bare "(no result)".
8import { expect } from "@effect/vitest";
9import { Effect } from "effect";
10
11import { scenario } from "../src/scenario";
12import { Mcp, Target } from "../src/services";
13
14scenario(
15 "Execute · an emit-only script reports its emitted output in the result envelope",
16 {},
17 Effect.gen(function* () {
18 const target = yield* Target;
19 const mcp = yield* Mcp;
20 const identity = yield* target.newIdentity();
21 const session = mcp.session(identity);
22
23 // A script that emits twice and returns nothing. Pre-change this came back
24 // as `{ result: null }` with no hint the emits landed.
25 const result = yield* session.call("execute", {
26 code: `emit("first line for the user"); emit("second line"); return undefined;`,
27 });
28 expect(result.ok, `execute completed (got: ${result.text.slice(0, 300)})`).toBe(true);
29
30 // The envelope (what the model-facing MCP client reads) now reports the
31 // emit count, so an emit-only script is no longer an indistinguishable
32 // `{ result: null }`.
33 const structured = (result.raw as { structuredContent?: Record<string, unknown> })
34 .structuredContent;
35 expect(structured?.status, "the run completed").toBe("completed");
36 expect(structured?.result, "an emit-only script has no return value").toBeNull();
37 expect(structured?.emitted, "the envelope counts the emitted items").toBe(2);
38
39 // The emitted text itself flows to the user-facing content blocks.
40 expect(result.text, "the emitted lines reach the user-facing content").toContain(
41 "first line for the user",
42 );
43
44 // A script that returns a value keeps the plain envelope (no emit noise).
45 const returned = yield* session.call("execute", { code: `return 6 * 7;` });
46 const returnedStructured = (returned.raw as { structuredContent?: Record<string, unknown> })
47 .structuredContent;
48 expect(returnedStructured?.result, "the return value comes back to the caller").toBe(42);
49 expect("emitted" in (returnedStructured ?? {}), "no emitted key when nothing was emitted").toBe(
50 false,
51 );
52 }),
53);
packages/core/execution/src/description.ts+1−1
@@ -115,7 +115,7 @@ 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.",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. Emitted output goes to the user, not back to you; the result envelope reports an `emitted` count so you can confirm it landed, but to read a value yourself, `return` it.",
119 '- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. Emit any attachment with `emit(result.data)`.',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, ... }`.',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.",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.",
packages/core/execution/src/engine.ts+27−6
@@ -80,22 +80,43 @@ export const formatExecuteResult = ( · 79 unmodified lines
80 80
81 const logText = result.logs && result.logs.length > 0 ? result.logs.join("\n") : null;81 const logText = result.logs && result.logs.length > 0 ? result.logs.join("\n") : null;
82 82
83 // `emit()` output is shown to the user, not returned to the model, so a
84 // script that only emits comes back with a null result. Acknowledge the
85 // emitted items in the envelope so an emit-without-return reads as "output
86 // went to the user" rather than a silent void.
87 const emitted = result.output?.length ?? 0;
88 const emittedNote =
89 emitted > 0 ? `${emitted} item${emitted === 1 ? "" : "s"} emitted to the user` : null;
90 const emittedField = emitted > 0 ? { emitted } : {};
91
83 if (result.error) {92 if (result.error) {
84 const parts = [`Error: ${result.error}`, ...(logText ? [`\nLogs:\n${logText}`] : [])];93 const parts = [`Error: ${result.error}`, ...(logText ? [`\nLogs:\n${logText}`] : [])];
85 return {94 return {
86 text: truncate(parts.join("\n"), MAX_PREVIEW_CHARS),95 text: truncate(parts.join("\n"), MAX_PREVIEW_CHARS),
87 structured: { status: "error", error: result.error, logs: result.logs ?? [] },96 structured: {
97 status: "error",
98 error: result.error,
99 ...emittedField,
100 logs: result.logs ?? [],
101 },
88 isError: true,102 isError: true,
89 };103 };
90 }104 }
91 105
92 const parts = [106 const resultPart = resultText
93 ...(resultText ? [truncate(resultText, MAX_PREVIEW_CHARS)] : ["(no result)"]),107 ? truncate(resultText, MAX_PREVIEW_CHARS)
94 ...(logText ? [`\nLogs:\n${logText}`] : []),108 : emittedNote
95 ];109 ? `(no return value; ${emittedNote})`
110 : "(no result)";
111 const parts = [resultPart, ...(logText ? [`\nLogs:\n${logText}`] : [])];
96 return {112 return {
97 text: parts.join("\n"),113 text: parts.join("\n"),
98 structured: { status: "completed", result: result.result ?? null, logs: result.logs ?? [] },114 structured: {
115 status: "completed",
116 result: result.result ?? null,
117 ...emittedField,
118 logs: result.logs ?? [],
119 },
99 isError: false,120 isError: false,
100 };121 };
101};122};