Skip to content

Commit 6f46e1b

Browse files
committed
Add code runtime output helpers
1 parent cb13484 commit 6f46e1b

10 files changed

Lines changed: 825 additions & 37 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { expect } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
4+
import { scenario } from "../src/scenario";
5+
import { Mcp, Target } from "../src/services";
6+
7+
const PNG_DATA =
8+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
9+
const PNG_DATA_URL = `data:image/png;base64,${PNG_DATA}`;
10+
11+
scenario(
12+
"Codemode · output helpers emit images, detail metadata, and notifications",
13+
{},
14+
Effect.gen(function* () {
15+
const target = yield* Target;
16+
const mcp = yield* Mcp;
17+
const identity = yield* target.newIdentity();
18+
const session = mcp.session(identity);
19+
20+
const result = yield* session.call("execute", {
21+
code: [
22+
'text("helper caption");',
23+
`image(${JSON.stringify(PNG_DATA_URL)}, "original");`,
24+
`image({ image_url: ${JSON.stringify(PNG_DATA_URL)}, detail: "low" });`,
25+
'notify({ message: "rendered previews", data: { count: 2 } });',
26+
"return {",
27+
' value: "structured return",',
28+
" toolKeys: {",
29+
" root: Object.keys(tools),",
30+
" describe: Object.keys(tools.describe),",
31+
" executor: Object.keys(tools.executor),",
32+
" sources: Object.keys(tools.executor.sources),",
33+
" },",
34+
"};",
35+
].join("\n"),
36+
});
37+
38+
expect(result.ok, `execute completed (got: ${result.text.slice(0, 300)})`).toBe(true);
39+
40+
const raw = result.raw as {
41+
content?: ReadonlyArray<Record<string, unknown>>;
42+
structuredContent?: Record<string, unknown>;
43+
};
44+
const content = raw.content ?? [];
45+
expect(result.text, "text() reaches the user-visible MCP text stream").toContain(
46+
"helper caption",
47+
);
48+
expect(result.text, "notify() renders as a distinct visible notification").toContain(
49+
"Notification: rendered previews",
50+
);
51+
52+
const images = content.filter((block) => block.type === "image");
53+
expect(images, "image() emits two MCP image blocks").toHaveLength(2);
54+
expect(images[0], "data URI image is converted to MCP image content").toMatchObject({
55+
type: "image",
56+
data: PNG_DATA,
57+
mimeType: "image/png",
58+
_meta: { "codex/imageDetail": "original" },
59+
});
60+
expect(images[1], "image_url object detail is preserved").toMatchObject({
61+
type: "image",
62+
data: PNG_DATA,
63+
mimeType: "image/png",
64+
_meta: { "codex/imageDetail": "low" },
65+
});
66+
67+
expect(raw.structuredContent?.result, "return value stays model-visible").toEqual({
68+
value: "structured return",
69+
toolKeys: {
70+
root: ["search", "describe", "executor"],
71+
describe: ["tool"],
72+
executor: ["sources"],
73+
sources: ["list"],
74+
},
75+
});
76+
expect(raw.structuredContent?.notifications, "notifications are structured too").toEqual([
77+
{ message: "rendered previews", data: { count: 2 } },
78+
]);
79+
}),
80+
);

