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 @@
1.reference/1.reference/
2.vercel
3.env*.local
v2/apps/convex/README.md+4−2
@@ -3,5 +3,7 @@ · 2 unmodified lines
3Convex remote server app scaffold for Executor v2.3Convex remote server app scaffold for Executor v2.
4 4
5Current scaffold includes:5Current scaffold includes:
6- Convex schema wiring at `convex/schema.ts`, sourced from `@executor-v2/persistence-convex`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`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
8 * @module8 * @module
9 */9 */
10 10
11import type * as executor from "../executor.js";
11import type * as http from "../http.js";12import type * as http from "../http.js";
12import type * as mcp from "../mcp.js";13import type * as mcp from "../mcp.js";
14import type * as rpc_exit from "../rpc_exit.js";
15import type * as run_executor from "../run_executor.js";
16import type * as runtimeCallbacks from "../runtimeCallbacks.js";
17import type * as runtime_adapter from "../runtime_adapter.js";
13 18
14import type {19import type {
15 ApiFromModules,20 ApiFromModules,
@@ -18,8 +23,13 @@ import type { · 2 unmodified lines
18} from "convex/server";23} from "convex/server";
19 24
20declare const fullApi: ApiFromModules<{25declare const fullApi: ApiFromModules<{
26 executor: typeof executor;
21 http: typeof http;27 http: typeof http;
22 mcp: typeof mcp;28 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;
23}>;33}>;
24 34
25/**35/**
v2/apps/convex/convex/executor.test.ts+65−0
@@ -0,0 +1,65 @@
1import { describe, expect, it } from "@effect/vitest";
2import { convexTest } from "convex-test";
3import * as Effect from "effect/Effect";
4
5import { api } from "./_generated/api";
6import { unwrapRpcSuccess } from "./rpc_exit";
7import schema from "./schema";
8
9const 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
18describe("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 @@
1import { createRpcFactory, makeRpcModule } from "@executor-v2/confect/rpc";
2import { executorConfectSchema } from "@executor-v2/persistence-convex";
3import type { ExecuteRunInput } from "@executor-v2/sdk";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";
7
8import {
9 ConvexRunExecutor,
10 ConvexRunExecutorLive,
11 ConvexToolProviderRegistryLive,
12} from "./run_executor";
13
14const 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
28const factory = createRpcFactory({
29 schema: executorConfectSchema,
30});
31
32const ConvexExecuteDependenciesLive = Layer.merge(
33 ConvexRunExecutorLive,
34 ConvexToolProviderRegistryLive,
35);
36
37export 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
45const executeRunEndpoint = factory.action({
46 payload: {
47 code: Schema.String,
48 timeoutMs: Schema.optional(Schema.Number),
49 },
50 success: executeRunResultSchema,
51});
52
53executeRunEndpoint.implement((payload) =>
54 executeRunImpl({
55 code: payload.code,
56 timeoutMs: payload.timeoutMs,
57 }),
58);
59
60const executorRpc = makeRpcModule({
61 executeRun: executeRunEndpoint,
62});
63
64export const executeRun = executorRpc.handlers.executeRun;
v2/apps/convex/convex/http.ts+11−0
@@ -1,5 +1,6 @@
1import { httpRouter } from "convex/server";1import { httpRouter } from "convex/server";
2import { mcpHandler } from "./mcp";2import { mcpHandler } from "./mcp";
3import { handleToolCallHttp } from "./runtimeCallbacks";
3 4
4const http = httpRouter();5const http = httpRouter();
5 6
@@ -9,5 +10,15 @@ http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler }); · 3 unmodified lines
9http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler });10http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler });
10http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler });11http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler });
11http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler });12http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler });
13http.route({
14 path: "/runtime/tool-call",
15 method: "POST",
16 handler: handleToolCallHttp,
17});
18http.route({
19 path: "/v1/runtime/tool-call",
20 method: "POST",
21 handler: handleToolCallHttp,
22});
12 23
13export default http;24export default http;
v2/apps/convex/convex/mcp.ts+12−8
@@ -1,15 +1,19 @@
1import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";1import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";
2import { createExecutorRunClient } from "@executor-v2/sdk";2import { createExecutorRunClient } from "@executor-v2/sdk";
3import { api } from "./_generated/api";
3import { httpAction } from "./_generated/server";4import { httpAction } from "./_generated/server";
4const runClient = createExecutorRunClient(async () => ({5import { unwrapRpcSuccess } from "./rpc_exit";
5 runId: `run_${Date.now()}`,6export const mcpHandler = httpAction(async (ctx, request) => {
6 status: "failed" as const,7 const runClient = createExecutorRunClient((input) =>
7 error: "Convex run client is not wired yet",8 ctx
8}));9 .runAction(api.executor.executeRun, input)
9export const mcpHandler = httpAction(async (_ctx, request) =>10 .then((result) => unwrapRpcSuccess(result, "executor.executeRun")),
10 handleMcpHttpRequest(request, {11 );
12
13 return handleMcpHttpRequest(request, {
11 target: "remote",14 target: "remote",
12 serverName: "executor-v2-convex",15 serverName: "executor-v2-convex",
13 serverVersion: "0.0.0",16 serverVersion: "0.0.0",
14 runClient,17 runClient,
15 }));18 });
19});
v2/apps/convex/convex/rpc_exit.ts+50−0
@@ -0,0 +1,50 @@
1type RpcExitSuccess<T> = {
2 _tag: "Success";
3 value: T;
4};
5
6type RpcExitFailure = {
7 _tag: "Failure";
8 cause: unknown;
9};
10
11const 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
18const isRpcExitFailure = (value: unknown): value is RpcExitFailure =>
19 typeof value === "object" &&
20 value !== null &&
21 "_tag" in value &&
22 value._tag === "Failure";
23
24const 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
36export 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 @@
1import {
2 makeToolProviderRegistry,
3 ToolProviderRegistryService,
4} from "@executor-v2/engine/tool-providers";
5import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk";
6import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9
10import { makeLocalInProcessRuntimeAdapter } from "./runtime_adapter";
11
12export type ConvexRunExecutorService = {
13 executeRun: (
14 input: ExecuteRunInput,
15 ) => Effect.Effect<ExecuteRunResult, never, ToolProviderRegistryService>;
16};
17
18export class ConvexRunExecutor extends Context.Tag(
19 "@executor-v2/app-convex/ConvexRunExecutor",
20)<ConvexRunExecutor, ConvexRunExecutorService>() {}
21
22const runtimeAdapter = makeLocalInProcessRuntimeAdapter();
23
24const 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
41const 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
79export const ConvexRunExecutorLive = Layer.succeed(
80 ConvexRunExecutor,
81 ConvexRunExecutor.of({
82 executeRun,
83 }),
84);
85
86export const ConvexToolProviderRegistryLive = Layer.succeed(
87 ToolProviderRegistryService,
88 makeToolProviderRegistry([]),
89);
v2/apps/convex/convex/runtimeCallbacks.ts+119−0
@@ -0,0 +1,119 @@
1import { createRpcFactory, makeRpcModule } from "@executor-v2/confect/rpc";
2import { executorConfectSchema } from "@executor-v2/persistence-convex";
3import type {
4 RuntimeToolCallRequest,
5 RuntimeToolCallResult,
6} from "@executor-v2/sdk";
7import * as Effect from "effect/Effect";
8import * as Schema from "effect/Schema";
9import { api } from "./_generated/api";
10import { httpAction } from "./_generated/server";
11import { unwrapRpcSuccess } from "./rpc_exit";
12
13const 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
22const factory = createRpcFactory({
23 schema: executorConfectSchema,
24});
25
26export 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
35const 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
50handleToolCallEndpoint.implement((payload) =>
51 handleToolCallImpl({
52 runId: payload.runId,
53 callId: payload.callId,
54 toolPath: payload.toolPath,
55 input: payload.input,
56 }),
57);
58
59const runtimeCallbacksRpc = makeRpcModule({
60 handleToolCall: handleToolCallEndpoint,
61});
62
63export const handleToolCall = runtimeCallbacksRpc.handlers.handleToolCall;
64
65const 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
75export 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 @@
1import { executeJavaScriptWithTools } from "@executor-v2/engine/local-runner";
2import type {
3 CanonicalToolDescriptor,
4 ToolProviderRegistryService,
5} from "@executor-v2/engine/tool-providers";
6import type { Source } from "@executor-v2/schema";
7import * as Effect from "effect/Effect";
8
9export type RuntimeRunnableTool = {
10 descriptor: CanonicalToolDescriptor;
11 source: Source | null;
12};
13
14export type RuntimeExecuteInput = {
15 code: string;
16 tools: ReadonlyArray<RuntimeRunnableTool>;
17 timeoutMs?: number;
18};
19
20export type RuntimeAdapter = {
21 kind: "local-inproc";
22 isAvailable: () => Effect.Effect<boolean>;
23 execute: (
24 input: RuntimeExecuteInput,
25 ) => Effect.Effect<unknown, unknown, ToolProviderRegistryService>;
26};
27
28export 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
4 "type": "module",4 "type": "module",
5 "scripts": {5 "scripts": {
6 "dev": "bunx convex dev",6 "dev": "bunx convex dev",
7 "test": "bunx vitest run --no-watch convex/*.test.ts",
7 "typecheck": "bunx tsc --noEmit -p tsconfig.json",8 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
8 "convex:codegen": "bunx convex codegen"9 "convex:codegen": "bunx convex codegen"
9 },10 },
10 "dependencies": {11 "dependencies": {
12 "@executor-v2/confect": "workspace:*",
13 "@executor-v2/engine": "workspace:*",
11 "@executor-v2/mcp-gateway": "workspace:*",14 "@executor-v2/mcp-gateway": "workspace:*",
12 "@executor-v2/persistence-convex": "workspace:*",15 "@executor-v2/persistence-convex": "workspace:*",
13 "@executor-v2/sdk": "workspace:*",16 "@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:"
15 }24 }
16}25}
v2/apps/pm/README.md+1−0
@@ -7,6 +7,7 @@ Current scaffold includes: · 6 unmodified lines
7- health endpoint at `GET /healthz`7- health endpoint at `GET /healthz`
8- MCP tool routing via `@executor-v2/mcp-gateway`8- MCP tool routing via `@executor-v2/mcp-gateway`
9- local runtime adapter injection (`makeLocalInProcessRuntimeAdapter()` in entry wiring)9- local runtime adapter injection (`makeLocalInProcessRuntimeAdapter()` in entry wiring)
10 runtime callback endpoint at `POST /runtime/tool-call`
10 11
11App wiring is now split by responsibility:12App wiring is now split by responsibility:
12- `src/config.ts`: Effect config service (`PORT`)13- `src/config.ts`: Effect config service (`PORT`)
v2/apps/pm/package.json+5−0
@@ -5,6 +5,7 @@ · 4 unmodified lines
5 "scripts": {5 "scripts": {
6 "dev": "bun --watch src/main.ts",6 "dev": "bun --watch src/main.ts",
7 "start": "bun src/main.ts",7 "start": "bun src/main.ts",
8 "test": "bunx vitest run --no-watch src/*.test.ts",
8 "typecheck": "bunx tsc --noEmit -p tsconfig.json"9 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
9 },10 },
10 "dependencies": {11 "dependencies": {
@@ -12,5 +13,9 @@ · 1 unmodified line
12 "@executor-v2/mcp-gateway": "workspace:*",13 "@executor-v2/mcp-gateway": "workspace:*",
13 "@executor-v2/sdk": "workspace:*",14 "@executor-v2/sdk": "workspace:*",
14 "effect": "catalog:"15 "effect": "catalog:"
16 },
17 "devDependencies": {
18 "@effect/vitest": "catalog:",
19 "vitest": "catalog:"
15 }20 }
16}21}
v2/apps/pm/src/execute.e2e.test.ts+167−0
@@ -0,0 +1,167 @@
1import { spawn } from "node:child_process";
2import { createServer } from "node:net";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5import { describe, expect, it } from "@effect/vitest";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8
9const pmAppDir = new URL("..", import.meta.url).pathname;
10
11type McpCallToolResult = {
12 isError?: boolean;
13 content?: Array<{ type?: string; text?: string }>;
14};
15
16class ExecuteE2eError extends Data.TaggedError("ExecuteE2eError")<{
17 message: string;
18 details: string | null;
19}> {}
20
21const toExecuteE2eError = (message: string, cause: unknown): ExecuteE2eError =>
22 new ExecuteE2eError({
23 message,
24 details: cause instanceof Error ? cause.message : String(cause),
25 });
26
27const 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
61const 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
87const 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
107const 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
136describe("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
2 2
3import { PmConfig } from "./config";3import { PmConfig } from "./config";
4import { PmMcpHandler } from "./mcp-handler";4import { PmMcpHandler } from "./mcp-handler";
5import { PmToolCallHttpHandler } from "./tool-call-handler";
5 6
6export const startPmHttpServer = Effect.fn("@executor-v2/app-pm/http.start")(function* () {7export const startPmHttpServer = Effect.fn("@executor-v2/app-pm/http.start")(function* () {
7 const { port } = yield* PmConfig;8 const { port } = yield* PmConfig;
8 const { handleMcp } = yield* PmMcpHandler;9 const { handleMcp } = yield* PmMcpHandler;
10 const { handleToolCallHttp } = yield* PmToolCallHttpHandler;
9 11
10 const server = Bun.serve({12 const server = Bun.serve({
11 port,13 port,
@@ -23,6 +25,12 @@ export const startPmHttpServer = Effect.fn("@executor-v2/app-pm/http.start")(fun · 11 unmodified lines
23 POST: handleMcp,25 POST: handleMcp,
24 DELETE: handleMcp,26 DELETE: handleMcp,
25 },27 },
28 "/runtime/tool-call": {
29 POST: handleToolCallHttp,
30 },
31 "/v1/runtime/tool-call": {
32 POST: handleToolCallHttp,
33 },
26 },34 },
27 });35 });
28 36
v2/apps/pm/src/main.ts+10−1
@@ -10,6 +10,10 @@ import { · 9 unmodified lines
10 PmRunExecutorLive,10 PmRunExecutorLive,
11 PmToolProviderRegistryLive,11 PmToolProviderRegistryLive,
12} from "./run-executor";12} from "./run-executor";
13import {
14 PmToolCallHandlerLive,
15 PmToolCallHttpHandlerLive,
16} from "./tool-call-handler";
13 17
14const runtimeAdapter = makeLocalInProcessRuntimeAdapter();18const runtimeAdapter = makeLocalInProcessRuntimeAdapter();
15const PmMcpDependenciesLive = Layer.merge(19const PmMcpDependenciesLive = Layer.merge(
@@ -17,9 +21,14 @@ const PmMcpDependenciesLive = Layer.merge( · 1 unmodified line
17 PmToolProviderRegistryLive,21 PmToolProviderRegistryLive,
18);22);
19 23
20const PmAppLive = Layer.merge(24const PmToolCallDependenciesLive = PmToolCallHttpHandlerLive.pipe(
25 Layer.provide(PmToolCallHandlerLive),
26);
27
28const PmAppLive = Layer.mergeAll(
21 PmConfigLive,29 PmConfigLive,
22 PmMcpHandlerLive.pipe(Layer.provide(PmMcpDependenciesLive)),30 PmMcpHandlerLive.pipe(Layer.provide(PmMcpDependenciesLive)),
31 PmToolCallDependenciesLive,
23);32);
24 33
25const program = startPmHttpServer().pipe(Effect.provide(PmAppLive));34const program = startPmHttpServer().pipe(Effect.provide(PmAppLive));
v2/apps/pm/src/tool-call-handler.test.ts+68−0
@@ -0,0 +1,68 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import {
6 PmToolCallHandler,
7 PmToolCallHandlerLive,
8 PmToolCallHttpHandler,
9 PmToolCallHttpHandlerLive,
10} from "./tool-call-handler";
11
12describe("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 @@
1import type {
2 RuntimeToolCallRequest,
3 RuntimeToolCallResult,
4} from "@executor-v2/sdk";
5import * as Context from "effect/Context";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Runtime from "effect/Runtime";
10import * as Schema from "effect/Schema";
11
12export type PmToolCallHandlerService = {
13 handleToolCall: (input: RuntimeToolCallRequest) => Effect.Effect<RuntimeToolCallResult>;
14};
15
16export class PmToolCallHandler extends Context.Tag("@executor-v2/app-pm/PmToolCallHandler")<
17 PmToolCallHandler,
18 PmToolCallHandlerService
19>() {}
20
21class PmToolCallHttpRequestError extends Data.TaggedError(
22 "PmToolCallHttpRequestError",
23)<{
24 message: string;
25 details: string | null;
26}> {}
27
28const 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
38const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);
39
40const 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
57const 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
63const 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
73export const PmToolCallHandlerLive = Layer.succeed(
74 PmToolCallHandler,
75 PmToolCallHandler.of({
76 handleToolCall,
77 }),
78);
79
80export type PmToolCallHttpHandlerService = {
81 handleToolCallHttp: (request: Request) => Promise<Response>;
82};
83
84export class PmToolCallHttpHandler extends Context.Tag("@executor-v2/app-pm/PmToolCallHttpHandler")<
85 PmToolCallHttpHandler,
86 PmToolCallHttpHandlerService
87>() {}
88
89export 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
20 "apps/convex": {20 "apps/convex": {
21 "name": "@executor-v2/app-convex",21 "name": "@executor-v2/app-convex",
22 "dependencies": {22 "dependencies": {
23 "@executor-v2/confect": "workspace:*",
24 "@executor-v2/engine": "workspace:*",
23 "@executor-v2/mcp-gateway": "workspace:*",25 "@executor-v2/mcp-gateway": "workspace:*",
24 "@executor-v2/persistence-convex": "workspace:*",26 "@executor-v2/persistence-convex": "workspace:*",
25 "@executor-v2/sdk": "workspace:*",27 "@executor-v2/sdk": "workspace:*",
26 "convex": "catalog:",28 "convex": "catalog:",
29 "effect": "catalog:",
30 },
31 "devDependencies": {
32 "@effect/vitest": "catalog:",
33 "convex-test": "^0.0.41",
34 "vitest": "catalog:",
27 },35 },
28 },36 },
29 "apps/pm": {37 "apps/pm": {
@@ -766,6 +774,8 @@ · 736 unmodified lines
766 774
767 "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=="],775 "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=="],
768 776
777 "convex-test": ["convex-test@0.0.41", "", { "peerDependencies": { "convex": "^1.16.4" } }, "sha512-GPHeYFOi70n7UtW0eCEQFVhzl/+m8PvbWkDCbKpHLybI1MrScf4sVpGeM0cC2qmtxiduxa2nLPbehPalhh9oyQ=="],
778
769 "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],779 "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
770 780
771 "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],781 "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
26 "@types/bun": "^1.2.22",26 "@types/bun": "^1.2.22",
27 "@types/node": "catalog:",27 "@types/node": "catalog:",
28 "typescript": "catalog:"28 "typescript": "catalog:"
29 },
30 "dependencies": {
31 "vercel": "^50.23.2"
29 }32 }
30}33}
v2/packages/ai-sdk-adapter/package.json+2−2
@@ -15,10 +15,10 @@ · 14 unmodified lines
15 "zod": "^4.3.6"15 "zod": "^4.3.6"
16 },16 },
17 "devDependencies": {17 "devDependencies": {
18 "ai": "^6.0.0",
19 "effect": "catalog:",
20 "@effect/vitest": "catalog:",18 "@effect/vitest": "catalog:",
21 "@executor-v2/engine": "workspace:*",19 "@executor-v2/engine": "workspace:*",
20 "ai": "^6.0.0",
21 "effect": "catalog:",
22 "vitest": "catalog:"22 "vitest": "catalog:"
23 }23 }
24}24}
v2/packages/ai-sdk-adapter/src/index.test.ts+61−42
@@ -1,14 +1,17 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import {2import {
3 createInMemoryRuntimeRunClient,
4 createRuntimeRunClient,
3 makeDenoSubprocessRuntimeAdapter,5 makeDenoSubprocessRuntimeAdapter,
6 makeLocalInProcessRuntimeAdapter,
4 makeToolProviderRegistry,7 makeToolProviderRegistry,
5 ToolProviderRegistryService,
6} from "@executor-v2/engine";8} from "@executor-v2/engine";
7import { createExecutorRunClient } from "@executor-v2/sdk";9import { createExecutorRunClient } from "@executor-v2/sdk";
8import type { ExecuteRunResult } from "@executor-v2/sdk";10import type { ExecuteRunResult } from "@executor-v2/sdk";
9import { createGateway, generateText, stepCountIs, tool } from "ai";11import { createGateway, generateText, stepCountIs, tool } from "ai";
10import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
11import * as Runtime from "effect/Runtime";13
14import { z } from "zod";
12 15
13import { toAiSdkTools } from "./index";16import { toAiSdkTools } from "./index";
14 17
@@ -50,15 +53,11 @@ describe("toAiSdkTools", () => { · 35 unmodified lines
50 }),53 }),
51 );54 );
52 55
53 // Our mock ExecutorRunClient should have been invoked
54 expect(executionLog.length).toBeGreaterThanOrEqual(1);56 expect(executionLog.length).toBeGreaterThanOrEqual(1);
55 expect(executionLog[0]!.code).toBeTypeOf("string");57 expect(executionLog[0]!.code).toBeTypeOf("string");
56 expect(executionLog[0]!.code.length).toBeGreaterThan(0);58 expect(executionLog[0]!.code.length).toBeGreaterThan(0);
57
58 // timeoutMs should be set — either by the model or by the default
59 expect(executionLog[0]!.timeoutMs).toBeTypeOf("number");59 expect(executionLog[0]!.timeoutMs).toBeTypeOf("number");
60 60
61 // Check the steps contain a tool call
62 const toolCallSteps = result.steps.filter(61 const toolCallSteps = result.steps.filter(
63 (step) => step.toolCalls.length > 0,62 (step) => step.toolCalls.length > 0,
64 );63 );
@@ -68,7 +67,6 @@ describe("toAiSdkTools", () => { · 3 unmodified lines
68 expect(firstToolCall.toolName).toBe("execute");67 expect(firstToolCall.toolName).toBe("execute");
69 expect(firstToolCall.input).toHaveProperty("code");68 expect(firstToolCall.input).toHaveProperty("code");
70 69
71 // Tool results should contain our mock response
72 const toolResultSteps = result.steps.filter(70 const toolResultSteps = result.steps.filter(
73 (step) => step.toolResults.length > 0,71 (step) => step.toolResults.length > 0,
74 );72 );
@@ -77,12 +75,64 @@ describe("toAiSdkTools", () => { · 2 unmodified lines
77 expect(toolResult.toolName).toBe("execute");75 expect(toolResult.toolName).toBe("execute");
78 expect(toolResult.output).toMatchObject(mockResult);76 expect(toolResult.output).toMatchObject(mockResult);
79 77
80 // The model should have produced a final text response
81 expect(result.text).toBeTypeOf("string");78 expect(result.text).toBeTypeOf("string");
82 }),79 }),
83 { timeout: 30_000 },80 { timeout: 30_000 },
84 );81 );
85 82
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
86 it.effect(136 it.effect(
87 "executes code in a real Deno subprocess via generateText",137 "executes code in a real Deno subprocess via generateText",
88 () =>138 () =>
@@ -93,37 +143,9 @@ describe("toAiSdkTools", () => { · 4 unmodified lines
93 143
94 const toolProviderRegistry = makeToolProviderRegistry([]);144 const toolProviderRegistry = makeToolProviderRegistry([]);
95 145
96 // Build an Effect runtime that has the ToolProviderRegistryService146 const runClient = createRuntimeRunClient({
97 const effectRuntime = yield* Effect.runtime<never>();147 runtimeAdapter,
98 const runPromise = Runtime.runPromise(effectRuntime);148 toolProviderRegistry,
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 }
127 });149 });
128 150
129 const tools = toAiSdkTools({151 const tools = toAiSdkTools({
@@ -145,7 +167,6 @@ describe("toAiSdkTools", () => { · 15 unmodified lines
145 }),167 }),
146 );168 );
147 169
148 // Check the steps contain a tool call
149 const toolCallSteps = result.steps.filter(170 const toolCallSteps = result.steps.filter(
150 (step) => step.toolCalls.length > 0,171 (step) => step.toolCalls.length > 0,
151 );172 );
@@ -154,7 +175,6 @@ describe("toAiSdkTools", () => { · 2 unmodified lines
154 const firstToolCall = toolCallSteps[0]!.toolCalls[0]!;175 const firstToolCall = toolCallSteps[0]!.toolCalls[0]!;
155 expect(firstToolCall.toolName).toBe("execute");176 expect(firstToolCall.toolName).toBe("execute");
156 177
157 // Tool results should show the code was executed successfully in Deno
158 const toolResultSteps = result.steps.filter(178 const toolResultSteps = result.steps.filter(
159 (step) => step.toolResults.length > 0,179 (step) => step.toolResults.length > 0,
160 );180 );
@@ -167,7 +187,6 @@ describe("toAiSdkTools", () => { · 6 unmodified lines
167 result: 5,187 result: 5,
168 });188 });
169 189
170 // The model should have produced a final text response
171 expect(result.text).toBeTypeOf("string");190 expect(result.text).toBeTypeOf("string");
172 }),191 }),
173 { timeout: 30_000 },192 { timeout: 30_000 },
v2/packages/ai-sdk-adapter/src/index.ts+1−0
@@ -27,6 +27,7 @@ export type ToAiSdkToolsOptions<TTool> = { · 26 unmodified lines
27 timeoutMs?: number;27 timeoutMs?: number;
28 };28 };
29};29};
30
30const withDefaults = (31const withDefaults = (
31 input: ExecuteToolInput,32 input: ExecuteToolInput,
32 defaults?: {33 defaults?: {
v2/packages/confect/src/rpc/client.test.ts+1−1
@@ -40,7 +40,7 @@ listEndpoint.implement(() => · 39 unmodified lines
40const getEndpoint = factory.query({40const getEndpoint = factory.query({
41 payload: { id: Schema.String },41 payload: { id: Schema.String },
42 success: Schema.Struct({ author: Schema.String, message: Schema.String }),42 success: Schema.Struct({ author: Schema.String, message: Schema.String }),
43 error: Schema.Struct({ _tag: Schema.Literal("NotFound") }),43 error: Schema.TaggedStruct("NotFound", {}),
44});44});
45getEndpoint.implement((payload) =>45getEndpoint.implement((payload) =>
46 Effect.gen(function* () {46 Effect.gen(function* () {
v2/packages/engine/package.json+3−1
@@ -3,7 +3,9 @@ · 2 unmodified lines
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 ".": "./src/index.ts"6 ".": "./src/index.ts",
7 "./local-runner": "./src/local-runner.ts",
8 "./tool-providers": "./src/tool-providers.ts"
7 },9 },
8 "scripts": {10 "scripts": {
9 "test": "bunx vitest run --no-watch src/*.test.ts",11 "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
27 type RuntimeExecuteInput,27 type RuntimeExecuteInput,
28 type RuntimeRunnableTool,28 type RuntimeRunnableTool,
29} from "./runtime-adapters";29} from "./runtime-adapters";
30export {
31 createInMemoryRuntimeRunClient,
32 createRuntimeRunClient,
33 type CreateInMemoryRuntimeRunClientOptions,
34 type CreateRuntimeRunClientOptions,
35 type InMemorySandboxTool,
36 type InMemorySandboxToolMap,
37} from "./run-client";
30export {38export {
31 makeOpenApiToolProvider,39 makeOpenApiToolProvider,
32 openApiToolDescriptorsFromManifest,40 openApiToolDescriptorsFromManifest,
v2/packages/engine/src/local-runner.ts+4−3
@@ -14,7 +14,8 @@ export class LocalCodeRunnerError extends Data.TaggedError("LocalCodeRunnerError · 13 unmodified lines
14 operation: string;14 operation: string;
15 message: string;15 message: string;
16 details: string | null;16 details: string | null;
17}> {}17}> {
18}
18 19
19export type RunnableTool = {20export type RunnableTool = {
20 descriptor: CanonicalToolDescriptor;21 descriptor: CanonicalToolDescriptor;
@@ -63,7 +64,7 @@ const buildToolBindings = ( · 42 unmodified lines
63 64
64 return byToolId;65 return byToolId;
65 });66 });
66 67// todo: run in sandbox
67const runJavaScript = (68const runJavaScript = (
68 code: string,69 code: string,
69 toolsObject: Record<string, (args: unknown) => Promise<unknown>>,70 toolsObject: Record<string, (args: unknown) => Promise<unknown>>,
@@ -74,7 +75,7 @@ const runJavaScript = ( · 4 unmodified lines
74 "tools",75 "tools",
75 `"use strict"; return (async () => {\n${code}\n})();`,76 `"use strict"; return (async () => {\n${code}\n})();`,
76 ) as (tools: Record<string, (args: unknown) => Promise<unknown>>) =>77 ) as (tools: Record<string, (args: unknown) => Promise<unknown>>) =>
77 Promise<unknown>;78 Promise<unknown>;
78 79
79 return await execute(toolsObject);80 return await execute(toolsObject);
80 },81 },
v2/packages/engine/src/run-client.ts+215−0
@@ -0,0 +1,215 @@
1import * as Effect from "effect/Effect";
2import * as Either from "effect/Either";
3
4import {
5 ToolProviderError,
6 ToolProviderRegistryService,
7 makeToolProviderRegistry,
8 type CanonicalToolDescriptor,
9 type ToolProvider,
10 type ToolProviderRegistry,
11} from "./tool-providers";
12import type { RuntimeAdapter, RuntimeRunnableTool } from "./runtime-adapters";
13
14export type RuntimeRunClientExecuteInput = {
15 code: string;
16 timeoutMs?: number;
17};
18
19export 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
28export type RuntimeRunClient = {
29 execute: (
30 input: RuntimeRunClientExecuteInput,
31 ) => Promise<RuntimeRunClientExecuteResult>;
32};
33
34export type InMemorySandboxTool = {
35 description?: string | null;
36 execute?: (...args: Array<any>) => Promise<any> | any;
37};
38
39export type InMemorySandboxToolMap = Record<string, InMemorySandboxTool>;
40
41export type CreateRuntimeRunClientOptions = {
42 runtimeAdapter: RuntimeAdapter;
43 tools?: ReadonlyArray<RuntimeRunnableTool>;
44 toolProviderRegistry: ToolProviderRegistry;
45 defaults?: {
46 timeoutMs?: number;
47 };
48 makeRunId?: () => string;
49};
50
51export type CreateInMemoryRuntimeRunClientOptions = {
52 runtimeAdapter: RuntimeAdapter;
53 tools: InMemorySandboxToolMap;
54 defaults?: {
55 timeoutMs?: number;
56 };
57 makeRunId?: () => string;
58};
59
60const 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
77const 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
92const makeInMemoryRuntimeTools = (
93 tools: InMemorySandboxToolMap,
94): ReadonlyArray<RuntimeRunnableTool> =>
95 Object.entries(tools).map(([toolName, tool]) => ({
96 descriptor: toInMemoryDescriptor(toolName, tool),
97 source: null,
98 }));
99
100const 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
136export 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
201export 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};