mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix(cli): preserve sanitized tool schema inputs (#12166)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user