History

feat: wire v2 execute and callback flows through shared run services

Rhys Sullivan committed 8 months ago

Commit c4376dc1 parent 9b0333f29 files · +1163 −61Browse files

Unified · Split

29 files
  1. v2/.gitignore +2 −0
  2. v2/apps/convex/README.md +4 −2
  3. v2/apps/convex/convex/_generated/api.d.ts +10 −0
  4. v2/apps/convex/convex/executor.test.ts +65 −0
  5. v2/apps/convex/convex/executor.ts +64 −0
  6. v2/apps/convex/convex/http.ts +11 −0
  7. v2/apps/convex/convex/mcp.ts +12 −8
  8. v2/apps/convex/convex/rpc_exit.ts +50 −0
  9. v2/apps/convex/convex/run_executor.ts +89 −0
  10. v2/apps/convex/convex/runtimeCallbacks.ts +119 −0
  11. v2/apps/convex/convex/runtime_adapter.ts +36 −0
  12. v2/apps/convex/package.json +10 −1
  13. v2/apps/pm/README.md +1 −0
  14. v2/apps/pm/package.json +5 −0
  15. v2/apps/pm/src/execute.e2e.test.ts +167 −0
  16. v2/apps/pm/src/http-server.ts +8 −0
  17. v2/apps/pm/src/main.ts +10 −1
  18. v2/apps/pm/src/tool-call-handler.test.ts +68 −0
  19. v2/apps/pm/src/tool-call-handler.ts +124 −0
  20. v2/bun.lock +10 −0
  21. v2/package.json +3 −0
  22. v2/packages/ai-sdk-adapter/package.json +2 −2
  23. v2/packages/ai-sdk-adapter/src/index.test.ts +61 −42
  24. v2/packages/ai-sdk-adapter/src/index.ts +1 −0
  25. v2/packages/confect/src/rpc/client.test.ts +1 −1
  26. v2/packages/engine/package.json +3 −1
  27. v2/packages/engine/src/index.ts +8 −0
  28. v2/packages/engine/src/local-runner.ts +4 −3
  29. v2/packages/engine/src/run-client.ts +215 −0
