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 | 1 | export * from "./types"; |
| 2 | +export * from "./validation"; | |
| 2 | 3 | export * from "./json-schema"; |
| 3 | −export * from "./schema-types"; | |
| 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"; | |
| 4 | +export * from "./effect-errors"; | |
| @@ -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 | 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; |
| 2 | 2 | import type * as Effect from "effect/Effect"; |
| 3 | −import * as Schema from "effect/Schema"; | |
| 4 | 3 | |
| 4 | +/** Branded tool path */ | |
| 5 | 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 | 10 | export type StandardSchema<Input = unknown, Output = unknown> = |
| 8 | 11 | StandardSchemaV1<Input, Output>; |
| 9 | 12 | |
| 10 | −export type ElicitationFormRequest = { | |
| 11 | − mode?: "form"; | |
| 12 | − message: string; | |
| 13 | − requestedSchema: Record<string, unknown>; | |
| 14 | −}; | |
| 15 | − | |
| 16 | −export type ElicitationUrlRequest = { | |
| 17 | − mode: "url"; | |
| 18 | − message: string; | |
| 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>; | |
| 13 | +/** A tool that can be invoked */ | |
| 14 | +export interface Tool { | |
| 15 | + readonly path: ToolPath; | |
| 16 | + readonly description?: string; | |
| 17 | + readonly inputSchema: StandardSchema; | |
| 18 | + readonly outputSchema?: StandardSchema; | |
| 19 | + readonly execute: ( | |
| 20 | + input: unknown, | |
| 21 | + ) => unknown | Promise<unknown>; | |
| 211 | 22 | } |
| 212 | 23 | |
| 213 | −export type CatalogPrimitive = { | |
| 214 | − namespaces(input: { | |
| 215 | − limit?: number; | |
| 216 | − }): Effect.Effect< | |
| 217 | − { namespaces: readonly ToolNamespace[] }, | |
| 218 | − unknown | |
| 219 | − >; | |
| 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 | −}; | |
| 24 | +/** Invoke a tool by path */ | |
| 25 | +export interface ToolInvoker { | |
| 26 | + invoke(input: { | |
| 27 | + path: string; | |
| 28 | + args: unknown; | |
| 29 | + }): Effect.Effect<unknown, unknown>; | |
| 30 | +} | |
| 258 | 31 | |
| 32 | +/** Result of executing code in a sandbox */ | |
| 259 | 33 | export type ExecuteResult = { |
| 260 | 34 | result: unknown; |
| 261 | 35 | error?: string; |
| 262 | 36 | logs?: string[]; |
| 263 | 37 | }; |
| 264 | 38 | |
| 265 | −export type ToolInvocationInput = { | |
| 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 | − | |
| 39 | +/** Executes code in a sandboxed runtime with tool access */ | |
| 275 | 40 | export interface CodeExecutor { |
| 276 | 41 | execute( |
| 277 | 42 | code: string, |
| @@ -279,8 +44,13 @@ export interface CodeExecutor { · 1 unmodified line | ||
| 279 | 44 | ): Effect.Effect<ExecuteResult, unknown>; |
| 280 | 45 | } |
| 281 | 46 | |
| 282 | −export type CodeToolOutput = { | |
| 283 | − code: string; | |
| 284 | − result: unknown; | |
| 285 | − logs?: string[]; | |
| 47 | +/** Accept-anything schema for tools with no input validation */ | |
| 48 | +export const unknownInputSchema: StandardSchema = { | |
| 49 | + "~standard": { | |
| 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 | 4 | "type": "module", |
| 5 | 5 | "version": "0.0.0", |
| 6 | 6 | "exports": { |
| 7 | − ".": "./src/index.ts", | |
| 8 | − "./catalog": "./src/catalog.ts", | |
| 9 | − "./ids": "./src/ids.ts", | |
| 10 | − "./model": "./src/model.ts" | |
| 7 | + ".": "./src/index.ts" | |
| 11 | 8 | }, |
| 12 | 9 | "scripts": { |
| 13 | 10 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", |
| @@ -15,7 +12,6 @@ · 1 unmodified line | ||
| 15 | 12 | "test:watch": "vitest" |
| 16 | 13 | }, |
| 17 | 14 | "dependencies": { |
| 18 | − "@executor/codemode-core": "workspace:*", | |
| 19 | 15 | "effect": "catalog:" |
| 20 | 16 | }, |
| 21 | 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 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 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 | 5 | import { makeQuickJsExecutor } from "./index"; |
| 12 | 6 | |
| 13 | −const numberPairInputSchema = Schema.standardSchemaV1( | |
| 14 | − Schema.Struct({ | |
| 15 | − a: Schema.Number, | |
| 16 | − b: Schema.Number, | |
| 17 | − }), | |
| 18 | −); | |
| 19 | − | |
| 20 | −const messageInputSchema = Schema.standardSchemaV1( | |
| 21 | − Schema.Struct({ | |
| 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 }), | |
| 7 | +const makeTestInvoker = ( | |
| 8 | + handlers: Record<string, (args: unknown) => unknown>, | |
| 9 | +): ToolInvoker => ({ | |
| 10 | + invoke: ({ path, args }) => { | |
| 11 | + const handler = handlers[path]; | |
| 12 | + if (!handler) { | |
| 13 | + return Effect.fail(new Error(`Unknown tool: ${path}`)); | |
| 14 | + } | |
| 15 | + return Effect.try(() => handler(args)); | |
| 52 | 16 | }, |
| 53 | − "notifications.send": toExecutorTool({ | |
| 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 | − ); | |
| 17 | +}); | |
| 120 | 18 | |
| 121 | − expect(output.result).toBe(42); | |
| 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 | − ); | |
| 19 | +const executor = makeQuickJsExecutor({ timeoutMs: 5_000 }); | |
| 127 | 20 | |
| 128 | − it.effect("reports execution errors without crashing", () => | |
| 21 | +describe("quickjs executor", () => { | |
| 22 | + it.effect("runs plain code", () => | |
| 129 | 23 | Effect.gen(function* () { |
| 130 | − const executor = makeQuickJsExecutor(); | |
| 131 | − const toolInvoker = makeToolInvokerFromTools({ tools }); | |
| 132 | − | |
| 133 | − const output = yield* executor.execute( | |
| 134 | − 'throw new Error("boom");', | |
| 135 | − toolInvoker, | |
| 24 | + const result = yield* executor.execute( | |
| 25 | + `return 1 + 2`, | |
| 26 | + makeTestInvoker({}), | |
| 136 | 27 | ); |
| 137 | − | |
| 138 | − expect(output.result).toBeNull(); | |
| 139 | − expect(output.error).toContain("boom"); | |
| 28 | + expect(result.result).toBe(3); | |
| 29 | + expect(result.error).toBeUndefined(); | |
| 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 | 34 | Effect.gen(function* () { |
| 145 | − const failingTools = { | |
| 146 | − "broken.thing": { | |
| 147 | − description: "Always fails", | |
| 148 | − inputSchema: Schema.standardSchemaV1(Schema.Struct({})), | |
| 149 | − execute: () => { | |
| 150 | − throw new Error("tool is broken"); | |
| 151 | − }, | |
| 152 | − }, | |
| 153 | − }; | |
| 154 | − | |
| 155 | − const executor = makeQuickJsExecutor(); | |
| 156 | − const toolInvoker = makeToolInvokerFromTools({ | |
| 157 | − tools: failingTools, | |
| 35 | + const invoker = makeTestInvoker({ | |
| 36 | + "math.add": (args: any) => ({ | |
| 37 | + sum: args.a + args.b, | |
| 38 | + }), | |
| 158 | 39 | }); |
| 159 | 40 | |
| 160 | − const output = yield* executor.execute( | |
| 161 | − "return await tools.broken.thing({});", | |
| 162 | − toolInvoker, | |
| 41 | + const result = yield* executor.execute( | |
| 42 | + ` | |
| 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(); | |
| 166 | − expect(output.error).toContain("tool is broken"); | |
| 49 | + expect(result.error).toBeUndefined(); | |
| 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 | 55 | Effect.gen(function* () { |
| 172 | − const executor = makeQuickJsExecutor({ | |
| 173 | − timeoutMs: 250, | |
| 56 | + const invoker = makeTestInvoker({ | |
| 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( | |
| 178 | − "while (true) {}", | |
| 179 | − toolInvoker, | |
| 66 | + const result = yield* executor.execute( | |
| 67 | + ` | |
| 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(); | |
| 183 | − expect(output.error).toContain("timed out"); | |
| 75 | + expect(result.error).toBeUndefined(); | |
| 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 | 81 | Effect.gen(function* () { |
| 189 | − const executor = makeQuickJsExecutor(); | |
| 190 | − const toolInvoker = makeToolInvokerFromTools({ tools }); | |
| 82 | + const invoker = makeTestInvoker({ | |
| 83 | + "db.query": () => { | |
| 84 | + throw new Error("connection refused"); | |
| 85 | + }, | |
| 86 | + }); | |
| 191 | 87 | |
| 192 | − const output = yield* executor.execute( | |
| 193 | − 'await fetch("https://example.com"); return 1;', | |
| 194 | − toolInvoker, | |
| 88 | + const result = yield* executor.execute( | |
| 89 | + ` | |
| 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(); | |
| 198 | − expect(output.error).toContain("fetch is disabled in QuickJS executor"); | |
| 100 | + expect(result.error).toBeUndefined(); | |
| 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 | 106 | Effect.gen(function* () { |
| 204 | − const executor = makeQuickJsExecutor(); | |
| 205 | − const toolInvoker = makeToolInvokerFromTools({ tools }); | |
| 206 | − | |
| 207 | − const output = yield* executor.execute( | |
| 208 | − [ | |
| 209 | − "const r1 = await tools.math.add({ a: 1, b: 2 });", | |
| 210 | − "const r2 = await tools.math.add({ a: r1.sum, b: 10 });", | |
| 211 | − "const r3 = await tools.math.add({ a: r2.sum, b: 100 });", | |
| 212 | − "return r3;", | |
| 213 | − ].join("\n"), | |
| 214 | − toolInvoker, | |
| 107 | + const invoker = makeTestInvoker({}); | |
| 108 | + | |
| 109 | + const result = yield* executor.execute( | |
| 110 | + ` | |
| 111 | + try { | |
| 112 | + await tools.nonexistent.thing({ x: 1 }); | |
| 113 | + return "should not reach"; | |
| 114 | + } catch (e) { | |
| 115 | + return "caught: " + e.message; | |
| 116 | + } | |
| 117 | + `, | |
| 118 | + invoker, | |
| 215 | 119 | ); |
| 216 | 120 | |
| 217 | − expect(output.result).toEqual({ sum: 113 }); | |
| 218 | − expect(output.error).toBeUndefined(); | |
| 121 | + expect(result.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 | 127 | Effect.gen(function* () { |
| 224 | − const executor = makeQuickJsExecutor(); | |
| 225 | − const toolInvoker = makeToolInvokerFromTools({ tools }); | |
| 226 | − | |
| 227 | − const output = yield* executor.execute( | |
| 228 | − [ | |
| 229 | − "return {", | |
| 230 | − " hasInvokeTool: typeof globalThis.__executor_invokeTool !== 'undefined',", | |
| 231 | − " hasLogBridge: typeof globalThis.__executor_log !== 'undefined',", | |
| 232 | − " globalKeys: Object.keys(globalThis).sort(),", | |
| 233 | − "};", | |
| 234 | − ].join("\n"), | |
| 235 | − toolInvoker, | |
| 128 | + const result = yield* executor.execute( | |
| 129 | + ` | |
| 130 | + console.log("hello from sandbox"); | |
| 131 | + console.warn("a warning"); | |
| 132 | + return "done"; | |
| 133 | + `, | |
| 134 | + makeTestInvoker({}), | |
| 236 | 135 | ); |
| 237 | 136 | |
| 238 | − expect(output.result).toEqual({ | |
| 239 | − hasInvokeTool: false, | |
| 240 | − hasLogBridge: false, | |
| 241 | − globalKeys: [], | |
| 242 | − }); | |
| 243 | − expect(output.error).toBeUndefined(); | |
| 137 | + expect(result.error).toBeUndefined(); | |
| 138 | + expect(result.result).toBe("done"); | |
| 139 | + expect(result.logs).toContainEqual("[log] hello from sandbox"); | |
| 140 | + expect(result.logs).toContainEqual("[warn] a warning"); | |
| 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 | 145 | Effect.gen(function* () { |
| 249 | − const executor = makeQuickJsExecutor(); | |
| 250 | − const toolInvoker = makeToolInvokerFromTools({ | |
| 251 | − tools: { | |
| 252 | − discover: { | |
| 253 | − inputSchema: discoverInputSchema, | |
| 254 | − execute: () => ({ | |
| 255 | − bestPath: "github.issues.list", | |
| 256 | − results: [ | |
| 257 | − { | |
| 258 | − path: "github.issues.list", | |
| 259 | − score: 0.99, | |
| 260 | − }, | |
| 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 | − }, | |
| 146 | + const invoker = makeTestInvoker({ | |
| 147 | + "stripe.customers.list": () => ({ | |
| 148 | + data: [ | |
| 149 | + { id: "cus_1", email: "alice@example.com" }, | |
| 150 | + { id: "cus_2", email: "bob@example.com" }, | |
| 151 | + ], | |
| 152 | + }), | |
| 153 | + "stripe.invoices.create": (args: any) => ({ | |
| 154 | + id: "inv_1", | |
| 155 | + customer: args.customer, | |
| 156 | + amount: args.amount, | |
| 157 | + }), | |
| 283 | 158 | }); |
| 284 | 159 | |
| 285 | − const output = yield* executor.execute( | |
| 286 | − [ | |
| 287 | − 'const { results, bestPath } = await tools.discover({ query: "github issues", limit: 5 });', | |
| 288 | − "const path = bestPath ?? results[0]?.path;", | |
| 289 | − 'if (!path) throw new Error("No matching tools found.");', | |
| 290 | − "const detail = await tools.describe.tool({ path, includeSchemas: true });", | |
| 291 | − 'const issues = await tools.github.issues.list({ owner: "openai", repo: "codex" });', | |
| 292 | − "return { path, detail, issues };", | |
| 293 | − ].join("\n"), | |
| 294 | − toolInvoker, | |
| 160 | + const result = yield* executor.execute( | |
| 161 | + ` | |
| 162 | + const customers = await tools.stripe.customers.list(); | |
| 163 | + const invoice = await tools.stripe.invoices.create({ | |
| 164 | + customer: customers.data[0].id, | |
| 165 | + amount: 5000, | |
| 166 | + }); | |
| 167 | + return invoice; | |
| 168 | + `, | |
| 169 | + invoker, | |
| 295 | 170 | ); |
| 296 | 171 | |
| 297 | − expect(output.result).toEqual({ | |
| 298 | − path: "github.issues.list", | |
| 299 | − detail: { | |
| 300 | − path: "github.issues.list", | |
| 301 | − contract: { | |
| 302 | − inputTypePreview: "{ owner: string; repo: string }", | |
| 303 | − }, | |
| 304 | − }, | |
| 305 | − issues: { | |
| 306 | − owner: "openai", | |
| 307 | − repo: "codex", | |
| 308 | − issues: [{ id: "issue_1" }], | |
| 309 | − }, | |
| 172 | + expect(result.error).toBeUndefined(); | |
| 173 | + expect(result.result).toEqual({ | |
| 174 | + id: "inv_1", | |
| 175 | + customer: "cus_1", | |
| 176 | + amount: 5000, | |
| 310 | 177 | }); |
| 311 | − expect(output.error).toBeUndefined(); | |
| 312 | 178 | }), |
| 313 | 179 | ); |
| 314 | 180 | }); |