From 4618f1b092a948459374a733625f06d02447dc6e Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 13 Jul 2026 13:04:56 +0200 Subject: [PATCH] fix(cli): preserve sanitized tool schema inputs (#12166) --- .changeset/calm-tools-agree.md | 5 ++ .../src/kilocode/session/tool-schema.ts | 87 ++++++++++++++----- packages/opencode/src/session/llm.ts | 7 +- .../test/kilocode/session/tool-schema.test.ts | 47 +++++++++- 4 files changed, 117 insertions(+), 29 deletions(-) create mode 100644 .changeset/calm-tools-agree.md diff --git a/.changeset/calm-tools-agree.md b/.changeset/calm-tools-agree.md new file mode 100644 index 0000000000..dcb1b29323 --- /dev/null +++ b/.changeset/calm-tools-agree.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Preserve dynamic tool properties when removing unsupported regex lookarounds. diff --git a/packages/opencode/src/kilocode/session/tool-schema.ts b/packages/opencode/src/kilocode/session/tool-schema.ts index 55cb27de87..d06215a5fc 100644 --- a/packages/opencode/src/kilocode/session/tool-schema.ts +++ b/packages/opencode/src/kilocode/session/tool-schema.ts @@ -1,19 +1,16 @@ import { asSchema, jsonSchema, type JSONSchema7, type Tool } from "ai" const MAPS = ["$defs", "definitions", "dependencies", "dependentSchemas", "patternProperties", "properties"] +const DANGERS = ["contains", "if", "not", "oneOf"] const NODES = [ "additionalItems", "additionalProperties", "allOf", "anyOf", - "contains", "contentSchema", "else", "extends", - "if", "items", - "not", - "oneOf", "prefixItems", "propertyNames", "then", @@ -54,38 +51,76 @@ function lookaround(input: string) { return false } -function walk(input: unknown): { value: unknown; changed: boolean } { +function reference(input: unknown, danger = false): boolean { + if (Array.isArray(input)) return input.some((item) => reference(item, danger)) + if (!record(input)) return false + if (danger && typeof input.$ref === "string") return true + return Object.entries(input).some(([key, value]) => reference(value, danger || DANGERS.includes(key))) +} + +function walk(input: unknown): { value: unknown; changed: boolean; dynamic: boolean; hazard: boolean } { if (Array.isArray(input)) { const items = input.map(walk) const changed = items.some((item) => item.changed) - return { value: changed ? items.map((item) => item.value) : input, changed } + return { + value: changed ? items.map((item) => item.value) : input, + changed, + dynamic: items.some((item) => item.dynamic), + hazard: items.some((item) => item.hazard), + } } - if (!record(input)) return { value: input, changed: false } + if (!record(input)) return { value: input, changed: false, dynamic: false, hazard: false } const next = { ...input } const found = typeof input.pattern === "string" && lookaround(input.pattern) if (found) delete next.pattern - const maps = MAPS.reduce((changed, key) => { - const value = input[key] - if (!record(value)) return changed + const maps = MAPS.reduce( + (state, key) => { + const value = input[key] + if (!record(value)) return state - const items = Object.entries(value).map(([name, item]) => { - if (key === "patternProperties" && lookaround(name)) return { changed: true } - const result = walk(item) - return { changed: result.changed, entry: [name, result.value] as [string, unknown] } - }) - const nested = items.some((item) => item.changed) - if (nested) next[key] = Object.fromEntries(items.flatMap((item) => (item.entry ? [item.entry] : []))) - return nested || changed - }, found) + const items = Object.entries(value).map(([name, item]) => { + const result = walk(item) + const removed = key === "patternProperties" && lookaround(name) + return { + changed: removed || result.changed, + dynamic: removed || result.dynamic, + hazard: result.hazard, + entry: removed ? undefined : ([name, result.value] as const), + } + }) + const changed = items.some((item) => item.changed) + if (changed) next[key] = Object.fromEntries(items.flatMap((item) => (item.entry ? [item.entry] : []))) + return { + changed: changed || state.changed, + dynamic: items.some((item) => item.dynamic) || state.dynamic, + hazard: items.some((item) => item.hazard) || state.hazard, + } + }, + { changed: found, dynamic: false, hazard: false }, + ) - const changed = NODES.reduce((changed, key) => { + const nodes = NODES.reduce((state, key) => { const result = walk(input[key]) if (result.changed) next[key] = result.value - return result.changed || changed + return { + changed: result.changed || state.changed, + dynamic: result.dynamic || state.dynamic, + hazard: result.hazard || state.hazard, + } }, maps) - return { value: changed ? next : input, changed } + + const dangers = DANGERS.reduce((state, key) => { + const result = walk(input[key]) + if (result.changed) next[key] = result.value + return { + changed: result.changed || state.changed, + dynamic: result.dynamic || state.dynamic, + hazard: result.changed || result.hazard || state.hazard, + } + }, nodes) + return { value: dangers.changed ? next : input, ...dangers } } export async function sanitize(input: Record): Promise> { @@ -93,11 +128,15 @@ export async function sanitize(input: Record): Promise { if (item.type === "provider") return { name, tool: item, changed: false } const source = asSchema(item.inputSchema) - const result = walk(await source.jsonSchema) + const original = await source.jsonSchema + const result = walk(original) if (!result.changed) return { name, tool: item, changed: false } + // Tool inputs are object-root schemas. Complex widening falls back to accepting any object. + const fallback = result.dynamic || result.hazard || reference(original) + const schema = fallback ? { type: "object" as const, additionalProperties: true } : result.value return { name, - tool: { ...item, inputSchema: jsonSchema(result.value as JSONSchema7, { validate: source.validate }) }, + tool: { ...item, inputSchema: jsonSchema(schema as JSONSchema7, { validate: source.validate }) }, changed: true, } }), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 608a571387..0467b35115 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -123,6 +123,7 @@ const live: Layer.Layer< }) // kilocode_change start - compact at the configured threshold before contacting the provider + const tools = yield* Effect.promise(() => KiloToolSchema.sanitize(base.tools)) const isOpenaiOauth = item.id === "openai" && info?.type === "oauth" const estimated: ModelMessage[] = isOpenaiOauth || isWorkflow @@ -136,12 +137,11 @@ const live: Layer.Layer< : base.messages const preflight = input.preflight === true && KiloSessionOverflow.enabled({ cfg, model: input.model }) const cap = KiloLLM.needsEstimate({ model: input.model, configured: base.params.maxOutputTokens }) - const usage = - cap || preflight ? KiloSessionOverflow.measure({ messages: estimated, tools: base.tools }) : undefined + const usage = cap || preflight ? KiloSessionOverflow.measure({ messages: estimated, tools }) : undefined const maxOutputTokens = KiloLLM.capOutputTokens({ model: input.model, messages: estimated, - tools: base.tools, + tools, configured: base.params.maxOutputTokens, usage, reported: input.reportedContextTokens, @@ -159,7 +159,6 @@ const live: Layer.Layer< ) { return yield* Effect.fail(new KiloSessionOverflow.PreflightError()) } - const tools = yield* Effect.promise(() => KiloToolSchema.sanitize(base.tools)) const prepared = { ...base, tools, params: { ...base.params, maxOutputTokens } } // kilocode_change end diff --git a/packages/opencode/test/kilocode/session/tool-schema.test.ts b/packages/opencode/test/kilocode/session/tool-schema.test.ts index 6ae706a631..63280e6fa8 100644 --- a/packages/opencode/test/kilocode/session/tool-schema.test.ts +++ b/packages/opencode/test/kilocode/session/tool-schema.test.ts @@ -13,7 +13,6 @@ describe("provider tool schema sanitization", () => { type: "object", examples: [{ pattern: "(?=annotation)" }], patternProperties: { - "(?=private-)": { type: "string" }, "[(?=]": { type: "string" }, [escaped]: { type: "string" }, }, @@ -71,4 +70,50 @@ describe("provider tool schema sanitization", () => { expect(await KiloToolSchema.sanitize(input)).toBe(input) }) + + test("keeps strict dynamic properties available when their key pattern is removed", async () => { + const schema: JSONSchema7 = { + type: "object", + patternProperties: { + "^(?!reserved$).+$": { type: "string", minLength: 1, pattern: "(?=value)" }, + "^safe-": { type: "number" }, + }, + additionalProperties: false, + } + const input = { dynamic: tool({ inputSchema: jsonSchema(schema) }) } + + const output = await KiloToolSchema.sanitize(input) + const result = (await asSchema(output.dynamic.inputSchema).jsonSchema) as JSONSchema7 + + expect(result).toEqual({ type: "object", additionalProperties: true }) + expect(schema.patternProperties).toEqual({ + "^(?!reserved$).+$": { type: "string", minLength: 1, pattern: "(?=value)" }, + "^safe-": { type: "number" }, + }) + expect(schema.additionalProperties).toBe(false) + }) + + test("keeps object inputs available when an exact-match branch is widened", async () => { + const schema: JSONSchema7 = { + type: "object", + oneOf: [ + { + type: "object", + properties: { x: { type: "string", pattern: "(?=value)" } }, + required: ["x"], + }, + { + type: "object", + properties: { y: { type: "string" } }, + required: ["y"], + }, + ], + } + const input = { exact: tool({ inputSchema: jsonSchema(schema) }) } + + const output = await KiloToolSchema.sanitize(input) + const result = await asSchema(output.exact.inputSchema).jsonSchema + + expect(result).toEqual({ type: "object", additionalProperties: true }) + }) })