History

refactor: separate tool registration from invocation

ToolRegistration is now pure data (pluginKey, metadata, schemas) with no invoke closures. Plugins register a ToolInvoker that the registry delegates to at call time. SDK: - ToolRegistration: remove invoke closure, add pluginKey field - ToolInvoker: new interface (invoke by toolId) - ToolRegistry: add registerInvoker(pluginKey, invoker) - In-memory registry routes invoke() through invoker map OpenAPI plugin: - OperationBinding: minimal invocation data (method, path, params, body) - OpenApiOperationStore: plugin's own pluggable storage interface - makeOpenApiInvoker: single invoker looks up binding from store - plugin.ts is just registration (166 lines, down from 302) - No per-tool closures, no duplicated schemas In-memory tools plugin: - Single invoker with shared handler map - Registrations are pure data

Rhys Sullivan committed 7 months ago

Commit af627af1 parent 60368a210 files · +494 −274Browse files

Unified · Split

10 files
  1. packages/core/sdk/src/in-memory/tool-registry.ts +17 −3
  2. packages/core/sdk/src/index.ts +1 −0
  3. packages/core/sdk/src/plugins/in-memory-tools.ts +163 −114
  4. packages/core/sdk/src/tools.ts +26 −12
  5. packages/plugins/openapi/src/index.ts +12 −2
  6. packages/plugins/openapi/src/invoke.ts +70 −4
  7. packages/plugins/openapi/src/openapi-utils.ts +20 −0
  8. packages/plugins/openapi/src/operation-store.ts +77 −0
  9. packages/plugins/openapi/src/plugin.ts +95 −139
  10. packages/plugins/openapi/src/types.ts +13 −0
