Unified · Split
| @@ -1,486 +0,0 @@ | |||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | |||
| 3 | import { toolDescriptorsFromTools } from "./tool-map"; | ||
| 4 | import type { | ||
| 5 | CatalogPrimitive, | ||
| 6 | DescribePrimitive, | ||
| 7 | DiscoverPrimitive, | ||
| 8 | DiscoveryPrimitives, | ||
| 9 | SearchHit, | ||
| 10 | ToolCatalog, | ||
| 11 | ToolCatalogEntry, | ||
| 12 | ToolDescriptor, | ||
| 13 | ToolMap, | ||
| 14 | ToolNamespace, | ||
| 15 | ToolPath, | ||
| 16 | } from "./types"; | ||
| 17 | |||
| 18 | const tokenize = (value: string): string[] => | ||
| 19 | value | ||
| 20 | .toLowerCase() | ||
| 21 | .split(/\W+/) | ||
| 22 | .map((token) => token.trim()) | ||
| 23 | .filter(Boolean); | ||
| 24 | |||
| 25 | const searchableTextForTool = (tool: ToolDescriptor): string => | ||
| 26 | [ | ||
| 27 | tool.path, | ||
| 28 | tool.sourceKey, | ||
| 29 | tool.description ?? "", | ||
| 30 | tool.contract?.inputTypePreview ?? "", | ||
| 31 | tool.contract?.outputTypePreview ?? "", | ||
| 32 | ] | ||
| 33 | .join(" ") | ||
| 34 | .toLowerCase(); | ||
| 35 | |||
| 36 | const descriptorContract = ( | ||
| 37 | descriptor: ToolDescriptor, | ||
| 38 | includeSchemas: boolean, | ||
| 39 | ): ToolDescriptor["contract"] | undefined => { | ||
| 40 | const contract = descriptor.contract; | ||
| 41 | if (!contract) { | ||
| 42 | return undefined; | ||
| 43 | } | ||
| 44 | |||
| 45 | if (includeSchemas) { | ||
| 46 | return contract; | ||
| 47 | } | ||
| 48 | |||
| 49 | return { | ||
| 50 | ...(contract.inputTypePreview !== undefined | ||
| 51 | ? { inputTypePreview: contract.inputTypePreview } | ||
| 52 | : {}), | ||
| 53 | ...(contract.outputTypePreview !== undefined | ||
| 54 | ? { outputTypePreview: contract.outputTypePreview } | ||
| 55 | : {}), | ||
| 56 | ...(contract.exampleInput !== undefined | ||
| 57 | ? { exampleInput: contract.exampleInput } | ||
| 58 | : {}), | ||
| 59 | ...(contract.exampleOutput !== undefined | ||
| 60 | ? { exampleOutput: contract.exampleOutput } | ||
| 61 | : {}), | ||
| 62 | }; | ||
| 63 | }; | ||
| 64 | |||
| 65 | const projectDescriptor = (input: { | ||
| 66 | descriptor: ToolDescriptor; | ||
| 67 | includeSchemas: boolean; | ||
| 68 | }): ToolDescriptor => { | ||
| 69 | const { descriptor, includeSchemas } = input; | ||
| 70 | |||
| 71 | if (includeSchemas) { | ||
| 72 | return descriptor; | ||
| 73 | } | ||
| 74 | |||
| 75 | return { | ||
| 76 | ...descriptor, | ||
| 77 | ...(descriptorContract(descriptor, false) | ||
| 78 | ? { contract: descriptorContract(descriptor, false) } | ||
| 79 | : {}), | ||
| 80 | }; | ||
| 81 | }; | ||
| 82 | |||
| 83 | const formatToolLine = (tool: ToolDescriptor): string => | ||
| 84 | tool.description && tool.description.trim().length > 0 | ||
| 85 | ? `- ${tool.path}: ${tool.description.trim()}` | ||
| 86 | : `- ${tool.path}`; | ||
| 87 | |||
| 88 | const namespaceFromPath = (path: string): string => { | ||
| 89 | const [first, second] = path.split("."); | ||
| 90 | return second ? `${first}.${second}` : first; | ||
| 91 | }; | ||
| 92 | |||
| 93 | const namespaceFromCatalogEntry = (entry: ToolCatalogEntry): string => | ||
| 94 | entry.namespace ?? namespaceFromPath(entry.descriptor.path); | ||
| 95 | |||
| 96 | const searchableTextForEntry = (entry: ToolCatalogEntry): string => | ||
| 97 | entry.searchText?.trim().toLowerCase() || searchableTextForTool(entry.descriptor); | ||
| 98 | |||
| 99 | const scoreCatalogEntry = ( | ||
| 100 | queryTokens: readonly string[], | ||
| 101 | entry: ToolCatalogEntry, | ||
| 102 | ): number => { | ||
| 103 | if (entry.score) { | ||
| 104 | return entry.score(queryTokens); | ||
| 105 | } | ||
| 106 | |||
| 107 | const haystack = searchableTextForEntry(entry); | ||
| 108 | return queryTokens.reduce( | ||
| 109 | (total, token) => total + (haystack.includes(token) ? 1 : 0), | ||
| 110 | 0, | ||
| 111 | ); | ||
| 112 | }; | ||
| 113 | |||
| 114 | const mergeToolNamespaces = ( | ||
| 115 | groups: ReadonlyArray<readonly ToolNamespace[]>, | ||
| 116 | ): ToolNamespace[] => { | ||
| 117 | const merged = new Map<string, ToolNamespace>(); | ||
| 118 | |||
| 119 | for (const group of groups) { | ||
| 120 | for (const namespace of group) { | ||
| 121 | const existing = merged.get(namespace.namespace); | ||
| 122 | merged.set(namespace.namespace, { | ||
| 123 | namespace: namespace.namespace, | ||
| 124 | displayName: existing?.displayName ?? namespace.displayName, | ||
| 125 | ...(existing?.toolCount !== undefined || namespace.toolCount !== undefined | ||
| 126 | ? { | ||
| 127 | toolCount: | ||
| 128 | (existing?.toolCount ?? 0) + (namespace.toolCount ?? 0), | ||
| 129 | } | ||
| 130 | : {}), | ||
| 131 | }); | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | return [...merged.values()].sort((left, right) => | ||
| 136 | left.namespace.localeCompare(right.namespace), | ||
| 137 | ); | ||
| 138 | }; | ||
| 139 | |||
| 140 | const dedupeToolDescriptors = ( | ||
| 141 | groups: ReadonlyArray<readonly ToolDescriptor[]>, | ||
| 142 | ): ToolDescriptor[] => { | ||
| 143 | const merged = new Map<string, ToolDescriptor>(); | ||
| 144 | |||
| 145 | for (const group of groups) { | ||
| 146 | for (const descriptor of group) { | ||
| 147 | if (!merged.has(descriptor.path)) { | ||
| 148 | merged.set(descriptor.path, descriptor); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | return [...merged.values()].sort((left, right) => | ||
| 154 | left.path.localeCompare(right.path), | ||
| 155 | ); | ||
| 156 | }; | ||
| 157 | |||
| 158 | const dedupeSearchHits = ( | ||
| 159 | groups: ReadonlyArray<readonly SearchHit[]>, | ||
| 160 | ): SearchHit[] => { | ||
| 161 | const merged = new Map<string, SearchHit>(); | ||
| 162 | |||
| 163 | for (const group of groups) { | ||
| 164 | for (const hit of group) { | ||
| 165 | if (!merged.has(hit.path)) { | ||
| 166 | merged.set(hit.path, hit); | ||
| 167 | } | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | return [...merged.values()].sort( | ||
| 172 | (left, right) => right.score - left.score || left.path.localeCompare(right.path), | ||
| 173 | ); | ||
| 174 | }; | ||
| 175 | |||
| 176 | export function createToolCatalogFromTools(input: { | ||
| 177 | tools: ToolMap; | ||
| 178 | defaultNamespace?: string; | ||
| 179 | }): ToolCatalog { | ||
| 180 | return createToolCatalogFromEntries({ | ||
| 181 | entries: toolDescriptorsFromTools({ | ||
| 182 | tools: input.tools, | ||
| 183 | }).map((descriptor) => ({ | ||
| 184 | descriptor, | ||
| 185 | ...(input.defaultNamespace !== undefined | ||
| 186 | ? { namespace: input.defaultNamespace } | ||
| 187 | : {}), | ||
| 188 | })), | ||
| 189 | }); | ||
| 190 | } | ||
| 191 | |||
| 192 | export function createToolCatalogFromEntries(input: { | ||
| 193 | entries: ReadonlyArray<ToolCatalogEntry>; | ||
| 194 | }): ToolCatalog { | ||
| 195 | const entries = [...input.entries]; | ||
| 196 | const byPath = new Map( | ||
| 197 | entries.map((entry) => [entry.descriptor.path as string, entry]), | ||
| 198 | ); | ||
| 199 | const namespaceCounts = new Map<string, number>(); | ||
| 200 | |||
| 201 | for (const entry of entries) { | ||
| 202 | const namespace = namespaceFromCatalogEntry(entry); | ||
| 203 | namespaceCounts.set(namespace, (namespaceCounts.get(namespace) ?? 0) + 1); | ||
| 204 | } | ||
| 205 | |||
| 206 | return { | ||
| 207 | listNamespaces: ({ limit }) => | ||
| 208 | Effect.succeed( | ||
| 209 | [...namespaceCounts.entries()] | ||
| 210 | .map(([namespace, toolCount]) => ({ namespace, toolCount })) | ||
| 211 | .slice(0, limit), | ||
| 212 | ), | ||
| 213 | listTools: ({ namespace, query, limit, includeSchemas = false }) => | ||
| 214 | Effect.succeed( | ||
| 215 | entries | ||
| 216 | .filter((entry) => | ||
| 217 | !namespace || namespaceFromCatalogEntry(entry) === namespace, | ||
| 218 | ) | ||
| 219 | .filter((entry) => { | ||
| 220 | if (!query) { | ||
| 221 | return true; | ||
| 222 | } | ||
| 223 | |||
| 224 | const haystack = searchableTextForEntry(entry); | ||
| 225 | return tokenize(query).every((token) => haystack.includes(token)); | ||
| 226 | }) | ||
| 227 | .slice(0, limit) | ||
| 228 | .map((entry) => | ||
| 229 | projectDescriptor({ | ||
| 230 | descriptor: entry.descriptor, | ||
| 231 | includeSchemas, | ||
| 232 | }) | ||
| 233 | ), | ||
| 234 | ), | ||
| 235 | getToolByPath: ({ path, includeSchemas }) => | ||
| 236 | Effect.succeed( | ||
| 237 | byPath.get(path) | ||
| 238 | ? projectDescriptor({ | ||
| 239 | descriptor: byPath.get(path)!.descriptor, | ||
| 240 | includeSchemas, | ||
| 241 | }) | ||
| 242 | : null, | ||
| 243 | ), | ||
| 244 | searchTools: ({ query, namespace, limit }) => { | ||
| 245 | const queryTokens = tokenize(query); | ||
| 246 | |||
| 247 | return Effect.succeed( | ||
| 248 | entries | ||
| 249 | .filter((entry) => | ||
| 250 | !namespace || namespaceFromCatalogEntry(entry) === namespace, | ||
| 251 | ) | ||
| 252 | .map((entry) => ({ | ||
| 253 | path: entry.descriptor.path, | ||
| 254 | score: scoreCatalogEntry(queryTokens, entry), | ||
| 255 | })) | ||
| 256 | .filter((hit) => hit.score > 0) | ||
| 257 | .sort((left, right) => right.score - left.score) | ||
| 258 | .slice(0, limit), | ||
| 259 | ); | ||
| 260 | }, | ||
| 261 | } satisfies ToolCatalog; | ||
| 262 | } | ||
| 263 | |||
| 264 | export function mergeToolCatalogs(input: { | ||
| 265 | catalogs: ReadonlyArray<ToolCatalog>; | ||
| 266 | }): ToolCatalog { | ||
| 267 | const catalogs = [...input.catalogs]; | ||
| 268 | |||
| 269 | return { | ||
| 270 | listNamespaces: ({ limit }) => | ||
| 271 | Effect.gen(function* () { | ||
| 272 | const groups = yield* Effect.forEach( | ||
| 273 | catalogs, | ||
| 274 | (catalog) => catalog.listNamespaces({ limit: Math.max(limit, limit * catalogs.length) }), | ||
| 275 | { concurrency: "unbounded" }, | ||
| 276 | ); | ||
| 277 | |||
| 278 | return mergeToolNamespaces(groups).slice(0, limit); | ||
| 279 | }), | ||
| 280 | |||
| 281 | listTools: ({ namespace, query, limit, includeSchemas = false }) => | ||
| 282 | Effect.gen(function* () { | ||
| 283 | const groups = yield* Effect.forEach( | ||
| 284 | catalogs, | ||
| 285 | (catalog) => | ||
| 286 | catalog.listTools({ | ||
| 287 | ...(namespace !== undefined ? { namespace } : {}), | ||
| 288 | ...(query !== undefined ? { query } : {}), | ||
| 289 | limit: Math.max(limit, limit * catalogs.length), | ||
| 290 | includeSchemas, | ||
| 291 | }), | ||
| 292 | { concurrency: "unbounded" }, | ||
| 293 | ); | ||
| 294 | |||
| 295 | return dedupeToolDescriptors(groups).slice(0, limit); | ||
| 296 | }), | ||
| 297 | |||
| 298 | getToolByPath: ({ path, includeSchemas }) => | ||
| 299 | Effect.gen(function* () { | ||
| 300 | for (const catalog of catalogs) { | ||
| 301 | const descriptor = yield* catalog.getToolByPath({ path, includeSchemas }); | ||
| 302 | if (descriptor) { | ||
| 303 | return descriptor; | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | return null; | ||
| 308 | }), | ||
| 309 | |||
| 310 | searchTools: ({ query, namespace, limit }) => | ||
| 311 | Effect.gen(function* () { | ||
| 312 | const groups = yield* Effect.forEach( | ||
| 313 | catalogs, | ||
| 314 | (catalog) => | ||
| 315 | catalog.searchTools({ | ||
| 316 | query, | ||
| 317 | ...(namespace !== undefined ? { namespace } : {}), | ||
| 318 | limit: Math.max(limit, limit * catalogs.length), | ||
| 319 | }), | ||
| 320 | { concurrency: "unbounded" }, | ||
| 321 | ); | ||
| 322 | |||
| 323 | return dedupeSearchHits(groups).slice(0, limit); | ||
| 324 | }), | ||
| 325 | } satisfies ToolCatalog; | ||
| 326 | } | ||
| 327 | |||
| 328 | export function createStaticDiscoveryFromTools(input: { | ||
| 329 | tools: ToolMap; | ||
| 330 | sourceKey?: string; | ||
| 331 | }): { | ||
| 332 | tools: ToolDescriptor[]; | ||
| 333 | executeDescription: Effect.Effect<string, never>; | ||
| 334 | } { | ||
| 335 | const tools = toolDescriptorsFromTools({ | ||
| 336 | tools: input.tools, | ||
| 337 | sourceKey: input.sourceKey, | ||
| 338 | }); | ||
| 339 | |||
| 340 | return { | ||
| 341 | tools, | ||
| 342 | executeDescription: Effect.succeed(buildStaticExecuteDescription({ tools })), | ||
| 343 | }; | ||
| 344 | } | ||
| 345 | |||
| 346 | export function createToolCatalogDiscovery(input: { | ||
| 347 | catalog: ToolCatalog; | ||
| 348 | }): { | ||
| 349 | primitives: DiscoveryPrimitives; | ||
| 350 | executeDescription: Effect.Effect<string, unknown>; | ||
| 351 | } { | ||
| 352 | const primitives = createDiscoveryPrimitivesFromToolCatalog({ | ||
| 353 | catalog: input.catalog, | ||
| 354 | }); | ||
| 355 | |||
| 356 | return { | ||
| 357 | primitives, | ||
| 358 | executeDescription: buildDynamicExecuteDescription({ | ||
| 359 | catalog: input.catalog, | ||
| 360 | }), | ||
| 361 | }; | ||
| 362 | } | ||
| 363 | |||
| 364 | export function createDiscoveryPrimitivesFromToolCatalog(input: { | ||
| 365 | catalog: ToolCatalog; | ||
| 366 | }): DiscoveryPrimitives { | ||
| 367 | const { catalog } = input; | ||
| 368 | |||
| 369 | const describe: DescribePrimitive = { | ||
| 370 | tool: ({ path, includeSchemas = false }) => | ||
| 371 | catalog.getToolByPath({ path, includeSchemas }), | ||
| 372 | }; | ||
| 373 | |||
| 374 | const discover: DiscoverPrimitive = ({ | ||
| 375 | query, | ||
| 376 | sourceKey: _sourceKey, | ||
| 377 | limit = 12, | ||
| 378 | includeSchemas = false, | ||
| 379 | }) => | ||
| 380 | Effect.gen(function* () { | ||
| 381 | const hits = yield* catalog.searchTools({ | ||
| 382 | query, | ||
| 383 | limit, | ||
| 384 | }); | ||
| 385 | |||
| 386 | if (hits.length === 0) { | ||
| 387 | return { | ||
| 388 | bestPath: null, | ||
| 389 | results: [], | ||
| 390 | total: 0, | ||
| 391 | }; | ||
| 392 | } | ||
| 393 | |||
| 394 | const descriptors = yield* Effect.forEach( | ||
| 395 | hits, | ||
| 396 | (hit) => | ||
| 397 | catalog.getToolByPath({ | ||
| 398 | path: hit.path, | ||
| 399 | includeSchemas, | ||
| 400 | }), | ||
| 401 | { concurrency: "unbounded" }, | ||
| 402 | ); | ||
| 403 | |||
| 404 | const hydrated = hits | ||
| 405 | .map((hit, index) => { | ||
| 406 | const descriptor = descriptors[index]; | ||
| 407 | if (!descriptor) { | ||
| 408 | return null; | ||
| 409 | } | ||
| 410 | |||
| 411 | return { | ||
| 412 | path: descriptor.path, | ||
| 413 | score: hit.score, | ||
| 414 | description: descriptor.description, | ||
| 415 | interaction: descriptor.interaction ?? "auto", | ||
| 416 | ...(descriptorContract(descriptor, includeSchemas) | ||
| 417 | ? { contract: descriptorContract(descriptor, includeSchemas) } | ||
| 418 | : {}), | ||
| 419 | }; | ||
| 420 | }) | ||
| 421 | .filter(Boolean) as Array< | ||
| 422 | Record<string, unknown> & { path: ToolPath; score: number } | ||
| 423 | >; | ||
| 424 | |||
| 425 | return { | ||
| 426 | bestPath: hydrated[0]?.path ?? null, | ||
| 427 | results: hydrated, | ||
| 428 | total: hydrated.length, | ||
| 429 | }; | ||
| 430 | }); | ||
| 431 | |||
| 432 | const catalogPrimitive: CatalogPrimitive = { | ||
| 433 | namespaces: ({ limit = 200 }) => | ||
| 434 | catalog.listNamespaces({ limit }).pipe( | ||
| 435 | Effect.map((namespaces) => ({ namespaces })), | ||
| 436 | ), | ||
| 437 | tools: ({ namespace, query, limit = 200, includeSchemas = false }) => | ||
| 438 | catalog.listTools({ | ||
| 439 | ...(namespace !== undefined ? { namespace } : {}), | ||
| 440 | ...(query !== undefined ? { query } : {}), | ||
| 441 | limit, | ||
| 442 | includeSchemas, | ||
| 443 | }).pipe( | ||
| 444 | Effect.map((results) => ({ results })), | ||
| 445 | ), | ||
| 446 | }; | ||
| 447 | |||
| 448 | return { | ||
| 449 | catalog: catalogPrimitive, | ||
| 450 | describe, | ||
| 451 | discover, | ||
| 452 | }; | ||
| 453 | } | ||
| 454 | |||
| 455 | export function buildStaticExecuteDescription(input: { | ||
| 456 | tools: readonly ToolDescriptor[]; | ||
| 457 | }): string { | ||
| 458 | return [ | ||
| 459 | "Execute TypeScript in sandbox; call tools directly.", | ||
| 460 | "Available tools:", | ||
| 461 | ...input.tools.map(formatToolLine), | ||
| 462 | "Do not use fetch; use tools.* only.", | ||
| 463 | ].join("\n"); | ||
| 464 | } | ||
| 465 | |||
| 466 | export function buildDynamicExecuteDescription(input: { | ||
| 467 | catalog: ToolCatalog; | ||
| 468 | }): Effect.Effect<string, unknown> { | ||
| 469 | return Effect.gen(function* () { | ||
| 470 | const namespaces = yield* input.catalog.listNamespaces({ limit: 200 }); | ||
| 471 | |||
| 472 | return [ | ||
| 473 | "Execute TypeScript in sandbox; call tools via discovery workflow.", | ||
| 474 | "Available namespaces:", | ||
| 475 | ...namespaces.map((namespace) => `- ${namespace.displayName ?? namespace.namespace}`), | ||
| 476 | "Workflow:", | ||
| 477 | '1) const { results, bestPath } = await tools.discover({ query: "<intent>", limit: 12 });', | ||
| 478 | '2) const path = bestPath ?? results[0]?.path; if (!path) return "No matching tools found.";', | ||
| 479 | "3) const details = await tools.describe.tool({ path, includeSchemas: true });", | ||
| 480 | "4) Read details.contract?.inputSchema/details.contract?.outputSchema when you need the projected shape.", | ||
| 481 | "5) Call selected tools.<path>(input).", | ||
| 482 | "The tools object is a lazy proxy, so Object.keys(tools) is not a useful way to discover capabilities.", | ||
| 483 | "Do not use fetch; use tools.* only.", | ||
| 484 | ].join("\n"); | ||
| 485 | }); | ||
| 486 | } | ||
| @@ -1,164 +0,0 @@ | |||
| 1 | export type HttpRequestPlacements = { | ||
| 2 | headers?: Readonly<Record<string, string>>; | ||
| 3 | queryParams?: Readonly<Record<string, string>>; | ||
| 4 | cookies?: Readonly<Record<string, string>>; | ||
| 5 | bodyValues?: Readonly<Record<string, string>>; | ||
| 6 | }; | ||
| 7 | |||
| 8 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 9 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 10 | |||
| 11 | const decodeJsonPointerSegment = (value: string): string => | ||
| 12 | value.replaceAll("~1", "/").replaceAll("~0", "~"); | ||
| 13 | |||
| 14 | const pathSegmentsFromPlacementPath = (value: string): ReadonlyArray<string> => { | ||
| 15 | const trimmed = value.trim(); | ||
| 16 | if (trimmed.length === 0) { | ||
| 17 | return []; | ||
| 18 | } | ||
| 19 | |||
| 20 | if (trimmed.startsWith("/")) { | ||
| 21 | return trimmed | ||
| 22 | .split("/") | ||
| 23 | .slice(1) | ||
| 24 | .map(decodeJsonPointerSegment) | ||
| 25 | .filter((segment) => segment.length > 0); | ||
| 26 | } | ||
| 27 | |||
| 28 | return trimmed.split(".").filter((segment) => segment.length > 0); | ||
| 29 | }; | ||
| 30 | |||
| 31 | const findHeaderKey = ( | ||
| 32 | headers: Readonly<Record<string, string>>, | ||
| 33 | name: string, | ||
| 34 | ): string | null => { | ||
| 35 | const lowered = name.toLowerCase(); | ||
| 36 | for (const key of Object.keys(headers)) { | ||
| 37 | if (key.toLowerCase() === lowered) { | ||
| 38 | return key; | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | return null; | ||
| 43 | }; | ||
| 44 | |||
| 45 | const parseCookieHeader = (value: string): Record<string, string> => { | ||
| 46 | const parsed: Record<string, string> = {}; | ||
| 47 | |||
| 48 | for (const part of value.split(";")) { | ||
| 49 | const trimmed = part.trim(); | ||
| 50 | if (trimmed.length === 0) { | ||
| 51 | continue; | ||
| 52 | } | ||
| 53 | |||
| 54 | const separatorIndex = trimmed.indexOf("="); | ||
| 55 | if (separatorIndex === -1) { | ||
| 56 | parsed[trimmed] = ""; | ||
| 57 | continue; | ||
| 58 | } | ||
| 59 | |||
| 60 | const name = trimmed.slice(0, separatorIndex).trim(); | ||
| 61 | const cookieValue = trimmed.slice(separatorIndex + 1).trim(); | ||
| 62 | if (name.length > 0) { | ||
| 63 | parsed[name] = cookieValue; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | return parsed; | ||
| 68 | }; | ||
| 69 | |||
| 70 | const setNestedRecordValue = ( | ||
| 71 | target: Record<string, unknown>, | ||
| 72 | path: ReadonlyArray<string>, | ||
| 73 | value: string, | ||
| 74 | ) => { | ||
| 75 | let current = target; | ||
| 76 | |||
| 77 | for (let index = 0; index < path.length - 1; index += 1) { | ||
| 78 | const segment = path[index]!; | ||
| 79 | const next = current[segment]; | ||
| 80 | if (isRecord(next)) { | ||
| 81 | current = next; | ||
| 82 | continue; | ||
| 83 | } | ||
| 84 | |||
| 85 | const replacement: Record<string, unknown> = {}; | ||
| 86 | current[segment] = replacement; | ||
| 87 | current = replacement; | ||
| 88 | } | ||
| 89 | |||
| 90 | current[path[path.length - 1]!] = value; | ||
| 91 | }; | ||
| 92 | |||
| 93 | export const applyHttpQueryPlacementsToUrl = (input: { | ||
| 94 | url: string | URL; | ||
| 95 | queryParams?: Readonly<Record<string, string>>; | ||
| 96 | }): URL => { | ||
| 97 | const url = input.url instanceof URL ? new URL(input.url.toString()) : new URL(input.url); | ||
| 98 | |||
| 99 | for (const [key, value] of Object.entries(input.queryParams ?? {})) { | ||
| 100 | url.searchParams.set(key, value); | ||
| 101 | } | ||
| 102 | |||
| 103 | return url; | ||
| 104 | }; | ||
| 105 | |||
| 106 | export const applyCookiePlacementsToHeaders = (input: { | ||
| 107 | headers: Readonly<Record<string, string>>; | ||
| 108 | cookies?: Readonly<Record<string, string>>; | ||
| 109 | }): Record<string, string> => { | ||
| 110 | const cookies = input.cookies ?? {}; | ||
| 111 | if (Object.keys(cookies).length === 0) { | ||
| 112 | return { ...input.headers }; | ||
| 113 | } | ||
| 114 | |||
| 115 | const cookieHeaderKey = findHeaderKey(input.headers, "cookie"); | ||
| 116 | const cookieHeaderValue = cookieHeaderKey ? input.headers[cookieHeaderKey] : null; | ||
| 117 | const mergedCookies = { | ||
| 118 | ...(cookieHeaderValue ? parseCookieHeader(cookieHeaderValue) : {}), | ||
| 119 | ...cookies, | ||
| 120 | }; | ||
| 121 | const headers = { ...input.headers }; | ||
| 122 | |||
| 123 | headers[cookieHeaderKey ?? "cookie"] = Object.entries(mergedCookies) | ||
| 124 | .map(([name, value]) => `${name}=${encodeURIComponent(value)}`) | ||
| 125 | .join("; "); | ||
| 126 | |||
| 127 | return headers; | ||
| 128 | }; | ||
| 129 | |||
| 130 | export const applyJsonBodyPlacements = (input: { | ||
| 131 | body: unknown; | ||
| 132 | bodyValues?: Readonly<Record<string, string>>; | ||
| 133 | label?: string; | ||
| 134 | }): unknown => { | ||
| 135 | const bodyValues = input.bodyValues ?? {}; | ||
| 136 | if (Object.keys(bodyValues).length === 0) { | ||
| 137 | return input.body; | ||
| 138 | } | ||
| 139 | |||
| 140 | const root = | ||
| 141 | input.body == null | ||
| 142 | ? {} | ||
| 143 | : isRecord(input.body) | ||
| 144 | ? structuredClone(input.body) | ||
| 145 | : null; | ||
| 146 | |||
| 147 | if (root === null) { | ||
| 148 | throw new Error( | ||
| 149 | `${input.label ?? "HTTP request"} auth body placements require an object JSON body`, | ||
| 150 | ); | ||
| 151 | } | ||
| 152 | |||
| 153 | for (const [rawPath, value] of Object.entries(bodyValues)) { | ||
| 154 | const path = pathSegmentsFromPlacementPath(rawPath); | ||
| 155 | if (path.length === 0) { | ||
| 156 | throw new Error( | ||
| 157 | `${input.label ?? "HTTP request"} auth body placement path cannot be empty`, | ||
| 158 | ); | ||
| 159 | } | ||
| 160 | setNestedRecordValue(root, path, value); | ||
| 161 | } | ||
| 162 | |||
| 163 | return root; | ||
| 164 | }; | ||
| @@ -1,684 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { assertInstanceOf, assertNone, assertTrue } from "@effect/vitest/utils"; | ||
| 3 | import * as Either from "effect/Either"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | |||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as TestClock from "effect/TestClock"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | allowAllToolInteractions, | ||
| 12 | createToolCatalogDiscovery, | ||
| 13 | createToolCatalogFromEntries, | ||
| 14 | createToolCatalogFromTools, | ||
| 15 | createStaticDiscoveryFromTools, | ||
| 16 | createSystemToolMap, | ||
| 17 | makeToolInvokerFromTools, | ||
| 18 | mergeToolMaps, | ||
| 19 | standardSchemaFromJsonSchema, | ||
| 20 | ToolInteractionDeniedError, | ||
| 21 | ToolInteractionPendingError, | ||
| 22 | toTool, | ||
| 23 | type ToolCatalog, | ||
| 24 | type CodeExecutor, | ||
| 25 | type ToolDescriptor, | ||
| 26 | type ToolMap, | ||
| 27 | type ToolPath, | ||
| 28 | } from "./index"; | ||
| 29 | |||
| 30 | const asToolPath = (value: string): ToolPath => value as ToolPath; | ||
| 31 | |||
| 32 | const numberPairInputSchema = Schema.standardSchemaV1( | ||
| 33 | Schema.Struct({ | ||
| 34 | a: Schema.Number, | ||
| 35 | b: Schema.Number, | ||
| 36 | }), | ||
| 37 | ); | ||
| 38 | |||
| 39 | const titleInputSchema = Schema.standardSchemaV1( | ||
| 40 | Schema.Struct({ | ||
| 41 | title: Schema.String, | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | |||
| 45 | const messageInputSchema = Schema.standardSchemaV1( | ||
| 46 | Schema.Struct({ | ||
| 47 | message: Schema.String, | ||
| 48 | }), | ||
| 49 | ); | ||
| 50 | |||
| 51 | |||
| 52 | describe("codemode-core", () => { | ||
| 53 | it.effect("builds static discovery from tool map keys", () => | ||
| 54 | Effect.gen(function* () { | ||
| 55 | const tools = { | ||
| 56 | "math.add": { | ||
| 57 | description: "Add two numbers", | ||
| 58 | inputSchema: numberPairInputSchema, | ||
| 59 | execute: async ({ a, b }: { a: number; b: number }) => ({ sum: a + b }), | ||
| 60 | }, | ||
| 61 | "issues.create": toTool({ | ||
| 62 | tool: { | ||
| 63 | description: "Create issue", | ||
| 64 | inputSchema: titleInputSchema, | ||
| 65 | execute: async ({ title }: { title: string }) => ({ id: "issue_1", title }), | ||
| 66 | }, | ||
| 67 | metadata: { | ||
| 68 | interaction: "required", | ||
| 69 | }, | ||
| 70 | }), | ||
| 71 | } satisfies ToolMap; | ||
| 72 | |||
| 73 | const staticDiscovery = createStaticDiscoveryFromTools({ | ||
| 74 | tools, | ||
| 75 | sourceKey: "api.demo", | ||
| 76 | }); | ||
| 77 | |||
| 78 | expect(staticDiscovery.tools.map((tool) => tool.path)).toEqual([ | ||
| 79 | "issues.create", | ||
| 80 | "math.add", | ||
| 81 | ]); | ||
| 82 | |||
| 83 | const createIssueDescriptor = staticDiscovery.tools.find( | ||
| 84 | (tool) => tool.path === "issues.create", | ||
| 85 | ); | ||
| 86 | expect(createIssueDescriptor?.interaction).toBe("required"); | ||
| 87 | expect(createIssueDescriptor?.sourceKey).toBe("api.demo"); | ||
| 88 | expect(yield* staticDiscovery.executeDescription).toBe( | ||
| 89 | [ | ||
| 90 | "Execute TypeScript in sandbox; call tools directly.", | ||
| 91 | "Available tools:", | ||
| 92 | "- issues.create: Create issue", | ||
| 93 | "- math.add: Add two numbers", | ||
| 94 | "Do not use fetch; use tools.* only.", | ||
| 95 | ].join("\n"), | ||
| 96 | ); | ||
| 97 | }), | ||
| 98 | ); | ||
| 99 | |||
| 100 | |||
| 101 | it.effect("returns pending interaction error for required tools", () => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | const invoker = makeToolInvokerFromTools({ | ||
| 104 | tools: { | ||
| 105 | "issues.create": toTool({ | ||
| 106 | tool: { | ||
| 107 | inputSchema: titleInputSchema, | ||
| 108 | execute: ({ title }: { title: string }) => ({ id: "issue_1", title }), | ||
| 109 | }, | ||
| 110 | metadata: { | ||
| 111 | interaction: "required", | ||
| 112 | }, | ||
| 113 | }), | ||
| 114 | }, | ||
| 115 | }); | ||
| 116 | |||
| 117 | const pendingError = yield* Effect.flip( | ||
| 118 | invoker.invoke({ | ||
| 119 | path: "issues.create", | ||
| 120 | args: { title: "hello" }, | ||
| 121 | }), | ||
| 122 | ); | ||
| 123 | |||
| 124 | assertInstanceOf(pendingError, ToolInteractionPendingError); | ||
| 125 | expect(pendingError.path).toBe("issues.create"); | ||
| 126 | expect(pendingError.elicitation.mode).toBe("form"); | ||
| 127 | }), | ||
| 128 | ); | ||
| 129 | |||
| 130 | it.effect("waits for elicitation response and executes when accepted", () => | ||
| 131 | Effect.gen(function* () { | ||
| 132 | const invoker = makeToolInvokerFromTools({ | ||
| 133 | tools: { | ||
| 134 | "issues.create": toTool({ | ||
| 135 | tool: { | ||
| 136 | inputSchema: titleInputSchema, | ||
| 137 | execute: ({ title }: { title: string }) => ({ id: "issue_1", title }), | ||
| 138 | }, | ||
| 139 | metadata: { | ||
| 140 | interaction: "required", | ||
| 141 | }, | ||
| 142 | }), | ||
| 143 | }, | ||
| 144 | onElicitation: () => | ||
| 145 | Effect.succeed({ | ||
| 146 | action: "accept" as const, | ||
| 147 | }), | ||
| 148 | }); | ||
| 149 | |||
| 150 | const output = yield* invoker.invoke({ | ||
| 151 | path: "issues.create", | ||
| 152 | args: { title: "hello" }, | ||
| 153 | context: { runId: "run_1", callId: "call_1" }, | ||
| 154 | }); | ||
| 155 | |||
| 156 | expect(output).toEqual({ id: "issue_1", title: "hello" }); | ||
| 157 | }), | ||
| 158 | ); | ||
| 159 | |||
| 160 | it.effect("waits through polling-style elicitation callback and then executes", () => | ||
| 161 | Effect.gen(function* () { | ||
| 162 | let pollingSleeps = 0; | ||
| 163 | |||
| 164 | const invoker = makeToolInvokerFromTools({ | ||
| 165 | tools: { | ||
| 166 | "issues.create": toTool({ | ||
| 167 | tool: { | ||
| 168 | inputSchema: titleInputSchema, | ||
| 169 | execute: ({ title }: { title: string }) => ({ id: "issue_1", title }), | ||
| 170 | }, | ||
| 171 | metadata: { | ||
| 172 | interaction: "required", | ||
| 173 | }, | ||
| 174 | }), | ||
| 175 | }, | ||
| 176 | onElicitation: () => | ||
| 177 | Effect.gen(function* () { | ||
| 178 | for (let attempt = 0; attempt < 3; attempt += 1) { | ||
| 179 | pollingSleeps += 1; | ||
| 180 | yield* Effect.sleep("200 seconds"); | ||
| 181 | } | ||
| 182 | |||
| 183 | return { | ||
| 184 | action: "accept" as const, | ||
| 185 | }; | ||
| 186 | }), | ||
| 187 | }); | ||
| 188 | |||
| 189 | const fiber = yield* invoker.invoke({ | ||
| 190 | path: "issues.create", | ||
| 191 | args: { title: "hello" }, | ||
| 192 | context: { runId: "run_poll", callId: "call_poll" }, | ||
| 193 | }).pipe(Effect.fork); | ||
| 194 | |||
| 195 | yield* TestClock.adjust("200 seconds"); | ||
| 196 | const firstPoll = yield* Fiber.poll(fiber); | ||
| 197 | assertNone(firstPoll); | ||
| 198 | |||
| 199 | yield* TestClock.adjust("200 seconds"); | ||
| 200 | const secondPoll = yield* Fiber.poll(fiber); | ||
| 201 | assertNone(secondPoll); | ||
| 202 | |||
| 203 | yield* TestClock.adjust("200 seconds"); | ||
| 204 | const output = yield* Fiber.join(fiber); | ||
| 205 | |||
| 206 | expect(output).toEqual({ id: "issue_1", title: "hello" }); | ||
| 207 | expect(pollingSleeps).toBe(3); | ||
| 208 | }), | ||
| 209 | ); | ||
| 210 | |||
| 211 | it.effect("fails when elicitation is declined", () => | ||
| 212 | Effect.gen(function* () { | ||
| 213 | const invoker = makeToolInvokerFromTools({ | ||
| 214 | tools: { | ||
| 215 | "issues.create": toTool({ | ||
| 216 | tool: { | ||
| 217 | inputSchema: titleInputSchema, | ||
| 218 | execute: ({ title }: { title: string }) => ({ id: "issue_1", title }), | ||
| 219 | }, | ||
| 220 | metadata: { | ||
| 221 | interaction: "required", | ||
| 222 | }, | ||
| 223 | }), | ||
| 224 | }, | ||
| 225 | onElicitation: () => | ||
| 226 | Effect.succeed({ | ||
| 227 | action: "decline" as const, | ||
| 228 | content: { | ||
| 229 | reason: "User declined tool execution", | ||
| 230 | }, | ||
| 231 | }), | ||
| 232 | }); | ||
| 233 | |||
| 234 | const declinedError = yield* Effect.flip( | ||
| 235 | invoker.invoke({ | ||
| 236 | path: "issues.create", | ||
| 237 | args: { title: "hello" }, | ||
| 238 | }), | ||
| 239 | ); | ||
| 240 | |||
| 241 | assertInstanceOf(declinedError, ToolInteractionDeniedError); | ||
| 242 | expect(declinedError.reason).toContain("User declined"); | ||
| 243 | }), | ||
| 244 | ); | ||
| 245 | |||
| 246 | it.effect("builds Standard Schema validators from JSON Schema", () => | ||
| 247 | Effect.gen(function* () { | ||
| 248 | const tools = { | ||
| 249 | "math.add": { | ||
| 250 | description: "Add two numbers", | ||
| 251 | inputSchema: standardSchemaFromJsonSchema({ | ||
| 252 | type: "object", | ||
| 253 | required: ["a", "b"], | ||
| 254 | properties: { | ||
| 255 | a: { type: "number" }, | ||
| 256 | b: { type: "number" }, | ||
| 257 | }, | ||
| 258 | additionalProperties: false, | ||
| 259 | }), | ||
| 260 | execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }), | ||
| 261 | }, | ||
| 262 | } satisfies ToolMap; | ||
| 263 | |||
| 264 | const invoker = makeToolInvokerFromTools({ tools }); | ||
| 265 | |||
| 266 | const success = yield* invoker.invoke({ | ||
| 267 | path: "math.add", | ||
| 268 | args: { a: 1, b: 2 }, | ||
| 269 | }); | ||
| 270 | expect(success).toEqual({ sum: 3 }); | ||
| 271 | |||
| 272 | const failure = yield* Effect.either( | ||
| 273 | invoker.invoke({ | ||
| 274 | path: "math.add", | ||
| 275 | args: { a: "1", b: 2 }, | ||
| 276 | }), | ||
| 277 | ); | ||
| 278 | |||
| 279 | assertTrue(Either.isLeft(failure)); | ||
| 280 | assertInstanceOf(failure.left, Error); | ||
| 281 | expect(failure.left.message).toContain("Input validation failed"); | ||
| 282 | }), | ||
| 283 | ); | ||
| 284 | |||
| 285 | it.effect("enforces standard JSON Schema string formats", () => | ||
| 286 | Effect.gen(function* () { | ||
| 287 | const tools = { | ||
| 288 | "events.create": { | ||
| 289 | inputSchema: standardSchemaFromJsonSchema({ | ||
| 290 | type: "object", | ||
| 291 | required: ["dateFrom", "issueId", "email", "documentationUrl"], | ||
| 292 | properties: { | ||
| 293 | dateFrom: { type: "string", format: "date-time" }, | ||
| 294 | issueId: { type: "string", format: "uuid" }, | ||
| 295 | email: { type: "string", format: "email" }, | ||
| 296 | documentationUrl: { type: "string", format: "uri" }, | ||
| 297 | }, | ||
| 298 | additionalProperties: false, | ||
| 299 | }), | ||
| 300 | execute: (input: unknown) => input, | ||
| 301 | }, | ||
| 302 | } satisfies ToolMap; | ||
| 303 | |||
| 304 | const invoker = makeToolInvokerFromTools({ tools }); | ||
| 305 | |||
| 306 | const success = yield* invoker.invoke({ | ||
| 307 | path: "events.create", | ||
| 308 | args: { | ||
| 309 | dateFrom: "2026-03-30T12:00:00Z", | ||
| 310 | issueId: "2c981f4a-7318-47a3-a3d3-8c145ee8ca1f", | ||
| 311 | email: "rhys@example.com", | ||
| 312 | documentationUrl: "https://example.com/docs", | ||
| 313 | }, | ||
| 314 | }); | ||
| 315 | |||
| 316 | expect(success).toEqual({ | ||
| 317 | dateFrom: "2026-03-30T12:00:00Z", | ||
| 318 | issueId: "2c981f4a-7318-47a3-a3d3-8c145ee8ca1f", | ||
| 319 | email: "rhys@example.com", | ||
| 320 | documentationUrl: "https://example.com/docs", | ||
| 321 | }); | ||
| 322 | |||
| 323 | const failure = yield* Effect.either( | ||
| 324 | invoker.invoke({ | ||
| 325 | path: "events.create", | ||
| 326 | args: { | ||
| 327 | dateFrom: "not-a-date", | ||
| 328 | issueId: "not-a-uuid", | ||
| 329 | email: "not-an-email", | ||
| 330 | documentationUrl: "not-a-uri", | ||
| 331 | }, | ||
| 332 | }), | ||
| 333 | ); | ||
| 334 | |||
| 335 | assertTrue(Either.isLeft(failure)); | ||
| 336 | assertInstanceOf(failure.left, Error); | ||
| 337 | expect(failure.left.message).toContain('dateFrom: format: must match format "date-time"'); | ||
| 338 | expect(failure.left.message).toContain('issueId: format: must match format "uuid"'); | ||
| 339 | expect(failure.left.message).toContain('email: format: must match format "email"'); | ||
| 340 | expect(failure.left.message).toContain( | ||
| 341 | 'documentationUrl: format: must match format "uri"', | ||
| 342 | ); | ||
| 343 | }), | ||
| 344 | ); | ||
| 345 | |||
| 346 | it.effect("hydrates dynamic discover results via tool catalog", () => | ||
| 347 | Effect.gen(function* () { | ||
| 348 | const descriptors: Record<string, ToolDescriptor> = { | ||
| 349 | "source.docs.search": { | ||
| 350 | path: asToolPath("source.docs.search"), | ||
| 351 | sourceKey: "source.docs", | ||
| 352 | description: "Search docs", | ||
| 353 | contract: { | ||
| 354 | inputTypePreview: "object", | ||
| 355 | outputTypePreview: "object", | ||
| 356 | }, | ||
| 357 | }, | ||
| 358 | "source.issues.create": { | ||
| 359 | path: asToolPath("source.issues.create"), | ||
| 360 | sourceKey: "source.issues", | ||
| 361 | description: "Create issue", | ||
| 362 | interaction: "required", | ||
| 363 | contract: { | ||
| 364 | inputTypePreview: "object", | ||
| 365 | outputTypePreview: "object", | ||
| 366 | }, | ||
| 367 | }, | ||
| 368 | }; | ||
| 369 | |||
| 370 | const catalog: ToolCatalog = { | ||
| 371 | listNamespaces: () => | ||
| 372 | Effect.succeed([ | ||
| 373 | { | ||
| 374 | namespace: "docs", | ||
| 375 | displayName: "Docs", | ||
| 376 | toolCount: 1, | ||
| 377 | }, | ||
| 378 | { | ||
| 379 | namespace: "issues", | ||
| 380 | displayName: "Issues", | ||
| 381 | toolCount: 1, | ||
| 382 | }, | ||
| 383 | ]), | ||
| 384 | listTools: ({ namespace, limit, includeSchemas = false }) => | ||
| 385 | Effect.succeed( | ||
| 386 | Object.values(descriptors) | ||
| 387 | .filter((descriptor) => | ||
| 388 | !namespace | ||
| 389 | || descriptor.path.startsWith(`${namespace}.`) | ||
| 390 | || descriptor.path.startsWith(`source.${namespace}.`) | ||
| 391 | ) | ||
| 392 | .slice(0, limit) | ||
| 393 | .map((descriptor) => ({ | ||
| 394 | ...descriptor, | ||
| 395 | ...(descriptor.contract | ||
| 396 | ? { | ||
| 397 | contract: { | ||
| 398 | ...(descriptor.contract.inputTypePreview !== undefined | ||
| 399 | ? { inputTypePreview: descriptor.contract.inputTypePreview } | ||
| 400 | : {}), | ||
| 401 | ...(descriptor.contract.outputTypePreview !== undefined | ||
| 402 | ? { outputTypePreview: descriptor.contract.outputTypePreview } | ||
| 403 | : {}), | ||
| 404 | ...(includeSchemas && descriptor.contract.inputSchema !== undefined | ||
| 405 | ? { inputSchema: descriptor.contract.inputSchema } | ||
| 406 | : {}), | ||
| 407 | ...(includeSchemas && descriptor.contract.outputSchema !== undefined | ||
| 408 | ? { outputSchema: descriptor.contract.outputSchema } | ||
| 409 | : {}), | ||
| 410 | }, | ||
| 411 | } | ||
| 412 | : {}), | ||
| 413 | })), | ||
| 414 | ), | ||
| 415 | getToolByPath: ({ path }) => | ||
| 416 | Effect.succeed(descriptors[path] ?? null), | ||
| 417 | searchTools: () => | ||
| 418 | Effect.succeed([ | ||
| 419 | { path: asToolPath("source.issues.create"), score: 0.93 }, | ||
| 420 | { path: asToolPath("source.docs.search"), score: 0.72 }, | ||
| 421 | ]), | ||
| 422 | }; | ||
| 423 | |||
| 424 | const dynamic = createToolCatalogDiscovery({ catalog }); | ||
| 425 | |||
| 426 | const namespaces = yield* dynamic.primitives.catalog!.namespaces({ limit: 10 }); | ||
| 427 | expect(namespaces.namespaces).toHaveLength(2); | ||
| 428 | |||
| 429 | const discovered = yield* dynamic.primitives.discover!({ | ||
| 430 | query: "create issue", | ||
| 431 | limit: 5, | ||
| 432 | }); | ||
| 433 | |||
| 434 | expect(discovered.bestPath).toBe("source.issues.create"); | ||
| 435 | expect(discovered.results[0]?.path).toBe("source.issues.create"); | ||
| 436 | expect(discovered.results[0]?.interaction).toBe("required"); | ||
| 437 | |||
| 438 | const executeDescription = yield* dynamic.executeDescription; | ||
| 439 | expect(executeDescription).toContain( | ||
| 440 | 'const { results, bestPath } = await tools.discover({ query: "<intent>", limit: 12 });', | ||
| 441 | ); | ||
| 442 | expect(executeDescription).toContain( | ||
| 443 | 'const path = bestPath ?? results[0]?.path; if (!path) return "No matching tools found.";', | ||
| 444 | ); | ||
| 445 | expect(executeDescription).toContain( | ||
| 446 | "Object.keys(tools) is not a useful way to discover capabilities.", | ||
| 447 | ); | ||
| 448 | }), | ||
| 449 | ); | ||
| 450 | |||
| 451 | it.effect("builds a tool catalog from descriptor entries", () => | ||
| 452 | Effect.gen(function* () { | ||
| 453 | const catalog = createToolCatalogFromEntries({ | ||
| 454 | entries: [ | ||
| 455 | { | ||
| 456 | namespace: "github.repos", | ||
| 457 | searchText: "github repository details get repo", | ||
| 458 | descriptor: { | ||
| 459 | path: asToolPath("github.repos.getRepo"), | ||
| 460 | sourceKey: "source.github", | ||
| 461 | description: "Get repository details", | ||
| 462 | contract: { | ||
| 463 | inputTypePreview: "{ owner: string; repo: string }", | ||
| 464 | outputTypePreview: "Repository", | ||
| 465 | inputSchema: { type: "object" }, | ||
| 466 | }, | ||
| 467 | }, | ||
| 468 | }, | ||
| 469 | { | ||
| 470 | namespace: "github.issues", | ||
| 471 | searchText: "github issues list repository issues", | ||
| 472 | score: (queryTokens) => | ||
| 473 | queryTokens.includes("issues") && queryTokens.includes("repository") | ||
| 474 | ? 50 | ||
| 475 | : 0, | ||
| 476 | descriptor: { | ||
| 477 | path: asToolPath("github.issues.list"), | ||
| 478 | sourceKey: "source.github", | ||
| 479 | description: "List repository issues", | ||
| 480 | contract: { | ||
| 481 | inputTypePreview: "{ owner: string; repo: string }", | ||
| 482 | outputTypePreview: "Issue[]", | ||
| 483 | }, | ||
| 484 | }, | ||
| 485 | }, | ||
| 486 | ], | ||
| 487 | }); | ||
| 488 | |||
| 489 | const namespaces = yield* catalog.listNamespaces({ limit: 10 }); | ||
| 490 | expect(namespaces).toEqual([ | ||
| 491 | { namespace: "github.repos", toolCount: 1 }, | ||
| 492 | { namespace: "github.issues", toolCount: 1 }, | ||
| 493 | ]); | ||
| 494 | |||
| 495 | const listed = yield* catalog.listTools({ | ||
| 496 | namespace: "github.repos", | ||
| 497 | query: "repository details", | ||
| 498 | limit: 10, | ||
| 499 | includeSchemas: false, | ||
| 500 | }); | ||
| 501 | expect(listed[0]?.path).toBe("github.repos.getRepo"); | ||
| 502 | expect(listed[0]?.contract?.inputSchema).toBeUndefined(); | ||
| 503 | |||
| 504 | const discovered = yield* catalog.searchTools({ | ||
| 505 | query: "repository issues", | ||
| 506 | limit: 10, | ||
| 507 | }); | ||
| 508 | expect(discovered[0]?.path).toBe("github.issues.list"); | ||
| 509 | |||
| 510 | const described = yield* catalog.getToolByPath({ | ||
| 511 | path: asToolPath("github.repos.getRepo"), | ||
| 512 | includeSchemas: true, | ||
| 513 | }); | ||
| 514 | expect(described?.contract?.inputSchema).toEqual({ type: "object" }); | ||
| 515 | }), | ||
| 516 | ); | ||
| 517 | |||
| 518 | it.effect("system tools can be composed as normal tools", () => | ||
| 519 | Effect.gen(function* () { | ||
| 520 | const catalog = createToolCatalogFromTools({ | ||
| 521 | tools: { | ||
| 522 | "source.issues.create": toTool({ | ||
| 523 | tool: { | ||
| 524 | description: "Create issue", | ||
| 525 | inputSchema: titleInputSchema, | ||
| 526 | execute: ({ title }: { title: string }) => ({ id: "issue_1", title }), | ||
| 527 | }, | ||
| 528 | metadata: { | ||
| 529 | interaction: "required", | ||
| 530 | sourceKey: "source.issues", | ||
| 531 | }, | ||
| 532 | }), | ||
| 533 | }, | ||
| 534 | defaultNamespace: "issues", | ||
| 535 | }); | ||
| 536 | |||
| 537 | const systemTools = createSystemToolMap({ catalog }); | ||
| 538 | const allTools = mergeToolMaps([ | ||
| 539 | { | ||
| 540 | "math.add": { | ||
| 541 | inputSchema: numberPairInputSchema, | ||
| 542 | execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }), | ||
| 543 | }, | ||
| 544 | }, | ||
| 545 | systemTools, | ||
| 546 | ]); | ||
| 547 | |||
| 548 | const invoker = makeToolInvokerFromTools({ tools: allTools }); | ||
| 549 | const discovered = yield* invoker.invoke({ | ||
| 550 | path: "discover", | ||
| 551 | args: { query: "create issue", limit: 5 }, | ||
| 552 | }); | ||
| 553 | |||
| 554 | expect(discovered).toMatchObject({ | ||
| 555 | bestPath: "source.issues.create", | ||
| 556 | total: 1, | ||
| 557 | }); | ||
| 558 | |||
| 559 | const namespaces = yield* invoker.invoke({ | ||
| 560 | path: "catalog.namespaces", | ||
| 561 | args: { limit: 10 }, | ||
| 562 | }); | ||
| 563 | |||
| 564 | expect(namespaces).toEqual({ | ||
| 565 | namespaces: [ | ||
| 566 | { | ||
| 567 | namespace: "issues", | ||
| 568 | toolCount: 1, | ||
| 569 | }, | ||
| 570 | ], | ||
| 571 | }); | ||
| 572 | }), | ||
| 573 | ); | ||
| 574 | |||
| 575 | it.effect("executes code against tool map via executor contract", () => | ||
| 576 | Effect.gen(function* () { | ||
| 577 | const tools = { | ||
| 578 | "math.add": { | ||
| 579 | inputSchema: numberPairInputSchema, | ||
| 580 | execute: async ({ a, b }: { a: number; b: number }) => ({ sum: a + b }), | ||
| 581 | }, | ||
| 582 | "notifications.send": toTool({ | ||
| 583 | tool: { | ||
| 584 | inputSchema: messageInputSchema, | ||
| 585 | execute: async ({ message }: { message: string }) => ({ delivered: true, message }), | ||
| 586 | }, | ||
| 587 | metadata: { interaction: "required" }, | ||
| 588 | }), | ||
| 589 | } satisfies ToolMap; | ||
| 590 | |||
| 591 | const executor: CodeExecutor = { | ||
| 592 | execute: (code, toolInvoker) => | ||
| 593 | Effect.gen(function* () { | ||
| 594 | const math = yield* toolInvoker.invoke({ | ||
| 595 | path: "math.add", | ||
| 596 | args: { a: 2, b: 3 }, | ||
| 597 | }); | ||
| 598 | const notification = yield* toolInvoker.invoke({ | ||
| 599 | path: "notifications.send", | ||
| 600 | args: { message: "sum is 5" }, | ||
| 601 | }); | ||
| 602 | return { | ||
| 603 | result: { code, math, notification }, | ||
| 604 | logs: ["executed"], | ||
| 605 | }; | ||
| 606 | }), | ||
| 607 | }; | ||
| 608 | |||
| 609 | const code = "return await tools.math.add({ a: 2, b: 3 });"; | ||
| 610 | const output = yield* executor.execute( | ||
| 611 | code, | ||
| 612 | makeToolInvokerFromTools({ | ||
| 613 | tools, | ||
| 614 | onToolInteraction: allowAllToolInteractions, | ||
| 615 | }), | ||
| 616 | ); | ||
| 617 | |||
| 618 | expect(output.result).toEqual({ | ||
| 619 | code, | ||
| 620 | math: { sum: 5 }, | ||
| 621 | notification: { delivered: true, message: "sum is 5" }, | ||
| 622 | }); | ||
| 623 | expect(output.logs).toEqual(["executed"]); | ||
| 624 | }), | ||
| 625 | ); | ||
| 626 | |||
| 627 | it.effect("supports lazy tool invoker without passing tools", () => | ||
| 628 | Effect.gen(function* () { | ||
| 629 | let mathCalls = 0; | ||
| 630 | |||
| 631 | const toolInvoker = makeToolInvokerFromTools({ | ||
| 632 | tools: { | ||
| 633 | "math.add": { | ||
| 634 | inputSchema: numberPairInputSchema, | ||
| 635 | execute: ({ a, b }: { a: number; b: number }) => { | ||
| 636 | mathCalls += 1; | ||
| 637 | return { sum: a + b }; | ||
| 638 | }, | ||
| 639 | }, | ||
| 640 | }, | ||
| 641 | }); | ||
| 642 | |||
| 643 | const executor: CodeExecutor = { | ||
| 644 | execute: (code, invoker) => | ||
| 645 | Effect.gen(function* () { | ||
| 646 | const math = yield* invoker.invoke({ | ||
| 647 | path: "math.add", | ||
| 648 | args: { a: 20, b: 22 }, | ||
| 649 | }); | ||
| 650 | return { | ||
| 651 | result: { code, math }, | ||
| 652 | logs: ["lazy"], | ||
| 653 | }; | ||
| 654 | }), | ||
| 655 | }; | ||
| 656 | |||
| 657 | const code = "return await tools.math.add({ a: 20, b: 22 });"; | ||
| 658 | const output = yield* executor.execute(code, toolInvoker); | ||
| 659 | |||
| 660 | expect(mathCalls).toBe(1); | ||
| 661 | expect(output.result).toEqual({ | ||
| 662 | code, | ||
| 663 | math: { sum: 42 }, | ||
| 664 | }); | ||
| 665 | expect(output.logs).toEqual(["lazy"]); | ||
| 666 | }), | ||
| 667 | ); | ||
| 668 | |||
| 669 | it.effect("surfaces executor-reported errors", () => | ||
| 670 | Effect.gen(function* () { | ||
| 671 | const executor: CodeExecutor = { | ||
| 672 | execute: (_code, _toolInvoker) => | ||
| 673 | Effect.succeed({ result: null, error: "boom" }), | ||
| 674 | }; | ||
| 675 | |||
| 676 | const output = yield* executor.execute( | ||
| 677 | "return 1", | ||
| 678 | makeToolInvokerFromTools({ tools: {} }), | ||
| 679 | ); | ||
| 680 | |||
| 681 | expect(output.error).toBe("boom"); | ||
| 682 | }), | ||
| 683 | ); | ||
| 684 | }); | ||
| @@ -1,8 +1,4 @@ | |||
| 1 | export * from "./types"; | 1 | export * from "./types"; |
| 2 | export * from "./validation"; | ||
| 2 | export * from "./json-schema"; | 3 | export * from "./json-schema"; |
| 3 | export * from "./schema-types"; | 4 | export * from "./effect-errors"; |
| 4 | export * from "./tool-map"; | ||
| 5 | export * from "./discovery"; | ||
| 6 | export * from "./system-tools"; | ||
| 7 | export * from "./http-request-placements"; | ||
| 8 | export * from "./isomorphic-hash"; | ||
| @@ -1,132 +0,0 @@ | |||
| 1 | /** | ||
| 2 | * Isomorphic SHA-256 implementation that works in both Node.js and the browser. | ||
| 3 | * | ||
| 4 | * Avoids importing `node:crypto`, which Vite externalizes for browser builds, | ||
| 5 | * causing runtime errors when this module is pulled into client bundles. | ||
| 6 | * | ||
| 7 | * Uses the same SHA-256 algorithm as `node:crypto`, producing identical hex output. | ||
| 8 | */ | ||
| 9 | |||
| 10 | // SHA-256 constants: first 32 bits of the fractional parts of the cube roots of the first 64 primes | ||
| 11 | const K = new Uint32Array([ | ||
| 12 | 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, | ||
| 13 | 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, | ||
| 14 | 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, | ||
| 15 | 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, | ||
| 16 | 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, | ||
| 17 | 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, | ||
| 18 | 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, | ||
| 19 | 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, | ||
| 20 | ]); | ||
| 21 | |||
| 22 | const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n)); | ||
| 23 | |||
| 24 | const sha256Compress = (state: Uint32Array, block: Uint32Array): void => { | ||
| 25 | const w = new Uint32Array(64); | ||
| 26 | for (let i = 0; i < 16; i++) w[i] = block[i]; | ||
| 27 | for (let i = 16; i < 64; i++) { | ||
| 28 | const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); | ||
| 29 | const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); | ||
| 30 | w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; | ||
| 31 | } | ||
| 32 | |||
| 33 | let a = state[0], b = state[1], c = state[2], d = state[3]; | ||
| 34 | let e = state[4], f = state[5], g = state[6], h = state[7]; | ||
| 35 | |||
| 36 | for (let i = 0; i < 64; i++) { | ||
| 37 | const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); | ||
| 38 | const ch = (e & f) ^ (~e & g); | ||
| 39 | const temp1 = (h + S1 + ch + K[i] + w[i]) | 0; | ||
| 40 | const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); | ||
| 41 | const maj = (a & b) ^ (a & c) ^ (b & c); | ||
| 42 | const temp2 = (S0 + maj) | 0; | ||
| 43 | |||
| 44 | h = g; g = f; f = e; e = (d + temp1) | 0; | ||
| 45 | d = c; c = b; b = a; a = (temp1 + temp2) | 0; | ||
| 46 | } | ||
| 47 | |||
| 48 | state[0] = (state[0] + a) | 0; | ||
| 49 | state[1] = (state[1] + b) | 0; | ||
| 50 | state[2] = (state[2] + c) | 0; | ||
| 51 | state[3] = (state[3] + d) | 0; | ||
| 52 | state[4] = (state[4] + e) | 0; | ||
| 53 | state[5] = (state[5] + f) | 0; | ||
| 54 | state[6] = (state[6] + g) | 0; | ||
| 55 | state[7] = (state[7] + h) | 0; | ||
| 56 | }; | ||
| 57 | |||
| 58 | const utf8Encode = (str: string): Uint8Array => { | ||
| 59 | if (typeof TextEncoder !== "undefined") { | ||
| 60 | return new TextEncoder().encode(str); | ||
| 61 | } | ||
| 62 | |||
| 63 | // Fallback for environments without TextEncoder | ||
| 64 | const bytes: number[] = []; | ||
| 65 | for (let i = 0; i < str.length; i++) { | ||
| 66 | let c = str.charCodeAt(i); | ||
| 67 | if (c < 0x80) { | ||
| 68 | bytes.push(c); | ||
| 69 | } else if (c < 0x800) { | ||
| 70 | bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); | ||
| 71 | } else if (c >= 0xd800 && c < 0xdc00 && i + 1 < str.length) { | ||
| 72 | const next = str.charCodeAt(++i); | ||
| 73 | c = 0x10000 + ((c - 0xd800) << 10) + (next - 0xdc00); | ||
| 74 | bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); | ||
| 75 | } else { | ||
| 76 | bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); | ||
| 77 | } | ||
| 78 | } | ||
| 79 | return new Uint8Array(bytes); | ||
| 80 | }; | ||
| 81 | |||
| 82 | const sha256 = (data: Uint8Array): Uint8Array => { | ||
| 83 | const state = new Uint32Array([ | ||
| 84 | 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, | ||
| 85 | 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, | ||
| 86 | ]); | ||
| 87 | |||
| 88 | const bitLen = data.length * 8; | ||
| 89 | |||
| 90 | // Padding: append 1 bit, then zeros, then 64-bit big-endian length | ||
| 91 | const padded = new Uint8Array(Math.ceil((data.length + 9) / 64) * 64); | ||
| 92 | padded.set(data); | ||
| 93 | padded[data.length] = 0x80; | ||
| 94 | // Write the 64-bit big-endian bit length at the end | ||
| 95 | const view = new DataView(padded.buffer); | ||
| 96 | // For strings up to ~256 MB, the high 32 bits of the bit length are zero | ||
| 97 | view.setUint32(padded.length - 4, bitLen, false); | ||
| 98 | if (bitLen > 0xffffffff) { | ||
| 99 | view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000), false); | ||
| 100 | } | ||
| 101 | |||
| 102 | // Process each 64-byte block | ||
| 103 | const block = new Uint32Array(16); | ||
| 104 | for (let offset = 0; offset < padded.length; offset += 64) { | ||
| 105 | for (let i = 0; i < 16; i++) { | ||
| 106 | block[i] = view.getUint32(offset + i * 4, false); | ||
| 107 | } | ||
| 108 | sha256Compress(state, block); | ||
| 109 | } | ||
| 110 | |||
| 111 | const result = new Uint8Array(32); | ||
| 112 | const resultView = new DataView(result.buffer); | ||
| 113 | for (let i = 0; i < 8; i++) { | ||
| 114 | resultView.setUint32(i * 4, state[i], false); | ||
| 115 | } | ||
| 116 | return result; | ||
| 117 | }; | ||
| 118 | |||
| 119 | const hexFromBytes = (bytes: Uint8Array): string => { | ||
| 120 | let hex = ""; | ||
| 121 | for (let i = 0; i < bytes.length; i++) { | ||
| 122 | hex += bytes[i].toString(16).padStart(2, "0"); | ||
| 123 | } | ||
| 124 | return hex; | ||
| 125 | }; | ||
| 126 | |||
| 127 | /** | ||
| 128 | * Compute a SHA-256 hex digest of a UTF-8 string. | ||
| 129 | * Drop-in replacement for `createHash("sha256").update(value).digest("hex")`. | ||
| 130 | */ | ||
| 131 | export const sha256Hex = (value: string): string => | ||
| 132 | hexFromBytes(sha256(utf8Encode(value))); | ||
| @@ -1,98 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { typeSignatureFromSchema } from "./schema-types"; | ||
| 4 | |||
| 5 | describe("schema-types", () => { | ||
| 6 | it("expands local $defs refs in type signatures", () => { | ||
| 7 | const schema = { | ||
| 8 | type: "object", | ||
| 9 | properties: { | ||
| 10 | args: { | ||
| 11 | type: "object", | ||
| 12 | properties: { | ||
| 13 | input: { | ||
| 14 | $ref: "#/$defs/input", | ||
| 15 | }, | ||
| 16 | }, | ||
| 17 | required: ["input"], | ||
| 18 | additionalProperties: false, | ||
| 19 | }, | ||
| 20 | }, | ||
| 21 | required: ["args"], | ||
| 22 | additionalProperties: false, | ||
| 23 | $defs: { | ||
| 24 | input: { | ||
| 25 | type: "object", | ||
| 26 | properties: { | ||
| 27 | issueId: { | ||
| 28 | type: "string", | ||
| 29 | }, | ||
| 30 | externalLink: { | ||
| 31 | type: "string", | ||
| 32 | }, | ||
| 33 | }, | ||
| 34 | required: ["issueId"], | ||
| 35 | additionalProperties: false, | ||
| 36 | }, | ||
| 37 | }, | ||
| 38 | }; | ||
| 39 | |||
| 40 | expect(typeSignatureFromSchema(schema, "unknown", Infinity)).toBe( | ||
| 41 | "{ args: { input: { issueId: string, externalLink?: string } } }", | ||
| 42 | ); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("renders GraphQL error arrays with object item shapes", () => { | ||
| 46 | const schema = { | ||
| 47 | type: "object", | ||
| 48 | properties: { | ||
| 49 | data: { | ||
| 50 | type: "object", | ||
| 51 | properties: { | ||
| 52 | success: { | ||
| 53 | type: "boolean", | ||
| 54 | }, | ||
| 55 | }, | ||
| 56 | required: ["success"], | ||
| 57 | additionalProperties: false, | ||
| 58 | }, | ||
| 59 | errors: { | ||
| 60 | type: "array", | ||
| 61 | items: { | ||
| 62 | type: "object", | ||
| 63 | properties: { | ||
| 64 | message: { | ||
| 65 | type: "string", | ||
| 66 | }, | ||
| 67 | }, | ||
| 68 | required: ["message"], | ||
| 69 | additionalProperties: true, | ||
| 70 | }, | ||
| 71 | }, | ||
| 72 | }, | ||
| 73 | required: ["data", "errors"], | ||
| 74 | additionalProperties: false, | ||
| 75 | }; | ||
| 76 | |||
| 77 | expect(typeSignatureFromSchema(schema, "unknown", Infinity)).toContain( | ||
| 78 | "errors: { message: string }[]", | ||
| 79 | ); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("quotes invalid object property names", () => { | ||
| 83 | const schema = { | ||
| 84 | type: "object", | ||
| 85 | properties: { | ||
| 86 | "x-request-id": { | ||
| 87 | type: "string", | ||
| 88 | }, | ||
| 89 | }, | ||
| 90 | required: ["x-request-id"], | ||
| 91 | additionalProperties: false, | ||
| 92 | }; | ||
| 93 | |||
| 94 | expect(typeSignatureFromSchema(schema, "unknown", Infinity)).toBe( | ||
| 95 | '{ "x-request-id": string }', | ||
| 96 | ); | ||
| 97 | }); | ||
| 98 | }); | ||
| @@ -1,249 +0,0 @@ | |||
| 1 | type JsonSchemaRecord = Record<string, unknown>; | ||
| 2 | |||
| 3 | export type TypeSignatureRenderOptions = { | ||
| 4 | maxLength?: number; | ||
| 5 | maxDepth?: number; | ||
| 6 | maxProperties?: number; | ||
| 7 | }; | ||
| 8 | |||
| 9 | const asRecord = (value: unknown): JsonSchemaRecord => | ||
| 10 | typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 11 | ? (value as JsonSchemaRecord) | ||
| 12 | : {}; | ||
| 13 | |||
| 14 | const asStringArray = (value: unknown): Array<string> => | ||
| 15 | Array.isArray(value) | ||
| 16 | ? value.filter((item): item is string => typeof item === "string") | ||
| 17 | : []; | ||
| 18 | |||
| 19 | const truncate = (value: string, maxLength: number): string => | ||
| 20 | value.length <= maxLength | ||
| 21 | ? value | ||
| 22 | : `${value.slice(0, Math.max(0, maxLength - 4))} ...`; | ||
| 23 | |||
| 24 | const VALID_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; | ||
| 25 | |||
| 26 | const formatPropertyKey = (value: string): string => | ||
| 27 | VALID_IDENTIFIER_PATTERN.test(value) ? value : JSON.stringify(value); | ||
| 28 | |||
| 29 | const compositeLabel = ( | ||
| 30 | key: "oneOf" | "anyOf" | "allOf", | ||
| 31 | schema: JsonSchemaRecord, | ||
| 32 | render: (input: unknown) => string, | ||
| 33 | ): string | null => { | ||
| 34 | const items = Array.isArray(schema[key]) ? schema[key].map(asRecord) : []; | ||
| 35 | if (items.length === 0) { | ||
| 36 | return null; | ||
| 37 | } | ||
| 38 | |||
| 39 | const labels = items | ||
| 40 | .map((item) => render(item)) | ||
| 41 | .filter((label) => label.length > 0); | ||
| 42 | |||
| 43 | if (labels.length === 0) { | ||
| 44 | return null; | ||
| 45 | } | ||
| 46 | |||
| 47 | return labels.join(key === "allOf" ? " & " : " | "); | ||
| 48 | }; | ||
| 49 | |||
| 50 | const refSegments = (ref: string): Array<string> | null => { | ||
| 51 | if (!ref.startsWith("#/")) { | ||
| 52 | return null; | ||
| 53 | } | ||
| 54 | |||
| 55 | return ref | ||
| 56 | .slice(2) | ||
| 57 | .split("/") | ||
| 58 | .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")); | ||
| 59 | }; | ||
| 60 | |||
| 61 | const resolveLocalRef = ( | ||
| 62 | root: JsonSchemaRecord, | ||
| 63 | ref: string, | ||
| 64 | ): JsonSchemaRecord | null => { | ||
| 65 | const segments = refSegments(ref); | ||
| 66 | if (!segments || segments.length === 0) { | ||
| 67 | return null; | ||
| 68 | } | ||
| 69 | |||
| 70 | let current: unknown = root; | ||
| 71 | for (const segment of segments) { | ||
| 72 | const record = asRecord(current); | ||
| 73 | if (!(segment in record)) { | ||
| 74 | return null; | ||
| 75 | } | ||
| 76 | current = record[segment]; | ||
| 77 | } | ||
| 78 | |||
| 79 | const resolved = asRecord(current); | ||
| 80 | return Object.keys(resolved).length > 0 ? resolved : null; | ||
| 81 | }; | ||
| 82 | |||
| 83 | const refFallbackLabel = (ref: string): string => { | ||
| 84 | const segments = refSegments(ref); | ||
| 85 | return segments?.at(-1) ?? ref; | ||
| 86 | }; | ||
| 87 | |||
| 88 | export const schemaToTypeSignatureWithOptions = ( | ||
| 89 | input: unknown, | ||
| 90 | options: TypeSignatureRenderOptions = {}, | ||
| 91 | ): string => { | ||
| 92 | const root = asRecord(input); | ||
| 93 | const maxLength = options.maxLength ?? 220; | ||
| 94 | const boundedSummary = Number.isFinite(maxLength); | ||
| 95 | const maxDepth = options.maxDepth ?? (boundedSummary ? 4 : Number.POSITIVE_INFINITY); | ||
| 96 | const maxProperties = options.maxProperties ?? (boundedSummary ? 6 : Number.POSITIVE_INFINITY); | ||
| 97 | |||
| 98 | const render = ( | ||
| 99 | currentInput: unknown, | ||
| 100 | seenRefs: ReadonlySet<string>, | ||
| 101 | depthRemaining: number, | ||
| 102 | ): string => { | ||
| 103 | const schema = asRecord(currentInput); | ||
| 104 | |||
| 105 | if (depthRemaining <= 0) { | ||
| 106 | if (typeof schema.title === "string" && schema.title.length > 0) { | ||
| 107 | return schema.title; | ||
| 108 | } | ||
| 109 | |||
| 110 | if (schema.type === "array") { | ||
| 111 | return "unknown[]"; | ||
| 112 | } | ||
| 113 | |||
| 114 | if (schema.type === "object" || schema.properties) { | ||
| 115 | return schema.additionalProperties ? "Record<string, unknown>" : "object"; | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | if (typeof schema.$ref === "string") { | ||
| 120 | const ref = schema.$ref.trim(); | ||
| 121 | if (ref.length === 0) { | ||
| 122 | return "unknown"; | ||
| 123 | } | ||
| 124 | |||
| 125 | if (seenRefs.has(ref)) { | ||
| 126 | return refFallbackLabel(ref); | ||
| 127 | } | ||
| 128 | |||
| 129 | const target = resolveLocalRef(root, ref); | ||
| 130 | if (!target) { | ||
| 131 | return refFallbackLabel(ref); | ||
| 132 | } | ||
| 133 | |||
| 134 | return render(target, new Set([...seenRefs, ref]), depthRemaining - 1); | ||
| 135 | } | ||
| 136 | |||
| 137 | if ("const" in schema) { | ||
| 138 | return JSON.stringify(schema.const); | ||
| 139 | } | ||
| 140 | |||
| 141 | const enumValues = Array.isArray(schema.enum) ? schema.enum : []; | ||
| 142 | if (enumValues.length > 0) { | ||
| 143 | return truncate( | ||
| 144 | enumValues.map((value) => JSON.stringify(value)).join(" | "), | ||
| 145 | maxLength, | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | |||
| 149 | const composite = | ||
| 150 | compositeLabel("oneOf", schema, (value) => render(value, seenRefs, depthRemaining - 1)) | ||
| 151 | ?? compositeLabel("anyOf", schema, (value) => render(value, seenRefs, depthRemaining - 1)) | ||
| 152 | ?? compositeLabel("allOf", schema, (value) => render(value, seenRefs, depthRemaining - 1)); | ||
| 153 | if (composite) { | ||
| 154 | return truncate(composite, maxLength); | ||
| 155 | } | ||
| 156 | |||
| 157 | if (schema.type === "array") { | ||
| 158 | const itemLabel = schema.items | ||
| 159 | ? render(schema.items, seenRefs, depthRemaining - 1) | ||
| 160 | : "unknown"; | ||
| 161 | return truncate(`${itemLabel}[]`, maxLength); | ||
| 162 | } | ||
| 163 | |||
| 164 | if (schema.type === "object" || schema.properties) { | ||
| 165 | const properties = asRecord(schema.properties); | ||
| 166 | const keys = Object.keys(properties); | ||
| 167 | if (keys.length === 0) { | ||
| 168 | return schema.additionalProperties ? "Record<string, unknown>" : "object"; | ||
| 169 | } | ||
| 170 | |||
| 171 | const required = new Set(asStringArray(schema.required)); | ||
| 172 | const visibleKeys = keys.slice(0, maxProperties); | ||
| 173 | const parts = visibleKeys.map((key) => | ||
| 174 | `${formatPropertyKey(key)}${required.has(key) ? "" : "?"}: ${render(properties[key], seenRefs, depthRemaining - 1)}` | ||
| 175 | ); | ||
| 176 | if (visibleKeys.length < keys.length) { | ||
| 177 | parts.push("..."); | ||
| 178 | } | ||
| 179 | |||
| 180 | return truncate(`{ ${parts.join(", ")} }`, maxLength); | ||
| 181 | } | ||
| 182 | |||
| 183 | if (Array.isArray(schema.type)) { | ||
| 184 | return truncate(schema.type.join(" | "), maxLength); | ||
| 185 | } | ||
| 186 | |||
| 187 | if (typeof schema.type === "string") { | ||
| 188 | return schema.type; | ||
| 189 | } | ||
| 190 | |||
| 191 | return "unknown"; | ||
| 192 | }; | ||
| 193 | |||
| 194 | return render(root, new Set(), maxDepth); | ||
| 195 | }; | ||
| 196 | |||
| 197 | export const schemaToTypeSignature = ( | ||
| 198 | input: unknown, | ||
| 199 | maxLength: number = 220, | ||
| 200 | ): string => | ||
| 201 | schemaToTypeSignatureWithOptions(input, { maxLength }); | ||
| 202 | |||
| 203 | export const typeSignatureFromSchemaJson = ( | ||
| 204 | schemaJson: string | undefined, | ||
| 205 | fallback: string, | ||
| 206 | maxLength: number = 220, | ||
| 207 | ): string => { | ||
| 208 | if (!schemaJson) { | ||
| 209 | return fallback; | ||
| 210 | } | ||
| 211 | |||
| 212 | try { | ||
| 213 | return schemaToTypeSignature(JSON.parse(schemaJson) as unknown, maxLength); | ||
| 214 | } catch { | ||
| 215 | return fallback; | ||
| 216 | } | ||
| 217 | }; | ||
| 218 | |||
| 219 | export const typeSignatureFromSchema = ( | ||
| 220 | schema: unknown, | ||
| 221 | fallback: string, | ||
| 222 | maxLength: number = 220, | ||
| 223 | ): string => { | ||
| 224 | if (schema === undefined) { | ||
| 225 | return fallback; | ||
| 226 | } | ||
| 227 | |||
| 228 | try { | ||
| 229 | return schemaToTypeSignature(schema, maxLength); | ||
| 230 | } catch { | ||
| 231 | return fallback; | ||
| 232 | } | ||
| 233 | }; | ||
| 234 | |||
| 235 | export const typeSignatureFromSchemaWithOptions = ( | ||
| 236 | schema: unknown, | ||
| 237 | fallback: string, | ||
| 238 | options: TypeSignatureRenderOptions = {}, | ||
| 239 | ): string => { | ||
| 240 | if (schema === undefined) { | ||
| 241 | return fallback; | ||
| 242 | } | ||
| 243 | |||
| 244 | try { | ||
| 245 | return schemaToTypeSignatureWithOptions(schema, options); | ||
| 246 | } catch { | ||
| 247 | return fallback; | ||
| 248 | } | ||
| 249 | }; | ||
| @@ -1,247 +0,0 @@ | |||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | import { createDiscoveryPrimitivesFromToolCatalog } from "./discovery"; | ||
| 5 | import { toTool } from "./tool-map"; | ||
| 6 | import { | ||
| 7 | ToolDescriptorSchema, | ||
| 8 | ToolNamespaceSchema, | ||
| 9 | type ToolCatalog, | ||
| 10 | type ToolMap, | ||
| 11 | type ToolPath, | ||
| 12 | } from "./types"; | ||
| 13 | |||
| 14 | const asToolPath = (value: string): ToolPath => value as ToolPath; | ||
| 15 | |||
| 16 | const catalogNamespacesInputSchema = Schema.standardSchemaV1( | ||
| 17 | Schema.Struct({ | ||
| 18 | limit: Schema.optional(Schema.Number), | ||
| 19 | }), | ||
| 20 | ); | ||
| 21 | |||
| 22 | const catalogNamespacesOutputSchema = Schema.standardSchemaV1( | ||
| 23 | Schema.Struct({ | ||
| 24 | namespaces: Schema.Array(ToolNamespaceSchema), | ||
| 25 | }), | ||
| 26 | ); | ||
| 27 | |||
| 28 | const catalogToolsInputSchema = Schema.standardSchemaV1( | ||
| 29 | Schema.Struct({ | ||
| 30 | namespace: Schema.optional(Schema.String), | ||
| 31 | query: Schema.optional(Schema.String), | ||
| 32 | limit: Schema.optional(Schema.Number), | ||
| 33 | includeSchemas: Schema.optional(Schema.Boolean), | ||
| 34 | }), | ||
| 35 | ); | ||
| 36 | |||
| 37 | const catalogToolsOutputSchema = Schema.standardSchemaV1( | ||
| 38 | Schema.Struct({ | ||
| 39 | results: Schema.Array(ToolDescriptorSchema), | ||
| 40 | }), | ||
| 41 | ); | ||
| 42 | |||
| 43 | const describeToolInputSchema = Schema.standardSchemaV1( | ||
| 44 | Schema.Struct({ | ||
| 45 | path: Schema.String, | ||
| 46 | includeSchemas: Schema.optional(Schema.Boolean), | ||
| 47 | }), | ||
| 48 | ); | ||
| 49 | |||
| 50 | const describeToolOutputSchema = Schema.standardSchemaV1( | ||
| 51 | Schema.NullOr(ToolDescriptorSchema), | ||
| 52 | ); | ||
| 53 | |||
| 54 | const discoverInputSchema = Schema.standardSchemaV1( | ||
| 55 | Schema.Struct({ | ||
| 56 | query: Schema.String, | ||
| 57 | limit: Schema.optional(Schema.Number), | ||
| 58 | includeSchemas: Schema.optional(Schema.Boolean), | ||
| 59 | }), | ||
| 60 | ); | ||
| 61 | |||
| 62 | const discoverResultItemSchema = Schema.extend( | ||
| 63 | ToolDescriptorSchema, | ||
| 64 | Schema.Struct({ | ||
| 65 | score: Schema.Number, | ||
| 66 | }), | ||
| 67 | ); | ||
| 68 | |||
| 69 | const discoverOutputSchema = Schema.standardSchemaV1( | ||
| 70 | Schema.Struct({ | ||
| 71 | bestPath: Schema.NullOr(Schema.String), | ||
| 72 | results: Schema.Array(discoverResultItemSchema), | ||
| 73 | total: Schema.Number, | ||
| 74 | }), | ||
| 75 | ); | ||
| 76 | |||
| 77 | export type DiscoverResultItem = Record<string, unknown> & { | ||
| 78 | path: ToolPath; | ||
| 79 | score: number; | ||
| 80 | }; | ||
| 81 | |||
| 82 | export type CreateSystemToolMapInput = { | ||
| 83 | catalog?: ToolCatalog; | ||
| 84 | getCatalog?: () => ToolCatalog; | ||
| 85 | sourceKey?: string; | ||
| 86 | filterDiscoverResults?: ( | ||
| 87 | results: readonly DiscoverResultItem[], | ||
| 88 | ) => Promise<readonly DiscoverResultItem[]> | readonly DiscoverResultItem[]; | ||
| 89 | }; | ||
| 90 | |||
| 91 | export const createSystemToolMap = ( | ||
| 92 | input: CreateSystemToolMapInput, | ||
| 93 | ): ToolMap => { | ||
| 94 | const sourceKey = input.sourceKey ?? "system"; | ||
| 95 | const getCatalog = (): ToolCatalog => { | ||
| 96 | if (input.catalog) { | ||
| 97 | return input.catalog; | ||
| 98 | } | ||
| 99 | if (input.getCatalog) { | ||
| 100 | return input.getCatalog(); | ||
| 101 | } | ||
| 102 | |||
| 103 | throw new Error("createSystemToolMap requires a catalog or getCatalog"); | ||
| 104 | }; | ||
| 105 | const getPrimitives = () => | ||
| 106 | createDiscoveryPrimitivesFromToolCatalog({ | ||
| 107 | catalog: getCatalog(), | ||
| 108 | }) as Required<ReturnType<typeof createDiscoveryPrimitivesFromToolCatalog>>; | ||
| 109 | |||
| 110 | const tools: ToolMap = {}; | ||
| 111 | |||
| 112 | tools["catalog.namespaces"] = toTool({ | ||
| 113 | tool: { | ||
| 114 | description: "List available namespaces with display names and tool counts", | ||
| 115 | inputSchema: catalogNamespacesInputSchema, | ||
| 116 | outputSchema: catalogNamespacesOutputSchema, | ||
| 117 | execute: ({ limit }: { limit?: number }) => | ||
| 118 | Effect.runPromise( | ||
| 119 | getPrimitives().catalog.namespaces((limit !== undefined ? { limit } : {})), | ||
| 120 | ), | ||
| 121 | }, | ||
| 122 | metadata: { | ||
| 123 | sourceKey, | ||
| 124 | interaction: "auto", | ||
| 125 | }, | ||
| 126 | }); | ||
| 127 | |||
| 128 | tools["catalog.tools"] = toTool({ | ||
| 129 | tool: { | ||
| 130 | description: "List tools with optional namespace and query filters", | ||
| 131 | inputSchema: catalogToolsInputSchema, | ||
| 132 | outputSchema: catalogToolsOutputSchema, | ||
| 133 | execute: ( | ||
| 134 | toolInput: { | ||
| 135 | namespace?: string; | ||
| 136 | query?: string; | ||
| 137 | limit?: number; | ||
| 138 | includeSchemas?: boolean; | ||
| 139 | }, | ||
| 140 | ) => | ||
| 141 | Effect.runPromise( | ||
| 142 | getPrimitives().catalog.tools({ | ||
| 143 | ...(toolInput.namespace !== undefined | ||
| 144 | ? { namespace: toolInput.namespace } | ||
| 145 | : {}), | ||
| 146 | ...(toolInput.query !== undefined ? { query: toolInput.query } : {}), | ||
| 147 | ...(toolInput.limit !== undefined ? { limit: toolInput.limit } : {}), | ||
| 148 | ...(toolInput.includeSchemas !== undefined | ||
| 149 | ? { includeSchemas: toolInput.includeSchemas } | ||
| 150 | : {}), | ||
| 151 | }), | ||
| 152 | ), | ||
| 153 | }, | ||
| 154 | metadata: { | ||
| 155 | sourceKey, | ||
| 156 | interaction: "auto", | ||
| 157 | }, | ||
| 158 | }); | ||
| 159 | |||
| 160 | tools["describe.tool"] = toTool({ | ||
| 161 | tool: { | ||
| 162 | description: "Get metadata and optional schemas for a tool path", | ||
| 163 | inputSchema: describeToolInputSchema, | ||
| 164 | outputSchema: describeToolOutputSchema, | ||
| 165 | execute: ({ path, includeSchemas }: { path: string; includeSchemas?: boolean }) => | ||
| 166 | Effect.runPromise( | ||
| 167 | getPrimitives().describe.tool({ | ||
| 168 | path: asToolPath(path), | ||
| 169 | ...(includeSchemas !== undefined ? { includeSchemas } : {}), | ||
| 170 | }), | ||
| 171 | ), | ||
| 172 | }, | ||
| 173 | metadata: { | ||
| 174 | sourceKey, | ||
| 175 | interaction: "auto", | ||
| 176 | }, | ||
| 177 | }); | ||
| 178 | |||
| 179 | tools.discover = toTool({ | ||
| 180 | tool: { | ||
| 181 | description: "Search tools by intent and return ranked matches", | ||
| 182 | inputSchema: discoverInputSchema, | ||
| 183 | outputSchema: discoverOutputSchema, | ||
| 184 | execute: async ( | ||
| 185 | toolInput: { | ||
| 186 | query: string; | ||
| 187 | limit?: number; | ||
| 188 | includeSchemas?: boolean; | ||
| 189 | }, | ||
| 190 | ) => { | ||
| 191 | const result = await Effect.runPromise( | ||
| 192 | getPrimitives().discover({ | ||
| 193 | query: toolInput.query, | ||
| 194 | ...(toolInput.limit !== undefined ? { limit: toolInput.limit } : {}), | ||
| 195 | ...(toolInput.includeSchemas !== undefined | ||
| 196 | ? { includeSchemas: toolInput.includeSchemas } | ||
| 197 | : {}), | ||
| 198 | }), | ||
| 199 | ); | ||
| 200 | |||
| 201 | if (!input.filterDiscoverResults) { | ||
| 202 | return result; | ||
| 203 | } | ||
| 204 | |||
| 205 | const filtered = await input.filterDiscoverResults( | ||
| 206 | result.results as DiscoverResultItem[], | ||
| 207 | ); | ||
| 208 | |||
| 209 | return { | ||
| 210 | ...result, | ||
| 211 | results: filtered, | ||
| 212 | bestPath: filtered[0]?.path ?? null, | ||
| 213 | total: filtered.length, | ||
| 214 | }; | ||
| 215 | }, | ||
| 216 | }, | ||
| 217 | metadata: { | ||
| 218 | sourceKey, | ||
| 219 | interaction: "auto", | ||
| 220 | }, | ||
| 221 | }); | ||
| 222 | |||
| 223 | return tools; | ||
| 224 | }; | ||
| 225 | |||
| 226 | export type MergeToolMapsOptions = { | ||
| 227 | conflictMode?: "throw" | "override"; | ||
| 228 | }; | ||
| 229 | |||
| 230 | export const mergeToolMaps = ( | ||
| 231 | maps: ReadonlyArray<ToolMap>, | ||
| 232 | options: MergeToolMapsOptions = {}, | ||
| 233 | ): ToolMap => { | ||
| 234 | const conflictMode = options.conflictMode ?? "throw"; | ||
| 235 | const merged: ToolMap = {}; | ||
| 236 | |||
| 237 | for (const map of maps) { | ||
| 238 | for (const [path, tool] of Object.entries(map)) { | ||
| 239 | if (conflictMode === "throw" && path in merged) { | ||
| 240 | throw new Error(`Tool path conflict: ${path}`); | ||
| 241 | } | ||
| 242 | merged[path] = tool; | ||
| 243 | } | ||
| 244 | } | ||
| 245 | |||
| 246 | return merged; | ||
| 247 | }; | ||
| @@ -1,549 +0,0 @@ | |||
| 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; | ||
| 2 | import * as Data from "effect/Data"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as JSONSchema from "effect/JSONSchema"; | ||
| 5 | import { typeSignatureFromSchema } from "./schema-types"; | ||
| 6 | |||
| 7 | import type { | ||
| 8 | ElicitationRequest, | ||
| 9 | ElicitationResponse, | ||
| 10 | ExecutableTool, | ||
| 11 | OnElicitation, | ||
| 12 | OnToolInteraction, | ||
| 13 | ToolDefinition, | ||
| 14 | ToolDescriptor, | ||
| 15 | ToolElicitationRequest, | ||
| 16 | ToolInput, | ||
| 17 | ToolInteractionDecision, | ||
| 18 | ToolInteractionRequest, | ||
| 19 | ToolInvocationContext, | ||
| 20 | ToolInvoker, | ||
| 21 | ToolMap, | ||
| 22 | ToolMetadata, | ||
| 23 | ToolPath, | ||
| 24 | } from "./types"; | ||
| 25 | import { kernelCoreEffectError } from "./effect-errors"; | ||
| 26 | |||
| 27 | type ResolvedTool = { | ||
| 28 | path: ToolPath; | ||
| 29 | tool: ExecutableTool; | ||
| 30 | metadata?: ToolMetadata; | ||
| 31 | }; | ||
| 32 | |||
| 33 | const asToolPath = (value: string): ToolPath => value as ToolPath; | ||
| 34 | |||
| 35 | const toError = (cause: unknown): Error => | ||
| 36 | cause instanceof Error ? cause : new Error(String(cause)); | ||
| 37 | |||
| 38 | const getSchemaValidator = (schema: unknown): | ||
| 39 | | ((value: unknown, options?: StandardSchemaV1.Options) => | ||
| 40 | | StandardSchemaV1.Result<unknown> | ||
| 41 | | Promise<StandardSchemaV1.Result<unknown>>) | ||
| 42 | | null => { | ||
| 43 | if (!schema || (typeof schema !== "object" && typeof schema !== "function")) { | ||
| 44 | return null; | ||
| 45 | } | ||
| 46 | |||
| 47 | const standard = (schema as { "~standard"?: unknown })["~standard"]; | ||
| 48 | if (!standard || typeof standard !== "object") { | ||
| 49 | return null; | ||
| 50 | } | ||
| 51 | |||
| 52 | const validate = (standard as { validate?: unknown }).validate; | ||
| 53 | return typeof validate === "function" | ||
| 54 | ? (validate as ( | ||
| 55 | value: unknown, | ||
| 56 | options?: StandardSchemaV1.Options, | ||
| 57 | ) => | ||
| 58 | | StandardSchemaV1.Result<unknown> | ||
| 59 | | Promise<StandardSchemaV1.Result<unknown>>) | ||
| 60 | : null; | ||
| 61 | }; | ||
| 62 | |||
| 63 | const formatIssuePath = ( | ||
| 64 | path: ReadonlyArray<PropertyKey | StandardSchemaV1.PathSegment> | undefined, | ||
| 65 | ): string => { | ||
| 66 | if (!path || path.length === 0) { | ||
| 67 | return "$"; | ||
| 68 | } | ||
| 69 | |||
| 70 | return path | ||
| 71 | .map((segment) => | ||
| 72 | typeof segment === "object" && segment !== null && "key" in segment | ||
| 73 | ? String(segment.key) | ||
| 74 | : String(segment), | ||
| 75 | ) | ||
| 76 | .join("."); | ||
| 77 | }; | ||
| 78 | |||
| 79 | const formatIssues = (issues: ReadonlyArray<StandardSchemaV1.Issue>): string => | ||
| 80 | issues | ||
| 81 | .map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`) | ||
| 82 | .join("; "); | ||
| 83 | |||
| 84 | const parseInput = (input: { | ||
| 85 | schema: unknown; | ||
| 86 | value: unknown; | ||
| 87 | path: string; | ||
| 88 | }): Effect.Effect<unknown, Error> => { | ||
| 89 | const validate = getSchemaValidator(input.schema); | ||
| 90 | if (!validate) { | ||
| 91 | return Effect.fail( | ||
| 92 | kernelCoreEffectError("tool-map", `Tool ${input.path} has no Standard Schema validator on inputSchema`), | ||
| 93 | ); | ||
| 94 | } | ||
| 95 | |||
| 96 | return Effect.tryPromise({ | ||
| 97 | try: () => Promise.resolve(validate(input.value)), | ||
| 98 | catch: (cause) => kernelCoreEffectError("tool-map", `Validation error for ${input.path}: ${String(cause)}`), | ||
| 99 | }).pipe( | ||
| 100 | Effect.flatMap((result) => { | ||
| 101 | if ("issues" in result && result.issues) { | ||
| 102 | return Effect.fail( | ||
| 103 | kernelCoreEffectError("tool-map", | ||
| 104 | `Input validation failed for ${input.path}: ${formatIssues(result.issues)}`, | ||
| 105 | ), | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | return Effect.succeed(result.value); | ||
| 109 | }), | ||
| 110 | ); | ||
| 111 | }; | ||
| 112 | |||
| 113 | const defaultRequiredElicitation = (path: string): ElicitationRequest => ({ | ||
| 114 | mode: "form", | ||
| 115 | message: `Approval required before invoking ${path}`, | ||
| 116 | requestedSchema: { | ||
| 117 | type: "object", | ||
| 118 | properties: {}, | ||
| 119 | additionalProperties: false, | ||
| 120 | }, | ||
| 121 | }); | ||
| 122 | |||
| 123 | const executeDecision: ToolInteractionDecision = { kind: "execute" }; | ||
| 124 | |||
| 125 | const toElicitationDecision = (input: { | ||
| 126 | metadata?: ToolMetadata; | ||
| 127 | path: string; | ||
| 128 | }): ToolInteractionDecision | null => { | ||
| 129 | if (input.metadata?.elicitation) { | ||
| 130 | return { | ||
| 131 | kind: "elicit", | ||
| 132 | elicitation: input.metadata.elicitation, | ||
| 133 | }; | ||
| 134 | } | ||
| 135 | |||
| 136 | if (input.metadata?.interaction === "required") { | ||
| 137 | return { | ||
| 138 | kind: "elicit", | ||
| 139 | elicitation: defaultRequiredElicitation(input.path), | ||
| 140 | }; | ||
| 141 | } | ||
| 142 | |||
| 143 | return null; | ||
| 144 | }; | ||
| 145 | |||
| 146 | const defaultInteractionDecision = (input: { | ||
| 147 | metadata?: ToolMetadata; | ||
| 148 | path: string; | ||
| 149 | }): ToolInteractionDecision => | ||
| 150 | toElicitationDecision(input) ?? executeDecision; | ||
| 151 | |||
| 152 | export class ToolInteractionPendingError extends Data.TaggedError( | ||
| 153 | "ToolInteractionPendingError", | ||
| 154 | )<{ | ||
| 155 | readonly path: string; | ||
| 156 | readonly elicitation: ElicitationRequest; | ||
| 157 | readonly interactionId?: string; | ||
| 158 | }> {} | ||
| 159 | |||
| 160 | export class ToolInteractionDeniedError extends Data.TaggedError( | ||
| 161 | "ToolInteractionDeniedError", | ||
| 162 | )<{ | ||
| 163 | readonly path: string; | ||
| 164 | readonly reason: string; | ||
| 165 | }> {} | ||
| 166 | |||
| 167 | export const allowAllToolInteractions: OnToolInteraction = () => | ||
| 168 | Effect.succeed(executeDecision); | ||
| 169 | |||
| 170 | const evaluateInteractionDecision = (input: { | ||
| 171 | path: ToolPath; | ||
| 172 | args: unknown; | ||
| 173 | metadata?: ToolMetadata; | ||
| 174 | sourceKey: string; | ||
| 175 | context?: ToolInvocationContext; | ||
| 176 | onToolInteraction?: OnToolInteraction; | ||
| 177 | }): Effect.Effect<ToolInteractionDecision, unknown> => { | ||
| 178 | const defaultDecision = defaultInteractionDecision({ | ||
| 179 | metadata: input.metadata, | ||
| 180 | path: input.path, | ||
| 181 | }); | ||
| 182 | |||
| 183 | if (!input.onToolInteraction) { | ||
| 184 | return Effect.succeed(defaultDecision); | ||
| 185 | } | ||
| 186 | |||
| 187 | const request: ToolInteractionRequest = { | ||
| 188 | path: input.path, | ||
| 189 | sourceKey: input.sourceKey, | ||
| 190 | args: input.args, | ||
| 191 | metadata: input.metadata, | ||
| 192 | context: input.context, | ||
| 193 | defaultElicitation: | ||
| 194 | defaultDecision.kind === "elicit" | ||
| 195 | ? defaultDecision.elicitation | ||
| 196 | : null, | ||
| 197 | }; | ||
| 198 | |||
| 199 | return input.onToolInteraction(request); | ||
| 200 | }; | ||
| 201 | |||
| 202 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 203 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 204 | |||
| 205 | const mergeAcceptedElicitationContent = (input: { | ||
| 206 | path: ToolPath; | ||
| 207 | args: unknown; | ||
| 208 | response: ElicitationResponse; | ||
| 209 | inputSchema: unknown; | ||
| 210 | }): Effect.Effect<unknown, Error> => { | ||
| 211 | if (!input.response.content) { | ||
| 212 | return Effect.succeed(input.args); | ||
| 213 | } | ||
| 214 | |||
| 215 | if (!isRecord(input.args)) { | ||
| 216 | return Effect.fail( | ||
| 217 | kernelCoreEffectError("tool-map", | ||
| 218 | `Tool ${input.path} cannot merge elicitation content into non-object arguments`, | ||
| 219 | ), | ||
| 220 | ); | ||
| 221 | } | ||
| 222 | |||
| 223 | const merged = { | ||
| 224 | ...input.args, | ||
| 225 | ...input.response.content, | ||
| 226 | }; | ||
| 227 | |||
| 228 | return parseInput({ | ||
| 229 | schema: input.inputSchema, | ||
| 230 | value: merged, | ||
| 231 | path: input.path, | ||
| 232 | }); | ||
| 233 | }; | ||
| 234 | |||
| 235 | const declineReasonFromResponse = (input: { | ||
| 236 | path: ToolPath; | ||
| 237 | response: ElicitationResponse; | ||
| 238 | }): string => { | ||
| 239 | const reason = | ||
| 240 | input.response.content | ||
| 241 | && typeof input.response.content.reason === "string" | ||
| 242 | && input.response.content.reason.trim().length > 0 | ||
| 243 | ? input.response.content.reason.trim() | ||
| 244 | : null; | ||
| 245 | |||
| 246 | if (reason) { | ||
| 247 | return reason; | ||
| 248 | } | ||
| 249 | |||
| 250 | return input.response.action === "cancel" | ||
| 251 | ? `Interaction cancelled for ${input.path}` | ||
| 252 | : `Interaction declined for ${input.path}`; | ||
| 253 | }; | ||
| 254 | |||
| 255 | const resolveInteractionDecision = (input: { | ||
| 256 | path: ToolPath; | ||
| 257 | args: unknown; | ||
| 258 | inputSchema: unknown; | ||
| 259 | metadata?: ToolMetadata; | ||
| 260 | sourceKey: string; | ||
| 261 | context?: ToolInvocationContext; | ||
| 262 | decision: ToolInteractionDecision; | ||
| 263 | interactionId: string; | ||
| 264 | onElicitation?: OnElicitation; | ||
| 265 | }): Effect.Effect<unknown, ToolInteractionPendingError | ToolInteractionDeniedError | Error> => { | ||
| 266 | if (input.decision.kind === "execute") { | ||
| 267 | return Effect.succeed(input.args); | ||
| 268 | } | ||
| 269 | |||
| 270 | if (input.decision.kind === "decline") { | ||
| 271 | return Effect.fail( | ||
| 272 | new ToolInteractionDeniedError({ | ||
| 273 | path: input.path, | ||
| 274 | reason: input.decision.reason, | ||
| 275 | }), | ||
| 276 | ); | ||
| 277 | } | ||
| 278 | |||
| 279 | if (!input.onElicitation) { | ||
| 280 | return Effect.fail( | ||
| 281 | new ToolInteractionPendingError({ | ||
| 282 | path: input.path, | ||
| 283 | elicitation: input.decision.elicitation, | ||
| 284 | interactionId: input.interactionId, | ||
| 285 | }), | ||
| 286 | ); | ||
| 287 | } | ||
| 288 | |||
| 289 | const elicitationRequest: ToolElicitationRequest = { | ||
| 290 | interactionId: input.interactionId, | ||
| 291 | path: input.path, | ||
| 292 | sourceKey: input.sourceKey, | ||
| 293 | args: input.args, | ||
| 294 | metadata: input.metadata, | ||
| 295 | context: input.context, | ||
| 296 | elicitation: input.decision.elicitation, | ||
| 297 | }; | ||
| 298 | |||
| 299 | return input.onElicitation(elicitationRequest).pipe( | ||
| 300 | Effect.mapError(toError), | ||
| 301 | Effect.flatMap((response) => { | ||
| 302 | if (response.action !== "accept") { | ||
| 303 | return Effect.fail( | ||
| 304 | new ToolInteractionDeniedError({ | ||
| 305 | path: input.path, | ||
| 306 | reason: declineReasonFromResponse({ | ||
| 307 | path: input.path, | ||
| 308 | response, | ||
| 309 | }), | ||
| 310 | }), | ||
| 311 | ); | ||
| 312 | } | ||
| 313 | |||
| 314 | return mergeAcceptedElicitationContent({ | ||
| 315 | path: input.path, | ||
| 316 | args: input.args, | ||
| 317 | response, | ||
| 318 | inputSchema: input.inputSchema, | ||
| 319 | }); | ||
| 320 | }), | ||
| 321 | ); | ||
| 322 | }; | ||
| 323 | |||
| 324 | export function wrapTool(input: { | ||
| 325 | tool: ExecutableTool; | ||
| 326 | metadata?: ToolMetadata; | ||
| 327 | }): ToolDefinition { | ||
| 328 | return { | ||
| 329 | tool: input.tool, | ||
| 330 | metadata: input.metadata, | ||
| 331 | }; | ||
| 332 | } | ||
| 333 | |||
| 334 | export const toTool = wrapTool; | ||
| 335 | export const toExecutorTool = wrapTool; | ||
| 336 | |||
| 337 | const isToolDefinition = (value: ToolInput): value is ToolDefinition => | ||
| 338 | typeof value === "object" && value !== null && "tool" in value; | ||
| 339 | |||
| 340 | const deriveSchemaValue = (value: unknown): unknown | undefined => { | ||
| 341 | if (value === undefined || value === null) { | ||
| 342 | return undefined; | ||
| 343 | } | ||
| 344 | |||
| 345 | try { | ||
| 346 | if ( | ||
| 347 | (typeof value === "object" || typeof value === "function") | ||
| 348 | && value !== null | ||
| 349 | && "~standard" in value | ||
| 350 | ) { | ||
| 351 | return JSONSchema.make(value as any); | ||
| 352 | } | ||
| 353 | |||
| 354 | return value; | ||
| 355 | } catch { | ||
| 356 | return undefined; | ||
| 357 | } | ||
| 358 | }; | ||
| 359 | |||
| 360 | const inferTypeFromSchemaValue = ( | ||
| 361 | schema: unknown, | ||
| 362 | fallback: string, | ||
| 363 | maxLength: number = 240, | ||
| 364 | ): string => typeSignatureFromSchema(schema, fallback, maxLength); | ||
| 365 | |||
| 366 | export function createToolsFromRecord(input: { | ||
| 367 | tools: Record<string, ExecutableTool>; | ||
| 368 | sourceKey?: string; | ||
| 369 | }): ToolMap { | ||
| 370 | const { tools, sourceKey = "in_memory.tools" } = input; | ||
| 371 | |||
| 372 | return Object.fromEntries( | ||
| 373 | Object.entries(tools) | ||
| 374 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 375 | .map(([path, tool]) => [ | ||
| 376 | path, | ||
| 377 | wrapTool({ | ||
| 378 | tool, | ||
| 379 | metadata: { sourceKey }, | ||
| 380 | }), | ||
| 381 | ]), | ||
| 382 | ) as ToolMap; | ||
| 383 | } | ||
| 384 | |||
| 385 | const resolveToolsFromMap = (input: { | ||
| 386 | tools: ToolMap; | ||
| 387 | sourceKey?: string; | ||
| 388 | }): ResolvedTool[] => { | ||
| 389 | const defaultSourceKey = input.sourceKey ?? "in_memory.tools"; | ||
| 390 | |||
| 391 | return Object.entries(input.tools) | ||
| 392 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 393 | .map(([path, value]) => { | ||
| 394 | const entry = isToolDefinition(value) ? value : { tool: value }; | ||
| 395 | const metadata = entry.metadata | ||
| 396 | ? { | ||
| 397 | sourceKey: defaultSourceKey, | ||
| 398 | ...entry.metadata, | ||
| 399 | } | ||
| 400 | : { sourceKey: defaultSourceKey }; | ||
| 401 | |||
| 402 | return { | ||
| 403 | path: asToolPath(path), | ||
| 404 | tool: entry.tool, | ||
| 405 | metadata, | ||
| 406 | } satisfies ResolvedTool; | ||
| 407 | }); | ||
| 408 | }; | ||
| 409 | |||
| 410 | export function toolDescriptorsFromTools(input: { | ||
| 411 | tools: ToolMap; | ||
| 412 | sourceKey?: string; | ||
| 413 | }): ToolDescriptor[] { | ||
| 414 | const resolvedTools = resolveToolsFromMap({ | ||
| 415 | tools: input.tools, | ||
| 416 | sourceKey: input.sourceKey, | ||
| 417 | }); | ||
| 418 | |||
| 419 | return resolvedTools.map((entry) => { | ||
| 420 | const metadata = entry.metadata; | ||
| 421 | const definition = entry.tool; | ||
| 422 | const inputSchema = | ||
| 423 | metadata?.contract?.inputSchema | ||
| 424 | ?? deriveSchemaValue(definition.inputSchema) | ||
| 425 | ?? deriveSchemaValue(definition.parameters); | ||
| 426 | const outputSchema = | ||
| 427 | metadata?.contract?.outputSchema | ||
| 428 | ?? deriveSchemaValue(definition.outputSchema); | ||
| 429 | const contract = { | ||
| 430 | inputTypePreview: | ||
| 431 | metadata?.contract?.inputTypePreview ?? inferTypeFromSchemaValue(inputSchema, "unknown"), | ||
| 432 | outputTypePreview: | ||
| 433 | metadata?.contract?.outputTypePreview ?? inferTypeFromSchemaValue(outputSchema, "unknown"), | ||
| 434 | ...(inputSchema !== undefined ? { inputSchema } : {}), | ||
| 435 | ...(outputSchema !== undefined ? { outputSchema } : {}), | ||
| 436 | ...(metadata?.contract?.exampleInput !== undefined | ||
| 437 | ? { exampleInput: metadata.contract.exampleInput } | ||
| 438 | : {}), | ||
| 439 | ...(metadata?.contract?.exampleOutput !== undefined | ||
| 440 | ? { exampleOutput: metadata.contract.exampleOutput } | ||
| 441 | : {}), | ||
| 442 | } satisfies NonNullable<ToolDescriptor["contract"]>; | ||
| 443 | |||
| 444 | return { | ||
| 445 | path: entry.path, | ||
| 446 | sourceKey: metadata?.sourceKey ?? "in_memory.tools", | ||
| 447 | description: definition.description, | ||
| 448 | interaction: metadata?.interaction, | ||
| 449 | elicitation: metadata?.elicitation, | ||
| 450 | contract, | ||
| 451 | ...(metadata?.pluginKind ? { pluginKind: metadata.pluginKind } : {}), | ||
| 452 | ...(metadata?.pluginData !== undefined | ||
| 453 | ? { pluginData: metadata.pluginData } | ||
| 454 | : {}), | ||
| 455 | } satisfies ToolDescriptor; | ||
| 456 | }); | ||
| 457 | } | ||
| 458 | |||
| 459 | const asInteractionIdPart = (value: string): string => | ||
| 460 | value.replace(/[^a-zA-Z0-9._-]/g, "_"); | ||
| 461 | |||
| 462 | const createInteractionIdFactory = () => { | ||
| 463 | let sequence = 0; | ||
| 464 | |||
| 465 | return (input: { path: ToolPath; context?: ToolInvocationContext }): string => { | ||
| 466 | sequence += 1; | ||
| 467 | |||
| 468 | const runId = | ||
| 469 | typeof input.context?.runId === "string" && input.context.runId.length > 0 | ||
| 470 | ? input.context.runId | ||
| 471 | : "run"; | ||
| 472 | const callId = | ||
| 473 | typeof input.context?.callId === "string" && input.context.callId.length > 0 | ||
| 474 | ? input.context.callId | ||
| 475 | : `call_${String(sequence)}`; | ||
| 476 | |||
| 477 | return `${asInteractionIdPart(runId)}:${asInteractionIdPart(callId)}:${asInteractionIdPart(String(input.path))}:${String(sequence)}`; | ||
| 478 | }; | ||
| 479 | }; | ||
| 480 | |||
| 481 | export const makeToolInvokerFromTools = (input: { | ||
| 482 | tools: ToolMap; | ||
| 483 | sourceKey?: string; | ||
| 484 | onToolInteraction?: OnToolInteraction; | ||
| 485 | onElicitation?: OnElicitation; | ||
| 486 | }): ToolInvoker => { | ||
| 487 | const resolvedTools = resolveToolsFromMap({ | ||
| 488 | tools: input.tools, | ||
| 489 | sourceKey: input.sourceKey, | ||
| 490 | }); | ||
| 491 | const byPath = new Map(resolvedTools.map((entry) => [entry.path as string, entry])); | ||
| 492 | const nextInteractionId = createInteractionIdFactory(); | ||
| 493 | |||
| 494 | return { | ||
| 495 | invoke: ({ path, args, context }) => | ||
| 496 | Effect.gen(function* () { | ||
| 497 | const entry = byPath.get(path); | ||
| 498 | if (!entry) { | ||
| 499 | return yield* kernelCoreEffectError("tool-map", `Unknown tool path: ${path}`); | ||
| 500 | } | ||
| 501 | |||
| 502 | const parsedInput = yield* parseInput({ | ||
| 503 | schema: entry.tool.inputSchema, | ||
| 504 | value: args, | ||
| 505 | path, | ||
| 506 | }); | ||
| 507 | |||
| 508 | const decision = yield* evaluateInteractionDecision({ | ||
| 509 | path: entry.path, | ||
| 510 | args: parsedInput, | ||
| 511 | metadata: entry.metadata, | ||
| 512 | sourceKey: entry.metadata?.sourceKey ?? "in_memory.tools", | ||
| 513 | context, | ||
| 514 | onToolInteraction: input.onToolInteraction, | ||
| 515 | }); | ||
| 516 | |||
| 517 | const interactionId = | ||
| 518 | decision.kind === "elicit" && decision.interactionId | ||
| 519 | ? decision.interactionId | ||
| 520 | : nextInteractionId({ path: entry.path, context }); | ||
| 521 | |||
| 522 | const executableInput = yield* resolveInteractionDecision({ | ||
| 523 | path: entry.path, | ||
| 524 | args: parsedInput, | ||
| 525 | inputSchema: entry.tool.inputSchema, | ||
| 526 | metadata: entry.metadata, | ||
| 527 | sourceKey: entry.metadata?.sourceKey ?? "in_memory.tools", | ||
| 528 | context, | ||
| 529 | decision, | ||
| 530 | interactionId, | ||
| 531 | onElicitation: input.onElicitation, | ||
| 532 | }); | ||
| 533 | |||
| 534 | return yield* Effect.tryPromise({ | ||
| 535 | try: () => | ||
| 536 | Promise.resolve( | ||
| 537 | entry.tool.execute(executableInput, { | ||
| 538 | path: entry.path, | ||
| 539 | sourceKey: entry.metadata?.sourceKey ?? "in_memory.tools", | ||
| 540 | metadata: entry.metadata, | ||
| 541 | invocation: context, | ||
| 542 | onElicitation: input.onElicitation, | ||
| 543 | }), | ||
| 544 | ), | ||
| 545 | catch: (cause) => kernelCoreEffectError("tool-map", `Tool execution error for ${entry.path}: ${String(cause)}`), | ||
| 546 | }); | ||
| 547 | }), | ||
| 548 | }; | ||
| 549 | }; | ||
| @@ -1,277 +1,42 @@ | |||
| 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; | 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; |
| 2 | import type * as Effect from "effect/Effect"; | 2 | import type * as Effect from "effect/Effect"; |
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | 3 | ||
| 4 | /** Branded tool path */ | ||
| 5 | export type ToolPath = string & { readonly __toolPath: unique symbol }; | 5 | export type ToolPath = string & { readonly __toolPath: unique symbol }; |
| 6 | 6 | ||
| 7 | export const asToolPath = (value: string): ToolPath => value as ToolPath; | ||
| 8 | |||
| 9 | /** Standard Schema alias */ | ||
| 7 | export type StandardSchema<Input = unknown, Output = unknown> = | 10 | export type StandardSchema<Input = unknown, Output = unknown> = |
| 8 | StandardSchemaV1<Input, Output>; | 11 | StandardSchemaV1<Input, Output>; |
| 9 | 12 | ||
| 10 | export type ElicitationFormRequest = { | 13 | /** A tool that can be invoked */ |
| 11 | mode?: "form"; | 14 | export interface Tool { |
| 12 | message: string; | 15 | readonly path: ToolPath; |
| 13 | requestedSchema: Record<string, unknown>; | 16 | readonly description?: string; |
| 14 | }; | 17 | readonly inputSchema: StandardSchema; |
| 15 | 18 | readonly outputSchema?: StandardSchema; | |
| 16 | export type ElicitationUrlRequest = { | 19 | readonly execute: ( |
| 17 | mode: "url"; | 20 | input: unknown, |
| 18 | message: string; | 21 | ) => unknown | Promise<unknown>; |
| 19 | url: string; | ||
| 20 | elicitationId: string; | ||
| 21 | }; | ||
| 22 | |||
| 23 | export type ElicitationRequest = | ||
| 24 | | ElicitationFormRequest | ||
| 25 | | ElicitationUrlRequest; | ||
| 26 | |||
| 27 | export type ElicitationAction = "accept" | "decline" | "cancel"; | ||
| 28 | |||
| 29 | export type ElicitationResponse = { | ||
| 30 | action: ElicitationAction; | ||
| 31 | content?: Record<string, unknown>; | ||
| 32 | }; | ||
| 33 | |||
| 34 | export type ToolInvocationContext = { | ||
| 35 | runId?: string; | ||
| 36 | callId?: string; | ||
| 37 | scope?: string; | ||
| 38 | actor?: string; | ||
| 39 | [key: string]: unknown; | ||
| 40 | }; | ||
| 41 | |||
| 42 | export type ToolInteractionRequest = { | ||
| 43 | path: ToolPath; | ||
| 44 | sourceKey: string; | ||
| 45 | args: unknown; | ||
| 46 | metadata?: ToolMetadata; | ||
| 47 | context?: ToolInvocationContext; | ||
| 48 | defaultElicitation: ElicitationRequest | null; | ||
| 49 | }; | ||
| 50 | |||
| 51 | export type ToolInteractionDecision = | ||
| 52 | | { kind: "execute" } | ||
| 53 | | { kind: "decline"; reason: string } | ||
| 54 | | { | ||
| 55 | kind: "elicit"; | ||
| 56 | elicitation: ElicitationRequest; | ||
| 57 | interactionId?: string; | ||
| 58 | }; | ||
| 59 | |||
| 60 | export type ToolElicitationRequest = { | ||
| 61 | interactionId: string; | ||
| 62 | path: ToolPath; | ||
| 63 | sourceKey: string; | ||
| 64 | args: unknown; | ||
| 65 | metadata?: ToolMetadata; | ||
| 66 | context?: ToolInvocationContext; | ||
| 67 | elicitation: ElicitationRequest; | ||
| 68 | }; | ||
| 69 | |||
| 70 | export type OnToolInteraction = ( | ||
| 71 | input: ToolInteractionRequest, | ||
| 72 | ) => Effect.Effect<ToolInteractionDecision, unknown>; | ||
| 73 | |||
| 74 | export type OnElicitation = ( | ||
| 75 | input: ToolElicitationRequest, | ||
| 76 | ) => Effect.Effect<ElicitationResponse, unknown>; | ||
| 77 | |||
| 78 | export type ToolContract = { | ||
| 79 | inputTypePreview?: string; | ||
| 80 | outputTypePreview?: string; | ||
| 81 | inputSchema?: unknown; | ||
| 82 | outputSchema?: unknown; | ||
| 83 | exampleInput?: unknown; | ||
| 84 | exampleOutput?: unknown; | ||
| 85 | }; | ||
| 86 | |||
| 87 | export const ToolContractSchema = Schema.Struct({ | ||
| 88 | inputTypePreview: Schema.optional(Schema.String), | ||
| 89 | outputTypePreview: Schema.optional(Schema.String), | ||
| 90 | inputSchema: Schema.optional(Schema.Unknown), | ||
| 91 | outputSchema: Schema.optional(Schema.Unknown), | ||
| 92 | exampleInput: Schema.optional(Schema.Unknown), | ||
| 93 | exampleOutput: Schema.optional(Schema.Unknown), | ||
| 94 | }); | ||
| 95 | |||
| 96 | export type ToolMetadata = { | ||
| 97 | interaction?: "auto" | "required"; | ||
| 98 | elicitation?: ElicitationRequest; | ||
| 99 | contract?: ToolContract; | ||
| 100 | sourceKey?: string; | ||
| 101 | pluginKind?: string; | ||
| 102 | pluginData?: unknown; | ||
| 103 | }; | ||
| 104 | |||
| 105 | export type ToolExecutionContext = { | ||
| 106 | path: ToolPath; | ||
| 107 | sourceKey: string; | ||
| 108 | metadata?: ToolMetadata; | ||
| 109 | invocation?: ToolInvocationContext; | ||
| 110 | onElicitation?: OnElicitation; | ||
| 111 | }; | ||
| 112 | |||
| 113 | export type ExecutableTool = { | ||
| 114 | description?: string; | ||
| 115 | inputSchema: StandardSchema; | ||
| 116 | outputSchema?: StandardSchema; | ||
| 117 | parameters?: StandardSchema; | ||
| 118 | execute: (...args: any[]) => unknown; | ||
| 119 | }; | ||
| 120 | |||
| 121 | export const unknownInputSchema: StandardSchema = { | ||
| 122 | "~standard": { | ||
| 123 | version: 1, | ||
| 124 | vendor: "@executor/codemode-core", | ||
| 125 | validate: (value: unknown) => ({ | ||
| 126 | value, | ||
| 127 | }), | ||
| 128 | }, | ||
| 129 | }; | ||
| 130 | |||
| 131 | |||
| 132 | export type ToolDefinition = { | ||
| 133 | tool: ExecutableTool; | ||
| 134 | metadata?: ToolMetadata; | ||
| 135 | }; | ||
| 136 | |||
| 137 | export type ToolInput = ExecutableTool | ToolDefinition; | ||
| 138 | |||
| 139 | export type ToolMap = Record<string, ToolInput>; | ||
| 140 | |||
| 141 | export type ToolDescriptor = { | ||
| 142 | path: ToolPath; | ||
| 143 | sourceKey: string; | ||
| 144 | description?: string; | ||
| 145 | interaction?: "auto" | "required"; | ||
| 146 | elicitation?: ElicitationRequest; | ||
| 147 | contract?: ToolContract; | ||
| 148 | pluginKind?: string; | ||
| 149 | pluginData?: unknown; | ||
| 150 | }; | ||
| 151 | |||
| 152 | export const ToolDescriptorSchema = Schema.Struct({ | ||
| 153 | path: Schema.String, | ||
| 154 | sourceKey: Schema.String, | ||
| 155 | description: Schema.optional(Schema.String), | ||
| 156 | interaction: Schema.optional( | ||
| 157 | Schema.Union(Schema.Literal("auto"), Schema.Literal("required")), | ||
| 158 | ), | ||
| 159 | elicitation: Schema.optional(Schema.Unknown), | ||
| 160 | contract: Schema.optional(ToolContractSchema), | ||
| 161 | pluginKind: Schema.optional(Schema.String), | ||
| 162 | pluginData: Schema.optional(Schema.Unknown), | ||
| 163 | }); | ||
| 164 | |||
| 165 | export type ToolNamespace = { | ||
| 166 | namespace: string; | ||
| 167 | displayName?: string; | ||
| 168 | toolCount?: number; | ||
| 169 | }; | ||
| 170 | |||
| 171 | export const ToolNamespaceSchema = Schema.Struct({ | ||
| 172 | namespace: Schema.String, | ||
| 173 | displayName: Schema.optional(Schema.String), | ||
| 174 | toolCount: Schema.optional(Schema.Number), | ||
| 175 | }); | ||
| 176 | |||
| 177 | export type SearchHit = { | ||
| 178 | path: ToolPath; | ||
| 179 | score: number; | ||
| 180 | }; | ||
| 181 | |||
| 182 | export type ToolCatalogEntry = { | ||
| 183 | descriptor: ToolDescriptor; | ||
| 184 | namespace?: string; | ||
| 185 | searchText?: string; | ||
| 186 | score?: (queryTokens: readonly string[]) => number; | ||
| 187 | }; | ||
| 188 | |||
| 189 | export interface ToolCatalog { | ||
| 190 | listNamespaces(input: { | ||
| 191 | limit: number; | ||
| 192 | }): Effect.Effect<readonly ToolNamespace[], unknown>; | ||
| 193 | |||
| 194 | listTools(input: { | ||
| 195 | namespace?: string; | ||
| 196 | query?: string; | ||
| 197 | limit: number; | ||
| 198 | includeSchemas?: boolean; | ||
| 199 | }): Effect.Effect<readonly ToolDescriptor[], unknown>; | ||
| 200 | |||
| 201 | getToolByPath(input: { | ||
| 202 | path: ToolPath; | ||
| 203 | includeSchemas: boolean; | ||
| 204 | }): Effect.Effect<ToolDescriptor | null, unknown>; | ||
| 205 | |||
| 206 | searchTools(input: { | ||
| 207 | query: string; | ||
| 208 | namespace?: string; | ||
| 209 | limit: number; | ||
| 210 | }): Effect.Effect<readonly SearchHit[], unknown>; | ||
| 211 | } | 22 | } |
| 212 | 23 | ||
| 213 | export type CatalogPrimitive = { | 24 | /** Invoke a tool by path */ |
| 214 | namespaces(input: { | 25 | export interface ToolInvoker { |
| 215 | limit?: number; | 26 | invoke(input: { |
| 216 | }): Effect.Effect< | 27 | path: string; |
| 217 | { namespaces: readonly ToolNamespace[] }, | 28 | args: unknown; |
| 218 | unknown | 29 | }): Effect.Effect<unknown, unknown>; |
| 219 | >; | 30 | } |
| 220 | |||
| 221 | tools(input: { | ||
| 222 | namespace?: string; | ||
| 223 | query?: string; | ||
| 224 | limit?: number; | ||
| 225 | includeSchemas?: boolean; | ||
| 226 | }): Effect.Effect<{ results: readonly ToolDescriptor[] }, unknown>; | ||
| 227 | }; | ||
| 228 | |||
| 229 | export type DescribePrimitive = { | ||
| 230 | tool(input: { | ||
| 231 | path: ToolPath; | ||
| 232 | includeSchemas?: boolean; | ||
| 233 | }): Effect.Effect<ToolDescriptor | null, unknown>; | ||
| 234 | }; | ||
| 235 | |||
| 236 | export type DiscoverPrimitive = (input: { | ||
| 237 | query: string; | ||
| 238 | sourceKey?: string; | ||
| 239 | limit?: number; | ||
| 240 | includeSchemas?: boolean; | ||
| 241 | }) => Effect.Effect< | ||
| 242 | { | ||
| 243 | bestPath: ToolPath | null; | ||
| 244 | results: readonly (Record<string, unknown> & { | ||
| 245 | path: ToolPath; | ||
| 246 | score: number; | ||
| 247 | })[]; | ||
| 248 | total: number; | ||
| 249 | }, | ||
| 250 | unknown | ||
| 251 | >; | ||
| 252 | |||
| 253 | export type DiscoveryPrimitives = { | ||
| 254 | catalog?: CatalogPrimitive; | ||
| 255 | describe?: DescribePrimitive; | ||
| 256 | discover?: DiscoverPrimitive; | ||
| 257 | }; | ||
| 258 | 31 | ||
| 32 | /** Result of executing code in a sandbox */ | ||
| 259 | export type ExecuteResult = { | 33 | export type ExecuteResult = { |
| 260 | result: unknown; | 34 | result: unknown; |
| 261 | error?: string; | 35 | error?: string; |
| 262 | logs?: string[]; | 36 | logs?: string[]; |
| 263 | }; | 37 | }; |
| 264 | 38 | ||
| 265 | export type ToolInvocationInput = { | 39 | /** Executes code in a sandboxed runtime with tool access */ |
| 266 | path: string; | ||
| 267 | args: unknown; | ||
| 268 | context?: ToolInvocationContext; | ||
| 269 | }; | ||
| 270 | |||
| 271 | export interface ToolInvoker { | ||
| 272 | invoke(input: ToolInvocationInput): Effect.Effect<unknown, unknown>; | ||
| 273 | } | ||
| 274 | |||
| 275 | export interface CodeExecutor { | 40 | export interface CodeExecutor { |
| 276 | execute( | 41 | execute( |
| 277 | code: string, | 42 | code: string, |
| @@ -279,8 +44,13 @@ export interface CodeExecutor { · 1 unmodified line | |||
| 279 | ): Effect.Effect<ExecuteResult, unknown>; | 44 | ): Effect.Effect<ExecuteResult, unknown>; |
| 280 | } | 45 | } |
| 281 | 46 | ||
| 282 | export type CodeToolOutput = { | 47 | /** Accept-anything schema for tools with no input validation */ |
| 283 | code: string; | 48 | export const unknownInputSchema: StandardSchema = { |
| 284 | result: unknown; | 49 | "~standard": { |
| 285 | logs?: string[]; | 50 | version: 1, |
| 51 | vendor: "@executor/codemode-core", | ||
| 52 | validate: (value: unknown) => ({ | ||
| 53 | value, | ||
| 54 | }), | ||
| 55 | }, | ||
| 286 | }; | 56 | }; |
| @@ -0,0 +1,89 @@ | |||
| 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | import { kernelCoreEffectError } from "./effect-errors"; | ||
| 5 | |||
| 6 | const getSchemaValidator = ( | ||
| 7 | schema: unknown, | ||
| 8 | ): (( | ||
| 9 | value: unknown, | ||
| 10 | ) => | ||
| 11 | | StandardSchemaV1.Result<unknown> | ||
| 12 | | Promise<StandardSchemaV1.Result<unknown>>) | null => { | ||
| 13 | if (!schema || (typeof schema !== "object" && typeof schema !== "function")) { | ||
| 14 | return null; | ||
| 15 | } | ||
| 16 | |||
| 17 | const standard = (schema as { "~standard"?: unknown })["~standard"]; | ||
| 18 | if (!standard || typeof standard !== "object") { | ||
| 19 | return null; | ||
| 20 | } | ||
| 21 | |||
| 22 | const validate = (standard as { validate?: unknown }).validate; | ||
| 23 | return typeof validate === "function" | ||
| 24 | ? (validate as ( | ||
| 25 | value: unknown, | ||
| 26 | ) => | ||
| 27 | | StandardSchemaV1.Result<unknown> | ||
| 28 | | Promise<StandardSchemaV1.Result<unknown>>) | ||
| 29 | : null; | ||
| 30 | }; | ||
| 31 | |||
| 32 | const formatIssuePath = ( | ||
| 33 | path: ReadonlyArray<PropertyKey | StandardSchemaV1.PathSegment> | undefined, | ||
| 34 | ): string => { | ||
| 35 | if (!path || path.length === 0) { | ||
| 36 | return "$"; | ||
| 37 | } | ||
| 38 | |||
| 39 | return path | ||
| 40 | .map((segment) => | ||
| 41 | typeof segment === "object" && segment !== null && "key" in segment | ||
| 42 | ? String(segment.key) | ||
| 43 | : String(segment), | ||
| 44 | ) | ||
| 45 | .join("."); | ||
| 46 | }; | ||
| 47 | |||
| 48 | const formatIssues = (issues: ReadonlyArray<StandardSchemaV1.Issue>): string => | ||
| 49 | issues | ||
| 50 | .map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`) | ||
| 51 | .join("; "); | ||
| 52 | |||
| 53 | /** Validate a value against a Standard Schema */ | ||
| 54 | export const validateInput = (input: { | ||
| 55 | schema: unknown; | ||
| 56 | value: unknown; | ||
| 57 | path: string; | ||
| 58 | }): Effect.Effect<unknown, Error> => { | ||
| 59 | const validate = getSchemaValidator(input.schema); | ||
| 60 | if (!validate) { | ||
| 61 | return Effect.fail( | ||
| 62 | kernelCoreEffectError( | ||
| 63 | "validation", | ||
| 64 | `Tool ${input.path} has no Standard Schema validator on inputSchema`, | ||
| 65 | ), | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | |||
| 69 | return Effect.tryPromise({ | ||
| 70 | try: () => Promise.resolve(validate(input.value)), | ||
| 71 | catch: (cause) => | ||
| 72 | kernelCoreEffectError( | ||
| 73 | "validation", | ||
| 74 | `Validation error for ${input.path}: ${String(cause)}`, | ||
| 75 | ), | ||
| 76 | }).pipe( | ||
| 77 | Effect.flatMap((result) => { | ||
| 78 | if ("issues" in result && result.issues) { | ||
| 79 | return Effect.fail( | ||
| 80 | kernelCoreEffectError( | ||
| 81 | "validation", | ||
| 82 | `Input validation failed for ${input.path}: ${formatIssues(result.issues)}`, | ||
| 83 | ), | ||
| 84 | ); | ||
| 85 | } | ||
| 86 | return Effect.succeed(result.value); | ||
| 87 | }), | ||
| 88 | ); | ||
| 89 | }; | ||
| @@ -4,10 +4,7 @@ · 3 unmodified lines | |||
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "version": "0.0.0", | 5 | "version": "0.0.0", |
| 6 | "exports": { | 6 | "exports": { |
| 7 | ".": "./src/index.ts", | 7 | ".": "./src/index.ts" |
| 8 | "./catalog": "./src/catalog.ts", | ||
| 9 | "./ids": "./src/ids.ts", | ||
| 10 | "./model": "./src/model.ts" | ||
| 11 | }, | 8 | }, |
| 12 | "scripts": { | 9 | "scripts": { |
| 13 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", | 10 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", |
| @@ -15,7 +12,6 @@ · 1 unmodified line | |||
| 15 | "test:watch": "vitest" | 12 | "test:watch": "vitest" |
| 16 | }, | 13 | }, |
| 17 | "dependencies": { | 14 | "dependencies": { |
| 18 | "@executor/codemode-core": "workspace:*", | ||
| 19 | "effect": "catalog:" | 15 | "effect": "catalog:" |
| 20 | }, | 16 | }, |
| 21 | "devDependencies": { | 17 | "devDependencies": { |
| @@ -0,0 +1,8 @@ | |||
| 1 | export { | ||
| 2 | ToolRegistration, | ||
| 3 | SerializedCatalog, | ||
| 4 | type LiveToolRegistration, | ||
| 5 | type LiveCatalog, | ||
| 6 | } from "./registry"; | ||
| 7 | |||
| 8 | export { serialize, deserializeToJsonSchema } from "./serialize"; | ||
| @@ -0,0 +1,34 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | import type { JsonSchema7Root } from "effect/JSONSchema"; | ||
| 3 | |||
| 4 | export class ToolRegistration extends Schema.Class<ToolRegistration>("ToolRegistration")({ | ||
| 5 | path: Schema.String, | ||
| 6 | description: Schema.optional(Schema.String), | ||
| 7 | tags: Schema.optional(Schema.Array(Schema.String)), | ||
| 8 | namespace: Schema.optional(Schema.String), | ||
| 9 | input: Schema.optional(Schema.String), | ||
| 10 | output: Schema.optional(Schema.String), | ||
| 11 | error: Schema.optional(Schema.String), | ||
| 12 | }) {} | ||
| 13 | |||
| 14 | export class SerializedCatalog extends Schema.Class<SerializedCatalog>("SerializedCatalog")({ | ||
| 15 | version: Schema.Literal("v4.1"), | ||
| 16 | types: Schema.Record({ key: Schema.String, value: Schema.Unknown }), | ||
| 17 | tools: Schema.Array(ToolRegistration), | ||
| 18 | }) {} | ||
| 19 | |||
| 20 | export interface LiveToolRegistration { | ||
| 21 | readonly path: string; | ||
| 22 | readonly description?: string; | ||
| 23 | readonly tags?: readonly string[]; | ||
| 24 | readonly namespace?: string; | ||
| 25 | readonly input?: Schema.Schema.AnyNoContext; | ||
| 26 | readonly output?: Schema.Schema.AnyNoContext; | ||
| 27 | readonly error?: Schema.Schema.AnyNoContext; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface LiveCatalog { | ||
| 31 | readonly version: "v4.1"; | ||
| 32 | readonly types: Record<string, JsonSchema7Root>; | ||
| 33 | readonly tools: readonly LiveToolRegistration[]; | ||
| 34 | } | ||
| @@ -0,0 +1,87 @@ | |||
| 1 | import { JSONSchema, SchemaAST } from "effect"; | ||
| 2 | import { Option } from "effect"; | ||
| 3 | import type { JsonSchema7, JsonSchema7Root } from "effect/JSONSchema"; | ||
| 4 | import type { LiveCatalog, SerializedCatalog } from "./registry"; | ||
| 5 | |||
| 6 | function getSchemaIdentifier(schema: { ast: SchemaAST.AST }): string | undefined { | ||
| 7 | return Option.getOrUndefined(SchemaAST.getJSONIdentifier(schema.ast)); | ||
| 8 | } | ||
| 9 | |||
| 10 | export function serialize(catalog: LiveCatalog): typeof SerializedCatalog.Type { | ||
| 11 | const defs: Record<string, JsonSchema7> = {}; | ||
| 12 | |||
| 13 | function schemaToRef(schema: { ast: SchemaAST.AST } | undefined): string | undefined { | ||
| 14 | if (!schema) return undefined; | ||
| 15 | |||
| 16 | const identifier = getSchemaIdentifier(schema); | ||
| 17 | const jsonSchema = JSONSchema.fromAST(schema.ast, { | ||
| 18 | definitions: defs, | ||
| 19 | target: "jsonSchema2020-12", | ||
| 20 | }); | ||
| 21 | |||
| 22 | if (identifier) { | ||
| 23 | return identifier; | ||
| 24 | } | ||
| 25 | |||
| 26 | // Inline schema without an identifier — store under a generated key | ||
| 27 | const key = `__inline_${Object.keys(defs).length}`; | ||
| 28 | defs[key] = jsonSchema; | ||
| 29 | return key; | ||
| 30 | } | ||
| 31 | |||
| 32 | const tools = catalog.tools.map((tool) => ({ | ||
| 33 | path: tool.path, | ||
| 34 | description: tool.description, | ||
| 35 | tags: tool.tags ? [...tool.tags] : undefined, | ||
| 36 | namespace: tool.namespace, | ||
| 37 | input: schemaToRef(tool.input), | ||
| 38 | output: schemaToRef(tool.output), | ||
| 39 | error: schemaToRef(tool.error), | ||
| 40 | })); | ||
| 41 | |||
| 42 | return { | ||
| 43 | version: "v4.1" as const, | ||
| 44 | types: defs as Record<string, unknown>, | ||
| 45 | tools, | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function deserializeToJsonSchema( | ||
| 50 | serialized: typeof SerializedCatalog.Type, | ||
| 51 | ): { | ||
| 52 | tools: ReadonlyArray<{ | ||
| 53 | path: string; | ||
| 54 | description?: string; | ||
| 55 | tags?: ReadonlyArray<string>; | ||
| 56 | namespace?: string; | ||
| 57 | input?: JsonSchema7Root; | ||
| 58 | output?: JsonSchema7Root; | ||
| 59 | error?: JsonSchema7Root; | ||
| 60 | }>; | ||
| 61 | types: Record<string, unknown>; | ||
| 62 | } { | ||
| 63 | const types = serialized.types; | ||
| 64 | |||
| 65 | function resolveRef(ref: string | undefined): JsonSchema7Root | undefined { | ||
| 66 | if (!ref) return undefined; | ||
| 67 | const schema = types[ref]; | ||
| 68 | if (!schema) return undefined; | ||
| 69 | return { | ||
| 70 | ...(schema as JsonSchema7Root), | ||
| 71 | $defs: types as Record<string, JsonSchema7>, | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | |||
| 75 | return { | ||
| 76 | tools: serialized.tools.map((tool) => ({ | ||
| 77 | path: tool.path, | ||
| 78 | description: tool.description, | ||
| 79 | tags: tool.tags, | ||
| 80 | namespace: tool.namespace, | ||
| 81 | input: resolveRef(tool.input), | ||
| 82 | output: resolveRef(tool.output), | ||
| 83 | error: resolveRef(tool.error), | ||
| 84 | })), | ||
| 85 | types, | ||
| 86 | }; | ||
| 87 | } | ||
| @@ -1,314 +1,180 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | allowAllToolInteractions, | ||
| 7 | makeToolInvokerFromTools, | ||
| 8 | toExecutorTool, | ||
| 9 | } from "@executor/codemode-core"; | ||
| 10 | 3 | ||
| 4 | import type { ToolInvoker } from "@executor/codemode-core"; | ||
| 11 | import { makeQuickJsExecutor } from "./index"; | 5 | import { makeQuickJsExecutor } from "./index"; |
| 12 | 6 | ||
| 13 | const numberPairInputSchema = Schema.standardSchemaV1( | 7 | const makeTestInvoker = ( |
| 14 | Schema.Struct({ | 8 | handlers: Record<string, (args: unknown) => unknown>, |
| 15 | a: Schema.Number, | 9 | ): ToolInvoker => ({ |
| 16 | b: Schema.Number, | 10 | invoke: ({ path, args }) => { |
| 17 | }), | 11 | const handler = handlers[path]; |
| 18 | ); | 12 | if (!handler) { |
| 19 | 13 | return Effect.fail(new Error(`Unknown tool: ${path}`)); | |
| 20 | const messageInputSchema = Schema.standardSchemaV1( | 14 | } |
| 21 | Schema.Struct({ | 15 | return Effect.try(() => handler(args)); |
| 22 | message: Schema.String, | ||
| 23 | }), | ||
| 24 | ); | ||
| 25 | |||
| 26 | const discoverInputSchema = Schema.standardSchemaV1( | ||
| 27 | Schema.Struct({ | ||
| 28 | query: Schema.String, | ||
| 29 | limit: Schema.optional(Schema.Number), | ||
| 30 | }), | ||
| 31 | ); | ||
| 32 | |||
| 33 | const describeToolInputSchema = Schema.standardSchemaV1( | ||
| 34 | Schema.Struct({ | ||
| 35 | path: Schema.String, | ||
| 36 | includeSchemas: Schema.optional(Schema.Boolean), | ||
| 37 | }), | ||
| 38 | ); | ||
| 39 | |||
| 40 | const repoInputSchema = Schema.standardSchemaV1( | ||
| 41 | Schema.Struct({ | ||
| 42 | owner: Schema.String, | ||
| 43 | repo: Schema.String, | ||
| 44 | }), | ||
| 45 | ); | ||
| 46 | |||
| 47 | const tools = { | ||
| 48 | "math.add": { | ||
| 49 | description: "Add two numbers", | ||
| 50 | inputSchema: numberPairInputSchema, | ||
| 51 | execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }), | ||
| 52 | }, | 16 | }, |
| 53 | "notifications.send": toExecutorTool({ | 17 | }); |
| 54 | tool: { | ||
| 55 | description: "Send a message", | ||
| 56 | inputSchema: messageInputSchema, | ||
| 57 | execute: ({ message }: { message: string }) => ({ | ||
| 58 | delivered: true, | ||
| 59 | message, | ||
| 60 | }), | ||
| 61 | }, | ||
| 62 | metadata: { | ||
| 63 | interaction: "required", | ||
| 64 | }, | ||
| 65 | }), | ||
| 66 | }; | ||
| 67 | |||
| 68 | describe("runtime-quickjs", () => { | ||
| 69 | it.effect("executes simple code and returns result", () => | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const executor = makeQuickJsExecutor(); | ||
| 72 | const toolInvoker = makeToolInvokerFromTools({ tools }); | ||
| 73 | |||
| 74 | const output = yield* executor.execute( | ||
| 75 | "return 1 + 2;", | ||
| 76 | toolInvoker, | ||
| 77 | ); | ||
| 78 | |||
| 79 | expect(output.result).toBe(3); | ||
| 80 | expect(output.error).toBeUndefined(); | ||
| 81 | }), | ||
| 82 | ); | ||
| 83 | |||
| 84 | it.effect("executes code with tool calls", () => | ||
| 85 | Effect.gen(function* () { | ||
| 86 | const executor = makeQuickJsExecutor(); | ||
| 87 | const toolInvoker = makeToolInvokerFromTools({ | ||
| 88 | tools, | ||
| 89 | onToolInteraction: allowAllToolInteractions, | ||
| 90 | }); | ||
| 91 | |||
| 92 | const output = yield* executor.execute( | ||
| 93 | [ | ||
| 94 | "const math = await tools.math.add({ a: 19, b: 23 });", | ||
| 95 | "await tools.notifications.send({ message: `sum is ${math.sum}` });", | ||
| 96 | "return math;", | ||
| 97 | ].join("\n"), | ||
| 98 | toolInvoker, | ||
| 99 | ); | ||
| 100 | |||
| 101 | expect(output.result).toEqual({ sum: 42 }); | ||
| 102 | expect(output.error).toBeUndefined(); | ||
| 103 | }), | ||
| 104 | ); | ||
| 105 | |||
| 106 | it.effect("captures console output in logs", () => | ||
| 107 | Effect.gen(function* () { | ||
| 108 | const executor = makeQuickJsExecutor(); | ||
| 109 | const toolInvoker = makeToolInvokerFromTools({ tools }); | ||
| 110 | |||
| 111 | const output = yield* executor.execute( | ||
| 112 | [ | ||
| 113 | 'console.log("hello from quickjs");', | ||
| 114 | 'console.warn("a warning");', | ||
| 115 | 'console.error("an error");', | ||
| 116 | "return 42;", | ||
| 117 | ].join("\n"), | ||
| 118 | toolInvoker, | ||
| 119 | ); | ||
| 120 | 18 | ||
| 121 | expect(output.result).toBe(42); | 19 | const executor = makeQuickJsExecutor({ timeoutMs: 5_000 }); |
| 122 | expect(output.logs).toContain("[log] hello from quickjs"); | ||
| 123 | expect(output.logs).toContain("[warn] a warning"); | ||
| 124 | expect(output.logs).toContain("[error] an error"); | ||
| 125 | }), | ||
| 126 | ); | ||
| 127 | 20 | ||
| 128 | it.effect("reports execution errors without crashing", () => | 21 | describe("quickjs executor", () => { |
| 22 | it.effect("runs plain code", () => | ||
| 129 | Effect.gen(function* () { | 23 | Effect.gen(function* () { |
| 130 | const executor = makeQuickJsExecutor(); | 24 | const result = yield* executor.execute( |
| 131 | const toolInvoker = makeToolInvokerFromTools({ tools }); | 25 | `return 1 + 2`, |
| 132 | 26 | makeTestInvoker({}), | |
| 133 | const output = yield* executor.execute( | ||
| 134 | 'throw new Error("boom");', | ||
| 135 | toolInvoker, | ||
| 136 | ); | 27 | ); |
| 137 | 28 | expect(result.result).toBe(3); | |
| 138 | expect(output.result).toBeNull(); | 29 | expect(result.error).toBeUndefined(); |
| 139 | expect(output.error).toContain("boom"); | ||
| 140 | }), | 30 | }), |
| 141 | ); | 31 | ); |
| 142 | 32 | ||
| 143 | it.effect("handles tool call errors gracefully", () => | 33 | it.effect("invokes a tool and returns its result", () => |
| 144 | Effect.gen(function* () { | 34 | Effect.gen(function* () { |
| 145 | const failingTools = { | 35 | const invoker = makeTestInvoker({ |
| 146 | "broken.thing": { | 36 | "math.add": (args: any) => ({ |
| 147 | description: "Always fails", | 37 | sum: args.a + args.b, |
| 148 | inputSchema: Schema.standardSchemaV1(Schema.Struct({})), | 38 | }), |
| 149 | execute: () => { | ||
| 150 | throw new Error("tool is broken"); | ||
| 151 | }, | ||
| 152 | }, | ||
| 153 | }; | ||
| 154 | |||
| 155 | const executor = makeQuickJsExecutor(); | ||
| 156 | const toolInvoker = makeToolInvokerFromTools({ | ||
| 157 | tools: failingTools, | ||
| 158 | }); | 39 | }); |
| 159 | 40 | ||
| 160 | const output = yield* executor.execute( | 41 | const result = yield* executor.execute( |
| 161 | "return await tools.broken.thing({});", | 42 | ` |
| 162 | toolInvoker, | 43 | const res = await tools.math.add({ a: 5, b: 3 }); |
| 44 | return res.sum; | ||
| 45 | `, | ||
| 46 | invoker, | ||
| 163 | ); | 47 | ); |
| 164 | 48 | ||
| 165 | expect(output.result).toBeNull(); | 49 | expect(result.error).toBeUndefined(); |
| 166 | expect(output.error).toContain("tool is broken"); | 50 | expect(result.result).toBe(8); |
| 167 | }), | 51 | }), |
| 168 | ); | 52 | ); |
| 169 | 53 | ||
| 170 | it.effect("respects timeout", () => | 54 | it.effect("invokes multiple tools in sequence", () => |
| 171 | Effect.gen(function* () { | 55 | Effect.gen(function* () { |
| 172 | const executor = makeQuickJsExecutor({ | 56 | const invoker = makeTestInvoker({ |
| 173 | timeoutMs: 250, | 57 | "users.get": (args: any) => ({ |
| 58 | id: args.id, | ||
| 59 | name: `User ${args.id}`, | ||
| 60 | }), | ||
| 61 | "users.greet": (args: any) => ({ | ||
| 62 | message: `Hello, ${args.name}!`, | ||
| 63 | }), | ||
| 174 | }); | 64 | }); |
| 175 | const toolInvoker = makeToolInvokerFromTools({ tools }); | ||
| 176 | 65 | ||
| 177 | const output = yield* executor.execute( | 66 | const result = yield* executor.execute( |
| 178 | "while (true) {}", | 67 | ` |
| 179 | toolInvoker, | 68 | const user = await tools.users.get({ id: 42 }); |
| 69 | const greeting = await tools.users.greet({ name: user.name }); | ||
| 70 | return greeting.message; | ||
| 71 | `, | ||
| 72 | invoker, | ||
| 180 | ); | 73 | ); |
| 181 | 74 | ||
| 182 | expect(output.result).toBeNull(); | 75 | expect(result.error).toBeUndefined(); |
| 183 | expect(output.error).toContain("timed out"); | 76 | expect(result.result).toBe("Hello, User 42!"); |
| 184 | }), | 77 | }), |
| 185 | ); | 78 | ); |
| 186 | 79 | ||
| 187 | it.effect("network access is denied by default", () => | 80 | it.effect("handles tool errors", () => |
| 188 | Effect.gen(function* () { | 81 | Effect.gen(function* () { |
| 189 | const executor = makeQuickJsExecutor(); | 82 | const invoker = makeTestInvoker({ |
| 190 | const toolInvoker = makeToolInvokerFromTools({ tools }); | 83 | "db.query": () => { |
| 84 | throw new Error("connection refused"); | ||
| 85 | }, | ||
| 86 | }); | ||
| 191 | 87 | ||
| 192 | const output = yield* executor.execute( | 88 | const result = yield* executor.execute( |
| 193 | 'await fetch("https://example.com"); return 1;', | 89 | ` |
| 194 | toolInvoker, | 90 | try { |
| 91 | await tools.db.query({ sql: "SELECT 1" }); | ||
| 92 | return "should not reach"; | ||
| 93 | } catch (e) { | ||
| 94 | return "caught: " + e.message; | ||
| 95 | } | ||
| 96 | `, | ||
| 97 | invoker, | ||
| 195 | ); | 98 | ); |
| 196 | 99 | ||
| 197 | expect(output.result).toBeNull(); | 100 | expect(result.error).toBeUndefined(); |
| 198 | expect(output.error).toContain("fetch is disabled in QuickJS executor"); | 101 | expect(result.result).toContain("caught:"); |
| 199 | }), | 102 | }), |
| 200 | ); | 103 | ); |
| 201 | 104 | ||
| 202 | it.effect("multiple sequential tool calls work correctly", () => | 105 | it.effect("handles unknown tool path", () => |
| 203 | Effect.gen(function* () { | 106 | Effect.gen(function* () { |
| 204 | const executor = makeQuickJsExecutor(); | 107 | const invoker = makeTestInvoker({}); |
| 205 | const toolInvoker = makeToolInvokerFromTools({ tools }); | 108 | |
| 206 | 109 | const result = yield* executor.execute( | |
| 207 | const output = yield* executor.execute( | 110 | ` |
| 208 | [ | 111 | try { |
| 209 | "const r1 = await tools.math.add({ a: 1, b: 2 });", | 112 | await tools.nonexistent.thing({ x: 1 }); |
| 210 | "const r2 = await tools.math.add({ a: r1.sum, b: 10 });", | 113 | return "should not reach"; |
| 211 | "const r3 = await tools.math.add({ a: r2.sum, b: 100 });", | 114 | } catch (e) { |
| 212 | "return r3;", | 115 | return "caught: " + e.message; |
| 213 | ].join("\n"), | 116 | } |
| 214 | toolInvoker, | 117 | `, |
| 118 | invoker, | ||
| 215 | ); | 119 | ); |
| 216 | 120 | ||
| 217 | expect(output.result).toEqual({ sum: 113 }); | 121 | expect(result.error).toBeUndefined(); |
| 218 | expect(output.error).toBeUndefined(); | 122 | expect(result.result).toContain("caught:"); |
| 219 | }), | 123 | }), |
| 220 | ); | 124 | ); |
| 221 | 125 | ||
| 222 | it.effect("does not expose internal executor bridge globals to user code", () => | 126 | it.effect("captures console.log output", () => |
| 223 | Effect.gen(function* () { | 127 | Effect.gen(function* () { |
| 224 | const executor = makeQuickJsExecutor(); | 128 | const result = yield* executor.execute( |
| 225 | const toolInvoker = makeToolInvokerFromTools({ tools }); | 129 | ` |
| 226 | 130 | console.log("hello from sandbox"); | |
| 227 | const output = yield* executor.execute( | 131 | console.warn("a warning"); |
| 228 | [ | 132 | return "done"; |
| 229 | "return {", | 133 | `, |
| 230 | " hasInvokeTool: typeof globalThis.__executor_invokeTool !== 'undefined',", | 134 | makeTestInvoker({}), |
| 231 | " hasLogBridge: typeof globalThis.__executor_log !== 'undefined',", | ||
| 232 | " globalKeys: Object.keys(globalThis).sort(),", | ||
| 233 | "};", | ||
| 234 | ].join("\n"), | ||
| 235 | toolInvoker, | ||
| 236 | ); | 135 | ); |
| 237 | 136 | ||
| 238 | expect(output.result).toEqual({ | 137 | expect(result.error).toBeUndefined(); |
| 239 | hasInvokeTool: false, | 138 | expect(result.result).toBe("done"); |
| 240 | hasLogBridge: false, | 139 | expect(result.logs).toContainEqual("[log] hello from sandbox"); |
| 241 | globalKeys: [], | 140 | expect(result.logs).toContainEqual("[warn] a warning"); |
| 242 | }); | ||
| 243 | expect(output.error).toBeUndefined(); | ||
| 244 | }), | 141 | }), |
| 245 | ); | 142 | ); |
| 246 | 143 | ||
| 247 | it.effect("supports the documented discovery workflow shape", () => | 144 | it.effect("passes tool result into next tool call", () => |
| 248 | Effect.gen(function* () { | 145 | Effect.gen(function* () { |
| 249 | const executor = makeQuickJsExecutor(); | 146 | const invoker = makeTestInvoker({ |
| 250 | const toolInvoker = makeToolInvokerFromTools({ | 147 | "stripe.customers.list": () => ({ |
| 251 | tools: { | 148 | data: [ |
| 252 | discover: { | 149 | { id: "cus_1", email: "alice@example.com" }, |
| 253 | inputSchema: discoverInputSchema, | 150 | { id: "cus_2", email: "bob@example.com" }, |
| 254 | execute: () => ({ | 151 | ], |
| 255 | bestPath: "github.issues.list", | 152 | }), |
| 256 | results: [ | 153 | "stripe.invoices.create": (args: any) => ({ |
| 257 | { | 154 | id: "inv_1", |
| 258 | path: "github.issues.list", | 155 | customer: args.customer, |
| 259 | score: 0.99, | 156 | amount: args.amount, |
| 260 | }, | 157 | }), |
| 261 | ], | ||
| 262 | total: 1, | ||
| 263 | }), | ||
| 264 | }, | ||
| 265 | "describe.tool": { | ||
| 266 | inputSchema: describeToolInputSchema, | ||
| 267 | execute: ({ path }: { path: string; includeSchemas?: boolean }) => ({ | ||
| 268 | path, | ||
| 269 | contract: { | ||
| 270 | inputTypePreview: "{ owner: string; repo: string }", | ||
| 271 | }, | ||
| 272 | }), | ||
| 273 | }, | ||
| 274 | "github.issues.list": { | ||
| 275 | inputSchema: repoInputSchema, | ||
| 276 | execute: ({ owner, repo }: { owner: string; repo: string }) => ({ | ||
| 277 | owner, | ||
| 278 | repo, | ||
| 279 | issues: [{ id: "issue_1" }], | ||
| 280 | }), | ||
| 281 | }, | ||
| 282 | }, | ||
| 283 | }); | 158 | }); |
| 284 | 159 | ||
| 285 | const output = yield* executor.execute( | 160 | const result = yield* executor.execute( |
| 286 | [ | 161 | ` |
| 287 | 'const { results, bestPath } = await tools.discover({ query: "github issues", limit: 5 });', | 162 | const customers = await tools.stripe.customers.list(); |
| 288 | "const path = bestPath ?? results[0]?.path;", | 163 | const invoice = await tools.stripe.invoices.create({ |
| 289 | 'if (!path) throw new Error("No matching tools found.");', | 164 | customer: customers.data[0].id, |
| 290 | "const detail = await tools.describe.tool({ path, includeSchemas: true });", | 165 | amount: 5000, |
| 291 | 'const issues = await tools.github.issues.list({ owner: "openai", repo: "codex" });', | 166 | }); |
| 292 | "return { path, detail, issues };", | 167 | return invoice; |
| 293 | ].join("\n"), | 168 | `, |
| 294 | toolInvoker, | 169 | invoker, |
| 295 | ); | 170 | ); |
| 296 | 171 | ||
| 297 | expect(output.result).toEqual({ | 172 | expect(result.error).toBeUndefined(); |
| 298 | path: "github.issues.list", | 173 | expect(result.result).toEqual({ |
| 299 | detail: { | 174 | id: "inv_1", |
| 300 | path: "github.issues.list", | 175 | customer: "cus_1", |
| 301 | contract: { | 176 | amount: 5000, |
| 302 | inputTypePreview: "{ owner: string; repo: string }", | ||
| 303 | }, | ||
| 304 | }, | ||
| 305 | issues: { | ||
| 306 | owner: "openai", | ||
| 307 | repo: "codex", | ||
| 308 | issues: [{ id: "issue_1" }], | ||
| 309 | }, | ||
| 310 | }); | 177 | }); |
| 311 | expect(output.error).toBeUndefined(); | ||
| 312 | }), | 178 | }), |
| 313 | ); | 179 | ); |
| 314 | }); | 180 | }); |