Unified · Split
| @@ -1 +1,3 @@ | |||
| 1 | .reference/ | 1 | .reference/ |
| 2 | .vercel | ||
| 3 | .env*.local | ||
| @@ -3,5 +3,7 @@ · 2 unmodified lines | |||
| 3 | Convex remote server app scaffold for Executor v2. | 3 | Convex remote server app scaffold for Executor v2. |
| 4 | 4 | ||
| 5 | Current scaffold includes: | 5 | Current 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` | ||
| @@ -8,8 +8,13 @@ · 7 unmodified lines | |||
| 8 | * @module | 8 | * @module |
| 9 | */ | 9 | */ |
| 10 | 10 | ||
| 11 | import type * as executor from "../executor.js"; | ||
| 11 | import type * as http from "../http.js"; | 12 | import type * as http from "../http.js"; |
| 12 | import type * as mcp from "../mcp.js"; | 13 | 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"; | ||
| 13 | 18 | ||
| 14 | import type { | 19 | import type { |
| 15 | ApiFromModules, | 20 | ApiFromModules, |
| @@ -18,8 +23,13 @@ import type { · 2 unmodified lines | |||
| 18 | } from "convex/server"; | 23 | } from "convex/server"; |
| 19 | 24 | ||
| 20 | declare const fullApi: ApiFromModules<{ | 25 | declare 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 | /** |
| @@ -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 | }); | ||
| @@ -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; | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | import { httpRouter } from "convex/server"; | 1 | import { httpRouter } from "convex/server"; |
| 2 | import { mcpHandler } from "./mcp"; | 2 | import { mcpHandler } from "./mcp"; |
| 3 | import { handleToolCallHttp } from "./runtimeCallbacks"; | ||
| 3 | 4 | ||
| 4 | const http = httpRouter(); | 5 | const http = httpRouter(); |
| 5 | 6 | ||
| @@ -9,5 +10,15 @@ http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler }); · 3 unmodified lines | |||
| 9 | http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler }); | 10 | http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler }); |
| 10 | http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler }); | 11 | http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler }); |
| 11 | http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler }); | 12 | 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 | }); | ||
| 12 | 23 | ||
| 13 | export default http; | 24 | export default http; |
| @@ -1,15 +1,19 @@ | |||
| 1 | import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; | 1 | import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; |
| 2 | import { createExecutorRunClient } from "@executor-v2/sdk"; | 2 | import { createExecutorRunClient } from "@executor-v2/sdk"; |
| 3 | import { api } from "./_generated/api"; | ||
| 3 | import { httpAction } from "./_generated/server"; | 4 | import { httpAction } from "./_generated/server"; |
| 4 | const runClient = createExecutorRunClient(async () => ({ | 5 | import { unwrapRpcSuccess } from "./rpc_exit"; |
| 5 | runId: `run_${Date.now()}`, | 6 | export 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) |
| 9 | export 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 | }); | ||
| @@ -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 | }; | ||
| @@ -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 | ); | ||
| @@ -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 | }); | ||
| @@ -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 | }); | ||
| @@ -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 | } |
| @@ -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 | ||
| 11 | App wiring is now split by responsibility: | 12 | App wiring is now split by responsibility: |
| 12 | - `src/config.ts`: Effect config service (`PORT`) | 13 | - `src/config.ts`: Effect config service (`PORT`) |
| @@ -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 | } |
| @@ -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 | }); | ||
| @@ -2,10 +2,12 @@ import * as Effect from "effect/Effect"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { PmConfig } from "./config"; | 3 | import { PmConfig } from "./config"; |
| 4 | import { PmMcpHandler } from "./mcp-handler"; | 4 | import { PmMcpHandler } from "./mcp-handler"; |
| 5 | import { PmToolCallHttpHandler } from "./tool-call-handler"; | ||
| 5 | 6 | ||
| 6 | export const startPmHttpServer = Effect.fn("@executor-v2/app-pm/http.start")(function* () { | 7 | export 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 | ||
| @@ -10,6 +10,10 @@ import { · 9 unmodified lines | |||
| 10 | PmRunExecutorLive, | 10 | PmRunExecutorLive, |
| 11 | PmToolProviderRegistryLive, | 11 | PmToolProviderRegistryLive, |
| 12 | } from "./run-executor"; | 12 | } from "./run-executor"; |
| 13 | import { | ||
| 14 | PmToolCallHandlerLive, | ||
| 15 | PmToolCallHttpHandlerLive, | ||
| 16 | } from "./tool-call-handler"; | ||
| 13 | 17 | ||
| 14 | const runtimeAdapter = makeLocalInProcessRuntimeAdapter(); | 18 | const runtimeAdapter = makeLocalInProcessRuntimeAdapter(); |
| 15 | const PmMcpDependenciesLive = Layer.merge( | 19 | const PmMcpDependenciesLive = Layer.merge( |
| @@ -17,9 +21,14 @@ const PmMcpDependenciesLive = Layer.merge( · 1 unmodified line | |||
| 17 | PmToolProviderRegistryLive, | 21 | PmToolProviderRegistryLive, |
| 18 | ); | 22 | ); |
| 19 | 23 | ||
| 20 | const PmAppLive = Layer.merge( | 24 | const PmToolCallDependenciesLive = PmToolCallHttpHandlerLive.pipe( |
| 25 | Layer.provide(PmToolCallHandlerLive), | ||
| 26 | ); | ||
| 27 | |||
| 28 | const 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 | ||
| 25 | const program = startPmHttpServer().pipe(Effect.provide(PmAppLive)); | 34 | const program = startPmHttpServer().pipe(Effect.provide(PmAppLive)); |
| @@ -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 | }); | ||
| @@ -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 | ); | ||
| @@ -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=="], |
| @@ -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 | } |
| @@ -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 | } |
| @@ -1,14 +1,17 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { | 2 | import { |
| 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"; |
| 7 | import { createExecutorRunClient } from "@executor-v2/sdk"; | 9 | import { createExecutorRunClient } from "@executor-v2/sdk"; |
| 8 | import type { ExecuteRunResult } from "@executor-v2/sdk"; | 10 | import type { ExecuteRunResult } from "@executor-v2/sdk"; |
| 9 | import { createGateway, generateText, stepCountIs, tool } from "ai"; | 11 | import { createGateway, generateText, stepCountIs, tool } from "ai"; |
| 10 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 11 | import * as Runtime from "effect/Runtime"; | 13 | |
| 14 | import { z } from "zod"; | ||
| 12 | 15 | ||
| 13 | import { toAiSdkTools } from "./index"; | 16 | import { 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 ToolProviderRegistryService | 146 | 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 }, |
| @@ -27,6 +27,7 @@ export type ToAiSdkToolsOptions<TTool> = { · 26 unmodified lines | |||
| 27 | timeoutMs?: number; | 27 | timeoutMs?: number; |
| 28 | }; | 28 | }; |
| 29 | }; | 29 | }; |
| 30 | |||
| 30 | const withDefaults = ( | 31 | const withDefaults = ( |
| 31 | input: ExecuteToolInput, | 32 | input: ExecuteToolInput, |
| 32 | defaults?: { | 33 | defaults?: { |
| @@ -40,7 +40,7 @@ listEndpoint.implement(() => · 39 unmodified lines | |||
| 40 | const getEndpoint = factory.query({ | 40 | const 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 | }); |
| 45 | getEndpoint.implement((payload) => | 45 | getEndpoint.implement((payload) => |
| 46 | Effect.gen(function* () { | 46 | Effect.gen(function* () { |
| @@ -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", |
| @@ -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"; |
| 30 | export { | ||
| 31 | createInMemoryRuntimeRunClient, | ||
| 32 | createRuntimeRunClient, | ||
| 33 | type CreateInMemoryRuntimeRunClientOptions, | ||
| 34 | type CreateRuntimeRunClientOptions, | ||
| 35 | type InMemorySandboxTool, | ||
| 36 | type InMemorySandboxToolMap, | ||
| 37 | } from "./run-client"; | ||
| 30 | export { | 38 | export { |
| 31 | makeOpenApiToolProvider, | 39 | makeOpenApiToolProvider, |
| 32 | openApiToolDescriptorsFromManifest, | 40 | openApiToolDescriptorsFromManifest, |
| @@ -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 | ||
| 19 | export type RunnableTool = { | 20 | export 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 | |
| 67 | const runJavaScript = ( | 68 | const 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 | }, |
| @@ -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 | }; | ||