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
Unified · Split
| @@ -1,12 +1,13 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect } from "effect"; |
| 2 | 2 | ||
| 3 | import { ToolId } from "../ids"; | 3 | import { ToolId } from "../ids"; |
| 4 | import { ToolNotFoundError } from "../errors"; | 4 | import { ToolNotFoundError, ToolInvocationError } from "../errors"; |
| 5 | import type { ToolRegistration, InvokeOptions } from "../tools"; | 5 | import type { ToolRegistration, ToolInvoker, InvokeOptions } from "../tools"; |
| 6 | import { reattachDefs } from "../schema-refs"; | 6 | import { reattachDefs } from "../schema-refs"; |
| 7 | 7 | ||
| 8 | export const makeInMemoryToolRegistry = () => { | 8 | export 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[]) => |
| @@ -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 | ||
| @@ -7,6 +7,7 @@ import type { Secret } from "../secrets"; · 6 unmodified lines | |||
| 7 | import { | 7 | import { |
| 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"; |
| 12 | import { | 13 | import { |
| @@ -75,26 +76,33 @@ export interface InMemoryToolsPluginExtension { · 62 unmodified lines | |||
| 75 | } | 76 | } |
| 76 | 77 | ||
| 77 | // --------------------------------------------------------------------------- | 78 | // --------------------------------------------------------------------------- |
| 78 | // Registration builder | 79 | // Internal handler entry |
| 79 | // --------------------------------------------------------------------------- | 80 | // --------------------------------------------------------------------------- |
| 80 | 81 | ||
| 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 = ( | ||
| 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) { | 131 | const 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 | ElicitationDeclinedError | 166 | 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 schema | 252 | // 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 first | 285 | // 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 schemas | 292 | // 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 | }); |
| @@ -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 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 | ||
| 99 | // --------------------------------------------------------------------------- | 123 | // --------------------------------------------------------------------------- |
| 100 | 124 | ||
| 101 | export interface ToolRegistration { | 125 | export 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 | } |
| @@ -1,7 +1,16 @@ | |||
| 1 | export { parse } from "./parse"; | 1 | export { parse } from "./parse"; |
| 2 | export { extract } from "./extract"; | 2 | export { extract } from "./extract"; |
| 3 | export { invoke } from "./invoke"; | 3 | export { invoke, makeOpenApiInvoker } from "./invoke"; |
| 4 | export { openApiPlugin, type OpenApiSpecConfig, type OpenApiPluginExtension } from "./plugin"; | 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"; | ||
| 5 | 14 | ||
| 6 | export { | 15 | export { |
| 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, |
| @@ -1,13 +1,21 @@ | |||
| 1 | import { Effect, Match, Option } from "effect"; | 1 | import { Effect, Layer, Match, Option } from "effect"; |
| 2 | import { | 2 | import { |
| 3 | HttpClient, | 3 | HttpClient, |
| 4 | HttpClientRequest, | 4 | HttpClientRequest, |
| 5 | } from "@effect/platform"; | 5 | } from "@effect/platform"; |
| 6 | 6 | ||
| 7 | import { | ||
| 8 | type ToolId, | ||
| 9 | type ToolInvoker, | ||
| 10 | ToolInvocationResult, | ||
| 11 | ToolInvocationError, | ||
| 12 | } from "@executor/sdk"; | ||
| 13 | |||
| 7 | import { OpenApiInvocationError } from "./errors"; | 14 | import { OpenApiInvocationError } from "./errors"; |
| 15 | import type { OpenApiOperationStore } from "./operation-store"; | ||
| 8 | import { | 16 | import { |
| 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 API | 165 | // 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. */ |
| 161 | export const invoke = Effect.fn("OpenApi.invoke")(function* ( | 169 | export 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 | |||
| 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 | }); | ||
| @@ -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 | ||
| 7 | import { Option } from "effect"; | ||
| 7 | import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; | 8 | import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; |
| 8 | import type { ParsedDocument } from "./parse"; | 9 | import type { ParsedDocument } from "./parse"; |
| 9 | 10 | ||
| @@ -49,6 +50,25 @@ export class DocResolver { · 39 unmodified lines | |||
| 49 | const isRef = (value: unknown): value is { $ref: string } => | 50 | const 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 | |||
| 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 | |||
| 52 | // --------------------------------------------------------------------------- | 72 | // --------------------------------------------------------------------------- |
| 53 | // Content negotiation | 73 | // Content negotiation |
| 54 | // --------------------------------------------------------------------------- | 74 | // --------------------------------------------------------------------------- |
| @@ -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 | }; | ||
| @@ -1,57 +1,51 @@ | |||
| 1 | import { Effect, Layer, Option } from "effect"; | 1 | import { Effect, Option } from "effect"; |
| 2 | import { | 2 | import { FetchHttpClient, HttpClient } from "@effect/platform"; |
| 3 | FetchHttpClient, | 3 | import type { Layer } from "effect"; |
| 4 | HttpClient, | ||
| 5 | HttpClientRequest, | ||
| 6 | } from "@effect/platform"; | ||
| 7 | 4 | ||
| 8 | import { | 5 | import { |
| 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 | ||
| 18 | import { parse } from "./parse"; | 13 | import { parse } from "./parse"; |
| 19 | import { extract } from "./extract"; | 14 | 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"; | ||
| 21 | import { | 21 | import { |
| 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 spec | 30 | // Plugin config |
| 31 | // --------------------------------------------------------------------------- | 31 | // --------------------------------------------------------------------------- |
| 32 | 32 | ||
| 33 | export interface OpenApiSpecConfig { | 33 | export 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.openapi | 41 | // Plugin extension |
| 46 | // --------------------------------------------------------------------------- | 42 | // --------------------------------------------------------------------------- |
| 47 | 43 | ||
| 48 | export interface OpenApiPluginExtension { | 44 | export 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 | // Helpers | 53 | // Helpers |
| 60 | // --------------------------------------------------------------------------- | 54 | // --------------------------------------------------------------------------- |
| 61 | 55 | ||
| 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 | |||
| 77 | const operationDescription = (op: ExtractedOperation): string => | 56 | const 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 | ||
| 84 | const operationToRegistration = ( | 63 | const 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), | 76 | const 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 factory | 85 | // Plugin factory |
| 124 | // --------------------------------------------------------------------------- | 86 | // --------------------------------------------------------------------------- |
| 125 | 87 | ||
| 126 | export const openApiPlugin = (options?: { | 88 | export 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 first | 111 | 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 request | 122 | const invocationConfig = new InvocationConfig({ |
| 162 | const clientWithBaseUrl = baseUrl | 123 | 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 | }; |
| @@ -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 | |||
| 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 | |||
| 107 | // --------------------------------------------------------------------------- | 120 | // --------------------------------------------------------------------------- |
| 108 | // Invocation | 121 | // Invocation |
| 109 | // --------------------------------------------------------------------------- | 122 | // --------------------------------------------------------------------------- |