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 @@
11 import { Effect } from "effect";
22
33 import { ToolId } from "../ids";
4−import { ToolNotFoundError } from "../errors";
5−import type { ToolRegistration, InvokeOptions } from "../tools";
4+import { ToolNotFoundError, ToolInvocationError } from "../errors";
5+import type { ToolRegistration, ToolInvoker, InvokeOptions } from "../tools";
66 import { reattachDefs } from "../schema-refs";
77
88 export const makeInMemoryToolRegistry = () => {
99 const tools = new Map<string, ToolRegistration>();
10+ const invokers = new Map<string, ToolInvoker>();
1011 const sharedDefs = new Map<string, unknown>();
1112
1213 return {
@@ -64,12 +65,25 @@ export const makeInMemoryToolRegistry = () => { · 51 unmodified lines
6465 }
6566 }),
6667
68+ registerInvoker: (pluginKey: string, invoker: ToolInvoker) =>
69+ Effect.sync(() => {
70+ invokers.set(pluginKey, invoker);
71+ }),
72+
6773 invoke: (toolId: ToolId, args: unknown, options?: InvokeOptions) =>
6874 Effect.gen(function* () {
6975 const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe(
7076 Effect.mapError(() => new ToolNotFoundError({ toolId })),
7177 );
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);
7387 }),
7488
7589 register: (newTools: readonly ToolRegistration[]) =>
packages/core/sdk/src/index.ts+1−0
@@ -17,6 +17,7 @@ export { · 16 unmodified lines
1717 ToolInvocationResult,
1818 ToolRegistry,
1919 type ToolRegistration,
20+ type ToolInvoker,
2021 type InvokeOptions,
2122 } from "./tools";
2223
packages/core/sdk/src/plugins/in-memory-tools.ts+163−114
@@ -7,6 +7,7 @@ import type { Secret } from "../secrets"; · 6 unmodified lines
77 import {
88 ToolInvocationResult,
99 type ToolRegistration,
10+ type ToolInvoker,
1011 type InvokeOptions,
1112 } from "../tools";
1213 import {
@@ -75,26 +76,33 @@ export interface InMemoryToolsPluginExtension { · 62 unmodified lines
7576 }
7677
7778 // ---------------------------------------------------------------------------
78−// Registration builder
79+// Internal handler entry
7980 // ---------------------------------------------------------------------------
8081
81−const toRegistrationWithDefs = (
82+interface 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+
92+const buildRegistration = (
8293 namespace: string,
8394 def: MemoryToolDefinition,
84− pluginCtx: PluginContext,
85−): { registration: ToolRegistration; definitions: Record<string, unknown> } => {
95+): { registration: ToolRegistration; entry: HandlerEntry; definitions: Record<string, unknown> } => {
8696 const id = ToolId.make(`${namespace}.${def.name}`);
8797 const decode = Schema.decodeUnknownSync(def.inputSchema);
88− const isEffectHandler = def.handler.length >= 2;
98+ const isEffect = def.handler.length >= 2;
8999
90− // Convert to JSON Schema and hoist definitions
91100 const inputJson = JSONSchema.make(def.inputSchema);
92101 const outputJson = def.outputSchema ? JSONSchema.make(def.outputSchema) : undefined;
93102
94103 const inputHoist = hoistDefinitions(inputJson);
95104 const outputHoist = hoistDefinitions(outputJson);
96105
97− // Merge all definitions
98106 const allDefs: Record<string, unknown> = {
99107 ...inputHoist.defs,
100108 ...outputHoist.defs,
@@ -102,121 +110,143 @@ const toRegistrationWithDefs = ( · 1 unmodified line
102110
103111 const registration: ToolRegistration = {
104112 id,
113+ pluginKey: "inMemoryTools",
105114 name: def.name,
106115 description: def.description,
107116 tags: def.tags ? [...def.tags] : undefined,
108117 inputSchema: inputHoist.stripped,
109118 outputSchema: outputHoist.stripped,
110− mayElicit: isEffectHandler,
111− invoke: (args, options?: InvokeOptions) => {
112− const parsed = Effect.try({
113− try: () => decode(args),
114− catch: (err) =>
115− new ToolInvocationError({
116− toolId: id,
117− message: `Invalid input: ${err instanceof Error ? err.message : String(err)}`,
118− cause: err,
119− }),
120− });
121−
122− if (!isEffectHandler) {
123− return parsed.pipe(
124− Effect.flatMap((input) =>
125− Effect.try({
126− try: () =>
127− new ToolInvocationResult({
128− data: (def.handler as (args: unknown) => unknown)(input),
129− error: null,
130− }),
131− catch: (err) =>
132− new ToolInvocationError({
133− toolId: id,
134− message:
135− err instanceof Error ? err.message : String(err),
136− cause: err,
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− };
119+ mayElicit: isEffect,
120+ };
121+
122+ const entry: HandlerEntry = { decode, handler: def.handler as MemoryToolHandler<unknown>, isEffect };
123+
124+ return { registration, entry, definitions: allDefs };
125+};
126+
127+// ---------------------------------------------------------------------------
128+// Invoker — single function that handles all in-memory tools
129+// ---------------------------------------------------------------------------
130+
131+const makeInvoker = (
132+ handlers: Map<string, HandlerEntry>,
133+ pluginCtx: PluginContext,
134+): ToolInvoker => ({
135+ invoke: (toolId: ToolId, args: unknown, options?: InvokeOptions) => {
136+ const entry = handlers.get(toolId);
137+ if (!entry) {
138+ return Effect.fail(
139+ new ToolInvocationError({
140+ toolId,
141+ message: `No handler registered for tool "${toolId}"`,
142+ cause: undefined,
143+ }),
144+ );
145+ }
180146
181− const effectHandler = def.handler as (
182− args: unknown,
183− ctx: MemoryToolContext,
184− ) => Effect.Effect<unknown, unknown>;
147+ const parsed = Effect.try({
148+ try: () => entry.decode(args),
149+ catch: (err) =>
150+ new ToolInvocationError({
151+ toolId,
152+ message: `Invalid input: ${err instanceof Error ? err.message : String(err)}`,
153+ cause: err,
154+ }),
155+ });
185156
157+ if (!entry.isEffect) {
186158 return parsed.pipe(
187− Effect.flatMap((input) => effectHandler(input, ctx)),
188− Effect.map(
189− (data) => new ToolInvocationResult({ data, error: null }),
190− ),
191− Effect.catchAll(
192− (err): Effect.Effect<
193− ToolInvocationResult,
194− ToolInvocationError | ElicitationDeclinedError
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(
159+ Effect.flatMap((input) =>
160+ Effect.try({
161+ try: () =>
162+ new ToolInvocationResult({
163+ data: (entry.handler as (args: unknown) => unknown)(input),
164+ error: null,
165+ }),
166+ catch: (err) =>
205167 new ToolInvocationError({
206− toolId: id,
207− message:
208− err instanceof Error ? err.message : String(err),
168+ toolId,
169+ message: err instanceof Error ? err.message : String(err),
209170 cause: err,
210171 }),
211− );
212− },
172+ }),
213173 ),
214174 );
215− },
216− };
175+ }
217176
218− return { registration, definitions: allDefs };
219−};
177+ // Effect handler — build context with elicit + sdk access
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+});
220250
221251 // ---------------------------------------------------------------------------
222252 // Tool definition helper — infers TInput from the schema
@@ -242,8 +272,16 @@ export const inMemoryToolsPlugin = (config: { · 19 unmodified lines
242272 key: "inMemoryTools",
243273 init: (ctx: PluginContext) =>
244274 Effect.gen(function* () {
245− const results = config.tools.map((t) => toRegistrationWithDefs(ns, t, ctx));
246−
275+ // Shared handler map for all tools in this plugin
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+
247285 // Register all definitions first
248286 const allDefs: Record<string, unknown> = {};
249287 for (const { definitions } of results) {
@@ -251,7 +289,10 @@ export const inMemoryToolsPlugin = (config: { · 1 unmodified line
251289 }
252290 yield* ctx.tools.registerDefinitions(allDefs);
253291
254− // Then register tools with stripped schemas
292+ // Store handler entries + register tool data
293+ for (const { registration, entry } of results) {
294+ handlers.set(registration.id, entry);
295+ }
255296 const registrations = results.map(({ registration }) => registration);
256297 yield* ctx.tools.register(registrations);
257298
@@ -259,20 +300,28 @@ export const inMemoryToolsPlugin = (config: { · 1 unmodified line
259300 extension: {
260301 addTools: (newTools: readonly MemoryToolDefinition<any, any>[]) =>
261302 Effect.gen(function* () {
262− const newResults = newTools.map((t) => toRegistrationWithDefs(ns, t, ctx));
263−
303+ const newResults = newTools.map((t) => buildRegistration(ns, t));
304+
264305 const newDefs: Record<string, unknown> = {};
265306 for (const { definitions } of newResults) {
266307 Object.assign(newDefs, definitions);
267308 }
268309 yield* ctx.tools.registerDefinitions(newDefs);
269310
311+ for (const { registration, entry } of newResults) {
312+ handlers.set(registration.id, entry);
313+ }
270314 const newRegistrations = newResults.map(({ registration }) => registration);
271315 yield* ctx.tools.register(newRegistrations);
272316 }),
273317 },
274318 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+ }),
276325 };
277326 }),
278327 });
packages/core/sdk/src/tools.ts+26−12
@@ -82,6 +82,15 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 81 unmodified lines
8282 defs: Record<string, unknown>,
8383 ) => Effect.Effect<void>;
8484
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+
8594 /** Register tools (used by plugins to push tools into the registry) */
8695 readonly register: (
8796 tools: readonly ToolRegistration[],
@@ -95,26 +104,31 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 7 unmodified lines
95104 >() {}
96105
97106 // ---------------------------------------------------------------------------
98−// ToolRegistration — what a plugin provides when registering a tool
107+// ToolInvoker — plugin-provided invocation handler
108+// ---------------------------------------------------------------------------
109+
110+export 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
99123 // ---------------------------------------------------------------------------
100124
101125 export interface ToolRegistration {
102126 readonly id: ToolId;
127+ readonly pluginKey: string;
103128 readonly name: string;
104129 readonly description?: string;
105130 readonly tags?: readonly string[];
106131 readonly mayElicit?: boolean;
107132 readonly inputSchema?: unknown;
108133 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− >;
120134 }
packages/plugins/openapi/src/index.ts+12−2
@@ -1,7 +1,16 @@
11 export { parse } from "./parse";
22 export { extract } from "./extract";
3−export { invoke } from "./invoke";
4−export { openApiPlugin, type OpenApiSpecConfig, type OpenApiPluginExtension } from "./plugin";
3+export { invoke, makeOpenApiInvoker } from "./invoke";
4+export {
5+ openApiPlugin,
6+ type OpenApiSpecConfig,
7+ type OpenApiPluginExtension,
8+} from "./plugin";
9+export {
10+ makeInMemoryOperationStore,
11+ type OpenApiOperationStore,
12+} from "./operation-store";
13+export { DocResolver, resolveBaseUrl, preferredContent } from "./openapi-utils";
514
615 export {
716 OpenApiParseError,
@@ -18,6 +27,7 @@ export { · 10 unmodified lines
1827 ExtractionResult,
1928 InvocationConfig,
2029 InvocationResult,
30+ OperationBinding,
2131 OperationParameter,
2232 OperationRequestBody,
2333 ServerInfo,
packages/plugins/openapi/src/invoke.ts+70−4
@@ -1,13 +1,21 @@
1−import { Effect, Match, Option } from "effect";
1+import { Effect, Layer, Match, Option } from "effect";
22 import {
33 HttpClient,
44 HttpClientRequest,
55 } from "@effect/platform";
66
7+import {
8+ type ToolId,
9+ type ToolInvoker,
10+ ToolInvocationResult,
11+ ToolInvocationError,
12+} from "@executor/sdk";
13+
714 import { OpenApiInvocationError } from "./errors";
15+import type { OpenApiOperationStore } from "./operation-store";
816 import {
917 type AuthConfig,
10− type ExtractedOperation,
18+ type OperationBinding,
1119 InvocationConfig,
1220 InvocationResult,
1321 type OperationParameter,
@@ -157,9 +165,9 @@ const isJsonContentType = (ct: string | null | undefined): boolean => { · 143 unmodified lines
157165 // Public API
158166 // ---------------------------------------------------------------------------
159167
160−/** Invoke an extracted OpenAPI operation. Requires HttpClient in the context. */
168+/** Invoke an OpenAPI operation binding. Requires HttpClient in the context. */
161169 export const invoke = Effect.fn("OpenApi.invoke")(function* (
162− operation: ExtractedOperation,
170+ operation: OperationBinding,
163171 args: Record<string, unknown>,
164172 config: InvocationConfig,
165173 ) {
@@ -251,3 +259,61 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 85 unmodified lines
251259 error: ok ? null : responseBody,
252260 });
253261 });
262+
263+// ---------------------------------------------------------------------------
264+// ToolInvoker — bridges operation store + HTTP client into SDK invoker
265+// ---------------------------------------------------------------------------
266+
267+export 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
44 // Wraps the openapi-types V3/V3_1 union mess and provides clean ref resolution.
55 // ---------------------------------------------------------------------------
66
7+import { Option } from "effect";
78 import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
89 import type { ParsedDocument } from "./parse";
910
@@ -49,6 +50,25 @@ export class DocResolver { · 39 unmodified lines
4950 const isRef = (value: unknown): value is { $ref: string } =>
5051 typeof value === "object" && value !== null && "$ref" in value;
5152
53+// ---------------------------------------------------------------------------
54+// Server URL resolution
55+// ---------------------------------------------------------------------------
56+
57+export 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+
5272 // ---------------------------------------------------------------------------
5373 // Content negotiation
5474 // ---------------------------------------------------------------------------
packages/plugins/openapi/src/operation-store.ts+77−0
@@ -0,0 +1,77 @@
1+import { Effect } from "effect";
2+import type { ToolId } from "@executor/sdk";
3+
4+import type { OperationBinding, InvocationConfig } from "./types";
5+
6+// ---------------------------------------------------------------------------
7+// Operation store — plugin's own storage for invocation data
8+// ---------------------------------------------------------------------------
9+
10+export 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+
35+export 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 @@
1−import { Effect, Layer, Option } from "effect";
2−import {
3− FetchHttpClient,
4− HttpClient,
5− HttpClientRequest,
6−} from "@effect/platform";
1+import { Effect, Option } from "effect";
2+import { FetchHttpClient, HttpClient } from "@effect/platform";
3+import type { Layer } from "effect";
74
85 import {
96 definePlugin,
107 type ExecutorPlugin,
118 type PluginContext,
129 ToolId,
13− ToolInvocationResult,
14− ToolInvocationError,
1510 type ToolRegistration,
1611 } from "@executor/sdk";
1712
1813 import { parse } from "./parse";
1914 import { extract } from "./extract";
20−import { invoke } from "./invoke";
15+import { makeOpenApiInvoker } from "./invoke";
16+import { resolveBaseUrl } from "./openapi-utils";
17+import {
18+ makeInMemoryOperationStore,
19+ type OpenApiOperationStore,
20+} from "./operation-store";
2121 import {
2222 type AuthConfig,
2323 type ExtractedOperation,
2424 InvocationConfig,
2525 NoAuth,
26− type ServerInfo,
26+ OperationBinding,
2727 } from "./types";
2828
2929 // ---------------------------------------------------------------------------
30−// Plugin config — what you pass when adding a spec
30+// Plugin config
3131 // ---------------------------------------------------------------------------
3232
3333 export interface OpenApiSpecConfig {
34− /** Raw spec text (JSON or YAML) or a URL to fetch */
3534 readonly spec: string;
36− /** Override the base URL (defaults to first server in spec) */
3735 readonly baseUrl?: string;
38− /** Namespace prefix for tool IDs (e.g. "stripe" → "stripe.listCustomers") */
3936 readonly namespace?: string;
40− /** Auth configuration */
4137 readonly auth?: AuthConfig;
4238 }
4339
4440 // ---------------------------------------------------------------------------
45−// Plugin extension — the public API on executor.openapi
41+// Plugin extension
4642 // ---------------------------------------------------------------------------
4743
4844 export interface OpenApiPluginExtension {
49− /** Add an OpenAPI spec and register its operations as tools */
5045 readonly addSpec: (
5146 config: OpenApiSpecConfig,
5247 ) => Effect.Effect<{ readonly toolCount: number }, Error>;
5348
54− /** Remove all tools from a previously added spec by namespace */
5549 readonly removeSpec: (namespace: string) => Effect.Effect<void>;
5650 }
5751
@@ -59,21 +53,6 @@ export interface OpenApiPluginExtension { · 1 unmodified line
5953 // Helpers
6054 // ---------------------------------------------------------------------------
6155
62−const 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−
7756 const operationDescription = (op: ExtractedOperation): string =>
7857 Option.getOrElse(op.description, () =>
7958 Option.getOrElse(op.summary, () =>
@@ -81,130 +60,107 @@ const operationDescription = (op: ExtractedOperation): string => · 1 unmodified line
8160 ),
8261 );
8362
84−const operationToRegistration = (
63+const toRegistration = (
8564 op: ExtractedOperation,
8665 namespace: string,
87− invocationConfig: InvocationConfig,
88− clientLayer: Layer.Layer<HttpClient.HttpClient>,
89−): ToolRegistration => {
90− const id = ToolId.make(`${namespace}.${op.operationId}`);
91−
92− return {
93− id,
94− name: op.operationId as string,
95− description: operationDescription(op),
96− tags: [...op.tags, "openapi", namespace],
97− inputSchema: Option.getOrUndefined(op.inputSchema),
98− outputSchema: Option.getOrUndefined(op.outputSchema),
99− invoke: (args) =>
100− invoke(op, (args ?? {}) as Record<string, unknown>, invocationConfig).pipe(
101− Effect.map(
102− (result) =>
103− new ToolInvocationResult({
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−};
66+): ToolRegistration => ({
67+ id: ToolId.make(`${namespace}.${op.operationId}`),
68+ pluginKey: "openapi",
69+ name: op.operationId as string,
70+ description: operationDescription(op),
71+ tags: [...op.tags, "openapi", namespace],
72+ inputSchema: Option.getOrUndefined(op.inputSchema),
73+ outputSchema: Option.getOrUndefined(op.outputSchema),
74+});
75+
76+const toBinding = (op: ExtractedOperation): OperationBinding =>
77+ new OperationBinding({
78+ method: op.method,
79+ pathTemplate: op.pathTemplate,
80+ parameters: [...op.parameters],
81+ requestBody: op.requestBody,
82+ });
12183
12284 // ---------------------------------------------------------------------------
12385 // Plugin factory
12486 // ---------------------------------------------------------------------------
12587
12688 export const openApiPlugin = (options?: {
127− /** Provide a custom HttpClient layer. Defaults to FetchHttpClient. */
12889 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
90+ readonly operationStore?: OpenApiOperationStore;
12991 }): ExecutorPlugin<"openapi", OpenApiPluginExtension> => {
130− const httpClientLayer =
131− options?.httpClientLayer ?? FetchHttpClient.layer;
132− const registeredTools = new Map<string, readonly ToolId[]>();
92+ const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
93+ const operationStore = options?.operationStore ?? makeInMemoryOperationStore();
13394
13495 return definePlugin({
13596 key: "openapi",
13697 init: (ctx: PluginContext) =>
137− Effect.succeed({
138− extension: {
139− addSpec: (config: OpenApiSpecConfig) =>
140− Effect.gen(function* () {
141− const doc = yield* parse(config.spec);
142− const result = yield* extract(doc);
143−
144− const namespace =
145− config.namespace ??
146− Option.getOrElse(result.title, () => "api")
147− .toLowerCase()
148− .replace(/[^a-z0-9]+/g, "_");
149−
150− // Register shared component schemas first
151− const components = doc.components;
152− if (components?.schemas) {
153− yield* ctx.tools.registerDefinitions(components.schemas);
154− }
155−
156− const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);
157−
158− const auth = config.auth ?? new NoAuth();
159− const invocationConfig = new InvocationConfig({ baseUrl, auth });
160−
161− // Build a client layer with the base URL prepended to every request
162− const clientWithBaseUrl = baseUrl
163− ? Layer.effect(
164− HttpClient.HttpClient,
165− Effect.map(
166− HttpClient.HttpClient,
167− HttpClient.mapRequest(
168− HttpClientRequest.prependUrl(baseUrl),
169− ),
98+ Effect.gen(function* () {
99+ yield* ctx.tools.registerInvoker(
100+ "openapi",
101+ makeOpenApiInvoker(operationStore, httpClientLayer),
102+ );
103+
104+ return {
105+ extension: {
106+ addSpec: (config: OpenApiSpecConfig) =>
107+ Effect.gen(function* () {
108+ const doc = yield* parse(config.spec);
109+ const result = yield* extract(doc);
110+
111+ const namespace =
112+ config.namespace ??
113+ Option.getOrElse(result.title, () => "api")
114+ .toLowerCase()
115+ .replace(/[^a-z0-9]+/g, "_");
116+
117+ if (doc.components?.schemas) {
118+ yield* ctx.tools.registerDefinitions(doc.components.schemas);
119+ }
120+
121+ const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);
122+ const invocationConfig = new InvocationConfig({
123+ baseUrl,
124+ auth: config.auth ?? new NoAuth(),
125+ });
126+
127+ const registrations = result.operations.map((op) =>
128+ toRegistration(op, namespace),
129+ );
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,
170139 ),
171− ).pipe(Layer.provide(httpClientLayer))
172− : httpClientLayer;
173−
174− const registrations = result.operations.map((op) =>
175− operationToRegistration(
176− op,
177− namespace,
178− invocationConfig,
179− clientWithBaseUrl,
180− ),
181− );
182−
183− yield* ctx.tools.register(registrations);
184−
185− const toolIds = registrations.map((r) => r.id);
186− registeredTools.set(namespace, toolIds);
187−
188− return { toolCount: registrations.length };
189− }),
190−
191− removeSpec: (namespace: string) =>
140+ { discard: true },
141+ );
142+
143+ yield* ctx.tools.register(registrations);
144+ return { toolCount: registrations.length };
145+ }),
146+
147+ removeSpec: (namespace: string) =>
148+ Effect.gen(function* () {
149+ const toolIds = yield* operationStore.removeByNamespace(namespace);
150+ if (toolIds.length > 0) {
151+ yield* ctx.tools.unregister(toolIds);
152+ }
153+ }),
154+ },
155+
156+ close: () =>
192157 Effect.gen(function* () {
193− const toolIds = registeredTools.get(namespace);
194− if (toolIds) {
195− yield* ctx.tools.unregister(toolIds);
196− registeredTools.delete(namespace);
158+ const allTools = yield* ctx.tools.list({ tags: ["openapi"] });
159+ if (allTools.length > 0) {
160+ yield* ctx.tools.unregister(allTools.map((t) => t.id));
197161 }
198162 }),
199− },
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− }),
163+ };
208164 }),
209165 });
210166 };
packages/plugins/openapi/src/types.ts+13−0
@@ -104,6 +104,19 @@ export class ExtractionResult extends Schema.Class<ExtractionResult>( · 103 unmodified lines
104104 operations: Schema.Array(ExtractedOperation),
105105 }) {}
106106
107+// ---------------------------------------------------------------------------
108+// Operation binding — minimal invocation data (no schemas/metadata)
109+// ---------------------------------------------------------------------------
110+
111+export 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+
107120 // ---------------------------------------------------------------------------
108121 // Invocation
109122 // ---------------------------------------------------------------------------