Unified · Split
| @@ -28,11 +28,5 @@ export const ToolsHandlers = HttpApiBuilder.group( · 27 unmodified lines | |||
| 28 | const executor = yield* ExecutorService; | 28 | const executor = yield* ExecutorService; |
| 29 | return yield* executor.tools.schema(path.toolId); | 29 | return yield* executor.tools.schema(path.toolId); |
| 30 | }), | 30 | }), |
| 31 | ) | ||
| 32 | .handle("invoke", ({ path, payload }) => | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const executor = yield* ExecutorService; | ||
| 35 | return yield* executor.tools.invoke(path.toolId, payload.args); | ||
| 36 | }), | ||
| 37 | ), | 31 | ), |
| 38 | ); | 32 | ); |
| @@ -5,6 +5,63 @@ import { createServerHandlers } from "@executor/server"; · 4 unmodified lines | |||
| 5 | 5 | ||
| 6 | const handlersPromise = createServerHandlers(); | 6 | const handlersPromise = createServerHandlers(); |
| 7 | 7 | ||
| 8 | // Build a Web Request from a Node IncomingMessage | ||
| 9 | const toWebRequest = async ( | ||
| 10 | req: import("http").IncomingMessage, | ||
| 11 | ): Promise<Request> => { | ||
| 12 | const headers = new Headers(); | ||
| 13 | for (const [key, value] of Object.entries(req.headers)) { | ||
| 14 | if (value === undefined) continue; | ||
| 15 | if (Array.isArray(value)) { | ||
| 16 | for (const v of value) headers.append(key, v); | ||
| 17 | } else { | ||
| 18 | headers.set(key, value); | ||
| 19 | } | ||
| 20 | } | ||
| 21 | |||
| 22 | const host = req.headers.host ?? "localhost"; | ||
| 23 | const url = `http://${host}${req.url}`; | ||
| 24 | |||
| 25 | return new Request(url, { | ||
| 26 | method: req.method, | ||
| 27 | headers, | ||
| 28 | body: | ||
| 29 | req.method !== "GET" && req.method !== "HEAD" | ||
| 30 | ? (await new Promise<Buffer>((resolve) => { | ||
| 31 | const chunks: Buffer[] = []; | ||
| 32 | req.on("data", (c: Buffer) => chunks.push(c)); | ||
| 33 | req.on("end", () => resolve(Buffer.concat(chunks))); | ||
| 34 | }) as unknown as BodyInit) | ||
| 35 | : undefined, | ||
| 36 | duplex: "half" as const, | ||
| 37 | }); | ||
| 38 | }; | ||
| 39 | |||
| 40 | // Pipe a Web Response back to a Node ServerResponse, streaming if needed | ||
| 41 | const sendWebResponse = async ( | ||
| 42 | webRes: Response, | ||
| 43 | nodeRes: import("http").ServerResponse, | ||
| 44 | ) => { | ||
| 45 | nodeRes.statusCode = webRes.status; | ||
| 46 | webRes.headers.forEach((value, key) => nodeRes.setHeader(key, value)); | ||
| 47 | |||
| 48 | if (!webRes.body) { | ||
| 49 | nodeRes.end(); | ||
| 50 | return; | ||
| 51 | } | ||
| 52 | |||
| 53 | const reader = webRes.body.getReader(); | ||
| 54 | try { | ||
| 55 | for (;;) { | ||
| 56 | const { done, value } = await reader.read(); | ||
| 57 | if (done) break; | ||
| 58 | nodeRes.write(value); | ||
| 59 | } | ||
| 60 | } finally { | ||
| 61 | nodeRes.end(); | ||
| 62 | } | ||
| 63 | }; | ||
| 64 | |||
| 8 | export default defineConfig({ | 65 | export default defineConfig({ |
| 9 | plugins: [ | 66 | plugins: [ |
| 10 | react(), | 67 | react(), |
| @@ -24,50 +81,13 @@ export default defineConfig({ · 13 unmodified lines | |||
| 24 | if (!isApi && !isMcp) return next(); | 81 | if (!isApi && !isMcp) return next(); |
| 25 | 82 | ||
| 26 | const handlers = await handlersPromise; | 83 | const handlers = await handlersPromise; |
| 27 | 84 | const request = await toWebRequest(req); | |
| 28 | // Build a standard Request from the Node HTTP request | ||
| 29 | const headers = new Headers(); | ||
| 30 | for (const [key, value] of Object.entries(req.headers)) { | ||
| 31 | if (value === undefined) continue; | ||
| 32 | if (Array.isArray(value)) { | ||
| 33 | for (const v of value) headers.append(key, v); | ||
| 34 | } else { | ||
| 35 | headers.set(key, value); | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | const host = req.headers.host ?? "localhost"; | ||
| 40 | const request = new Request(`http://${host}${url}`, { | ||
| 41 | method: req.method, | ||
| 42 | headers, | ||
| 43 | body: | ||
| 44 | req.method !== "GET" && req.method !== "HEAD" | ||
| 45 | ? (await new Promise<Uint8Array>((resolve) => { | ||
| 46 | const chunks: Uint8Array[] = []; | ||
| 47 | req.on("data", (c: Uint8Array) => chunks.push(c)); | ||
| 48 | req.on("end", () => { | ||
| 49 | const total = chunks.reduce((n, c) => n + c.length, 0); | ||
| 50 | const buf = new Uint8Array(total); | ||
| 51 | let offset = 0; | ||
| 52 | for (const c of chunks) { | ||
| 53 | buf.set(c, offset); | ||
| 54 | offset += c.length; | ||
| 55 | } | ||
| 56 | resolve(buf); | ||
| 57 | }); | ||
| 58 | }) as unknown as BodyInit) | ||
| 59 | : undefined, | ||
| 60 | }); | ||
| 61 | 85 | ||
| 62 | const response = isMcp | 86 | const response = isMcp |
| 63 | ? await handlers.mcp.handleRequest(request) | 87 | ? await handlers.mcp.handleRequest(request) |
| 64 | : await handlers.api.handler(request); | 88 | : await handlers.api.handler(request); |
| 65 | 89 | ||
| 66 | res.statusCode = response.status; | 90 | await sendWebResponse(response, res); |
| 67 | response.headers.forEach((value, key) => res.setHeader(key, value)); | ||
| 68 | |||
| 69 | const body = await response.arrayBuffer(); | ||
| 70 | res.end(Buffer.from(body)); | ||
| 71 | }); | 91 | }); |
| 72 | }, | 92 | }, |
| 73 | }, | 93 | }, |
| @@ -58,7 +58,7 @@ export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) => · 57 unmodified lines | |||
| 58 | // Mutation atoms | 58 | // Mutation atoms |
| 59 | // --------------------------------------------------------------------------- | 59 | // --------------------------------------------------------------------------- |
| 60 | 60 | ||
| 61 | export const invokeTool = ExecutorApiClient.mutation("tools", "invoke"); | 61 | |
| 62 | 62 | ||
| 63 | export const setSecret = ExecutorApiClient.mutation("secrets", "set"); | 63 | export const setSecret = ExecutorApiClient.mutation("secrets", "set"); |
| 64 | 64 | ||
| @@ -28,7 +28,6 @@ export { · 27 unmodified lines | |||
| 28 | sourceAtom, | 28 | sourceAtom, |
| 29 | secretsAtom, | 29 | secretsAtom, |
| 30 | secretStatusAtom, | 30 | secretStatusAtom, |
| 31 | invokeTool, | ||
| 32 | setSecret, | 31 | setSecret, |
| 33 | resolveSecret, | 32 | resolveSecret, |
| 34 | removeSecret, | 33 | removeSecret, |
| @@ -4,9 +4,6 @@ import { · 3 unmodified lines | |||
| 4 | ScopeId, | 4 | ScopeId, |
| 5 | ToolId, | 5 | ToolId, |
| 6 | ToolNotFoundError, | 6 | ToolNotFoundError, |
| 7 | ToolInvocationError, | ||
| 8 | PolicyDeniedError, | ||
| 9 | ElicitationDeclinedError, | ||
| 10 | } from "@executor/sdk"; | 7 | } from "@executor/sdk"; |
| 11 | 8 | ||
| 12 | // --------------------------------------------------------------------------- | 9 | // --------------------------------------------------------------------------- |
| @@ -35,16 +32,6 @@ const ToolSchemaResponse = Schema.Struct({ · 22 unmodified lines | |||
| 35 | outputSchema: Schema.optional(Schema.Unknown), | 32 | outputSchema: Schema.optional(Schema.Unknown), |
| 36 | }); | 33 | }); |
| 37 | 34 | ||
| 38 | const ToolInvokePayload = Schema.Struct({ | ||
| 39 | args: Schema.Unknown, | ||
| 40 | }); | ||
| 41 | |||
| 42 | const ToolInvokeResponse = Schema.Struct({ | ||
| 43 | data: Schema.Unknown, | ||
| 44 | error: Schema.NullOr(Schema.Unknown), | ||
| 45 | status: Schema.optional(Schema.Number), | ||
| 46 | }); | ||
| 47 | |||
| 48 | // --------------------------------------------------------------------------- | 35 | // --------------------------------------------------------------------------- |
| 49 | // Error schemas with HTTP status annotations | 36 | // Error schemas with HTTP status annotations |
| 50 | // --------------------------------------------------------------------------- | 37 | // --------------------------------------------------------------------------- |
| @@ -52,15 +39,6 @@ const ToolInvokeResponse = Schema.Struct({ · 1 unmodified line | |||
| 52 | const ToolNotFound = ToolNotFoundError.annotations( | 39 | const ToolNotFound = ToolNotFoundError.annotations( |
| 53 | HttpApiSchema.annotations({ status: 404 }), | 40 | HttpApiSchema.annotations({ status: 404 }), |
| 54 | ); | 41 | ); |
| 55 | const ToolInvocation = ToolInvocationError.annotations( | ||
| 56 | HttpApiSchema.annotations({ status: 500 }), | ||
| 57 | ); | ||
| 58 | const PolicyDenied = PolicyDeniedError.annotations( | ||
| 59 | HttpApiSchema.annotations({ status: 403 }), | ||
| 60 | ); | ||
| 61 | const ElicitationDeclined = ElicitationDeclinedError.annotations( | ||
| 62 | HttpApiSchema.annotations({ status: 422 }), | ||
| 63 | ); | ||
| 64 | 42 | ||
| 65 | // --------------------------------------------------------------------------- | 43 | // --------------------------------------------------------------------------- |
| 66 | // Group | 44 | // Group |
| @@ -76,13 +54,4 @@ export class ToolsApi extends HttpApiGroup.make("tools") · 9 unmodified lines | |||
| 76 | .addSuccess(ToolSchemaResponse) | 54 | .addSuccess(ToolSchemaResponse) |
| 77 | .addError(ToolNotFound), | 55 | .addError(ToolNotFound), |
| 78 | ) | 56 | ) |
| 79 | .add( | ||
| 80 | HttpApiEndpoint.post("invoke")`/scopes/${scopeIdParam}/tools/${toolIdParam}/invoke` | ||
| 81 | .setPayload(ToolInvokePayload) | ||
| 82 | .addSuccess(ToolInvokeResponse) | ||
| 83 | .addError(ToolNotFound) | ||
| 84 | .addError(ToolInvocation) | ||
| 85 | .addError(PolicyDenied) | ||
| 86 | .addError(ElicitationDeclined), | ||
| 87 | ) | ||
| 88 | .prefix("/v1") {} | 57 | .prefix("/v1") {} |
| @@ -125,7 +125,7 @@ export const formatPausedExecution = (paused: PausedExecution): { · 124 unmodified lines | |||
| 125 | 125 | ||
| 126 | const makeFullInvoker = ( | 126 | const makeFullInvoker = ( |
| 127 | executor: Executor, | 127 | executor: Executor, |
| 128 | invokeOptions?: InvokeOptions, | 128 | invokeOptions: InvokeOptions, |
| 129 | ): SandboxToolInvoker => { | 129 | ): SandboxToolInvoker => { |
| 130 | const base = makeExecutorToolInvoker(executor, { invokeOptions }); | 130 | const base = makeExecutorToolInvoker(executor, { invokeOptions }); |
| 131 | return { | 131 | return { |
| @@ -155,7 +155,7 @@ export type ExecutionEngine = { · 23 unmodified lines | |||
| 155 | */ | 155 | */ |
| 156 | readonly execute: ( | 156 | readonly execute: ( |
| 157 | code: string, | 157 | code: string, |
| 158 | options?: { readonly onElicitation?: ElicitationHandler }, | 158 | options: { readonly onElicitation: ElicitationHandler }, |
| 159 | ) => Promise<ExecuteResult>; | 159 | ) => Promise<ExecuteResult>; |
| 160 | 160 | ||
| 161 | /** | 161 | /** |
| @@ -188,7 +188,7 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 26 unmodified lines | |||
| 188 | return { | 188 | return { |
| 189 | execute: async (code, options) => { | 189 | execute: async (code, options) => { |
| 190 | const invoker = makeFullInvoker(executor, { | 190 | const invoker = makeFullInvoker(executor, { |
| 191 | onElicitation: options?.onElicitation, | 191 | onElicitation: options.onElicitation, |
| 192 | }); | 192 | }); |
| 193 | return runEffect(codeExecutor.execute(code, invoker)); | 193 | return runEffect(codeExecutor.execute(code, invoker)); |
| 194 | }, | 194 | }, |
| @@ -8,14 +8,14 @@ import type { SandboxToolInvoker } from "@executor/codemode-core"; · 7 unmodified lines | |||
| 8 | */ | 8 | */ |
| 9 | export const makeExecutorToolInvoker = ( | 9 | export const makeExecutorToolInvoker = ( |
| 10 | executor: Executor, | 10 | executor: Executor, |
| 11 | options?: { readonly invokeOptions?: InvokeOptions }, | 11 | options: { readonly invokeOptions: InvokeOptions }, |
| 12 | ): SandboxToolInvoker => ({ | 12 | ): SandboxToolInvoker => ({ |
| 13 | invoke: ({ path, args }) => | 13 | invoke: ({ path, args }) => |
| 14 | Effect.gen(function* () { | 14 | Effect.gen(function* () { |
| 15 | const result = yield* executor.tools.invoke( | 15 | const result = yield* executor.tools.invoke( |
| 16 | path as ToolId, | 16 | path as ToolId, |
| 17 | args, | 17 | args, |
| 18 | options?.invokeOptions, | 18 | options.invokeOptions, |
| 19 | ); | 19 | ); |
| 20 | if (result.error !== null && result.error !== undefined) { | 20 | if (result.error !== null && result.error !== undefined) { |
| 21 | return yield* Effect.fail(result.error); | 21 | return yield* Effect.fail(result.error); |
| @@ -25,7 +25,17 @@ import type { · 24 unmodified lines | |||
| 25 | SecretResolutionError, | 25 | SecretResolutionError, |
| 26 | PolicyDeniedError, | 26 | PolicyDeniedError, |
| 27 | } from "./errors"; | 27 | } from "./errors"; |
| 28 | import type { ElicitationDeclinedError } from "./elicitation"; | 28 | import { |
| 29 | FormElicitation, | ||
| 30 | ElicitationDeclinedError, | ||
| 31 | ElicitationResponse, | ||
| 32 | type ElicitationHandler, | ||
| 33 | } from "./elicitation"; | ||
| 34 | |||
| 35 | const resolveElicitationHandler = (options: InvokeOptions): ElicitationHandler => | ||
| 36 | options.onElicitation === "accept-all" | ||
| 37 | ? () => Effect.succeed(new ElicitationResponse({ action: "accept" })) | ||
| 38 | : options.onElicitation; | ||
| 29 | 39 | ||
| 30 | // --------------------------------------------------------------------------- | 40 | // --------------------------------------------------------------------------- |
| 31 | // Executor — the main public API, expands with plugins | 41 | // Executor — the main public API, expands with plugins |
| @@ -46,7 +56,7 @@ export type Executor< · 14 unmodified lines | |||
| 46 | readonly invoke: ( | 56 | readonly invoke: ( |
| 47 | toolId: string, | 57 | toolId: string, |
| 48 | args: unknown, | 58 | args: unknown, |
| 49 | options?: InvokeOptions, | 59 | options: InvokeOptions, |
| 50 | ) => Effect.Effect< | 60 | ) => Effect.Effect< |
| 51 | ToolInvocationResult, | 61 | ToolInvocationResult, |
| 52 | | ToolNotFoundError | 62 | | ToolNotFoundError |
| @@ -149,10 +159,31 @@ export const createExecutor = < · 96 unmodified lines | |||
| 149 | list: (filter?: ToolListFilter) => tools.list(filter), | 159 | list: (filter?: ToolListFilter) => tools.list(filter), |
| 150 | schema: (toolId: string) => tools.schema(toolId as ToolId), | 160 | schema: (toolId: string) => tools.schema(toolId as ToolId), |
| 151 | definitions: () => tools.definitions(), | 161 | definitions: () => tools.definitions(), |
| 152 | invoke: (toolId: string, args: unknown, options?: InvokeOptions) => { | 162 | invoke: (toolId: string, args: unknown, options: InvokeOptions) => { |
| 153 | const tid = toolId as ToolId; | 163 | const tid = toolId as ToolId; |
| 154 | return Effect.gen(function* () { | 164 | return Effect.gen(function* () { |
| 155 | yield* policies.check({ scopeId: scope.id, toolId: tid }); | 165 | yield* policies.check({ scopeId: scope.id, toolId: tid }); |
| 166 | |||
| 167 | // Dynamically resolve annotations from the plugin | ||
| 168 | const annotations = yield* tools.resolveAnnotations(tid); | ||
| 169 | if (annotations?.requiresApproval) { | ||
| 170 | const handler = resolveElicitationHandler(options); | ||
| 171 | const response = yield* handler({ | ||
| 172 | toolId: tid, | ||
| 173 | args, | ||
| 174 | request: new FormElicitation({ | ||
| 175 | message: annotations.approvalDescription ?? `Approve ${toolId}?`, | ||
| 176 | requestedSchema: {}, | ||
| 177 | }), | ||
| 178 | }); | ||
| 179 | if (response.action !== "accept") { | ||
| 180 | return yield* new ElicitationDeclinedError({ | ||
| 181 | toolId: tid, | ||
| 182 | action: response.action, | ||
| 183 | }); | ||
| 184 | } | ||
| 185 | } | ||
| 186 | |||
| 156 | return yield* tools.invoke(tid, args, options); | 187 | return yield* tools.invoke(tid, args, options); |
| 157 | }); | 188 | }); |
| 158 | }, | 189 | }, |
| @@ -66,7 +66,16 @@ export const makeInMemoryToolRegistry = () => { · 65 unmodified lines | |||
| 66 | invokers.set(pluginKey, invoker); | 66 | invokers.set(pluginKey, invoker); |
| 67 | }), | 67 | }), |
| 68 | 68 | ||
| 69 | invoke: (toolId: ToolId, args: unknown, options?: InvokeOptions) => | 69 | resolveAnnotations: (toolId: ToolId) => |
| 70 | Effect.gen(function* () { | ||
| 71 | const tool = tools.get(toolId); | ||
| 72 | if (!tool) return undefined; | ||
| 73 | const invoker = invokers.get(tool.pluginKey); | ||
| 74 | if (!invoker?.resolveAnnotations) return undefined; | ||
| 75 | return yield* invoker.resolveAnnotations(toolId); | ||
| 76 | }), | ||
| 77 | |||
| 78 | invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => | ||
| 70 | Effect.gen(function* () { | 79 | Effect.gen(function* () { |
| 71 | const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe( | 80 | const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe( |
| 72 | Effect.mapError(() => new ToolNotFoundError({ toolId })), | 81 | Effect.mapError(() => new ToolNotFoundError({ toolId })), |
| @@ -11,9 +11,12 @@ import { · 10 unmodified lines | |||
| 11 | ElicitationResponse, | 11 | ElicitationResponse, |
| 12 | type MemoryToolContext, | 12 | type MemoryToolContext, |
| 13 | type ToolId, | 13 | type ToolId, |
| 14 | type InvokeOptions, | ||
| 14 | SecretId, | 15 | SecretId, |
| 15 | } from "./index"; | 16 | } from "./index"; |
| 16 | 17 | ||
| 18 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; | ||
| 19 | |||
| 17 | // --------------------------------------------------------------------------- | 20 | // --------------------------------------------------------------------------- |
| 18 | // Schemas | 21 | // Schemas |
| 19 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
| @@ -95,7 +98,7 @@ describe("SDK Executor", () => { · 75 unmodified lines | |||
| 95 | }), | 98 | }), |
| 96 | ); | 99 | ); |
| 97 | 100 | ||
| 98 | const result = yield* executor.tools.invoke("inventory.getItem", { itemId: 42 }); | 101 | const result = yield* executor.tools.invoke("inventory.getItem", { itemId: 42 }, autoApprove); |
| 99 | expect(result.data).toEqual({ id: 42, name: "Widget" }); | 102 | expect(result.data).toEqual({ id: 42, name: "Widget" }); |
| 100 | expect(result.error).toBeNull(); | 103 | expect(result.error).toBeNull(); |
| 101 | }), | 104 | }), |
| @@ -121,12 +124,12 @@ describe("SDK Executor", () => { · 19 unmodified lines | |||
| 121 | ); | 124 | ); |
| 122 | 125 | ||
| 123 | const exit = yield* executor.tools | 126 | const exit = yield* executor.tools |
| 124 | .invoke("inventory.getItem", { itemId: "not-a-number" }) | 127 | .invoke("inventory.getItem", { itemId: "not-a-number" }, autoApprove) |
| 125 | .pipe(Effect.exit); | 128 | .pipe(Effect.exit); |
| 126 | 129 | ||
| 127 | expect(Exit.isFailure(exit)).toBe(true); | 130 | expect(Exit.isFailure(exit)).toBe(true); |
| 128 | const error = yield* Effect.flip( | 131 | const error = yield* Effect.flip( |
| 129 | executor.tools.invoke("inventory.getItem", { itemId: "not-a-number" }), | 132 | executor.tools.invoke("inventory.getItem", { itemId: "not-a-number" }, autoApprove), |
| 130 | ); | 133 | ); |
| 131 | expect(error._tag).toBe("ToolInvocationError"); | 134 | expect(error._tag).toBe("ToolInvocationError"); |
| 132 | }), | 135 | }), |
| @@ -136,7 +139,7 @@ describe("SDK Executor", () => { · 3 unmodified lines | |||
| 136 | Effect.gen(function* () { | 139 | Effect.gen(function* () { |
| 137 | const executor = yield* createExecutor(makeTestConfig()); | 140 | const executor = yield* createExecutor(makeTestConfig()); |
| 138 | const error = yield* Effect.flip( | 141 | const error = yield* Effect.flip( |
| 139 | executor.tools.invoke("nonexistent", {}), | 142 | executor.tools.invoke("nonexistent", {}, autoApprove), |
| 140 | ); | 143 | ); |
| 141 | expect(error._tag).toBe("ToolNotFoundError"); | 144 | expect(error._tag).toBe("ToolNotFoundError"); |
| 142 | }), | 145 | }), |
| @@ -204,7 +207,7 @@ describe("SDK Executor", () => { · 61 unmodified lines | |||
| 204 | expect(tools).toHaveLength(1); | 207 | expect(tools).toHaveLength(1); |
| 205 | expect(tools[0]!.name).toBe("dynamicTool"); | 208 | expect(tools[0]!.name).toBe("dynamicTool"); |
| 206 | 209 | ||
| 207 | const result = yield* executor.tools.invoke("runtime.dynamicTool", {}); | 210 | const result = yield* executor.tools.invoke("runtime.dynamicTool", {}, autoApprove); |
| 208 | expect(result.data).toBe("dynamic result"); | 211 | expect(result.data).toBe("dynamic result"); |
| 209 | }), | 212 | }), |
| 210 | ); | 213 | ); |
| @@ -352,7 +355,7 @@ describe("SDK Executor", () => { · 141 unmodified lines | |||
| 352 | ); | 355 | ); |
| 353 | 356 | ||
| 354 | const error = yield* Effect.flip( | 357 | const error = yield* Effect.flip( |
| 355 | executor.tools.invoke("auth.login", {}), | 358 | executor.tools.invoke("auth.login", {}, autoApprove), |
| 356 | ); | 359 | ); |
| 357 | expect(error._tag).toBe("ElicitationDeclinedError"); | 360 | expect(error._tag).toBe("ElicitationDeclinedError"); |
| 358 | }), | 361 | }), |
| @@ -471,7 +474,7 @@ describe("SDK Executor", () => { · 112 unmodified lines | |||
| 471 | const result = yield* executor.tools.invoke("vault.rotateKey", { | 474 | const result = yield* executor.tools.invoke("vault.rotateKey", { |
| 472 | secretName: "DB_PASSWORD", | 475 | secretName: "DB_PASSWORD", |
| 473 | newValue: "correct-horse-battery-staple", | 476 | newValue: "correct-horse-battery-staple", |
| 474 | }); | 477 | }, autoApprove); |
| 475 | 478 | ||
| 476 | // 4. Verify the tool returned old and new values | 479 | // 4. Verify the tool returned old and new values |
| 477 | expect(result.data).toEqual({ | 480 | expect(result.data).toEqual({ |
| @@ -17,6 +17,7 @@ export { · 16 unmodified lines | |||
| 17 | ToolInvocationResult, | 17 | ToolInvocationResult, |
| 18 | ToolRegistry, | 18 | ToolRegistry, |
| 19 | ToolRegistration, | 19 | ToolRegistration, |
| 20 | ToolAnnotations, | ||
| 20 | ToolListFilter, | 21 | ToolListFilter, |
| 21 | type ToolInvoker, | 22 | type ToolInvoker, |
| 22 | type InvokeOptions, | 23 | type InvokeOptions, |
| @@ -12,6 +12,8 @@ import { · 11 unmodified lines | |||
| 12 | } from "../tools"; | 12 | } from "../tools"; |
| 13 | import { | 13 | import { |
| 14 | ElicitationDeclinedError, | 14 | ElicitationDeclinedError, |
| 15 | ElicitationResponse, | ||
| 16 | type ElicitationHandler, | ||
| 15 | type ElicitationRequest, | 17 | type ElicitationRequest, |
| 16 | } from "../elicitation"; | 18 | } from "../elicitation"; |
| 17 | import { definePlugin, type PluginContext } from "../plugin"; | 19 | import { definePlugin, type PluginContext } from "../plugin"; |
| @@ -192,13 +194,16 @@ const makeInvoker = ( · 174 unmodified lines | |||
| 192 | }, | 194 | }, |
| 193 | elicit: (request) => | 195 | elicit: (request) => |
| 194 | Effect.gen(function* () { | 196 | Effect.gen(function* () { |
| 195 | const handler = options?.onElicitation; | 197 | const raw = options?.onElicitation; |
| 196 | if (!handler) { | 198 | if (!raw) { |
| 197 | return yield* new ElicitationDeclinedError({ | 199 | return yield* new ElicitationDeclinedError({ |
| 198 | toolId, | 200 | toolId, |
| 199 | action: "decline", | 201 | action: "decline", |
| 200 | }); | 202 | }); |
| 201 | } | 203 | } |
| 204 | const handler: ElicitationHandler = raw === "accept-all" | ||
| 205 | ? () => Effect.succeed(new ElicitationResponse({ action: "accept" })) | ||
| 206 | : raw; | ||
| 202 | const response = yield* handler({ | 207 | const response = yield* handler({ |
| 203 | toolId, | 208 | toolId, |
| 204 | args, | 209 | args, |
| @@ -11,6 +11,13 @@ import type { · 10 unmodified lines | |||
| 11 | // Tool models | 11 | // Tool models |
| 12 | // --------------------------------------------------------------------------- | 12 | // --------------------------------------------------------------------------- |
| 13 | 13 | ||
| 14 | export class ToolAnnotations extends Schema.Class<ToolAnnotations>("ToolAnnotations")({ | ||
| 15 | /** Whether this tool requires user approval before execution */ | ||
| 16 | requiresApproval: Schema.optional(Schema.Boolean), | ||
| 17 | /** Human-readable description shown in the approval prompt */ | ||
| 18 | approvalDescription: Schema.optional(Schema.String), | ||
| 19 | }) {} | ||
| 20 | |||
| 14 | export class ToolMetadata extends Schema.Class<ToolMetadata>("ToolMetadata")({ | 21 | export class ToolMetadata extends Schema.Class<ToolMetadata>("ToolMetadata")({ |
| 15 | id: ToolId, | 22 | id: ToolId, |
| 16 | pluginKey: Schema.String, | 23 | pluginKey: Schema.String, |
| @@ -51,8 +58,8 @@ export class ToolListFilter extends Schema.Class<ToolListFilter>("ToolListFilter · 34 unmodified lines | |||
| 51 | // --------------------------------------------------------------------------- | 58 | // --------------------------------------------------------------------------- |
| 52 | 59 | ||
| 53 | export interface InvokeOptions { | 60 | export interface InvokeOptions { |
| 54 | /** Handler for elicitation requests. If not provided, elicitations are auto-declined. */ | 61 | /** Handler for elicitation requests, or "accept-all" to auto-approve everything. */ |
| 55 | readonly onElicitation?: ElicitationHandler; | 62 | readonly onElicitation: ElicitationHandler | "accept-all"; |
| 56 | } | 63 | } |
| 57 | 64 | ||
| 58 | // --------------------------------------------------------------------------- | 65 | // --------------------------------------------------------------------------- |
| @@ -71,7 +78,7 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 12 unmodified lines | |||
| 71 | readonly invoke: ( | 78 | readonly invoke: ( |
| 72 | toolId: ToolId, | 79 | toolId: ToolId, |
| 73 | args: unknown, | 80 | args: unknown, |
| 74 | options?: InvokeOptions, | 81 | options: InvokeOptions, |
| 75 | ) => Effect.Effect< | 82 | ) => Effect.Effect< |
| 76 | ToolInvocationResult, | 83 | ToolInvocationResult, |
| 77 | ToolNotFoundError | ToolInvocationError | ElicitationDeclinedError | 84 | ToolNotFoundError | ToolInvocationError | ElicitationDeclinedError |
| @@ -100,6 +107,13 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 22 unmodified lines | |||
| 100 | invoker: ToolInvoker, | 107 | invoker: ToolInvoker, |
| 101 | ) => Effect.Effect<void>; | 108 | ) => Effect.Effect<void>; |
| 102 | 109 | ||
| 110 | /** | ||
| 111 | * Resolve annotations for a tool by delegating to the plugin's invoker. | ||
| 112 | */ | ||
| 113 | readonly resolveAnnotations: ( | ||
| 114 | toolId: ToolId, | ||
| 115 | ) => Effect.Effect<ToolAnnotations | undefined>; | ||
| 116 | |||
| 103 | /** Register tools (used by plugins to push tools into the registry) */ | 117 | /** Register tools (used by plugins to push tools into the registry) */ |
| 104 | readonly register: ( | 118 | readonly register: ( |
| 105 | tools: readonly ToolRegistration[], | 119 | tools: readonly ToolRegistration[], |
| @@ -125,11 +139,16 @@ export interface ToolInvoker { · 19 unmodified lines | |||
| 125 | readonly invoke: ( | 139 | readonly invoke: ( |
| 126 | toolId: ToolId, | 140 | toolId: ToolId, |
| 127 | args: unknown, | 141 | args: unknown, |
| 128 | options?: InvokeOptions, | 142 | options: InvokeOptions, |
| 129 | ) => Effect.Effect< | 143 | ) => Effect.Effect< |
| 130 | ToolInvocationResult, | 144 | ToolInvocationResult, |
| 131 | ToolInvocationError | ElicitationDeclinedError | 145 | ToolInvocationError | ElicitationDeclinedError |
| 132 | >; | 146 | >; |
| 147 | |||
| 148 | /** Dynamically compute annotations for a tool (e.g. approval requirements). */ | ||
| 149 | readonly resolveAnnotations?: ( | ||
| 150 | toolId: ToolId, | ||
| 151 | ) => Effect.Effect<ToolAnnotations | undefined>; | ||
| 133 | } | 152 | } |
| 134 | 153 | ||
| 135 | // --------------------------------------------------------------------------- | 154 | // --------------------------------------------------------------------------- |
| @@ -99,7 +99,16 @@ export const makeKvToolRegistry = ( · 98 unmodified lines | |||
| 99 | registerInvoker: (pluginKey: string, invoker: ToolInvoker) => | 99 | registerInvoker: (pluginKey: string, invoker: ToolInvoker) => |
| 100 | Effect.sync(() => { invokers.set(pluginKey, invoker); }), | 100 | Effect.sync(() => { invokers.set(pluginKey, invoker); }), |
| 101 | 101 | ||
| 102 | invoke: (toolId: ToolId, args: unknown, options?: InvokeOptions) => | 102 | resolveAnnotations: (toolId: ToolId) => |
| 103 | Effect.gen(function* () { | ||
| 104 | const tool = yield* getTool(toolId); | ||
| 105 | if (!tool) return undefined; | ||
| 106 | const invoker = invokers.get(tool.pluginKey); | ||
| 107 | if (!invoker?.resolveAnnotations) return undefined; | ||
| 108 | return yield* invoker.resolveAnnotations(toolId); | ||
| 109 | }), | ||
| 110 | |||
| 111 | invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => | ||
| 103 | Effect.gen(function* () { | 112 | Effect.gen(function* () { |
| 104 | const tool = yield* getTool(toolId); | 113 | const tool = yield* getTool(toolId); |
| 105 | if (!tool) return yield* new ToolNotFoundError({ toolId }); | 114 | if (!tool) return yield* new ToolNotFoundError({ toolId }); |
| @@ -278,13 +278,36 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 277 unmodified lines | |||
| 278 | // ToolInvoker — bridges operation store + HTTP client into SDK invoker | 278 | // ToolInvoker — bridges operation store + HTTP client into SDK invoker |
| 279 | // --------------------------------------------------------------------------- | 279 | // --------------------------------------------------------------------------- |
| 280 | 280 | ||
| 281 | const SAFE_METHODS = new Set(["get", "head", "options"]); | ||
| 282 | |||
| 283 | /** | ||
| 284 | * Derive tool annotations from the HTTP method and path. | ||
| 285 | */ | ||
| 286 | export const annotationsForOperation = (method: string, pathTemplate: string): { | ||
| 287 | requiresApproval?: boolean; | ||
| 288 | approvalDescription?: string; | ||
| 289 | } => { | ||
| 290 | if (SAFE_METHODS.has(method.toLowerCase())) return {}; | ||
| 291 | return { | ||
| 292 | requiresApproval: true, | ||
| 293 | approvalDescription: `${method.toUpperCase()} ${pathTemplate}`, | ||
| 294 | }; | ||
| 295 | }; | ||
| 296 | |||
| 281 | export const makeOpenApiInvoker = (opts: { | 297 | export const makeOpenApiInvoker = (opts: { |
| 282 | readonly operationStore: OpenApiOperationStore; | 298 | readonly operationStore: OpenApiOperationStore; |
| 283 | readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>; | 299 | readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>; |
| 284 | readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> }; | 300 | readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> }; |
| 285 | readonly scopeId: ScopeId; | 301 | readonly scopeId: ScopeId; |
| 286 | }): ToolInvoker => ({ | 302 | }): ToolInvoker => ({ |
| 287 | invoke: (toolId: ToolId, args: unknown, _options) => | 303 | resolveAnnotations: (toolId: ToolId) => |
| 304 | Effect.gen(function* () { | ||
| 305 | const entry = yield* opts.operationStore.get(toolId); | ||
| 306 | if (!entry) return undefined; | ||
| 307 | return annotationsForOperation(entry.binding.method, entry.binding.pathTemplate); | ||
| 308 | }), | ||
| 309 | |||
| 310 | invoke: (toolId: ToolId, args: unknown) => | ||
| 288 | Effect.gen(function* () { | 311 | Effect.gen(function* () { |
| 289 | const entry = yield* opts.operationStore.get(toolId); | 312 | const entry = yield* opts.operationStore.get(toolId); |
| 290 | if (!entry) { | 313 | if (!entry) { |
| @@ -11,9 +11,11 @@ import { · 10 unmodified lines | |||
| 11 | } from "@effect/platform"; | 11 | } from "@effect/platform"; |
| 12 | import { NodeHttpServer } from "@effect/platform-node"; | 12 | import { NodeHttpServer } from "@effect/platform-node"; |
| 13 | 13 | ||
| 14 | import { createExecutor, makeTestConfig, SecretId } from "@executor/sdk"; | 14 | import { createExecutor, makeTestConfig, SecretId, type InvokeOptions } from "@executor/sdk"; |
| 15 | import { openApiPlugin } from "./plugin"; | 15 | import { openApiPlugin } from "./plugin"; |
| 16 | 16 | ||
| 17 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; | ||
| 18 | |||
| 17 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
| 18 | // Define a test API with Effect HttpApi | 20 | // Define a test API with Effect HttpApi |
| 19 | // --------------------------------------------------------------------------- | 21 | // --------------------------------------------------------------------------- |
| @@ -151,6 +153,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 131 unmodified lines | |||
| 151 | const result = yield* executor.tools.invoke( | 153 | const result = yield* executor.tools.invoke( |
| 152 | "authed.items.echoHeaders", | 154 | "authed.items.echoHeaders", |
| 153 | {}, | 155 | {}, |
| 156 | autoApprove, | ||
| 154 | ); | 157 | ); |
| 155 | 158 | ||
| 156 | expect(result.error).toBeNull(); | 159 | expect(result.error).toBeNull(); |
| @@ -185,7 +188,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 28 unmodified lines | |||
| 185 | 188 | ||
| 186 | // Invoke — should fail with a clear error about the missing secret | 189 | // Invoke — should fail with a clear error about the missing secret |
| 187 | const error = yield* Effect.flip( | 190 | const error = yield* Effect.flip( |
| 188 | executor.tools.invoke("noauth.items.listItems", {}), | 191 | executor.tools.invoke("noauth.items.listItems", {}, autoApprove), |
| 189 | ); | 192 | ); |
| 190 | 193 | ||
| 191 | expect(error._tag).toBe("ToolInvocationError"); | 194 | expect(error._tag).toBe("ToolInvocationError"); |
| @@ -240,7 +243,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 48 unmodified lines | |||
| 240 | baseUrl: "", | 243 | baseUrl: "", |
| 241 | }); | 244 | }); |
| 242 | 245 | ||
| 243 | const result = yield* executor.tools.invoke("test.items.listItems", {}); | 246 | const result = yield* executor.tools.invoke("test.items.listItems", {}, autoApprove); |
| 244 | expect(result.error).toBeNull(); | 247 | expect(result.error).toBeNull(); |
| 245 | expect(result.data).toEqual(ITEMS); | 248 | expect(result.data).toEqual(ITEMS); |
| 246 | }), | 249 | }), |
| @@ -267,7 +270,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 20 unmodified lines | |||
| 267 | 270 | ||
| 268 | const result = yield* executor.tools.invoke("test.items.getItem", { | 271 | const result = yield* executor.tools.invoke("test.items.getItem", { |
| 269 | itemId: "2", | 272 | itemId: "2", |
| 270 | }); | 273 | }, autoApprove); |
| 271 | expect(result.error).toBeNull(); | 274 | expect(result.error).toBeNull(); |
| 272 | expect(result.data).toEqual({ id: 2, name: "Gadget" }); | 275 | expect(result.data).toEqual({ id: 2, name: "Gadget" }); |
| 273 | }), | 276 | }), |
| @@ -26,6 +26,7 @@ import { · 25 unmodified lines | |||
| 26 | OperationBinding, | 26 | OperationBinding, |
| 27 | } from "./types"; | 27 | } from "./types"; |
| 28 | 28 | ||
| 29 | |||
| 29 | // --------------------------------------------------------------------------- | 30 | // --------------------------------------------------------------------------- |
| 30 | // Plugin config | 31 | // Plugin config |
| 31 | // --------------------------------------------------------------------------- | 32 | // --------------------------------------------------------------------------- |