| @@ -10,41 +10,66 @@ import type { · 9 unmodified lines |
| 10 | import type { SandboxToolInvoker } from "@executor/codemode-core"; | 10 | import type { SandboxToolInvoker } from "@executor/codemode-core"; |
| 11 | import { ExecutionToolError } from "./errors"; | 11 | import { ExecutionToolError } from "./errors"; |
| 12 | | 12 | |
| | 13 | /** |
| | 14 | * Extract the source namespace from a tool path. Tool paths look like |
| | 15 | * "<sourceId>.<op>" or "<sourceId>.<group>.<op>" — we take the first |
| | 16 | * segment as a cheap, non-lookup stand-in for the source id so the span |
| | 17 | * attribute is always populated without hitting `executor.sources.list()` |
| | 18 | * per call. |
| | 19 | */ |
| | 20 | const extractSourceNamespace = (path: string): string => { |
| | 21 | const idx = path.indexOf("."); |
| | 22 | return idx === -1 ? path : path.slice(0, idx); |
| | 23 | }; |
| | 24 | |
| 13 | /** | 25 | /** |
| 14 | * Bridges QuickJS `tools.someSource.someOp(args)` calls into | 26 | * Bridges QuickJS `tools.someSource.someOp(args)` calls into |
| 15 | * `executor.tools.invoke(toolId, args)`. | 27 | * `executor.tools.invoke(toolId, args)`. |
| | 28 | * |
| | 29 | * Wrapped in `Effect.fn("mcp.tool.dispatch")` so every tool call becomes a |
| | 30 | * span in the Effect tracer. Attributes: |
| | 31 | * - `mcp.tool.name` — full tool path (e.g. "github.repos.get") |
| | 32 | * - `mcp.tool.source_id` — first segment of the path (namespace) |
| | 33 | * |
| | 34 | * `mcp.tool.kind` (openapi | mcp | graphql | code) is NOT annotated here |
| | 35 | * because it would require a `sources.list()` lookup on every invocation. |
| | 36 | * Callers that already know the source kind can annotate at their own span. |
| 16 | */ | 37 | */ |
| 17 | export const makeExecutorToolInvoker = ( | 38 | export const makeExecutorToolInvoker = ( |
| 18 | executor: Executor, | 39 | executor: Executor, |
| 19 | options: { readonly invokeOptions: InvokeOptions }, | 40 | options: { readonly invokeOptions: InvokeOptions }, |
| 20 | ): SandboxToolInvoker => ({ | 41 | ): SandboxToolInvoker => ({ |
| 21 | invoke: ({ path, args }) => | 42 | invoke: Effect.fn("mcp.tool.dispatch")(function* ({ path, args }) { |
| 22 | Effect.gen(function* () { | 43 | yield* Effect.annotateCurrentSpan({ |
| 23 | const result = yield* executor.tools.invoke(path as ToolId, args, options.invokeOptions).pipe( | 44 | "mcp.tool.name": path, |
| 24 | Effect.catchTag("ElicitationDeclinedError", (err) => | 45 | "mcp.tool.source_id": extractSourceNamespace(path), |
| 25 | Effect.fail( | 46 | }); |
| 26 | new ExecutionToolError({ | 47 | |
| 27 | message: `Tool "${err.toolId}" requires approval but the request was ${err.action === "cancel" ? "cancelled" : "declined"} by the user.`, | 48 | const result = yield* executor.tools.invoke(path as ToolId, args, options.invokeOptions).pipe( |
| 28 | cause: err, | 49 | Effect.catchTag("ElicitationDeclinedError", (err) => |
| 29 | }), | 50 | Effect.fail( |
| 30 | ), | 51 | new ExecutionToolError({ |
| | 52 | message: `Tool "${err.toolId}" requires approval but the request was ${err.action === "cancel" ? "cancelled" : "declined"} by the user.`, |
| | 53 | cause: err, |
| | 54 | }), |
| 31 | ), | 55 | ), |
| 32 | ); | 56 | ), |
| 33 | const r = result as { readonly error?: unknown; readonly data?: unknown } | unknown; | 57 | ); |
| 34 | if ( | 58 | const r = result as { readonly error?: unknown; readonly data?: unknown } | unknown; |
| 35 | r !== null && | 59 | if ( |
| 36 | typeof r === "object" && | 60 | r !== null && |
| 37 | "error" in r && | 61 | typeof r === "object" && |
| 38 | (r as { error?: unknown }).error !== null && | 62 | "error" in r && |
| 39 | (r as { error?: unknown }).error !== undefined | 63 | (r as { error?: unknown }).error !== null && |
| 40 | ) { | 64 | (r as { error?: unknown }).error !== undefined |
| 41 | return yield* Effect.fail((r as { error: unknown }).error); | 65 | ) { |
| 42 | } | 66 | return yield* Effect.fail((r as { error: unknown }).error); |
| 43 | if (r !== null && typeof r === "object" && "data" in r) { | 67 | } |
| 44 | return (r as { data: unknown }).data; | 68 | if (r !== null && typeof r === "object" && "data" in r) { |
| 45 | } | 69 | return (r as { data: unknown }).data; |
| 46 | return r; | 70 | } |
| 47 | }), | 71 | return r; |
| | 72 | }), |
| 48 | }); | 73 | }); |
| 49 | | 74 | |
| 50 | export type ToolDiscoveryResult = { | 75 | export type ToolDiscoveryResult = { |
| @@ -246,103 +271,98 @@ const scoreToolMatch = (tool: SearchableTool, query: string): ToolDiscoveryResul · 195 unmodified lines |
| 246 | }; | 271 | }; |
| 247 | | 272 | |
| 248 | /** What `tools.search()` calls inside the sandbox. */ | 273 | /** What `tools.search()` calls inside the sandbox. */ |
| 249 | export const searchTools = ( | 274 | export const searchTools = Effect.fn("executor.tools.search")(function* ( |
| 250 | executor: Executor, | 275 | executor: Executor, |
| 251 | query: string, | 276 | query: string, |
| 252 | limit = 12, | 277 | limit = 12, |
| 253 | options?: { readonly namespace?: string }, | 278 | options?: { readonly namespace?: string }, |
| 254 | ): Effect.Effect<ReadonlyArray<ToolDiscoveryResult>> => | 279 | ) { |
| 255 | Effect.gen(function* () { | 280 | yield* Effect.annotateCurrentSpan({ |
| 256 | if (normalizeSearchText(query).length === 0) { | 281 | "executor.search.query_length": query.length, |
| 257 | return []; | 282 | "executor.search.limit": limit, |
| 258 | } | 283 | ...(options?.namespace ? { "executor.search.namespace": options.namespace } : {}), |
| 259 | | | |
| 260 | const all = yield* executor.tools.list().pipe(Effect.orDie); | | |
| 261 | return all | | |
| 262 | .filter((tool: Tool) => matchesNamespace(tool, options?.namespace)) | | |
| 263 | .map((tool: Tool) => scoreToolMatch(tool, query)) | | |
| 264 | .filter((tool): tool is ToolDiscoveryResult => tool !== null) | | |
| 265 | .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)) | | |
| 266 | .slice(0, limit); | | |
| 267 | }); | 284 | }); |
| 268 | | 285 | |
| | 286 | if (normalizeSearchText(query).length === 0) { |
| | 287 | return [] as ReadonlyArray<ToolDiscoveryResult>; |
| | 288 | } |
| | 289 | |
| | 290 | const all = yield* executor.tools.list().pipe(Effect.orDie); |
| | 291 | return all |
| | 292 | .filter((tool: Tool) => matchesNamespace(tool, options?.namespace)) |
| | 293 | .map((tool: Tool) => scoreToolMatch(tool, query)) |
| | 294 | .filter((tool): tool is ToolDiscoveryResult => tool !== null) |
| | 295 | .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)) |
| | 296 | .slice(0, limit); |
| | 297 | }); |
| | 298 | |
| 269 | /** What `tools.executor.sources.list()` calls inside the sandbox. */ | 299 | /** What `tools.executor.sources.list()` calls inside the sandbox. */ |
| 270 | export const listExecutorSources = ( | 300 | export const listExecutorSources = Effect.fn("executor.sources.list")(function* ( |
| 271 | executor: Executor, | 301 | executor: Executor, |
| 272 | options?: { readonly query?: string; readonly limit?: number }, | 302 | options?: { readonly query?: string; readonly limit?: number }, |
| 273 | ): Effect.Effect<ReadonlyArray<ExecutorSourceListItem>> => | 303 | ) { |
| 274 | Effect.gen(function* () { | 304 | const normalizedQuery = normalizeSearchText(options?.query ?? ""); |
| 275 | const normalizedQuery = normalizeSearchText(options?.query ?? ""); | 305 | const limit = options?.limit ?? 200; |
| 276 | const limit = options?.limit ?? 200; | 306 | const sources = yield* executor.sources.list().pipe(Effect.orDie); |
| 277 | const sources = yield* executor.sources.list().pipe(Effect.orDie); | 307 | |
| 278 | | 308 | const filtered = |
| 279 | const filtered = | 309 | normalizedQuery.length === 0 |
| 280 | normalizedQuery.length === 0 | 310 | ? sources |
| 281 | ? sources | 311 | : sources.filter((source: Source) => { |
| 282 | : sources.filter((source: Source) => { | 312 | const haystack = normalizeSearchText([source.id, source.name, source.kind].join(" ")); |
| 283 | const haystack = normalizeSearchText([source.id, source.name, source.kind].join(" ")); | 313 | return tokenizeSearchText(normalizedQuery).every((token) => haystack.includes(token)); |
| 284 | return tokenizeSearchText(normalizedQuery).every((token) => haystack.includes(token)); | 314 | }); |
| 285 | }); | 315 | |
| 286 | | 316 | // Single query for all tools, then count per source in memory. |
| 287 | // Single query for all tools, then count per source in memory. | 317 | const allTools = yield* executor.tools.list().pipe(Effect.orDie); |
| 288 | const allTools = yield* executor.tools.list().pipe(Effect.orDie); | 318 | const toolCountBySource = new Map<string, number>(); |
| 289 | const toolCountBySource = new Map<string, number>(); | 319 | for (const tool of allTools) { |
| 290 | for (const tool of allTools) { | 320 | toolCountBySource.set(tool.sourceId, (toolCountBySource.get(tool.sourceId) ?? 0) + 1); |
| 291 | toolCountBySource.set(tool.sourceId, (toolCountBySource.get(tool.sourceId) ?? 0) + 1); | 321 | } |
| 292 | } | | |
| 293 | | | |
| 294 | const withCounts = filtered.map( | | |
| 295 | (source: Source) => | | |
| 296 | ({ | | |
| 297 | id: source.id, | | |
| 298 | name: source.name, | | |
| 299 | kind: source.kind, | | |
| 300 | runtime: source.runtime, | | |
| 301 | canRemove: source.canRemove, | | |
| 302 | canRefresh: source.canRefresh, | | |
| 303 | toolCount: toolCountBySource.get(source.id) ?? 0, | | |
| 304 | }) satisfies ExecutorSourceListItem, | | |
| 305 | ); | | |
| 306 | | 322 | |
| 307 | return withCounts | 323 | const withCounts = filtered.map( |
| 308 | .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)) | 324 | (source: Source) => |
| 309 | .slice(0, limit); | 325 | ({ |
| 310 | }); | 326 | id: source.id, |
| | 327 | name: source.name, |
| | 328 | kind: source.kind, |
| | 329 | runtime: source.runtime, |
| | 330 | canRemove: source.canRemove, |
| | 331 | canRefresh: source.canRefresh, |
| | 332 | toolCount: toolCountBySource.get(source.id) ?? 0, |
| | 333 | }) satisfies ExecutorSourceListItem, |
| | 334 | ); |
| | 335 | |
| | 336 | return withCounts |
| | 337 | .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)) |
| | 338 | .slice(0, limit); |
| | 339 | }); |
| 311 | | 340 | |
| 312 | /** What `tools.describe.tool()` calls inside the sandbox. */ | 341 | /** What `tools.describe.tool()` calls inside the sandbox. */ |
| 313 | export const describeTool = ( | 342 | export const describeTool = Effect.fn("executor.tools.describe")(function* ( |
| 314 | executor: Executor, | 343 | executor: Executor, |
| 315 | path: string, | 344 | path: string, |
| 316 | ): Effect.Effect< | 345 | ) { |
| 317 | { | 346 | yield* Effect.annotateCurrentSpan({ "mcp.tool.name": path }); |
| 318 | path: string; | | |
| 319 | name: string; | | |
| 320 | description?: string; | | |
| 321 | inputTypeScript?: string; | | |
| 322 | outputTypeScript?: string; | | |
| 323 | typeScriptDefinitions?: Record<string, string>; | | |
| 324 | }, | | |
| 325 | unknown | | |
| 326 | > => | | |
| 327 | Effect.gen(function* () { | | |
| 328 | // Single tools.schema() call — it already fetches the tool row | | |
| 329 | // internally. No need to also call tools.list() just for name/description. | | |
| 330 | const schema: ToolSchema | null = yield* executor.tools.schema(path); | | |
| 331 | | | |
| 332 | // tools.schema() returns null if the tool doesn't exist. Fall back to | | |
| 333 | // a minimal stub so callers can still render something. | | |
| 334 | if (schema === null) { | | |
| 335 | return { path, name: path }; | | |
| 336 | } | | |
| 337 | | 347 | |
| 338 | // The schema's id is the tool path; name/description come from the | 348 | // Single tools.schema() call — it already fetches the tool row |
| 339 | // tool row which tools.schema() already loaded. | 349 | // internally. No need to also call tools.list() just for name/description. |
| 340 | return { | 350 | const schema: ToolSchema | null = yield* executor.tools.schema(path); |
| 341 | path, | 351 | |
| 342 | name: schema.name ?? path, | 352 | // tools.schema() returns null if the tool doesn't exist. Fall back to |
| 343 | description: schema.description, | 353 | // a minimal stub so callers can still render something. |
| 344 | inputTypeScript: schema.inputTypeScript, | 354 | if (schema === null) { |
| 345 | outputTypeScript: schema.outputTypeScript, | 355 | return { path, name: path }; |
| 346 | typeScriptDefinitions: schema.typeScriptDefinitions, | 356 | } |
| 347 | }; | 357 | |
| 348 | }); | 358 | // The schema's id is the tool path; name/description come from the |
| | 359 | // tool row which tools.schema() already loaded. |
| | 360 | return { |
| | 361 | path, |
| | 362 | name: schema.name ?? path, |
| | 363 | description: schema.description, |
| | 364 | inputTypeScript: schema.inputTypeScript, |
| | 365 | outputTypeScript: schema.outputTypeScript, |
| | 366 | typeScriptDefinitions: schema.typeScriptDefinitions, |
| | 367 | }; |
| | 368 | }); |