packages/core/execution/src/description.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,15 @@ const formatDescription = (connectionEntries: readonly ConnectionInventoryEntry[
116116
"- Tool calls return a value union: `{ ok: true, data }` for success or `{ ok: false, error: { code, message, status?, details?, retryable? } }` for expected tool/domain failures. Branch on `result.ok`.",
117117
"- `data` is the upstream payload itself. HTTP-backed tools (OpenAPI) also set `http: { status, headers }` beside `data` — read `result.http?.headers` for pagination (Link) or rate-limit headers.",
118118
"- 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+
"- Prefer explicit output helpers when the content kind is known: `text(value)`, `image(value, detail?)`, `audio(block)`, `file(toolFile)`, `resource(block)`, and `notify(value)`. `image` accepts a base64 data URI string, `{ image_url, detail }`, or a raw MCP image block; `detail` may be `auto`, `low`, `high`, or `original`.",
119120
'- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. Emit any attachment with `emit(result.data)`.',
120121
'- 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, ... }`.',
121122
"- `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.",
122123
"- `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.",
123124
"- 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.",
124125
"- If `tools.search()` returns `hasMore: true` and you didn't find what you need, fetch the next page: `tools.search({ query, offset: nextOffset, limit })`.",
125126
"- 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.",
126-
"- The `tools` object is a lazy proxy`Object.keys(tools)` won't work. Use `tools.search()` or `tools.executor.coreTools.connections.list({})` instead.",
127+
"- The `tools` object is a lazy proxy. `Object.keys(tools)` returns only bounded built-in discovery keys (`search`, `describe`, `executor`), not the full API catalog. Use `tools.search()` or `tools.executor.coreTools.connections.list({})` for configured tools.",
127128
'- Pass an object to system tools, e.g. `tools.search({ query: "..." })`, `tools.executor.coreTools.connections.list({})`, and `tools.describe.tool({ path })`.',
128129
'- `tools.describe.tool()` returns compact TypeScript shapes. Use `inputTypeScript`, `outputTypeScript`, and `typeScriptDefinitions`. If the path doesn\'t resolve, the result carries `error: { code: "tool_not_found", suggestions }` — use a suggestion instead of retrying the same path.',
129130
"- For tools that return large collections (e.g. `getStates`, `getAll`), filter results in code rather than calling per-item tools.",

packages/core/execution/src/engine.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import type {
1010
ElicitationContext,
1111
} from "@executor-js/sdk/core";
1212
import { CodeExecutionError } from "@executor-js/codemode-core";
13-
import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor-js/codemode-core";
13+
import type {
14+
CodeExecutor,
15+
ExecuteNotification,
16+
ExecuteResult,
17+
SandboxToolInvoker,
18+
} from "@executor-js/codemode-core";
1419

1520
import {
1621
defaultToolDiscoveryProvider,
@@ -79,6 +84,16 @@ export const formatExecuteResult = (
7984
: null;
8085

8186
const logText = result.logs && result.logs.length > 0 ? result.logs.join("\n") : null;
87+
const notifications =
88+
result.output
89+
?.filter(
90+
(
91+
item,
92+
): item is { readonly type: "notification"; readonly notification: ExecuteNotification } =>
93+
item.type === "notification",
94+
)
95+
.map((item) => item.notification) ?? [];
96+
const notificationField = notifications.length > 0 ? { notifications } : {};
8297

8398
// `emit()` output is shown to the user, not returned to the model, so a
8499
// script that only emits comes back with a null result. Acknowledge the
@@ -97,6 +112,7 @@ export const formatExecuteResult = (
97112
status: "error",
98113
error: result.error,
99114
...emittedField,
115+
...notificationField,
100116
logs: result.logs ?? [],
101117
},
102118
isError: true,
@@ -115,6 +131,7 @@ export const formatExecuteResult = (
115131
status: "completed",
116132
result: result.result ?? null,
117133
...emittedField,
134+
...notificationField,
118135
logs: result.logs ?? [],
119136
},
120137
isError: false,

packages/hosts/mcp/src/tool-server.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,13 +373,36 @@ const isContentOutputItem = (
373373
): item is { readonly type: "content"; readonly content: ContentBlock } =>
374374
isRecord(item) && item.type === "content" && isMcpContentBlock(item.content);
375375

376+
const isNotificationOutputItem = (
377+
item: ExecuteOutputItem,
378+
): item is {
379+
readonly type: "notification";
380+
readonly notification: { readonly message: string; readonly data?: unknown };
381+
} =>
382+
isRecord(item) &&
383+
item.type === "notification" &&
384+
isRecord(item.notification) &&
385+
typeof item.notification.message === "string";
386+
387+
const notificationContent = (notification: {
388+
readonly message: string;
389+
readonly data?: unknown;
390+
}): ContentBlock[] => {
391+
const dataText =
392+
notification.data === undefined ? "" : `\n${JSON.stringify(notification.data, null, 2)}`;
393+
return [{ type: "text", text: `Notification: ${notification.message}${dataText}` }];
394+
};
395+
376396
const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => {
377397
if (isFileOutputItem(item)) {
378398
return outputFileContent(item.file);
379399
}
380400
if (isContentOutputItem(item)) {
381401
return [item.content];
382402
}
403+
if (isNotificationOutputItem(item)) {
404+
return notificationContent(item.notification);
405+
}
383406
return [{ type: "text", text: "Invalid execution output item omitted." }];
384407
};
385408

packages/kernel/core/src/types.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ export interface SandboxToolInvoker {
2727
}
2828

2929
/** User-visible output accumulated by sandbox helpers. */
30+
export type ImageDetail = "auto" | "low" | "high" | "original";
31+
32+
export const IMAGE_DETAIL_META_KEY = "codex/imageDetail";
33+
export const DEFAULT_IMAGE_DETAIL: ImageDetail = "high";
34+
35+
export type ExecuteNotification = {
36+
readonly message: string;
37+
readonly data?: unknown;
38+
};
39+
3040
export type ExecuteOutputItem =
3141
| {
3242
readonly type: "file";
@@ -35,8 +45,17 @@ export type ExecuteOutputItem =
3545
| {
3646
readonly type: "content";
3747
readonly content: unknown;
48+
}
49+
| {
50+
readonly type: "notification";
51+
readonly notification: ExecuteNotification;
3852
};
3953

54+
export type CodeExecutionOptions = {
55+
readonly onOutput?: (item: ExecuteOutputItem) => void | Promise<void>;
56+
readonly onYield?: () => void | Promise<void>;
57+
};
58+
4059
/** Result of executing code in a sandbox */
4160
export type ExecuteResult = {
4261
result: unknown;
@@ -55,7 +74,11 @@ export type ExecuteResult = {
5574
* `Data.TaggedError` subclass — e.g. `CodeExecutor<WorkerLoaderError>`.
5675
*/
5776
export interface CodeExecutor<E extends Cause.YieldableError = CodeExecutionError> {
58-
execute(code: string, toolInvoker: SandboxToolInvoker): Effect.Effect<ExecuteResult, E>;
77+
execute(
78+
code: string,
79+
toolInvoker: SandboxToolInvoker,
80+
options?: CodeExecutionOptions,
81+
): Effect.Effect<ExecuteResult, E>;
5982
}
6083

6184
/** Accept-anything schema for tools with no input validation */

0 commit comments

Comments
 (0)