Unified · Split
| @@ -28,11 +28,5 @@ export const ToolsHandlers = HttpApiBuilder.group( · 27 unmodified lines | ||
| 28 | 28 | const executor = yield* ExecutorService; |
| 29 | 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 | 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 | 65 | export default defineConfig({ |
| 9 | 66 | plugins: [ |
| 10 | 67 | react(), |
| @@ -24,50 +81,13 @@ export default defineConfig({ · 13 unmodified lines | ||
| 24 | 81 | if (!isApi && !isMcp) return next(); |
| 25 | 82 | |
| 26 | 83 | const handlers = await handlersPromise; |
| 27 | − | |
| 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 | − }); | |
| 84 | + const request = await toWebRequest(req); | |
| 61 | 85 | |
| 62 | 86 | const response = isMcp |
| 63 | 87 | ? await handlers.mcp.handleRequest(request) |
| 64 | 88 | : await handlers.api.handler(request); |
| 65 | 89 | |
| 66 | − res.statusCode = response.status; | |
| 67 | − response.headers.forEach((value, key) => res.setHeader(key, value)); | |
| 68 | − | |
| 69 | − const body = await response.arrayBuffer(); | |
| 70 | − res.end(Buffer.from(body)); | |
| 90 | + await sendWebResponse(response, res); | |
| 71 | 91 | }); |
| 72 | 92 | }, |
| 73 | 93 | }, |
| @@ -58,7 +58,7 @@ export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) => · 57 unmodified lines | ||
| 58 | 58 | // Mutation atoms |
| 59 | 59 | // --------------------------------------------------------------------------- |
| 60 | 60 | |
| 61 | −export const invokeTool = ExecutorApiClient.mutation("tools", "invoke"); | |
| 61 | + | |
| 62 | 62 | |
| 63 | 63 | export const setSecret = ExecutorApiClient.mutation("secrets", "set"); |
| 64 | 64 | |
| @@ -28,7 +28,6 @@ export { · 27 unmodified lines | ||
| 28 | 28 | sourceAtom, |
| 29 | 29 | secretsAtom, |
| 30 | 30 | secretStatusAtom, |
| 31 | − invokeTool, | |
| 32 | 31 | setSecret, |
| 33 | 32 | resolveSecret, |
| 34 | 33 | removeSecret, |
| @@ -4,9 +4,6 @@ import { · 3 unmodified lines | ||
| 4 | 4 | ScopeId, |
| 5 | 5 | ToolId, |
| 6 | 6 | ToolNotFoundError, |
| 7 | − ToolInvocationError, | |
| 8 | − PolicyDeniedError, | |
| 9 | − ElicitationDeclinedError, | |
| 10 | 7 | } from "@executor/sdk"; |
| 11 | 8 | |
| 12 | 9 | // --------------------------------------------------------------------------- |
| @@ -35,16 +32,6 @@ const ToolSchemaResponse = Schema.Struct({ · 22 unmodified lines | ||
| 35 | 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 | 36 | // Error schemas with HTTP status annotations |
| 50 | 37 | // --------------------------------------------------------------------------- |
| @@ -52,15 +39,6 @@ const ToolInvokeResponse = Schema.Struct({ · 1 unmodified line | ||
| 52 | 39 | const ToolNotFound = ToolNotFoundError.annotations( |
| 53 | 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 | 44 | // Group |
| @@ -76,13 +54,4 @@ export class ToolsApi extends HttpApiGroup.make("tools") · 9 unmodified lines | ||
| 76 | 54 | .addSuccess(ToolSchemaResponse) |
| 77 | 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 | 57 | .prefix("/v1") {} |
| @@ -125,7 +125,7 @@ export const formatPausedExecution = (paused: PausedExecution): { · 124 unmodified lines | ||
| 125 | 125 | |
| 126 | 126 | const makeFullInvoker = ( |
| 127 | 127 | executor: Executor, |
| 128 | − invokeOptions?: InvokeOptions, | |
| 128 | + invokeOptions: InvokeOptions, | |
| 129 | 129 | ): SandboxToolInvoker => { |
| 130 | 130 | const base = makeExecutorToolInvoker(executor, { invokeOptions }); |
| 131 | 131 | return { |
| @@ -155,7 +155,7 @@ export type ExecutionEngine = { · 23 unmodified lines | ||
| 155 | 155 | */ |
| 156 | 156 | readonly execute: ( |
| 157 | 157 | code: string, |
| 158 | − options?: { readonly onElicitation?: ElicitationHandler }, | |
| 158 | + options: { readonly onElicitation: ElicitationHandler }, | |
| 159 | 159 | ) => Promise<ExecuteResult>; |
| 160 | 160 | |
| 161 | 161 | /** |
| @@ -188,7 +188,7 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 26 unmodified lines | ||
| 188 | 188 | return { |
| 189 | 189 | execute: async (code, options) => { |
| 190 | 190 | const invoker = makeFullInvoker(executor, { |
| 191 | − onElicitation: options?.onElicitation, | |
| 191 | + onElicitation: options.onElicitation, | |
| 192 | 192 | }); |
| 193 | 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 | 9 | export const makeExecutorToolInvoker = ( |
| 10 | 10 | executor: Executor, |
| 11 | − options?: { readonly invokeOptions?: InvokeOptions }, | |
| 11 | + options: { readonly invokeOptions: InvokeOptions }, | |
| 12 | 12 | ): SandboxToolInvoker => ({ |
| 13 | 13 | invoke: ({ path, args }) => |
| 14 | 14 | Effect.gen(function* () { |
| 15 | 15 | const result = yield* executor.tools.invoke( |
| 16 | 16 | path as ToolId, |
| 17 | 17 | args, |
| 18 | − options?.invokeOptions, | |
| 18 | + options.invokeOptions, | |
| 19 | 19 | ); |
| 20 | 20 | if (result.error !== null && result.error !== undefined) { |
| 21 | 21 | return yield* Effect.fail(result.error); |
| @@ -25,7 +25,17 @@ import type { · 24 unmodified lines | ||
| 25 | 25 | SecretResolutionError, |
| 26 | 26 | PolicyDeniedError, |
| 27 | 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 | 41 | // Executor — the main public API, expands with plugins |
| @@ -46,7 +56,7 @@ export type Executor< · 14 unmodified lines | ||
| 46 | 56 | readonly invoke: ( |
| 47 | 57 | toolId: string, |
| 48 | 58 | args: unknown, |
| 49 | − options?: InvokeOptions, | |
| 59 | + options: InvokeOptions, | |
| 50 | 60 | ) => Effect.Effect< |
| 51 | 61 | ToolInvocationResult, |
| 52 | 62 | | ToolNotFoundError |
| @@ -149,10 +159,31 @@ export const createExecutor = < · 96 unmodified lines | ||
| 149 | 159 | list: (filter?: ToolListFilter) => tools.list(filter), |
| 150 | 160 | schema: (toolId: string) => tools.schema(toolId as ToolId), |
| 151 | 161 | definitions: () => tools.definitions(), |
| 152 | − invoke: (toolId: string, args: unknown, options?: InvokeOptions) => { | |
| 162 | + invoke: (toolId: string, args: unknown, options: InvokeOptions) => { | |
| 153 | 163 | const tid = toolId as ToolId; |
| 154 | 164 | return Effect.gen(function* () { |
| 155 | 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 | 187 | return yield* tools.invoke(tid, args, options); |
| 157 | 188 | }); |
| 158 | 189 | }, |
| @@ -66,7 +66,16 @@ export const makeInMemoryToolRegistry = () => { · 65 unmodified lines | ||
| 66 | 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 | 79 | Effect.gen(function* () { |
| 71 | 80 | const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe( |
| 72 | 81 | Effect.mapError(() => new ToolNotFoundError({ toolId })), |
| @@ -11,9 +11,12 @@ import { · 10 unmodified lines | ||
| 11 | 11 | ElicitationResponse, |
| 12 | 12 | type MemoryToolContext, |
| 13 | 13 | type ToolId, |
| 14 | + type InvokeOptions, | |
| 14 | 15 | SecretId, |
| 15 | 16 | } from "./index"; |
| 16 | 17 | |
| 18 | +const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; | |
| 19 | + | |
| 17 | 20 | // --------------------------------------------------------------------------- |
| 18 | 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 | 102 | expect(result.data).toEqual({ id: 42, name: "Widget" }); |
| 100 | 103 | expect(result.error).toBeNull(); |
| 101 | 104 | }), |
| @@ -121,12 +124,12 @@ describe("SDK Executor", () => { · 19 unmodified lines | ||
| 121 | 124 | ); |
| 122 | 125 | |
| 123 | 126 | const exit = yield* executor.tools |
| 124 | − .invoke("inventory.getItem", { itemId: "not-a-number" }) | |
| 127 | + .invoke("inventory.getItem", { itemId: "not-a-number" }, autoApprove) | |
| 125 | 128 | .pipe(Effect.exit); |
| 126 | 129 | |
| 127 | 130 | expect(Exit.isFailure(exit)).toBe(true); |
| 128 | 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 | 134 | expect(error._tag).toBe("ToolInvocationError"); |
| 132 | 135 | }), |
| @@ -136,7 +139,7 @@ describe("SDK Executor", () => { · 3 unmodified lines | ||
| 136 | 139 | Effect.gen(function* () { |
| 137 | 140 | const executor = yield* createExecutor(makeTestConfig()); |
| 138 | 141 | const error = yield* Effect.flip( |
| 139 | − executor.tools.invoke("nonexistent", {}), | |
| 142 | + executor.tools.invoke("nonexistent", {}, autoApprove), | |
| 140 | 143 | ); |
| 141 | 144 | expect(error._tag).toBe("ToolNotFoundError"); |
| 142 | 145 | }), |
| @@ -204,7 +207,7 @@ describe("SDK Executor", () => { · 61 unmodified lines | ||
| 204 | 207 | expect(tools).toHaveLength(1); |
| 205 | 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 | 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 | 357 | const error = yield* Effect.flip( |
| 355 | − executor.tools.invoke("auth.login", {}), | |
| 358 | + executor.tools.invoke("auth.login", {}, autoApprove), | |
| 356 | 359 | ); |
| 357 | 360 | expect(error._tag).toBe("ElicitationDeclinedError"); |
| 358 | 361 | }), |
| @@ -471,7 +474,7 @@ describe("SDK Executor", () => { · 112 unmodified lines | ||
| 471 | 474 | const result = yield* executor.tools.invoke("vault.rotateKey", { |
| 472 | 475 | secretName: "DB_PASSWORD", |
| 473 | 476 | newValue: "correct-horse-battery-staple", |
| 474 | − }); | |
| 477 | + }, autoApprove); | |
| 475 | 478 | |
| 476 | 479 | // 4. Verify the tool returned old and new values |
| 477 | 480 | expect(result.data).toEqual({ |
| @@ -17,6 +17,7 @@ export { · 16 unmodified lines | ||
| 17 | 17 | ToolInvocationResult, |
| 18 | 18 | ToolRegistry, |
| 19 | 19 | ToolRegistration, |
| 20 | + ToolAnnotations, | |
| 20 | 21 | ToolListFilter, |
| 21 | 22 | type ToolInvoker, |
| 22 | 23 | type InvokeOptions, |
| @@ -12,6 +12,8 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "../tools"; |
| 13 | 13 | import { |
| 14 | 14 | ElicitationDeclinedError, |
| 15 | + ElicitationResponse, | |
| 16 | + type ElicitationHandler, | |
| 15 | 17 | type ElicitationRequest, |
| 16 | 18 | } from "../elicitation"; |
| 17 | 19 | import { definePlugin, type PluginContext } from "../plugin"; |
| @@ -192,13 +194,16 @@ const makeInvoker = ( · 174 unmodified lines | ||
| 192 | 194 | }, |
| 193 | 195 | elicit: (request) => |
| 194 | 196 | Effect.gen(function* () { |
| 195 | − const handler = options?.onElicitation; | |
| 196 | − if (!handler) { | |
| 197 | + const raw = options?.onElicitation; | |
| 198 | + if (!raw) { | |
| 197 | 199 | return yield* new ElicitationDeclinedError({ |
| 198 | 200 | toolId, |
| 199 | 201 | action: "decline", |
| 200 | 202 | }); |
| 201 | 203 | } |
| 204 | + const handler: ElicitationHandler = raw === "accept-all" | |
| 205 | + ? () => Effect.succeed(new ElicitationResponse({ action: "accept" })) | |
| 206 | + : raw; | |
| 202 | 207 | const response = yield* handler({ |
| 203 | 208 | toolId, |
| 204 | 209 | args, |
| @@ -11,6 +11,13 @@ import type { · 10 unmodified lines | ||
| 11 | 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 | 21 | export class ToolMetadata extends Schema.Class<ToolMetadata>("ToolMetadata")({ |
| 15 | 22 | id: ToolId, |
| 16 | 23 | pluginKey: Schema.String, |
| @@ -51,8 +58,8 @@ export class ToolListFilter extends Schema.Class<ToolListFilter>("ToolListFilter · 34 unmodified lines | ||
| 51 | 58 | // --------------------------------------------------------------------------- |
| 52 | 59 | |
| 53 | 60 | export interface InvokeOptions { |
| 54 | − /** Handler for elicitation requests. If not provided, elicitations are auto-declined. */ | |
| 55 | − readonly onElicitation?: ElicitationHandler; | |
| 61 | + /** Handler for elicitation requests, or "accept-all" to auto-approve everything. */ | |
| 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 | 78 | readonly invoke: ( |
| 72 | 79 | toolId: ToolId, |
| 73 | 80 | args: unknown, |
| 74 | − options?: InvokeOptions, | |
| 81 | + options: InvokeOptions, | |
| 75 | 82 | ) => Effect.Effect< |
| 76 | 83 | ToolInvocationResult, |
| 77 | 84 | ToolNotFoundError | ToolInvocationError | ElicitationDeclinedError |
| @@ -100,6 +107,13 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 22 unmodified lines | ||
| 100 | 107 | invoker: ToolInvoker, |
| 101 | 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 | 117 | /** Register tools (used by plugins to push tools into the registry) */ |
| 104 | 118 | readonly register: ( |
| 105 | 119 | tools: readonly ToolRegistration[], |
| @@ -125,11 +139,16 @@ export interface ToolInvoker { · 19 unmodified lines | ||
| 125 | 139 | readonly invoke: ( |
| 126 | 140 | toolId: ToolId, |
| 127 | 141 | args: unknown, |
| 128 | − options?: InvokeOptions, | |
| 142 | + options: InvokeOptions, | |
| 129 | 143 | ) => Effect.Effect< |
| 130 | 144 | ToolInvocationResult, |
| 131 | 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 | 99 | registerInvoker: (pluginKey: string, invoker: ToolInvoker) => |
| 100 | 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 | 112 | Effect.gen(function* () { |
| 104 | 113 | const tool = yield* getTool(toolId); |
| 105 | 114 | if (!tool) return yield* new ToolNotFoundError({ toolId }); |
| @@ -278,13 +278,36 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 277 unmodified lines | ||
| 278 | 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 | 297 | export const makeOpenApiInvoker = (opts: { |
| 282 | 298 | readonly operationStore: OpenApiOperationStore; |
| 283 | 299 | readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>; |
| 284 | 300 | readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> }; |
| 285 | 301 | readonly scopeId: ScopeId; |
| 286 | 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 | 311 | Effect.gen(function* () { |
| 289 | 312 | const entry = yield* opts.operationStore.get(toolId); |
| 290 | 313 | if (!entry) { |
| @@ -11,9 +11,11 @@ import { · 10 unmodified lines | ||
| 11 | 11 | } from "@effect/platform"; |
| 12 | 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 | 15 | import { openApiPlugin } from "./plugin"; |
| 16 | 16 | |
| 17 | +const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; | |
| 18 | + | |
| 17 | 19 | // --------------------------------------------------------------------------- |
| 18 | 20 | // Define a test API with Effect HttpApi |
| 19 | 21 | // --------------------------------------------------------------------------- |
| @@ -151,6 +153,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 131 unmodified lines | ||
| 151 | 153 | const result = yield* executor.tools.invoke( |
| 152 | 154 | "authed.items.echoHeaders", |
| 153 | 155 | {}, |
| 156 | + autoApprove, | |
| 154 | 157 | ); |
| 155 | 158 | |
| 156 | 159 | expect(result.error).toBeNull(); |
| @@ -185,7 +188,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 28 unmodified lines | ||
| 185 | 188 | |
| 186 | 189 | // Invoke — should fail with a clear error about the missing secret |
| 187 | 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 | 194 | expect(error._tag).toBe("ToolInvocationError"); |
| @@ -240,7 +243,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 48 unmodified lines | ||
| 240 | 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 | 247 | expect(result.error).toBeNull(); |
| 245 | 248 | expect(result.data).toEqual(ITEMS); |
| 246 | 249 | }), |
| @@ -267,7 +270,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 20 unmodified lines | ||
| 267 | 270 | |
| 268 | 271 | const result = yield* executor.tools.invoke("test.items.getItem", { |
| 269 | 272 | itemId: "2", |
| 270 | − }); | |
| 273 | + }, autoApprove); | |
| 271 | 274 | expect(result.error).toBeNull(); |
| 272 | 275 | expect(result.data).toEqual({ id: 2, name: "Gadget" }); |
| 273 | 276 | }), |
| @@ -26,6 +26,7 @@ import { · 25 unmodified lines | ||
| 26 | 26 | OperationBinding, |
| 27 | 27 | } from "./types"; |
| 28 | 28 | |
| 29 | + | |
| 29 | 30 | // --------------------------------------------------------------------------- |
| 30 | 31 | // Plugin config |
| 31 | 32 | // --------------------------------------------------------------------------- |