fix(cli): preserve sanitized tool schema inputs (#12166)

This commit is contained in:
Marius
2026-07-13 13:04:56 +02:00
committed by GitHub
parent 90a49c8560
commit 4618f1b092
4 changed files with 117 additions and 29 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Preserve dynamic tool properties when removing unsupported regex lookarounds.
@@ -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<string, Tool>): Promise<Record<string, Tool>> {
@@ -93,11 +128,15 @@ export async function sanitize(input: Record<string, Tool>): Promise<Record<stri
Object.entries(input).map(async ([name, item]) => {
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,
}
}),
+3 -4
View File
@@ -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
@@ -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 })
})
})