diff --git a/packages/codemode/src/adapters/openapi/index.ts b/packages/codemode/src/adapters/openapi/index.ts index 8f1b3a31ee..adff9e4ffa 100644 --- a/packages/codemode/src/adapters/openapi/index.ts +++ b/packages/codemode/src/adapters/openapi/index.ts @@ -7,7 +7,7 @@ import { isRecord, methods, nonEmptyString, - operationName, + operationPath, operationParameters, outputSchema, requestBody, @@ -15,7 +15,7 @@ import { securitySchemes, specServerUrl, } from "./spec.js" -import type { Operation, Options, Result, Skipped } from "./types.js" +import type { Operation, Options, Result, Skipped, Tools } from "./types.js" export type { AuthResolver, @@ -43,15 +43,17 @@ export const fromSpec = (options: Options): Result => { const paths = isRecord(document.paths) ? document.paths : {} const base = options.baseUrl ?? specServerUrl(document) const used = new Set() + const namespaces = new Set() const skipped: Array = [] - const tools = Object.create(null) as Record> + const tools = Object.create(null) as Tools for (const [path, pathValue] of Object.entries(paths)) { if (!isRecord(pathValue)) continue for (const [method, operationValue] of Object.entries(pathValue)) { if (!methods.has(method) || !isRecord(operationValue)) continue + const segments = operationPath(method, path, operationValue, used, namespaces) const operation: Operation = { - id: operationName(method, path, operationValue, used), + id: segments.join("."), method: method.toUpperCase(), path, summary: nonEmptyString(operationValue.summary), @@ -82,16 +84,39 @@ export const fromSpec = (options: Options): Result => { headers: options.headers ?? {}, } used.add(operation.id) - tools[operation.id] = Tool.make({ - description: operation.description ?? operation.summary ?? `${operation.method} ${path}`, - input: inputSchema(plan.parameters, body, definitions), - output: outputSchema(document, operationValue, definitions), - run: (input) => invoke(plan, input), - }) + for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join(".")) + setTool( + tools, + segments, + Tool.make({ + description: operation.description ?? operation.summary ?? `${operation.method} ${path}`, + input: inputSchema(plan.parameters, body, definitions), + output: outputSchema(document, operationValue, definitions), + run: (input) => invoke(plan, input), + }), + ) } } return { tools, skipped } } +const setTool = ( + tools: Tools, + path: ReadonlyArray, + definition: Definition, +): void => { + const [head, ...rest] = path + if (head === undefined) return + if (rest.length === 0) { + tools[head] = definition + return + } + const child = tools[head] + if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") { + tools[head] = Object.create(null) as Tools + } + setTool(tools[head] as Tools, rest, definition) +} + export const OpenAPI = { fromSpec } diff --git a/packages/codemode/src/adapters/openapi/spec.ts b/packages/codemode/src/adapters/openapi/spec.ts index fa4036de93..4609288e32 100644 --- a/packages/codemode/src/adapters/openapi/spec.ts +++ b/packages/codemode/src/adapters/openapi/spec.ts @@ -206,24 +206,44 @@ export const outputSchema = ( return successes.length > 0 ? { type: "null" } : undefined } -export const operationName = ( - method: string, - path: string, - operation: Record, - used: ReadonlySet, -): string => { - const raw = nonEmptyString(operation.operationId) ?? `${method}_${path.replaceAll(/[{}]/g, "")}` +const sanitizeOperationSegment = (raw: string): string => { const base = raw .replaceAll(/[^A-Za-z0-9_$]+/g, "_") .replace(/^_+|_+$/g, "") .replace(/^([0-9])/, "_$1") || "operation" - if (!used.has(base) && !blockedOperationNames.has(base)) return base + return blockedOperationNames.has(base) ? `${base}_2` : base +} + +export const operationPath = ( + method: string, + path: string, + operation: Record, + used: ReadonlySet, + namespaces: ReadonlySet, +): ReadonlyArray => { + const raw = nonEmptyString(operation.operationId) + const base = (raw === undefined ? [`${method}_${path.replaceAll(/[{}]/g, "")}`] : raw.split(".")) + .map(sanitizeOperationSegment) + .filter((segment) => segment !== "") + const segments = base.length === 0 ? ["operation"] : base + if (isOperationPathAvailable(segments, used, namespaces)) return segments + const fallback = [segments.join("_")] const next = (index: number): string => { - const candidate = `${base}_${index}` - return used.has(candidate) || blockedOperationNames.has(candidate) ? next(index + 1) : candidate + const candidate = `${fallback[0]}_${index}` + return isOperationPathAvailable([candidate], used, namespaces) ? candidate : next(index + 1) } - return next(2) + return [next(2)] +} + +const isOperationPathAvailable = ( + segments: ReadonlyArray, + used: ReadonlySet, + namespaces: ReadonlySet, +): boolean => { + const key = segments.join(".") + if (used.has(key) || namespaces.has(key)) return false + return segments.slice(0, -1).every((_, index) => !used.has(segments.slice(0, index + 1).join("."))) } export const specServerUrl = (document: Document): string | Skip => { diff --git a/packages/codemode/src/adapters/openapi/types.ts b/packages/codemode/src/adapters/openapi/types.ts index 97af74c140..668bfa11cd 100644 --- a/packages/codemode/src/adapters/openapi/types.ts +++ b/packages/codemode/src/adapters/openapi/types.ts @@ -68,7 +68,7 @@ export type Skipped = { readonly reason: string } -export type Tools = { readonly [name: string]: Definition } +export type Tools = { [name: string]: Definition | Tools } export type Result = { /** Tool subtree; the host places it under a key in its `tools` tree. */ diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index b068848586..cb3db61571 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -20,26 +20,22 @@ export type HostTools = { [name: string]: HostTool | Definition | HostTools } -export type Services = Tools extends (...args: Array) => Effect.Effect - ? R - : Tools extends { - readonly _tag: "CodeModeTool" - readonly run: (input: unknown) => Effect.Effect - } +export type Services = ServicesOf + +type ServicesOf> = Depth["length"] extends 8 + ? never + : Tools extends (...args: Array) => Effect.Effect ? R - : Tools extends object - ? string extends keyof Tools - ? // Index-signature records (e.g. adapter-generated tool sets): read the value - // type directly. One level only - the recursive HostTools shape would not - // terminate, and concrete host trees always have literal keys. - Tools[string] extends { - readonly _tag: "CodeModeTool" - readonly run: (input: unknown) => Effect.Effect - } - ? R - : never - : Services - : never + : Tools extends { + readonly _tag: "CodeModeTool" + readonly run: (input: unknown) => Effect.Effect + } + ? R + : Tools extends object + ? string extends keyof Tools + ? ServicesOf + : ServicesOf + : never /** Minimal audit record retained for each admitted tool call. */ export type ToolCall = { diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 9993087aca..b29ed36e91 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -33,18 +33,27 @@ const operations = (spec: Document) => const nonEmptyString = (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined -const toolNameEntries = (spec: Document) => { +const toolPathEntries = (spec: Document) => { const used = new Set() + const namespaces = new Set() return operations(spec).map((item) => { const { path, method, operation } = item - const raw = nonEmptyString(operation.operationId) ?? `${method}_${path.replaceAll(/[{}]/g, "")}` - const base = raw.replaceAll(/[^A-Za-z0-9_$]+/g, "_").replace(/^_+|_+$/g, "").replace(/^([0-9])/, "_$1") || "operation" - const name = used.has(base) ? `${base}_2` : base + const raw = nonEmptyString(operation.operationId) + const segments = (raw === undefined ? [`${method}_${path.replaceAll(/[{}]/g, "")}`] : raw.split(".")) + .map((segment) => segment.replaceAll(/[^A-Za-z0-9_$]+/g, "_").replace(/^_+|_+$/g, "").replace(/^([0-9])/, "_$1") || "operation") + .map((segment) => (["__proto__", "constructor", "prototype"].includes(segment) ? `${segment}_2` : segment)) + const key = segments.join(".") + const prefixUsed = segments.slice(0, -1).some((_, index) => used.has(segments.slice(0, index + 1).join("."))) + const name = used.has(key) || namespaces.has(key) || prefixUsed ? `${segments.join("_")}_2` : key used.add(name) + for (const index of name.split(".").slice(0, -1).keys()) namespaces.add(name.split(".").slice(0, index + 1).join(".")) return { ...item, name } }) } +const toolAt = (tools: unknown, name: string) => + name.split(".").reduce((current, segment) => (isRecord(current) ? current[segment] : undefined), tools) + const jsonContentSchema = (content: unknown) => isRecord(content) ? Object.entries(content).find(([mediaType]) => { @@ -82,22 +91,23 @@ describe("OpenAPI.fromSpec", () => { const spec = await opencodeSpec() const result = OpenAPI.fromSpec({ spec, baseUrl }) - const entries = toolNameEntries(spec) - expect(Object.keys(result.tools).sort()).toStrictEqual(entries.map((entry) => entry.name).sort()) + const entries = toolPathEntries(spec) + expect(entries.every((entry) => toolAt(result.tools, entry.name) !== undefined)).toBe(true) expect(result.skipped).toStrictEqual([]) - expect(Object.keys(result.tools)).toContain("global_health") - expect(Object.keys(result.tools)).toContain("file_read") - expect(Object.keys(result.tools)).toContain("session_create") + expect(toolAt(result.tools, "global.health")).not.toBeUndefined() + expect(toolAt(result.tools, "file.read")).not.toBeUndefined() + expect(toolAt(result.tools, "session.create")).not.toBeUndefined() for (const item of entries) { - const tool = result.tools[item.name] + const tool = toolAt(result.tools, item.name) expect(tool).toMatchObject({ _tag: "CodeModeTool", description: nonEmptyString(item.operation.description) ?? nonEmptyString(item.operation.summary) ?? `${item.method.toUpperCase()} ${item.path}`, }) - const input = isRecord(tool?.input) ? tool.input : {} + const toolRecord = isRecord(tool) ? tool : {} + const input = isRecord(toolRecord.input) ? toolRecord.input : {} expect(input.type).toBe("object") const properties = isRecord(input.properties) ? input.properties : {} const parameters = Array.isArray(item.operation.parameters) ? item.operation.parameters.filter(isRecord) : [] @@ -112,9 +122,8 @@ describe("OpenAPI.fromSpec", () => { .filter((name): name is string => typeof name === "string") if (names.length === 0) continue const groupSchema = isRecord(properties[group.name]) ? properties[group.name] : {} - expect(Object.keys(isRecord(groupSchema.properties) ? groupSchema.properties : {}).sort()).toStrictEqual( - names.sort(), - ) + const groupProperties = isRecord(groupSchema) && isRecord(groupSchema.properties) ? groupSchema.properties : {} + expect(Object.keys(groupProperties).sort()).toStrictEqual(names.sort()) } const requestBody = isRecord(item.operation.requestBody) ? item.operation.requestBody : undefined @@ -126,7 +135,7 @@ describe("OpenAPI.fromSpec", () => { .filter(([status]) => /^2\d\d$/.test(status) || status.toUpperCase() === "2XX") .map(([, response]) => (isRecord(response) ? response : {})) if (successes.some((response) => jsonContentSchema(response.content) !== undefined)) { - expect(tool?.output).not.toBeUndefined() + expect(toolRecord.output).not.toBeUndefined() } } }) @@ -138,8 +147,10 @@ describe("OpenAPI.fromSpec", () => { expect(spec.security).toStrictEqual([]) expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([]) - expect(result.tools.global_health?.input).toMatchObject({ type: "object", properties: {} }) - const input = isRecord(result.tools.global_health?.input) ? result.tools.global_health.input : {} + const health = toolAt(result.tools, "global.health") + const healthInput = isRecord(health) ? health.input : undefined + expect(healthInput).toMatchObject({ type: "object", properties: {} }) + const input = isRecord(healthInput) ? healthInput : {} expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([]) }) @@ -157,7 +168,7 @@ describe("OpenAPI.fromSpec", () => { expect(result.value).toMatchObject({ items: [ { - path: "tools.opencode.global_health", + path: "tools.opencode.global.health", description: "Get health information about the OpenCode server.", }, ], @@ -176,8 +187,8 @@ describe("OpenAPI.fromSpec", () => { const result = await Effect.runPromise( runtime .execute(` - const file = await tools.opencode.file_read({ query: { path: "README.md", directory: "/repo" } }) - const session = await tools.opencode.session_create({ body: { title: "hello" } }) + const file = await tools.opencode.file.read({ query: { path: "README.md", directory: "/repo" } }) + const session = await tools.opencode.session.create({ body: { title: "hello" } }) return { file, session } `) .pipe(Effect.provide(layer)), @@ -201,7 +212,7 @@ describe("OpenAPI.fromSpec", () => { const runtime = CodeMode.make({ tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools } }) const result = await Effect.runPromise( - runtime.execute("return await tools.opencode.file_read({})").pipe(Effect.provide(layer)), + runtime.execute("return await tools.opencode.file.read({})").pipe(Effect.provide(layer)), ) expect(result).toMatchObject({ ok: false })