packages/core/sdk/src/in-memory/tool-registry.ts+17−3
@@ -1,12 +1,13 @@
1import { Effect } from "effect";1import { Effect } from "effect";
2 2
3import { ToolId } from "../ids";3import { ToolId } from "../ids";
4import { ToolNotFoundError } from "../errors";4import { ToolNotFoundError, ToolInvocationError } from "../errors";
5import type { ToolRegistration, InvokeOptions } from "../tools";5import type { ToolRegistration, ToolInvoker, InvokeOptions } from "../tools";
6import { reattachDefs } from "../schema-refs";6import { reattachDefs } from "../schema-refs";
7 7
8export const makeInMemoryToolRegistry = () => {8export const makeInMemoryToolRegistry = () => {
9 const tools = new Map<string, ToolRegistration>();9 const tools = new Map<string, ToolRegistration>();
10 const invokers = new Map<string, ToolInvoker>();
10 const sharedDefs = new Map<string, unknown>();11 const sharedDefs = new Map<string, unknown>();
11 12
12 return {13 return {
@@ -64,12 +65,25 @@ export const makeInMemoryToolRegistry = () => { · 51 unmodified lines
64 }65 }
65 }),66 }),
66 67
68 registerInvoker: (pluginKey: string, invoker: ToolInvoker) =>
69 Effect.sync(() => {
70 invokers.set(pluginKey, invoker);
71 }),
72
67 invoke: (toolId: ToolId, args: unknown, options?: InvokeOptions) =>73 invoke: (toolId: ToolId, args: unknown, options?: InvokeOptions) =>
68 Effect.gen(function* () {74 Effect.gen(function* () {
69 const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe(75 const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe(
70 Effect.mapError(() => new ToolNotFoundError({ toolId })),76 Effect.mapError(() => new ToolNotFoundError({ toolId })),
71 );77 );
72 return yield* tool.invoke(args, options);78 const invoker = invokers.get(tool.pluginKey);
79 if (!invoker) {
80 return yield* new ToolInvocationError({
81 toolId,
82 message: `No invoker registered for plugin "${tool.pluginKey}"`,
83 cause: undefined,
84 });
85 }
86 return yield* invoker.invoke(toolId, args, options);
73 }),87 }),
74 88
75 register: (newTools: readonly ToolRegistration[]) =>89 register: (newTools: readonly ToolRegistration[]) =>
packages/core/sdk/src/index.ts+1−0
@@ -17,6 +17,7 @@ export { · 16 unmodified lines
17 ToolInvocationResult,17 ToolInvocationResult,
18 ToolRegistry,18 ToolRegistry,
19 type ToolRegistration,19 type ToolRegistration,
20 type ToolInvoker,
20 type InvokeOptions,21 type InvokeOptions,
21} from "./tools";22} from "./tools";
22 23
packages/core/sdk/src/plugins/in-memory-tools.ts+163−114
@@ -7,6 +7,7 @@ import type { Secret } from "../secrets"; · 6 unmodified lines
7import {7import {
8 ToolInvocationResult,8 ToolInvocationResult,
9 type ToolRegistration,9 type ToolRegistration,
10 type ToolInvoker,
10 type InvokeOptions,11 type InvokeOptions,
11} from "../tools";12} from "../tools";
12import {13import {
@@ -75,26 +76,33 @@ export interface InMemoryToolsPluginExtension { · 62 unmodified lines
75}76}
76 77
77// ---------------------------------------------------------------------------78// ---------------------------------------------------------------------------
78// Registration builder79// Internal handler entry
79// ---------------------------------------------------------------------------80// ---------------------------------------------------------------------------
80 81
81const toRegistrationWithDefs = (82interface HandlerEntry {
83 readonly decode: (args: unknown) => unknown;
84 readonly handler: MemoryToolHandler<unknown>;
85 readonly isEffect: boolean;
86}
87
88// ---------------------------------------------------------------------------
89// Registration builder — returns pure data + handler entry
90// ---------------------------------------------------------------------------
91
92const buildRegistration = (
82 namespace: string,93 namespace: string,
83 def: MemoryToolDefinition,94 def: MemoryToolDefinition,
84 pluginCtx: PluginContext,95): { registration: ToolRegistration; entry: HandlerEntry; definitions: Record<string, unknown> } => {
85): { registration: ToolRegistration; definitions: Record<string, unknown> } => {
86 const id = ToolId.make(`${namespace}.${def.name}`);96 const id = ToolId.make(`${namespace}.${def.name}`);
87 const decode = Schema.decodeUnknownSync(def.inputSchema);97 const decode = Schema.decodeUnknownSync(def.inputSchema);
88 const isEffectHandler = def.handler.length >= 2;98 const isEffect = def.handler.length >= 2;
89 99
90 // Convert to JSON Schema and hoist definitions
91 const inputJson = JSONSchema.make(def.inputSchema);100 const inputJson = JSONSchema.make(def.inputSchema);
92 const outputJson = def.outputSchema ? JSONSchema.make(def.outputSchema) : undefined;101 const outputJson = def.outputSchema ? JSONSchema.make(def.outputSchema) : undefined;
93 102
94 const inputHoist = hoistDefinitions(inputJson);103 const inputHoist = hoistDefinitions(inputJson);
95 const outputHoist = hoistDefinitions(outputJson);104 const outputHoist = hoistDefinitions(outputJson);
96 105
97 // Merge all definitions
98 const allDefs: Record<string, unknown> = {106 const allDefs: Record<string, unknown> = {
99 ...inputHoist.defs,107 ...inputHoist.defs,
100 ...outputHoist.defs,108 ...outputHoist.defs,
@@ -102,121 +110,143 @@ const toRegistrationWithDefs = ( · 1 unmodified line
102 110
103 const registration: ToolRegistration = {111 const registration: ToolRegistration = {
104 id,112 id,
113 pluginKey: "inMemoryTools",
105 name: def.name,114 name: def.name,
106 description: def.description,115 description: def.description,
107 tags: def.tags ? [...def.tags] : undefined,116 tags: def.tags ? [...def.tags] : undefined,
108 inputSchema: inputHoist.stripped,117 inputSchema: inputHoist.stripped,
109 outputSchema: outputHoist.stripped,118 outputSchema: outputHoist.stripped,
110 mayElicit: isEffectHandler,119 mayElicit: isEffect,
111 invoke: (args, options?: InvokeOptions) => {120 };
112 const parsed = Effect.try({121
113 try: () => decode(args),122 const entry: HandlerEntry = { decode, handler: def.handler as MemoryToolHandler<unknown>, isEffect };
114 catch: (err) =>123
115 new ToolInvocationError({124 return { registration, entry, definitions: allDefs };
116 toolId: id,125};
117 message: `Invalid input: ${err instanceof Error ? err.message : String(err)}`,126
118 cause: err,127// ---------------------------------------------------------------------------
119 }),128// Invoker — single function that handles all in-memory tools
120 });129// ---------------------------------------------------------------------------
121 130
122 if (!isEffectHandler) {131const makeInvoker = (
123 return parsed.pipe(132 handlers: Map<string, HandlerEntry>,
124 Effect.flatMap((input) =>133 pluginCtx: PluginContext,
125 Effect.try({134): ToolInvoker => ({
126 try: () =>135 invoke: (toolId: ToolId, args: unknown, options?: InvokeOptions) => {
127 new ToolInvocationResult({136 const entry = handlers.get(toolId);
128 data: (def.handler as (args: unknown) => unknown)(input),137 if (!entry) {
129 error: null,138 return Effect.fail(
130 }),139 new ToolInvocationError({
131 catch: (err) =>140 toolId,
132 new ToolInvocationError({141 message: `No handler registered for tool "${toolId}"`,
133 toolId: id,142 cause: undefined,
134 message:143 }),
135 err instanceof Error ? err.message : String(err),144 );
136 cause: err,145 }
137 }),
138 }),
139 ),
140 );
141 }
142
143 // Effect handler — build context with elicit + sdk access
144 const ctx: MemoryToolContext = {
145 sdk: {
146 secrets: {
147 list: () => pluginCtx.secrets.list(pluginCtx.scope.id),
148 resolve: (secretId) => pluginCtx.secrets.resolve(secretId),
149 store: (input) =>
150 pluginCtx.secrets.store({
151 ...input,
152 scopeId: pluginCtx.scope.id,
153 }),
154 remove: (secretId) => pluginCtx.secrets.remove(secretId),
155 },
156 },
157 elicit: (request) =>
158 Effect.gen(function* () {
159 const handler = options?.onElicitation;
160 if (!handler) {
161 return yield* new ElicitationDeclinedError({
162 toolId: id,
163 action: "decline",
164 });
165 }
166 const response = yield* handler({
167 toolId: id,
168 args,
169 request,
170 });
171 if (response.action !== "accept") {
172 return yield* new ElicitationDeclinedError({
173 toolId: id,
174 action: response.action as "decline" | "cancel",
175 });
176 }
177 return response.content ?? {};
178 }),
179 };
180 146
181 const effectHandler = def.handler as (147 const parsed = Effect.try({
182 args: unknown,148 try: () => entry.decode(args),
183 ctx: MemoryToolContext,149 catch: (err) =>
184 ) => Effect.Effect<unknown, unknown>;150 new ToolInvocationError({
151 toolId,
152 message: `Invalid input: ${err instanceof Error ? err.message : String(err)}`,
153 cause: err,
154 }),
155 });
185 156
157 if (!entry.isEffect) {
186 return parsed.pipe(158 return parsed.pipe(
187 Effect.flatMap((input) => effectHandler(input, ctx)),159 Effect.flatMap((input) =>
188 Effect.map(160 Effect.try({
189 (data) => new ToolInvocationResult({ data, error: null }),161 try: () =>
190 ),162 new ToolInvocationResult({
191 Effect.catchAll(163 data: (entry.handler as (args: unknown) => unknown)(input),
192 (err): Effect.Effect<164 error: null,
193 ToolInvocationResult,165 }),
194 ToolInvocationError | ElicitationDeclinedError166 catch: (err) =>
195 > => {
196 if (
197 err != null &&
198 typeof err === "object" &&
199 "_tag" in err &&
200 (err as { _tag: string })._tag === "ElicitationDeclinedError"
201 ) {
202 return Effect.fail(err as ElicitationDeclinedError);
203 }
204 return Effect.fail(
205 new ToolInvocationError({167 new ToolInvocationError({
206 toolId: id,168 toolId,
207 message:169 message: err instanceof Error ? err.message : String(err),
208 err instanceof Error ? err.message : String(err),
209 cause: err,170 cause: err,
210 }),171 }),
211 );172 }),
212 },
213 ),173 ),
214 );174 );
215 },175 }
216 };
217 176
218 return { registration, definitions: allDefs };177 // Effect handler — build context with elicit + sdk access
219};178 const ctx: MemoryToolContext = {
179 sdk: {
180 secrets: {
181 list: () => pluginCtx.secrets.list(pluginCtx.scope.id),
182 resolve: (secretId) => pluginCtx.secrets.resolve(secretId),
183 store: (input) =>
184 pluginCtx.secrets.store({
185 ...input,
186 scopeId: pluginCtx.scope.id,
187 }),
188 remove: (secretId) => pluginCtx.secrets.remove(secretId),
189 },
190 },
191 elicit: (request) =>
192 Effect.gen(function* () {
193 const handler = options?.onElicitation;
194 if (!handler) {
195 return yield* new ElicitationDeclinedError({
196 toolId,
197 action: "decline",
198 });
199 }
200 const response = yield* handler({
201 toolId,
202 args,
203 request,
204 });
205 if (response.action !== "accept") {
206 return yield* new ElicitationDeclinedError({
207 toolId,
208 action: response.action as "decline" | "cancel",
209 });
210 }
211 return response.content ?? {};
212 }),
213 };
214
215 const effectHandler = entry.handler as (
216 args: unknown,
217 ctx: MemoryToolContext,
218 ) => Effect.Effect<unknown, unknown>;
219
220 return parsed.pipe(
221 Effect.flatMap((input) => effectHandler(input, ctx)),
222 Effect.map(
223 (data) => new ToolInvocationResult({ data, error: null }),
224 ),
225 Effect.catchAll(
226 (err): Effect.Effect<
227 ToolInvocationResult,
228 ToolInvocationError | ElicitationDeclinedError
229 > => {
230 if (
231 err != null &&
232 typeof err === "object" &&
233 "_tag" in err &&
234 (err as { _tag: string })._tag === "ElicitationDeclinedError"
235 ) {
236 return Effect.fail(err as ElicitationDeclinedError);
237 }
238 return Effect.fail(
239 new ToolInvocationError({
240 toolId,
241 message: err instanceof Error ? err.message : String(err),
242 cause: err,
243 }),
244 );
245 },
246 ),
247 );
248 },
249});
220 250
221// ---------------------------------------------------------------------------251// ---------------------------------------------------------------------------
222// Tool definition helper — infers TInput from the schema252// Tool definition helper — infers TInput from the schema
@@ -242,8 +272,16 @@ export const inMemoryToolsPlugin = (config: { · 19 unmodified lines
242 key: "inMemoryTools",272 key: "inMemoryTools",
243 init: (ctx: PluginContext) =>273 init: (ctx: PluginContext) =>
244 Effect.gen(function* () {274 Effect.gen(function* () {
245 const results = config.tools.map((t) => toRegistrationWithDefs(ns, t, ctx));275 // Shared handler map for all tools in this plugin
246 276 const handlers = new Map<string, HandlerEntry>();
277 const invoker = makeInvoker(handlers, ctx);
278
279 // Register the invoker once
280 yield* ctx.tools.registerInvoker("inMemoryTools", invoker);
281
282 // Build registrations + handler entries
283 const results = config.tools.map((t) => buildRegistration(ns, t));
284
247 // Register all definitions first285 // Register all definitions first
248 const allDefs: Record<string, unknown> = {};286 const allDefs: Record<string, unknown> = {};
249 for (const { definitions } of results) {287 for (const { definitions } of results) {
@@ -251,7 +289,10 @@ export const inMemoryToolsPlugin = (config: { · 1 unmodified line
251 }289 }
252 yield* ctx.tools.registerDefinitions(allDefs);290 yield* ctx.tools.registerDefinitions(allDefs);
253 291
254 // Then register tools with stripped schemas292 // Store handler entries + register tool data
293 for (const { registration, entry } of results) {
294 handlers.set(registration.id, entry);
295 }
255 const registrations = results.map(({ registration }) => registration);296 const registrations = results.map(({ registration }) => registration);
256 yield* ctx.tools.register(registrations);297 yield* ctx.tools.register(registrations);
257 298
@@ -259,20 +300,28 @@ export const inMemoryToolsPlugin = (config: { · 1 unmodified line
259 extension: {300 extension: {
260 addTools: (newTools: readonly MemoryToolDefinition<any, any>[]) =>301 addTools: (newTools: readonly MemoryToolDefinition<any, any>[]) =>
261 Effect.gen(function* () {302 Effect.gen(function* () {
262 const newResults = newTools.map((t) => toRegistrationWithDefs(ns, t, ctx));303 const newResults = newTools.map((t) => buildRegistration(ns, t));
263 304
264 const newDefs: Record<string, unknown> = {};305 const newDefs: Record<string, unknown> = {};
265 for (const { definitions } of newResults) {306 for (const { definitions } of newResults) {
266 Object.assign(newDefs, definitions);307 Object.assign(newDefs, definitions);
267 }308 }
268 yield* ctx.tools.registerDefinitions(newDefs);309 yield* ctx.tools.registerDefinitions(newDefs);
269 310
311 for (const { registration, entry } of newResults) {
312 handlers.set(registration.id, entry);
313 }
270 const newRegistrations = newResults.map(({ registration }) => registration);314 const newRegistrations = newResults.map(({ registration }) => registration);
271 yield* ctx.tools.register(newRegistrations);315 yield* ctx.tools.register(newRegistrations);
272 }),316 }),
273 },317 },
274 close: () =>318 close: () =>
275 ctx.tools.unregister(registrations.map((r) => r.id)),319 Effect.gen(function* () {
320 yield* ctx.tools.unregister(registrations.map((r) => r.id));
321 for (const { registration } of results) {
322 handlers.delete(registration.id);
323 }
324 }),
276 };325 };
277 }),326 }),
278 });327 });
packages/core/sdk/src/tools.ts+26−12
@@ -82,6 +82,15 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 81 unmodified lines
82 defs: Record<string, unknown>,82 defs: Record<string, unknown>,
83 ) => Effect.Effect<void>;83 ) => Effect.Effect<void>;
84 84
85 /**
86 * Register a plugin invoker. Must be called before registering tools
87 * with the corresponding pluginKey.
88 */
89 readonly registerInvoker: (
90 pluginKey: string,
91 invoker: ToolInvoker,
92 ) => Effect.Effect<void>;
93
85 /** Register tools (used by plugins to push tools into the registry) */94 /** Register tools (used by plugins to push tools into the registry) */
86 readonly register: (95 readonly register: (
87 tools: readonly ToolRegistration[],96 tools: readonly ToolRegistration[],
@@ -95,26 +104,31 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 7 unmodified lines
95>() {}104>() {}
96 105
97// ---------------------------------------------------------------------------106// ---------------------------------------------------------------------------
98// ToolRegistration — what a plugin provides when registering a tool107// ToolInvoker — plugin-provided invocation handler
108// ---------------------------------------------------------------------------
109
110export interface ToolInvoker {
111 readonly invoke: (
112 toolId: ToolId,
113 args: unknown,
114 options?: InvokeOptions,
115 ) => Effect.Effect<
116 ToolInvocationResult,
117 ToolInvocationError | ElicitationDeclinedError
118 >;
119}
120
121// ---------------------------------------------------------------------------
122// ToolRegistration — pure data, no closures
99// ---------------------------------------------------------------------------123// ---------------------------------------------------------------------------
100 124
101export interface ToolRegistration {125export interface ToolRegistration {
102 readonly id: ToolId;126 readonly id: ToolId;
127 readonly pluginKey: string;
103 readonly name: string;128 readonly name: string;
104 readonly description?: string;129 readonly description?: string;
105 readonly tags?: readonly string[];130 readonly tags?: readonly string[];
106 readonly mayElicit?: boolean;131 readonly mayElicit?: boolean;
107 readonly inputSchema?: unknown;132 readonly inputSchema?: unknown;
108 readonly outputSchema?: unknown;133 readonly outputSchema?: unknown;
109 /**
110 * The tool's invoke function. Receives args and an optional elicitation
111 * handler that it can call to request user input mid-invocation.
112 */
113 readonly invoke: (
114 args: unknown,
115 options?: InvokeOptions,
116 ) => Effect.Effect<
117 ToolInvocationResult,
118 ToolInvocationError | ElicitationDeclinedError
119 >;
120}134}
packages/plugins/openapi/src/index.ts+12−2
@@ -1,7 +1,16 @@
1export { parse } from "./parse";1export { parse } from "./parse";
2export { extract } from "./extract";2export { extract } from "./extract";
3export { invoke } from "./invoke";3export { invoke, makeOpenApiInvoker } from "./invoke";
4export { openApiPlugin, type OpenApiSpecConfig, type OpenApiPluginExtension } from "./plugin";4export {
5 openApiPlugin,
6 type OpenApiSpecConfig,
7 type OpenApiPluginExtension,
8} from "./plugin";
9export {
10 makeInMemoryOperationStore,
11 type OpenApiOperationStore,
12} from "./operation-store";
13export { DocResolver, resolveBaseUrl, preferredContent } from "./openapi-utils";
5 14
6export {15export {
7 OpenApiParseError,16 OpenApiParseError,
@@ -18,6 +27,7 @@ export { · 10 unmodified lines
18 ExtractionResult,27 ExtractionResult,
19 InvocationConfig,28 InvocationConfig,
20 InvocationResult,29 InvocationResult,
30 OperationBinding,
21 OperationParameter,31 OperationParameter,
22 OperationRequestBody,32 OperationRequestBody,
23 ServerInfo,33 ServerInfo,
packages/plugins/openapi/src/invoke.ts+70−4
@@ -1,13 +1,21 @@
1import { Effect, Match, Option } from "effect";1import { Effect, Layer, Match, Option } from "effect";
2import {2import {
3 HttpClient,3 HttpClient,
4 HttpClientRequest,4 HttpClientRequest,
5} from "@effect/platform";5} from "@effect/platform";
6 6
7import {
8 type ToolId,
9 type ToolInvoker,
10 ToolInvocationResult,
11 ToolInvocationError,
12} from "@executor/sdk";
13
7import { OpenApiInvocationError } from "./errors";14import { OpenApiInvocationError } from "./errors";
15import type { OpenApiOperationStore } from "./operation-store";
8import {16import {
9 type AuthConfig,17 type AuthConfig,
10 type ExtractedOperation,18 type OperationBinding,
11 InvocationConfig,19 InvocationConfig,
12 InvocationResult,20 InvocationResult,
13 type OperationParameter,21 type OperationParameter,
@@ -157,9 +165,9 @@ const isJsonContentType = (ct: string | null | undefined): boolean => { · 143 unmodified lines
157// Public API165// Public API
158// ---------------------------------------------------------------------------166// ---------------------------------------------------------------------------
159 167
160/** Invoke an extracted OpenAPI operation. Requires HttpClient in the context. */168/** Invoke an OpenAPI operation binding. Requires HttpClient in the context. */
161export const invoke = Effect.fn("OpenApi.invoke")(function* (169export const invoke = Effect.fn("OpenApi.invoke")(function* (
162 operation: ExtractedOperation,170 operation: OperationBinding,
163 args: Record<string, unknown>,171 args: Record<string, unknown>,
164 config: InvocationConfig,172 config: InvocationConfig,
165) {173) {
@@ -251,3 +259,61 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 85 unmodified lines
251 error: ok ? null : responseBody,259 error: ok ? null : responseBody,
252 });260 });
253});261});
262
263// ---------------------------------------------------------------------------
264// ToolInvoker — bridges operation store + HTTP client into SDK invoker
265// ---------------------------------------------------------------------------
266
267export const makeOpenApiInvoker = (
268 operationStore: OpenApiOperationStore,
269 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
270): ToolInvoker => ({
271 invoke: (toolId: ToolId, args: unknown, _options) =>
272 Effect.gen(function* () {
273 const entry = yield* operationStore.get(toolId);
274 if (!entry) {
275 return yield* new ToolInvocationError({
276 toolId,
277 message: `No operation found for tool "${toolId}"`,
278 cause: undefined,
279 });
280 }
281
282 const { binding, config } = entry;
283 const baseUrl = config.baseUrl;
284
285 const clientWithBaseUrl = baseUrl
286 ? Layer.effect(
287 HttpClient.HttpClient,
288 Effect.map(
289 HttpClient.HttpClient,
290 HttpClient.mapRequest(
291 HttpClientRequest.prependUrl(baseUrl),
292 ),
293 ),
294 ).pipe(Layer.provide(httpClientLayer))
295 : httpClientLayer;
296
297 const result = yield* invoke(
298 binding,
299 (args ?? {}) as Record<string, unknown>,
300 config,
301 ).pipe(Effect.provide(clientWithBaseUrl));
302
303 return new ToolInvocationResult({
304 data: result.data,
305 error: result.error,
306 status: result.status,
307 });
308 }).pipe(
309 Effect.mapError((err) =>
310 err._tag === "ToolInvocationError"
311 ? err
312 : new ToolInvocationError({
313 toolId,
314 message: `OpenAPI invocation failed: ${err.message}`,
315 cause: err,
316 }),
317 ),
318 ),
319});
packages/plugins/openapi/src/openapi-utils.ts+20−0
@@ -4,6 +4,7 @@ · 3 unmodified lines
4// Wraps the openapi-types V3/V3_1 union mess and provides clean ref resolution.4// Wraps the openapi-types V3/V3_1 union mess and provides clean ref resolution.
5// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
6 6
7import { Option } from "effect";
7import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";8import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
8import type { ParsedDocument } from "./parse";9import type { ParsedDocument } from "./parse";
9 10
@@ -49,6 +50,25 @@ export class DocResolver { · 39 unmodified lines
49const isRef = (value: unknown): value is { $ref: string } =>50const isRef = (value: unknown): value is { $ref: string } =>
50 typeof value === "object" && value !== null && "$ref" in value;51 typeof value === "object" && value !== null && "$ref" in value;
51 52
53// ---------------------------------------------------------------------------
54// Server URL resolution
55// ---------------------------------------------------------------------------
56
57export const resolveBaseUrl = (
58 servers: readonly { url: string; variables: import("effect/Option").Option<Record<string, string>> }[],
59): string => {
60 const server = servers[0];
61 if (!server) return "";
62
63 let url = server.url;
64 if (Option.isSome(server.variables)) {
65 for (const [name, value] of Object.entries(server.variables.value)) {
66 url = url.replaceAll(`{${name}}`, value);
67 }
68 }
69 return url;
70};
71
52// ---------------------------------------------------------------------------72// ---------------------------------------------------------------------------
53// Content negotiation73// Content negotiation
54// ---------------------------------------------------------------------------74// ---------------------------------------------------------------------------
packages/plugins/openapi/src/operation-store.ts+77−0
@@ -0,0 +1,77 @@
1import { Effect } from "effect";
2import type { ToolId } from "@executor/sdk";
3
4import type { OperationBinding, InvocationConfig } from "./types";
5
6// ---------------------------------------------------------------------------
7// Operation store — plugin's own storage for invocation data
8// ---------------------------------------------------------------------------
9
10export interface OpenApiOperationStore {
11 readonly get: (
12 toolId: ToolId,
13 ) => Effect.Effect<{ binding: OperationBinding; config: InvocationConfig } | null>;
14
15 readonly put: (
16 toolId: ToolId,
17 namespace: string,
18 binding: OperationBinding,
19 config: InvocationConfig,
20 ) => Effect.Effect<void>;
21
22 readonly remove: (toolId: ToolId) => Effect.Effect<void>;
23
24 /** List all tool IDs for a given namespace */
25 readonly listByNamespace: (namespace: string) => Effect.Effect<readonly ToolId[]>;
26
27 /** Remove all entries for a namespace */
28 readonly removeByNamespace: (namespace: string) => Effect.Effect<readonly ToolId[]>;
29}
30
31// ---------------------------------------------------------------------------
32// In-memory implementation
33// ---------------------------------------------------------------------------
34
35export const makeInMemoryOperationStore = (): OpenApiOperationStore => {
36 const store = new Map<
37 string,
38 { binding: OperationBinding; config: InvocationConfig; namespace: string }
39 >();
40
41 return {
42 get: (toolId) =>
43 Effect.sync(() => {
44 const entry = store.get(toolId);
45 return entry ? { binding: entry.binding, config: entry.config } : null;
46 }),
47
48 put: (toolId, namespace, binding, config) =>
49 Effect.sync(() => {
50 store.set(toolId, { binding, config, namespace });
51 }),
52
53 remove: (toolId) =>
54 Effect.sync(() => {
55 store.delete(toolId);
56 }),
57
58 listByNamespace: (namespace) =>
59 Effect.sync(() =>
60 [...store.entries()]
61 .filter(([, v]) => v.namespace === namespace)
62 .map(([k]) => k as ToolId),
63 ),
64
65 removeByNamespace: (namespace) =>
66 Effect.sync(() => {
67 const ids: ToolId[] = [];
68 for (const [k, v] of store) {
69 if (v.namespace === namespace) {
70 ids.push(k as ToolId);
71 store.delete(k);
72 }
73 }
74 return ids;
75 }),
76 };
77};
packages/plugins/openapi/src/plugin.ts+95−139
@@ -1,57 +1,51 @@
1import { Effect, Layer, Option } from "effect";1import { Effect, Option } from "effect";
2import {2import { FetchHttpClient, HttpClient } from "@effect/platform";
3 FetchHttpClient,3import type { Layer } from "effect";
4 HttpClient,
5 HttpClientRequest,
6} from "@effect/platform";
7 4
8import {5import {
9 definePlugin,6 definePlugin,
10 type ExecutorPlugin,7 type ExecutorPlugin,
11 type PluginContext,8 type PluginContext,
12 ToolId,9 ToolId,
13 ToolInvocationResult,
14 ToolInvocationError,
15 type ToolRegistration,10 type ToolRegistration,
16} from "@executor/sdk";11} from "@executor/sdk";
17 12
18import { parse } from "./parse";13import { parse } from "./parse";
19import { extract } from "./extract";14import { extract } from "./extract";
20import { invoke } from "./invoke";15import { makeOpenApiInvoker } from "./invoke";
16import { resolveBaseUrl } from "./openapi-utils";
17import {
18 makeInMemoryOperationStore,
19 type OpenApiOperationStore,
20} from "./operation-store";
21import {21import {
22 type AuthConfig,22 type AuthConfig,
23 type ExtractedOperation,23 type ExtractedOperation,
24 InvocationConfig,24 InvocationConfig,
25 NoAuth,25 NoAuth,
26 type ServerInfo,26 OperationBinding,
27} from "./types";27} from "./types";
28 28
29// ---------------------------------------------------------------------------29// ---------------------------------------------------------------------------
30// Plugin config — what you pass when adding a spec30// Plugin config
31// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
32 32
33export interface OpenApiSpecConfig {33export interface OpenApiSpecConfig {
34 /** Raw spec text (JSON or YAML) or a URL to fetch */
35 readonly spec: string;34 readonly spec: string;
36 /** Override the base URL (defaults to first server in spec) */
37 readonly baseUrl?: string;35 readonly baseUrl?: string;
38 /** Namespace prefix for tool IDs (e.g. "stripe" → "stripe.listCustomers") */
39 readonly namespace?: string;36 readonly namespace?: string;
40 /** Auth configuration */
41 readonly auth?: AuthConfig;37 readonly auth?: AuthConfig;
42}38}
43 39
44// ---------------------------------------------------------------------------40// ---------------------------------------------------------------------------
45// Plugin extension — the public API on executor.openapi41// Plugin extension
46// ---------------------------------------------------------------------------42// ---------------------------------------------------------------------------
47 43
48export interface OpenApiPluginExtension {44export interface OpenApiPluginExtension {
49 /** Add an OpenAPI spec and register its operations as tools */
50 readonly addSpec: (45 readonly addSpec: (
51 config: OpenApiSpecConfig,46 config: OpenApiSpecConfig,
52 ) => Effect.Effect<{ readonly toolCount: number }, Error>;47 ) => Effect.Effect<{ readonly toolCount: number }, Error>;
53 48
54 /** Remove all tools from a previously added spec by namespace */
55 readonly removeSpec: (namespace: string) => Effect.Effect<void>;49 readonly removeSpec: (namespace: string) => Effect.Effect<void>;
56}50}
57 51
@@ -59,21 +53,6 @@ export interface OpenApiPluginExtension { · 1 unmodified line
59// Helpers53// Helpers
60// ---------------------------------------------------------------------------54// ---------------------------------------------------------------------------
61 55
62const resolveBaseUrl = (
63 servers: readonly ServerInfo[],
64): string => {
65 const server = servers[0];
66 if (!server) return "";
67
68 let url = server.url;
69 if (Option.isSome(server.variables)) {
70 for (const [name, value] of Object.entries(server.variables.value)) {
71 url = url.replaceAll(`{${name}}`, value);
72 }
73 }
74 return url;
75};
76
77const operationDescription = (op: ExtractedOperation): string =>56const operationDescription = (op: ExtractedOperation): string =>
78 Option.getOrElse(op.description, () =>57 Option.getOrElse(op.description, () =>
79 Option.getOrElse(op.summary, () =>58 Option.getOrElse(op.summary, () =>
@@ -81,130 +60,107 @@ const operationDescription = (op: ExtractedOperation): string => · 1 unmodified line
81 ),60 ),
82 );61 );
83 62
84const operationToRegistration = (63const toRegistration = (
85 op: ExtractedOperation,64 op: ExtractedOperation,
86 namespace: string,65 namespace: string,
87 invocationConfig: InvocationConfig,66): ToolRegistration => ({
88 clientLayer: Layer.Layer<HttpClient.HttpClient>,67 id: ToolId.make(`${namespace}.${op.operationId}`),
89): ToolRegistration => {68 pluginKey: "openapi",
90 const id = ToolId.make(`${namespace}.${op.operationId}`);69 name: op.operationId as string,
91 70 description: operationDescription(op),
92 return {71 tags: [...op.tags, "openapi", namespace],
93 id,72 inputSchema: Option.getOrUndefined(op.inputSchema),
94 name: op.operationId as string,73 outputSchema: Option.getOrUndefined(op.outputSchema),
95 description: operationDescription(op),74});
96 tags: [...op.tags, "openapi", namespace],75
97 inputSchema: Option.getOrUndefined(op.inputSchema),76const toBinding = (op: ExtractedOperation): OperationBinding =>
98 outputSchema: Option.getOrUndefined(op.outputSchema),77 new OperationBinding({
99 invoke: (args) =>78 method: op.method,
100 invoke(op, (args ?? {}) as Record<string, unknown>, invocationConfig).pipe(79 pathTemplate: op.pathTemplate,
101 Effect.map(80 parameters: [...op.parameters],
102 (result) =>81 requestBody: op.requestBody,
103 new ToolInvocationResult({82 });
104 data: result.data,
105 error: result.error,
106 status: result.status,
107 }),
108 ),
109 Effect.mapError(
110 (err) =>
111 new ToolInvocationError({
112 toolId: id,
113 message: `OpenAPI invocation failed: ${err.message}`,
114 cause: err,
115 }),
116 ),
117 Effect.provide(clientLayer),
118 ),
119 };
120};
121 83
122// ---------------------------------------------------------------------------84// ---------------------------------------------------------------------------
123// Plugin factory85// Plugin factory
124// ---------------------------------------------------------------------------86// ---------------------------------------------------------------------------
125 87
126export const openApiPlugin = (options?: {88export const openApiPlugin = (options?: {
127 /** Provide a custom HttpClient layer. Defaults to FetchHttpClient. */
128 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;89 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
90 readonly operationStore?: OpenApiOperationStore;
129}): ExecutorPlugin<"openapi", OpenApiPluginExtension> => {91}): ExecutorPlugin<"openapi", OpenApiPluginExtension> => {
130 const httpClientLayer =92 const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
131 options?.httpClientLayer ?? FetchHttpClient.layer;93 const operationStore = options?.operationStore ?? makeInMemoryOperationStore();
132 const registeredTools = new Map<string, readonly ToolId[]>();
133 94
134 return definePlugin({95 return definePlugin({
135 key: "openapi",96 key: "openapi",
136 init: (ctx: PluginContext) =>97 init: (ctx: PluginContext) =>
137 Effect.succeed({98 Effect.gen(function* () {
138 extension: {99 yield* ctx.tools.registerInvoker(
139 addSpec: (config: OpenApiSpecConfig) =>100 "openapi",
140 Effect.gen(function* () {101 makeOpenApiInvoker(operationStore, httpClientLayer),
141 const doc = yield* parse(config.spec);102 );
142 const result = yield* extract(doc);103
143 104 return {
144 const namespace =105 extension: {
145 config.namespace ??106 addSpec: (config: OpenApiSpecConfig) =>
146 Option.getOrElse(result.title, () => "api")107 Effect.gen(function* () {
147 .toLowerCase()108 const doc = yield* parse(config.spec);
148 .replace(/[^a-z0-9]+/g, "_");109 const result = yield* extract(doc);
149 110
150 // Register shared component schemas first111 const namespace =
151 const components = doc.components;112 config.namespace ??
152 if (components?.schemas) {113 Option.getOrElse(result.title, () => "api")
153 yield* ctx.tools.registerDefinitions(components.schemas);114 .toLowerCase()
154 }115 .replace(/[^a-z0-9]+/g, "_");
155 116
156 const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);117 if (doc.components?.schemas) {
157 118 yield* ctx.tools.registerDefinitions(doc.components.schemas);
158 const auth = config.auth ?? new NoAuth();119 }
159 const invocationConfig = new InvocationConfig({ baseUrl, auth });120
160 121 const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);
161 // Build a client layer with the base URL prepended to every request122 const invocationConfig = new InvocationConfig({
162 const clientWithBaseUrl = baseUrl123 baseUrl,
163 ? Layer.effect(124 auth: config.auth ?? new NoAuth(),
164 HttpClient.HttpClient,125 });
165 Effect.map(126
166 HttpClient.HttpClient,127 const registrations = result.operations.map((op) =>
167 HttpClient.mapRequest(128 toRegistration(op, namespace),
168 HttpClientRequest.prependUrl(baseUrl),129 );
169 ),130
131 yield* Effect.forEach(
132 result.operations,
133 (op) =>
134 operationStore.put(
135 ToolId.make(`${namespace}.${op.operationId}`),
136 namespace,
137 toBinding(op),
138 invocationConfig,
170 ),139 ),
171 ).pipe(Layer.provide(httpClientLayer))140 { discard: true },
172 : httpClientLayer;141 );
173 142
174 const registrations = result.operations.map((op) =>143 yield* ctx.tools.register(registrations);
175 operationToRegistration(144 return { toolCount: registrations.length };
176 op,145 }),
177 namespace,146
178 invocationConfig,147 removeSpec: (namespace: string) =>
179 clientWithBaseUrl,148 Effect.gen(function* () {
180 ),149 const toolIds = yield* operationStore.removeByNamespace(namespace);
181 );150 if (toolIds.length > 0) {
182 151 yield* ctx.tools.unregister(toolIds);
183 yield* ctx.tools.register(registrations);152 }
184 153 }),
185 const toolIds = registrations.map((r) => r.id);154 },
186 registeredTools.set(namespace, toolIds);155
187 156 close: () =>
188 return { toolCount: registrations.length };
189 }),
190
191 removeSpec: (namespace: string) =>
192 Effect.gen(function* () {157 Effect.gen(function* () {
193 const toolIds = registeredTools.get(namespace);158 const allTools = yield* ctx.tools.list({ tags: ["openapi"] });
194 if (toolIds) {159 if (allTools.length > 0) {
195 yield* ctx.tools.unregister(toolIds);160 yield* ctx.tools.unregister(allTools.map((t) => t.id));
196 registeredTools.delete(namespace);
197 }161 }
198 }),162 }),
199 },163 };
200
201 close: () =>
202 Effect.gen(function* () {
203 for (const toolIds of registeredTools.values()) {
204 yield* ctx.tools.unregister(toolIds);
205 }
206 registeredTools.clear();
207 }),
208 }),164 }),
209 });165 });
210};166};
packages/plugins/openapi/src/types.ts+13−0
@@ -104,6 +104,19 @@ export class ExtractionResult extends Schema.Class<ExtractionResult>( · 103 unmodified lines
104 operations: Schema.Array(ExtractedOperation),104 operations: Schema.Array(ExtractedOperation),
105}) {}105}) {}
106 106
107// ---------------------------------------------------------------------------
108// Operation binding — minimal invocation data (no schemas/metadata)
109// ---------------------------------------------------------------------------
110
111export class OperationBinding extends Schema.Class<OperationBinding>(
112 "OperationBinding",
113)({
114 method: HttpMethod,
115 pathTemplate: Schema.String,
116 parameters: Schema.Array(OperationParameter),
117 requestBody: Schema.optionalWith(OperationRequestBody, { as: "Option" }),
118}) {}
119
107// ---------------------------------------------------------------------------120// ---------------------------------------------------------------------------
108// Invocation121// Invocation
109// ---------------------------------------------------------------------------122// ---------------------------------------------------------------------------