v2/.gitignore+2−0
@@ -1 +1,3 @@
11 .reference/
2+.vercel
3+.env*.local
v2/apps/convex/README.md+4−2
@@ -3,5 +3,7 @@ · 2 unmodified lines
33 Convex remote server app scaffold for Executor v2.
44
55 Current scaffold includes:
6−- Convex schema wiring at `convex/schema.ts`, sourced from `@executor-v2/persistence-convex`
7−- bare-minimum MCP HTTP endpoint with a stub tool at `convex/http.ts` and `convex/mcp.ts`
6+ Convex schema wiring at `convex/schema.ts`, sourced from `@executor-v2/persistence-convex`
7+ MCP HTTP endpoint at `convex/http.ts` and `convex/mcp.ts`, wired through a run client
8+ runtime callback endpoints at `POST /runtime/tool-call` and `POST /v1/runtime/tool-call`
9+ action-level callback handler scaffold in `convex/runtimeCallbacks.ts`
v2/apps/convex/convex/_generated/api.d.ts+10−0
@@ -8,8 +8,13 @@ · 7 unmodified lines
88 * @module
99 */
1010
11+import type * as executor from "../executor.js";
1112 import type * as http from "../http.js";
1213 import type * as mcp from "../mcp.js";
14+import type * as rpc_exit from "../rpc_exit.js";
15+import type * as run_executor from "../run_executor.js";
16+import type * as runtimeCallbacks from "../runtimeCallbacks.js";
17+import type * as runtime_adapter from "../runtime_adapter.js";
1318
1419 import type {
1520 ApiFromModules,
@@ -18,8 +23,13 @@ import type { · 2 unmodified lines
1823 } from "convex/server";
1924
2025 declare const fullApi: ApiFromModules<{
26+ executor: typeof executor;
2127 http: typeof http;
2228 mcp: typeof mcp;
29+ rpc_exit: typeof rpc_exit;
30+ run_executor: typeof run_executor;
31+ runtimeCallbacks: typeof runtimeCallbacks;
32+ runtime_adapter: typeof runtime_adapter;
2333 }>;
2434
2535 /**
v2/apps/convex/convex/executor.test.ts+65−0
@@ -0,0 +1,65 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { convexTest } from "convex-test";
3+import * as Effect from "effect/Effect";
4+
5+import { api } from "./_generated/api";
6+import { unwrapRpcSuccess } from "./rpc_exit";
7+import schema from "./schema";
8+
9+const setup = () =>
10+ convexTest(schema, {
11+ "./http.ts": () => import("./http"),
12+ "./mcp.ts": () => import("./mcp"),
13+ "./executor.ts": () => import("./executor"),
14+ "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"),
15+ "./_generated/api.js": () => import("./_generated/api.js"),
16+ });
17+
18+describe("Convex executor actions", () => {
19+ it.effect("executes code via executeRun action", () =>
20+ Effect.gen(function* () {
21+ const t = setup();
22+
23+ const result = unwrapRpcSuccess<{
24+ status: string;
25+ result?: unknown;
26+ }>(
27+ yield* Effect.tryPromise(() =>
28+ t.action(api.executor.executeRun, {
29+ code: "return 6 * 7;",
30+ }),
31+ ),
32+ "executor.executeRun",
33+ );
34+
35+ expect(result.status).toBe("completed");
36+ expect(result.result).toBe(42);
37+ }),
38+ );
39+
40+ it.effect("returns failed callback result from runtime callback action", () =>
41+ Effect.gen(function* () {
42+ const t = setup();
43+
44+ const result = unwrapRpcSuccess<{
45+ ok: boolean;
46+ kind?: string;
47+ error?: string;
48+ }>(
49+ yield* Effect.tryPromise(() =>
50+ t.action(api.runtimeCallbacks.handleToolCall, {
51+ runId: "run_1",
52+ callId: "call_1",
53+ toolPath: "tools.example.lookup",
54+ input: { query: "ping" },
55+ }),
56+ ),
57+ "runtimeCallbacks.handleToolCall",
58+ );
59+
60+ expect(result.ok).toBe(false);
61+ expect(result.kind).toBe("failed");
62+ expect(result.error).toContain("tools.example.lookup");
63+ }),
64+ );
65+});
v2/apps/convex/convex/executor.ts+64−0
@@ -0,0 +1,64 @@
1+import { createRpcFactory, makeRpcModule } from "@executor-v2/confect/rpc";
2+import { executorConfectSchema } from "@executor-v2/persistence-convex";
3+import type { ExecuteRunInput } from "@executor-v2/sdk";
4+import * as Effect from "effect/Effect";
5+import * as Layer from "effect/Layer";
6+import * as Schema from "effect/Schema";
7+
8+import {
9+ ConvexRunExecutor,
10+ ConvexRunExecutorLive,
11+ ConvexToolProviderRegistryLive,
12+} from "./run_executor";
13+
14+const executeRunResultSchema = Schema.Struct({
15+ runId: Schema.String,
16+ status: Schema.Union(
17+ Schema.Literal("completed"),
18+ Schema.Literal("failed"),
19+ Schema.Literal("timed_out"),
20+ Schema.Literal("denied"),
21+ ),
22+ result: Schema.optional(Schema.Unknown),
23+ error: Schema.optional(Schema.String),
24+ exitCode: Schema.optional(Schema.Number),
25+ durationMs: Schema.optional(Schema.Number),
26+});
27+
28+const factory = createRpcFactory({
29+ schema: executorConfectSchema,
30+});
31+
32+const ConvexExecuteDependenciesLive = Layer.merge(
33+ ConvexRunExecutorLive,
34+ ConvexToolProviderRegistryLive,
35+);
36+
37+export const executeRunImpl = (
38+ input: ExecuteRunInput,
39+): Effect.Effect<typeof executeRunResultSchema.Type> =>
40+ Effect.gen(function* () {
41+ const runExecutor = yield* ConvexRunExecutor;
42+ return yield* runExecutor.executeRun(input);
43+ }).pipe(Effect.provide(ConvexExecuteDependenciesLive));
44+
45+const executeRunEndpoint = factory.action({
46+ payload: {
47+ code: Schema.String,
48+ timeoutMs: Schema.optional(Schema.Number),
49+ },
50+ success: executeRunResultSchema,
51+});
52+
53+executeRunEndpoint.implement((payload) =>
54+ executeRunImpl({
55+ code: payload.code,
56+ timeoutMs: payload.timeoutMs,
57+ }),
58+);
59+
60+const executorRpc = makeRpcModule({
61+ executeRun: executeRunEndpoint,
62+});
63+
64+export const executeRun = executorRpc.handlers.executeRun;
v2/apps/convex/convex/http.ts+11−0
@@ -1,5 +1,6 @@
11 import { httpRouter } from "convex/server";
22 import { mcpHandler } from "./mcp";
3+import { handleToolCallHttp } from "./runtimeCallbacks";
34
45 const http = httpRouter();
56
@@ -9,5 +10,15 @@ http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler }); · 3 unmodified lines
910 http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler });
1011 http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler });
1112 http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler });
13+http.route({
14+ path: "/runtime/tool-call",
15+ method: "POST",
16+ handler: handleToolCallHttp,
17+});
18+http.route({
19+ path: "/v1/runtime/tool-call",
20+ method: "POST",
21+ handler: handleToolCallHttp,
22+});
1223
1324 export default http;
v2/apps/convex/convex/mcp.ts+12−8
@@ -1,15 +1,19 @@
11 import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";
22 import { createExecutorRunClient } from "@executor-v2/sdk";
3+import { api } from "./_generated/api";
34 import { httpAction } from "./_generated/server";
4−const runClient = createExecutorRunClient(async () => ({
5− runId: `run_${Date.now()}`,
6− status: "failed" as const,
7− error: "Convex run client is not wired yet",
8−}));
9−export const mcpHandler = httpAction(async (_ctx, request) =>
10− handleMcpHttpRequest(request, {
5+import { unwrapRpcSuccess } from "./rpc_exit";
6+export const mcpHandler = httpAction(async (ctx, request) => {
7+ const runClient = createExecutorRunClient((input) =>
8+ ctx
9+ .runAction(api.executor.executeRun, input)
10+ .then((result) => unwrapRpcSuccess(result, "executor.executeRun")),
11+ );
12+
13+ return handleMcpHttpRequest(request, {
1114 target: "remote",
1215 serverName: "executor-v2-convex",
1316 serverVersion: "0.0.0",
1417 runClient,
15− }));
18+ });
19+});
v2/apps/convex/convex/rpc_exit.ts+50−0
@@ -0,0 +1,50 @@
1+type RpcExitSuccess<T> = {
2+ _tag: "Success";
3+ value: T;
4+};
5+
6+type RpcExitFailure = {
7+ _tag: "Failure";
8+ cause: unknown;
9+};
10+
11+const isRpcExitSuccess = <T>(value: unknown): value is RpcExitSuccess<T> =>
12+ typeof value === "object" &&
13+ value !== null &&
14+ "_tag" in value &&
15+ value._tag === "Success" &&
16+ "value" in value;
17+
18+const isRpcExitFailure = (value: unknown): value is RpcExitFailure =>
19+ typeof value === "object" &&
20+ value !== null &&
21+ "_tag" in value &&
22+ value._tag === "Failure";
23+
24+const renderUnknown = (value: unknown): string => {
25+ if (value instanceof Error) {
26+ return value.message;
27+ }
28+
29+ try {
30+ return JSON.stringify(value);
31+ } catch {
32+ return String(value);
33+ }
34+};
35+
36+export const unwrapRpcSuccess = <T>(value: unknown, endpoint: string): T => {
37+ if (isRpcExitSuccess<T>(value)) {
38+ return value.value;
39+ }
40+
41+ if (isRpcExitFailure(value)) {
42+ throw new Error(
43+ `RPC endpoint '${endpoint}' returned failure exit: ${renderUnknown(value.cause)}`,
44+ );
45+ }
46+
47+ throw new Error(
48+ `RPC endpoint '${endpoint}' returned invalid exit payload: ${renderUnknown(value)}`,
49+ );
50+};
v2/apps/convex/convex/run_executor.ts+89−0
@@ -0,0 +1,89 @@
1+import {
2+ makeToolProviderRegistry,
3+ ToolProviderRegistryService,
4+} from "@executor-v2/engine/tool-providers";
5+import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk";
6+import * as Context from "effect/Context";
7+import * as Effect from "effect/Effect";
8+import * as Layer from "effect/Layer";
9+
10+import { makeLocalInProcessRuntimeAdapter } from "./runtime_adapter";
11+
12+export type ConvexRunExecutorService = {
13+ executeRun: (
14+ input: ExecuteRunInput,
15+ ) => Effect.Effect<ExecuteRunResult, never, ToolProviderRegistryService>;
16+};
17+
18+export class ConvexRunExecutor extends Context.Tag(
19+ "@executor-v2/app-convex/ConvexRunExecutor",
20+)<ConvexRunExecutor, ConvexRunExecutorService>() {}
21+
22+const runtimeAdapter = makeLocalInProcessRuntimeAdapter();
23+
24+const errorToText = (error: unknown): string => {
25+ if (error instanceof Error) {
26+ return error.message;
27+ }
28+
29+ if (
30+ typeof error === "object" &&
31+ error !== null &&
32+ "message" in error &&
33+ typeof error.message === "string"
34+ ) {
35+ return error.message;
36+ }
37+
38+ return String(error);
39+};
40+
41+const executeRun = Effect.fn(
42+ "@executor-v2/app-convex/run-executor.executeRun",
43+)(function* (input: ExecuteRunInput) {
44+ const runId = `run_${crypto.randomUUID()}`;
45+
46+ const isAvailable = yield* runtimeAdapter.isAvailable();
47+ if (!isAvailable) {
48+ return {
49+ runId,
50+ status: "failed",
51+ error: `Runtime '${runtimeAdapter.kind}' is not available in this Convex process.`,
52+ } satisfies ExecuteRunResult;
53+ }
54+
55+ return yield* runtimeAdapter
56+ .execute({
57+ code: input.code,
58+ timeoutMs: input.timeoutMs,
59+ tools: [],
60+ })
61+ .pipe(
62+ Effect.map(
63+ (result): ExecuteRunResult => ({
64+ runId,
65+ status: "completed",
66+ result,
67+ }),
68+ ),
69+ Effect.catchAll((error) =>
70+ Effect.succeed({
71+ runId,
72+ status: "failed",
73+ error: errorToText(error),
74+ } satisfies ExecuteRunResult),
75+ ),
76+ );
77+});
78+
79+export const ConvexRunExecutorLive = Layer.succeed(
80+ ConvexRunExecutor,
81+ ConvexRunExecutor.of({
82+ executeRun,
83+ }),
84+);
85+
86+export const ConvexToolProviderRegistryLive = Layer.succeed(
87+ ToolProviderRegistryService,
88+ makeToolProviderRegistry([]),
89+);
v2/apps/convex/convex/runtimeCallbacks.ts+119−0
@@ -0,0 +1,119 @@
1+import { createRpcFactory, makeRpcModule } from "@executor-v2/confect/rpc";
2+import { executorConfectSchema } from "@executor-v2/persistence-convex";
3+import type {
4+ RuntimeToolCallRequest,
5+ RuntimeToolCallResult,
6+} from "@executor-v2/sdk";
7+import * as Effect from "effect/Effect";
8+import * as Schema from "effect/Schema";
9+import { api } from "./_generated/api";
10+import { httpAction } from "./_generated/server";
11+import { unwrapRpcSuccess } from "./rpc_exit";
12+
13+const runtimeToolCallResultSchema = Schema.Struct({
14+ ok: Schema.Boolean,
15+ kind: Schema.optional(Schema.String),
16+ error: Schema.optional(Schema.String),
17+ value: Schema.optional(Schema.Unknown),
18+ approvalId: Schema.optional(Schema.String),
19+ retryAfterMs: Schema.optional(Schema.Number),
20+});
21+
22+const factory = createRpcFactory({
23+ schema: executorConfectSchema,
24+});
25+
26+export const handleToolCallImpl = (
27+ input: RuntimeToolCallRequest,
28+): Effect.Effect<RuntimeToolCallResult> =>
29+ Effect.succeed({
30+ ok: false,
31+ kind: "failed",
32+ error: `Convex runtime callback received tool '${input.toolPath}', but callback invocation is not wired yet.`,
33+ });
34+
35+const handleToolCallEndpoint = factory.action({
36+ payload: {
37+ runId: Schema.String,
38+ callId: Schema.String,
39+ toolPath: Schema.String,
40+ input: Schema.optional(
41+ Schema.Record({
42+ key: Schema.String,
43+ value: Schema.Unknown,
44+ }),
45+ ),
46+ },
47+ success: runtimeToolCallResultSchema,
48+});
49+
50+handleToolCallEndpoint.implement((payload) =>
51+ handleToolCallImpl({
52+ runId: payload.runId,
53+ callId: payload.callId,
54+ toolPath: payload.toolPath,
55+ input: payload.input,
56+ }),
57+);
58+
59+const runtimeCallbacksRpc = makeRpcModule({
60+ handleToolCall: handleToolCallEndpoint,
61+});
62+
63+export const handleToolCall = runtimeCallbacksRpc.handlers.handleToolCall;
64+
65+const badRequest = (message: string): Response =>
66+ Response.json(
67+ {
68+ ok: false,
69+ kind: "failed",
70+ error: message,
71+ } satisfies RuntimeToolCallResult,
72+ { status: 400 },
73+ );
74+
75+export const handleToolCallHttp = httpAction(async (ctx, request) => {
76+ let body: unknown;
77+ try {
78+ body = await request.json();
79+ } catch (cause) {
80+ return badRequest(
81+ `Invalid runtime callback request body: ${
82+ cause instanceof Error ? cause.message : String(cause)
83+ }`,
84+ );
85+ }
86+
87+ if (typeof body !== "object" || body === null) {
88+ return badRequest("Runtime callback request body must be an object");
89+ }
90+
91+ const payload = body as Partial<RuntimeToolCallRequest>;
92+
93+ if (
94+ typeof payload.runId !== "string" ||
95+ typeof payload.callId !== "string" ||
96+ typeof payload.toolPath !== "string"
97+ ) {
98+ return badRequest("Runtime callback request body is missing required fields");
99+ }
100+
101+ let result: RuntimeToolCallResult;
102+ try {
103+ result = unwrapRpcSuccess(
104+ await ctx.runAction(api.runtimeCallbacks.handleToolCall, {
105+ runId: payload.runId,
106+ callId: payload.callId,
107+ toolPath: payload.toolPath,
108+ input:
109+ payload.input && typeof payload.input === "object"
110+ ? (payload.input as Record<string, unknown>)
111+ : undefined,
112+ }),
113+ "runtimeCallbacks.handleToolCall",
114+ );
115+ } catch (cause) {
116+ return badRequest(cause instanceof Error ? cause.message : String(cause));
117+ }
118+ return Response.json(result, { status: 200 });
119+});
v2/apps/convex/convex/runtime_adapter.ts+36−0
@@ -0,0 +1,36 @@
1+import { executeJavaScriptWithTools } from "@executor-v2/engine/local-runner";
2+import type {
3+ CanonicalToolDescriptor,
4+ ToolProviderRegistryService,
5+} from "@executor-v2/engine/tool-providers";
6+import type { Source } from "@executor-v2/schema";
7+import * as Effect from "effect/Effect";
8+
9+export type RuntimeRunnableTool = {
10+ descriptor: CanonicalToolDescriptor;
11+ source: Source | null;
12+};
13+
14+export type RuntimeExecuteInput = {
15+ code: string;
16+ tools: ReadonlyArray<RuntimeRunnableTool>;
17+ timeoutMs?: number;
18+};
19+
20+export type RuntimeAdapter = {
21+ kind: "local-inproc";
22+ isAvailable: () => Effect.Effect<boolean>;
23+ execute: (
24+ input: RuntimeExecuteInput,
25+ ) => Effect.Effect<unknown, unknown, ToolProviderRegistryService>;
26+};
27+
28+export const makeLocalInProcessRuntimeAdapter = (): RuntimeAdapter => ({
29+ kind: "local-inproc",
30+ isAvailable: () => Effect.succeed(true),
31+ execute: (input) =>
32+ executeJavaScriptWithTools({
33+ code: input.code,
34+ tools: input.tools,
35+ }),
36+});
v2/apps/convex/package.json+10−1
@@ -4,13 +4,22 @@ · 3 unmodified lines
44 "type": "module",
55 "scripts": {
66 "dev": "bunx convex dev",
7+ "test": "bunx vitest run --no-watch convex/*.test.ts",
78 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
89 "convex:codegen": "bunx convex codegen"
910 },
1011 "dependencies": {
12+ "@executor-v2/confect": "workspace:*",
13+ "@executor-v2/engine": "workspace:*",
1114 "@executor-v2/mcp-gateway": "workspace:*",
1215 "@executor-v2/persistence-convex": "workspace:*",
1316 "@executor-v2/sdk": "workspace:*",
14− "convex": "catalog:"
17+ "convex": "catalog:",
18+ "effect": "catalog:"
19+ },
20+ "devDependencies": {
21+ "@effect/vitest": "catalog:",
22+ "convex-test": "^0.0.41",
23+ "vitest": "catalog:"
1524 }
1625 }
v2/apps/pm/README.md+1−0
@@ -7,6 +7,7 @@ Current scaffold includes: · 6 unmodified lines
77 - health endpoint at `GET /healthz`
88 - MCP tool routing via `@executor-v2/mcp-gateway`
99 - local runtime adapter injection (`makeLocalInProcessRuntimeAdapter()` in entry wiring)
10+ runtime callback endpoint at `POST /runtime/tool-call`
1011
1112 App wiring is now split by responsibility:
1213 - `src/config.ts`: Effect config service (`PORT`)
v2/apps/pm/package.json+5−0
@@ -5,6 +5,7 @@ · 4 unmodified lines
55 "scripts": {
66 "dev": "bun --watch src/main.ts",
77 "start": "bun src/main.ts",
8+ "test": "bunx vitest run --no-watch src/*.test.ts",
89 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
910 },
1011 "dependencies": {
@@ -12,5 +13,9 @@ · 1 unmodified line
1213 "@executor-v2/mcp-gateway": "workspace:*",
1314 "@executor-v2/sdk": "workspace:*",
1415 "effect": "catalog:"
16+ },
17+ "devDependencies": {
18+ "@effect/vitest": "catalog:",
19+ "vitest": "catalog:"
1520 }
1621 }
v2/apps/pm/src/execute.e2e.test.ts+167−0
@@ -0,0 +1,167 @@
1+import { spawn } from "node:child_process";
2+import { createServer } from "node:net";
3+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5+import { describe, expect, it } from "@effect/vitest";
6+import * as Data from "effect/Data";
7+import * as Effect from "effect/Effect";
8+
9+const pmAppDir = new URL("..", import.meta.url).pathname;
10+
11+type McpCallToolResult = {
12+ isError?: boolean;
13+ content?: Array<{ type?: string; text?: string }>;
14+};
15+
16+class ExecuteE2eError extends Data.TaggedError("ExecuteE2eError")<{
17+ message: string;
18+ details: string | null;
19+}> {}
20+
21+const toExecuteE2eError = (message: string, cause: unknown): ExecuteE2eError =>
22+ new ExecuteE2eError({
23+ message,
24+ details: cause instanceof Error ? cause.message : String(cause),
25+ });
26+
27+const findFreePort = Effect.async<number, ExecuteE2eError, never>((resume) => {
28+ const server = createServer();
29+
30+ server.once("error", (cause) => {
31+ resume(Effect.fail(toExecuteE2eError("Failed to allocate free TCP port", cause)));
32+ });
33+
34+ server.listen(0, "127.0.0.1", () => {
35+ const address = server.address();
36+ const port = typeof address === "object" && address ? address.port : null;
37+
38+ server.close((cause) => {
39+ if (cause) {
40+ resume(Effect.fail(toExecuteE2eError("Failed closing port probe server", cause)));
41+ return;
42+ }
43+
44+ if (!port) {
45+ resume(
46+ Effect.fail(
47+ new ExecuteE2eError({
48+ message: "Failed to allocate free TCP port",
49+ details: "No port returned from node:net server",
50+ }),
51+ ),
52+ );
53+ return;
54+ }
55+
56+ resume(Effect.succeed(port));
57+ });
58+ });
59+});
60+
61+const waitForHealth = (port: number) =>
62+ Effect.gen(function* () {
63+ for (let attempt = 0; attempt < 80; attempt += 1) {
64+ const healthy = yield* Effect.tryPromise({
65+ try: async () => {
66+ const signal = AbortSignal.timeout(250);
67+ const response = await fetch(`http://127.0.0.1:${port}/healthz`, { signal });
68+ return response.status === 200;
69+ },
70+ catch: (cause) =>
71+ toExecuteE2eError("Failed polling PM health endpoint", cause),
72+ }).pipe(Effect.catchAll(() => Effect.succeed(false)));
73+
74+ if (healthy) {
75+ return;
76+ }
77+
78+ yield* Effect.sleep("100 millis");
79+ }
80+
81+ return yield* new ExecuteE2eError({
82+ message: "PM server did not become healthy in time",
83+ details: `http://127.0.0.1:${port}/healthz`,
84+ });
85+ });
86+
87+const withPmProcess = (port: number) =>
88+ Effect.acquireRelease(
89+ Effect.sync(() =>
90+ spawn("bun", ["src/main.ts"], {
91+ cwd: pmAppDir,
92+ env: {
93+ ...process.env,
94+ PORT: String(port),
95+ },
96+ stdio: "pipe",
97+ }),
98+ ),
99+ (child) =>
100+ Effect.sync(() => {
101+ if (!child.killed) {
102+ child.kill("SIGTERM");
103+ }
104+ }),
105+ );
106+
107+const withMcpClient = (port: number) =>
108+ Effect.acquireRelease(
109+ Effect.tryPromise({
110+ try: async () => {
111+ const transport = new StreamableHTTPClientTransport(
112+ new URL(`http://127.0.0.1:${port}/mcp`),
113+ );
114+ const client = new Client(
115+ { name: "executor-v2-pm-e2e", version: "0.0.0" },
116+ { capabilities: {} },
117+ );
118+
119+ await client.connect(transport);
120+ return { client, transport };
121+ },
122+ catch: (cause) =>
123+ toExecuteE2eError("Failed to initialize MCP client transport", cause),
124+ }),
125+ ({ client, transport }) =>
126+ Effect.tryPromise({
127+ try: async () => {
128+ await transport.close().catch(() => undefined);
129+ await client.close().catch(() => undefined);
130+ },
131+ catch: (cause) =>
132+ toExecuteE2eError("Failed closing MCP client transport", cause),
133+ }).pipe(Effect.orDie),
134+ );
135+
136+describe("PM execute E2E", () => {
137+ it.live(
138+ "runs executor.execute code through MCP",
139+ () =>
140+ Effect.scoped(
141+ Effect.gen(function* () {
142+ const port = yield* findFreePort;
143+ yield* withPmProcess(port);
144+ yield* waitForHealth(port);
145+
146+ const { client } = yield* withMcpClient(port);
147+
148+ const result = (yield* Effect.tryPromise({
149+ try: () =>
150+ client.callTool({
151+ name: "executor.execute",
152+ arguments: {
153+ code: "return 2 + 3;",
154+ },
155+ }),
156+ catch: (cause) =>
157+ toExecuteE2eError("Failed calling executor.execute over MCP", cause),
158+ })) as McpCallToolResult;
159+
160+ expect(result.isError).toBe(false);
161+ const textPart = result.content?.find((part) => part.type === "text");
162+ expect(textPart?.text).toBe("5");
163+ }),
164+ ),
165+ 30_000,
166+ );
167+});
v2/apps/pm/src/http-server.ts+8−0
@@ -2,10 +2,12 @@ import * as Effect from "effect/Effect"; · 1 unmodified line
22
33 import { PmConfig } from "./config";
44 import { PmMcpHandler } from "./mcp-handler";
5+import { PmToolCallHttpHandler } from "./tool-call-handler";
56
67 export const startPmHttpServer = Effect.fn("@executor-v2/app-pm/http.start")(function* () {
78 const { port } = yield* PmConfig;
89 const { handleMcp } = yield* PmMcpHandler;
10+ const { handleToolCallHttp } = yield* PmToolCallHttpHandler;
911
1012 const server = Bun.serve({
1113 port,
@@ -23,6 +25,12 @@ export const startPmHttpServer = Effect.fn("@executor-v2/app-pm/http.start")(fun · 11 unmodified lines
2325 POST: handleMcp,
2426 DELETE: handleMcp,
2527 },
28+ "/runtime/tool-call": {
29+ POST: handleToolCallHttp,
30+ },
31+ "/v1/runtime/tool-call": {
32+ POST: handleToolCallHttp,
33+ },
2634 },
2735 });
2836
v2/apps/pm/src/main.ts+10−1
@@ -10,6 +10,10 @@ import { · 9 unmodified lines
1010 PmRunExecutorLive,
1111 PmToolProviderRegistryLive,
1212 } from "./run-executor";
13+import {
14+ PmToolCallHandlerLive,
15+ PmToolCallHttpHandlerLive,
16+} from "./tool-call-handler";
1317
1418 const runtimeAdapter = makeLocalInProcessRuntimeAdapter();
1519 const PmMcpDependenciesLive = Layer.merge(
@@ -17,9 +21,14 @@ const PmMcpDependenciesLive = Layer.merge( · 1 unmodified line
1721 PmToolProviderRegistryLive,
1822 );
1923
20−const PmAppLive = Layer.merge(
24+const PmToolCallDependenciesLive = PmToolCallHttpHandlerLive.pipe(
25+ Layer.provide(PmToolCallHandlerLive),
26+);
27+
28+const PmAppLive = Layer.mergeAll(
2129 PmConfigLive,
2230 PmMcpHandlerLive.pipe(Layer.provide(PmMcpDependenciesLive)),
31+ PmToolCallDependenciesLive,
2332 );
2433
2534 const program = startPmHttpServer().pipe(Effect.provide(PmAppLive));
v2/apps/pm/src/tool-call-handler.test.ts+68−0
@@ -0,0 +1,68 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as Layer from "effect/Layer";
4+
5+import {
6+ PmToolCallHandler,
7+ PmToolCallHandlerLive,
8+ PmToolCallHttpHandler,
9+ PmToolCallHttpHandlerLive,
10+} from "./tool-call-handler";
11+
12+describe("PM runtime tool-call handling", () => {
13+ it.effect("returns failed result while callback invocation is unwired", () =>
14+ Effect.gen(function* () {
15+ const handler = yield* PmToolCallHandler;
16+
17+ const result = yield* handler.handleToolCall({
18+ runId: "run_1",
19+ callId: "call_1",
20+ toolPath: "tools.example.lookup",
21+ input: { query: "ping" },
22+ });
23+
24+ expect(result.ok).toBe(false);
25+ if (!result.ok) {
26+ expect(result.kind).toBe("failed");
27+ expect(result.error).toContain("tools.example.lookup");
28+ }
29+ }).pipe(Effect.provide(PmToolCallHandlerLive)),
30+ );
31+
32+ it.effect("decodes callback request payload and returns JSON result", () =>
33+ Effect.gen(function* () {
34+ const httpHandler = yield* PmToolCallHttpHandler;
35+
36+ const response = yield* Effect.tryPromise(() =>
37+ httpHandler.handleToolCallHttp(
38+ new Request("http://127.0.0.1/runtime/tool-call", {
39+ method: "POST",
40+ headers: { "content-type": "application/json" },
41+ body: JSON.stringify({
42+ runId: "run_2",
43+ callId: "call_2",
44+ toolPath: "tools.example.weather",
45+ input: { city: "London" },
46+ }),
47+ }),
48+ ),
49+ );
50+
51+ expect(response.status).toBe(200);
52+
53+ const payload = (yield* Effect.tryPromise(() => response.json())) as {
54+ ok: boolean;
55+ kind?: string;
56+ error?: string;
57+ };
58+
59+ expect(payload.ok).toBe(false);
60+ expect(payload.kind).toBe("failed");
61+ expect(payload.error).toContain("tools.example.weather");
62+ }).pipe(
63+ Effect.provide(
64+ PmToolCallHttpHandlerLive.pipe(Layer.provide(PmToolCallHandlerLive)),
65+ ),
66+ ),
67+ );
68+});
v2/apps/pm/src/tool-call-handler.ts+124−0
@@ -0,0 +1,124 @@
1+import type {
2+ RuntimeToolCallRequest,
3+ RuntimeToolCallResult,
4+} from "@executor-v2/sdk";
5+import * as Context from "effect/Context";
6+import * as Data from "effect/Data";
7+import * as Effect from "effect/Effect";
8+import * as Layer from "effect/Layer";
9+import * as Runtime from "effect/Runtime";
10+import * as Schema from "effect/Schema";
11+
12+export type PmToolCallHandlerService = {
13+ handleToolCall: (input: RuntimeToolCallRequest) => Effect.Effect<RuntimeToolCallResult>;
14+};
15+
16+export class PmToolCallHandler extends Context.Tag("@executor-v2/app-pm/PmToolCallHandler")<
17+ PmToolCallHandler,
18+ PmToolCallHandlerService
19+>() {}
20+
21+class PmToolCallHttpRequestError extends Data.TaggedError(
22+ "PmToolCallHttpRequestError",
23+)<{
24+ message: string;
25+ details: string | null;
26+}> {}
27+
28+const RuntimeToolCallRequestSchema = Schema.Struct({
29+ runId: Schema.String,
30+ callId: Schema.String,
31+ toolPath: Schema.String,
32+ input: Schema.optional(Schema.Record({
33+ key: Schema.String,
34+ value: Schema.Unknown,
35+ })),
36+});
37+
38+const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);
39+
40+const errorToText = (error: unknown): string => {
41+ if (error instanceof Error) {
42+ return error.message;
43+ }
44+
45+ if (
46+ typeof error === "object" &&
47+ error !== null &&
48+ "message" in error &&
49+ typeof error.message === "string"
50+ ) {
51+ return error.message;
52+ }
53+
54+ return String(error);
55+};
56+
57+const decodeRequestBodyError = (cause: unknown): PmToolCallHttpRequestError =>
58+ new PmToolCallHttpRequestError({
59+ message: "Invalid runtime callback request body",
60+ details: cause instanceof Error ? cause.message : String(cause),
61+ });
62+
63+const handleToolCall = Effect.fn("@executor-v2/app-pm/tool-call.handle")(function* (
64+ input: RuntimeToolCallRequest,
65+) {
66+ return {
67+ ok: false,
68+ kind: "failed",
69+ error: `PM runtime callback received tool '${input.toolPath}', but callback invocation is not wired yet.`,
70+ } satisfies RuntimeToolCallResult;
71+});
72+
73+export const PmToolCallHandlerLive = Layer.succeed(
74+ PmToolCallHandler,
75+ PmToolCallHandler.of({
76+ handleToolCall,
77+ }),
78+);
79+
80+export type PmToolCallHttpHandlerService = {
81+ handleToolCallHttp: (request: Request) => Promise<Response>;
82+};
83+
84+export class PmToolCallHttpHandler extends Context.Tag("@executor-v2/app-pm/PmToolCallHttpHandler")<
85+ PmToolCallHttpHandler,
86+ PmToolCallHttpHandlerService
87+>() {}
88+
89+export const PmToolCallHttpHandlerLive = Layer.effect(
90+ PmToolCallHttpHandler,
91+ Effect.gen(function* () {
92+ const handler = yield* PmToolCallHandler;
93+ const runtime = yield* Effect.runtime<never>();
94+ const runPromise = Runtime.runPromise(runtime);
95+
96+ const handleToolCallHttp = (request: Request): Promise<Response> =>
97+ runPromise(
98+ Effect.tryPromise({
99+ try: () => request.json(),
100+ catch: decodeRequestBodyError,
101+ }).pipe(
102+ Effect.flatMap((body) => decodeRuntimeToolCallRequest(body)),
103+ Effect.flatMap((input) => handler.handleToolCall(input)),
104+ Effect.map((result) => Response.json(result, { status: 200 })),
105+ Effect.catchAll((error) =>
106+ Effect.succeed(
107+ Response.json(
108+ {
109+ ok: false,
110+ kind: "failed",
111+ error: errorToText(error),
112+ } satisfies RuntimeToolCallResult,
113+ { status: 400 },
114+ ),
115+ ),
116+ ),
117+ ),
118+ );
119+
120+ return PmToolCallHttpHandler.of({
121+ handleToolCallHttp,
122+ });
123+ }),
124+);
v2/bun.lock+10−0
@@ -20,10 +20,18 @@ · 19 unmodified lines
2020 "apps/convex": {
2121 "name": "@executor-v2/app-convex",
2222 "dependencies": {
23+ "@executor-v2/confect": "workspace:*",
24+ "@executor-v2/engine": "workspace:*",
2325 "@executor-v2/mcp-gateway": "workspace:*",
2426 "@executor-v2/persistence-convex": "workspace:*",
2527 "@executor-v2/sdk": "workspace:*",
2628 "convex": "catalog:",
29+ "effect": "catalog:",
30+ },
31+ "devDependencies": {
32+ "@effect/vitest": "catalog:",
33+ "convex-test": "^0.0.41",
34+ "vitest": "catalog:",
2735 },
2836 },
2937 "apps/pm": {
@@ -766,6 +774,8 @@ · 736 unmodified lines
766774
767775 "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="],
768776
777+ "convex-test": ["convex-test@0.0.41", "", { "peerDependencies": { "convex": "^1.16.4" } }, "sha512-GPHeYFOi70n7UtW0eCEQFVhzl/+m8PvbWkDCbKpHLybI1MrScf4sVpGeM0cC2qmtxiduxa2nLPbehPalhh9oyQ=="],
778+
769779 "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
770780
771781 "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
v2/package.json+3−0
@@ -26,5 +26,8 @@ · 25 unmodified lines
2626 "@types/bun": "^1.2.22",
2727 "@types/node": "catalog:",
2828 "typescript": "catalog:"
29+ },
30+ "dependencies": {
31+ "vercel": "^50.23.2"
2932 }
3033 }
v2/packages/ai-sdk-adapter/package.json+2−2
@@ -15,10 +15,10 @@ · 14 unmodified lines
1515 "zod": "^4.3.6"
1616 },
1717 "devDependencies": {
18− "ai": "^6.0.0",
19− "effect": "catalog:",
2018 "@effect/vitest": "catalog:",
2119 "@executor-v2/engine": "workspace:*",
20+ "ai": "^6.0.0",
21+ "effect": "catalog:",
2222 "vitest": "catalog:"
2323 }
2424 }
v2/packages/ai-sdk-adapter/src/index.test.ts+61−42
@@ -1,14 +1,17 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import {
3+ createInMemoryRuntimeRunClient,
4+ createRuntimeRunClient,
35 makeDenoSubprocessRuntimeAdapter,
6+ makeLocalInProcessRuntimeAdapter,
47 makeToolProviderRegistry,
5− ToolProviderRegistryService,
68 } from "@executor-v2/engine";
79 import { createExecutorRunClient } from "@executor-v2/sdk";
810 import type { ExecuteRunResult } from "@executor-v2/sdk";
911 import { createGateway, generateText, stepCountIs, tool } from "ai";
1012 import * as Effect from "effect/Effect";
11−import * as Runtime from "effect/Runtime";
13+
14+import { z } from "zod";
1215
1316 import { toAiSdkTools } from "./index";
1417
@@ -50,15 +53,11 @@ describe("toAiSdkTools", () => { · 35 unmodified lines
5053 }),
5154 );
5255
53− // Our mock ExecutorRunClient should have been invoked
5456 expect(executionLog.length).toBeGreaterThanOrEqual(1);
5557 expect(executionLog[0]!.code).toBeTypeOf("string");
5658 expect(executionLog[0]!.code.length).toBeGreaterThan(0);
57−
58− // timeoutMs should be set — either by the model or by the default
5959 expect(executionLog[0]!.timeoutMs).toBeTypeOf("number");
6060
61− // Check the steps contain a tool call
6261 const toolCallSteps = result.steps.filter(
6362 (step) => step.toolCalls.length > 0,
6463 );
@@ -68,7 +67,6 @@ describe("toAiSdkTools", () => { · 3 unmodified lines
6867 expect(firstToolCall.toolName).toBe("execute");
6968 expect(firstToolCall.input).toHaveProperty("code");
7069
71− // Tool results should contain our mock response
7270 const toolResultSteps = result.steps.filter(
7371 (step) => step.toolResults.length > 0,
7472 );
@@ -77,12 +75,64 @@ describe("toAiSdkTools", () => { · 2 unmodified lines
7775 expect(toolResult.toolName).toBe("execute");
7876 expect(toolResult.output).toMatchObject(mockResult);
7977
80− // The model should have produced a final text response
8178 expect(result.text).toBeTypeOf("string");
8279 }),
8380 { timeout: 30_000 },
8481 );
8582
83+ it.effect(
84+ "calls a normal AI SDK tool from the execute sandbox",
85+ () =>
86+ Effect.gen(function* () {
87+ const normalToolCalls: Array<{ query: string }> = [];
88+
89+ const searchDocsTool = tool({
90+ description: "Search docs by query",
91+ inputSchema: z.object({
92+ query: z.string(),
93+ }),
94+ execute: async (input: { query: string }) => {
95+ normalToolCalls.push(input);
96+ return {
97+ hits: [`match:${input.query}`],
98+ };
99+ },
100+ });
101+
102+ const runClient = createInMemoryRuntimeRunClient({
103+ runtimeAdapter: makeLocalInProcessRuntimeAdapter(),
104+ tools: {
105+ search_docs: searchDocsTool,
106+ },
107+ defaults: {
108+ timeoutMs: 30_000,
109+ },
110+ });
111+
112+ const tools = toAiSdkTools({
113+ runClient,
114+ makeTool: (def) => def,
115+ });
116+
117+ const result = yield* Effect.tryPromise(() =>
118+ tools.execute.execute({
119+ code: "return await tools.search_docs({ query: 'codemode adapter integration' });",
120+ }),
121+ );
122+
123+ expect(result.status).toBe("completed");
124+ expect(result.result).toEqual({
125+ hits: ["match:codemode adapter integration"],
126+ });
127+ expect(normalToolCalls).toEqual([
128+ {
129+ query: "codemode adapter integration",
130+ },
131+ ]);
132+ }),
133+ { timeout: 30_000 },
134+ );
135+
86136 it.effect(
87137 "executes code in a real Deno subprocess via generateText",
88138 () =>
@@ -93,37 +143,9 @@ describe("toAiSdkTools", () => { · 4 unmodified lines
93143
94144 const toolProviderRegistry = makeToolProviderRegistry([]);
95145
96− // Build an Effect runtime that has the ToolProviderRegistryService
97− const effectRuntime = yield* Effect.runtime<never>();
98− const runPromise = Runtime.runPromise(effectRuntime);
99−
100− const runClient = createExecutorRunClient(async (input) => {
101− const runId = `run_${crypto.randomUUID()}`;
102−
103− try {
104− const result = await runPromise(
105− runtimeAdapter
106− .execute({
107− code: input.code,
108− tools: [],
109− timeoutMs: input.timeoutMs,
110− })
111− .pipe(
112− Effect.provideService(
113− ToolProviderRegistryService,
114− toolProviderRegistry,
115− ),
116− ),
117− );
118−
119− return { runId, status: "completed", result } satisfies ExecuteRunResult;
120− } catch (error) {
121− return {
122− runId,
123− status: "failed",
124− error: error instanceof Error ? error.message : String(error),
125− } satisfies ExecuteRunResult;
126− }
146+ const runClient = createRuntimeRunClient({
147+ runtimeAdapter,
148+ toolProviderRegistry,
127149 });
128150
129151 const tools = toAiSdkTools({
@@ -145,7 +167,6 @@ describe("toAiSdkTools", () => { · 15 unmodified lines
145167 }),
146168 );
147169
148− // Check the steps contain a tool call
149170 const toolCallSteps = result.steps.filter(
150171 (step) => step.toolCalls.length > 0,
151172 );
@@ -154,7 +175,6 @@ describe("toAiSdkTools", () => { · 2 unmodified lines
154175 const firstToolCall = toolCallSteps[0]!.toolCalls[0]!;
155176 expect(firstToolCall.toolName).toBe("execute");
156177
157− // Tool results should show the code was executed successfully in Deno
158178 const toolResultSteps = result.steps.filter(
159179 (step) => step.toolResults.length > 0,
160180 );
@@ -167,7 +187,6 @@ describe("toAiSdkTools", () => { · 6 unmodified lines
167187 result: 5,
168188 });
169189
170− // The model should have produced a final text response
171190 expect(result.text).toBeTypeOf("string");
172191 }),
173192 { timeout: 30_000 },
v2/packages/ai-sdk-adapter/src/index.ts+1−0
@@ -27,6 +27,7 @@ export type ToAiSdkToolsOptions<TTool> = { · 26 unmodified lines
2727 timeoutMs?: number;
2828 };
2929 };
30+
3031 const withDefaults = (
3132 input: ExecuteToolInput,
3233 defaults?: {
v2/packages/confect/src/rpc/client.test.ts+1−1
@@ -40,7 +40,7 @@ listEndpoint.implement(() => · 39 unmodified lines
4040 const getEndpoint = factory.query({
4141 payload: { id: Schema.String },
4242 success: Schema.Struct({ author: Schema.String, message: Schema.String }),
43− error: Schema.Struct({ _tag: Schema.Literal("NotFound") }),
43+ error: Schema.TaggedStruct("NotFound", {}),
4444 });
4545 getEndpoint.implement((payload) =>
4646 Effect.gen(function* () {
v2/packages/engine/package.json+3−1
@@ -3,7 +3,9 @@ · 2 unmodified lines
33 "private": true,
44 "type": "module",
55 "exports": {
6− ".": "./src/index.ts"
6+ ".": "./src/index.ts",
7+ "./local-runner": "./src/local-runner.ts",
8+ "./tool-providers": "./src/tool-providers.ts"
79 },
810 "scripts": {
911 "test": "bunx vitest run --no-watch src/*.test.ts",
v2/packages/engine/src/index.ts+8−0
@@ -27,6 +27,14 @@ export { · 26 unmodified lines
2727 type RuntimeExecuteInput,
2828 type RuntimeRunnableTool,
2929 } from "./runtime-adapters";
30+export {
31+ createInMemoryRuntimeRunClient,
32+ createRuntimeRunClient,
33+ type CreateInMemoryRuntimeRunClientOptions,
34+ type CreateRuntimeRunClientOptions,
35+ type InMemorySandboxTool,
36+ type InMemorySandboxToolMap,
37+} from "./run-client";
3038 export {
3139 makeOpenApiToolProvider,
3240 openApiToolDescriptorsFromManifest,
v2/packages/engine/src/local-runner.ts+4−3
@@ -14,7 +14,8 @@ export class LocalCodeRunnerError extends Data.TaggedError("LocalCodeRunnerError · 13 unmodified lines
1414 operation: string;
1515 message: string;
1616 details: string | null;
17−}> {}
17+}> {
18+}
1819
1920 export type RunnableTool = {
2021 descriptor: CanonicalToolDescriptor;
@@ -63,7 +64,7 @@ const buildToolBindings = ( · 42 unmodified lines
6364
6465 return byToolId;
6566 });
66−
67+// todo: run in sandbox
6768 const runJavaScript = (
6869 code: string,
6970 toolsObject: Record<string, (args: unknown) => Promise<unknown>>,
@@ -74,7 +75,7 @@ const runJavaScript = ( · 4 unmodified lines
7475 "tools",
7576 `"use strict"; return (async () => {\n${code}\n})();`,
7677 ) as (tools: Record<string, (args: unknown) => Promise<unknown>>) =>
77− Promise<unknown>;
78+ Promise<unknown>;
7879
7980 return await execute(toolsObject);
8081 },
v2/packages/engine/src/run-client.ts+215−0
@@ -0,0 +1,215 @@
1+import * as Effect from "effect/Effect";
2+import * as Either from "effect/Either";
3+
4+import {
5+ ToolProviderError,
6+ ToolProviderRegistryService,
7+ makeToolProviderRegistry,
8+ type CanonicalToolDescriptor,
9+ type ToolProvider,
10+ type ToolProviderRegistry,
11+} from "./tool-providers";
12+import type { RuntimeAdapter, RuntimeRunnableTool } from "./runtime-adapters";
13+
14+export type RuntimeRunClientExecuteInput = {
15+ code: string;
16+ timeoutMs?: number;
17+};
18+
19+export type RuntimeRunClientExecuteResult = {
20+ runId: string;
21+ status: "completed" | "failed" | "timed_out" | "denied";
22+ result?: unknown;
23+ error?: string;
24+ exitCode?: number;
25+ durationMs?: number;
26+};
27+
28+export type RuntimeRunClient = {
29+ execute: (
30+ input: RuntimeRunClientExecuteInput,
31+ ) => Promise<RuntimeRunClientExecuteResult>;
32+};
33+
34+export type InMemorySandboxTool = {
35+ description?: string | null;
36+ execute?: (...args: Array<any>) => Promise<any> | any;
37+};
38+
39+export type InMemorySandboxToolMap = Record<string, InMemorySandboxTool>;
40+
41+export type CreateRuntimeRunClientOptions = {
42+ runtimeAdapter: RuntimeAdapter;
43+ tools?: ReadonlyArray<RuntimeRunnableTool>;
44+ toolProviderRegistry: ToolProviderRegistry;
45+ defaults?: {
46+ timeoutMs?: number;
47+ };
48+ makeRunId?: () => string;
49+};
50+
51+export type CreateInMemoryRuntimeRunClientOptions = {
52+ runtimeAdapter: RuntimeAdapter;
53+ tools: InMemorySandboxToolMap;
54+ defaults?: {
55+ timeoutMs?: number;
56+ };
57+ makeRunId?: () => string;
58+};
59+
60+const errorToText = (error: unknown): string => {
61+ if (error instanceof Error) {
62+ return error.message;
63+ }
64+
65+ if (
66+ typeof error === "object" &&
67+ error !== null &&
68+ "message" in error &&
69+ typeof error.message === "string"
70+ ) {
71+ return error.message;
72+ }
73+
74+ return String(error);
75+};
76+
77+const toInMemoryDescriptor = (
78+ toolName: string,
79+ tool: InMemorySandboxTool,
80+): CanonicalToolDescriptor => ({
81+ providerKind: "in_memory",
82+ sourceId: null,
83+ workspaceId: null,
84+ toolId: toolName,
85+ name: toolName,
86+ description: tool.description ?? null,
87+ invocationMode: "in_memory",
88+ availability: "local_only",
89+ providerPayload: null,
90+});
91+
92+const makeInMemoryRuntimeTools = (
93+ tools: InMemorySandboxToolMap,
94+): ReadonlyArray<RuntimeRunnableTool> =>
95+ Object.entries(tools).map(([toolName, tool]) => ({
96+ descriptor: toInMemoryDescriptor(toolName, tool),
97+ source: null,
98+ }));
99+
100+const makeInMemoryToolProvider = (
101+ tools: InMemorySandboxToolMap,
102+): ToolProvider => ({
103+ kind: "in_memory",
104+ invoke: (input) => {
105+ const implementation = tools[input.tool.toolId];
106+
107+ if (!implementation) {
108+ return Effect.succeed({
109+ output: `Unknown in-memory tool: ${input.tool.toolId}`,
110+ isError: true,
111+ });
112+ }
113+
114+ return Effect.tryPromise({
115+ try: async () => {
116+ if (!implementation.execute) {
117+ throw new Error(`In-memory tool '${input.tool.toolId}' has no execute function`);
118+ }
119+
120+ return {
121+ output: await implementation.execute(input.args, undefined),
122+ isError: false,
123+ };
124+ },
125+ catch: (cause) =>
126+ new ToolProviderError({
127+ operation: "invoke",
128+ providerKind: "in_memory",
129+ message: `In-memory tool invocation failed: ${input.tool.toolId}`,
130+ details: cause instanceof Error ? cause.message : String(cause),
131+ }),
132+ });
133+ },
134+});
135+
136+export const createRuntimeRunClient = (
137+ options: CreateRuntimeRunClientOptions,
138+): RuntimeRunClient => {
139+ const runIdFactory = options.makeRunId ?? (() => `run_${crypto.randomUUID()}`);
140+
141+ return {
142+ execute: async (
143+ input: RuntimeRunClientExecuteInput,
144+ ): Promise<RuntimeRunClientExecuteResult> => {
145+ const runId = runIdFactory();
146+
147+ const availabilityResult = await Effect.runPromise(
148+ Effect.either(options.runtimeAdapter.isAvailable()),
149+ );
150+
151+ if (Either.isLeft(availabilityResult)) {
152+ return {
153+ runId,
154+ status: "failed",
155+ error: errorToText(availabilityResult.left),
156+ };
157+ }
158+
159+ if (!availabilityResult.right) {
160+ return {
161+ runId,
162+ status: "failed",
163+ error: `Runtime '${options.runtimeAdapter.kind}' is not available`,
164+ };
165+ }
166+
167+ const executionResult = await Effect.runPromise(
168+ Effect.either(
169+ options.runtimeAdapter
170+ .execute({
171+ code: input.code,
172+ timeoutMs: input.timeoutMs ?? options.defaults?.timeoutMs,
173+ tools: options.tools ?? [],
174+ })
175+ .pipe(
176+ Effect.provideService(
177+ ToolProviderRegistryService,
178+ options.toolProviderRegistry,
179+ ),
180+ ),
181+ ),
182+ );
183+
184+ if (Either.isLeft(executionResult)) {
185+ return {
186+ runId,
187+ status: "failed",
188+ error: errorToText(executionResult.left),
189+ };
190+ }
191+
192+ return {
193+ runId,
194+ status: "completed",
195+ result: executionResult.right,
196+ };
197+ },
198+ };
199+};
200+
201+export const createInMemoryRuntimeRunClient = (
202+ options: CreateInMemoryRuntimeRunClientOptions,
203+): RuntimeRunClient => {
204+ const toolProviderRegistry = makeToolProviderRegistry([
205+ makeInMemoryToolProvider(options.tools),
206+ ]);
207+
208+ return createRuntimeRunClient({
209+ runtimeAdapter: options.runtimeAdapter,
210+ tools: makeInMemoryRuntimeTools(options.tools),
211+ toolProviderRegistry,
212+ defaults: options.defaults,
213+ makeRunId: options.makeRunId,
214+ });
215+};