Files
goclaw/internal/providers/schema_normalize.go
T
viettranx e48ba1df7f fix(mcp): prevent LLM hallucination of optional tool parameters
3-layer defense against GPT-5.4 filling all optional MCP tool params
with fabricated values (e.g. api_key:"optional", proxyUrl:"http://example.com"):

Layer 1 — bridge_tool.go: expand placeholder detection to catch "optional",
"skip", example URLs; type-aware empty string handling (keep for string-typed,
strip for non-string); add propertyType() helper.

Layer 2 — schema_strict.go: OpenAI strict mode transform — optional props
become nullable unions, all props required, additionalProperties:false.
Constrained decoding prevents invalid output. Only enabled for first-party
OpenAI/Codex providers.

Layer 3 — systemprompt_sections.go: concrete WRONG/RIGHT examples in MCP
optional param instruction.
2026-03-31 23:15:59 +07:00

150 lines
4.4 KiB
Go

package providers
// schema_normalize.go — entry point + ref resolution, null stripping, const/type/union transforms.
// Union flattening + key stripping live in schema_transforms.go.
// Shared helpers live in schema_helpers.go.
// maxSchemaDepth prevents stack overflow from malicious deeply-nested schemas.
const maxSchemaDepth = 64
// NormalizeSchema applies provider-specific normalization to a tool's JSON Schema.
// This is the single entry point — all providers should call this (directly or
// via CleanToolSchemas / CleanSchemaForProvider wrappers).
func NormalizeSchema(providerName string, schema map[string]any) map[string]any {
if schema == nil {
return nil
}
profile := profileForProvider(providerName)
result := copySchema(schema)
if profile.ResolveRefs {
defs := collectDefs(result)
result = resolveRefs(result, defs, nil, 0)
}
if profile.StripNullType {
result = stripNullVariants(result, 0)
}
if profile.FlattenUnions {
result = flattenUnions(result, 0)
}
if profile.ConvertConst {
result = convertConst(result, 0)
}
if profile.InjectObjectType {
result = injectObjectType(result, 0)
}
if profile.RemoveTypeOnUnion {
result = removeTypeOnUnion(result, 0)
}
if len(profile.StripKeys) > 0 {
result = stripKeys(result, profile.StripKeys, 0)
}
if profile.StrictToolMode {
result = applyStrictMode(result, 0)
}
return result
}
// ---------------------------------------------------------------------------
// $ref resolution
// ---------------------------------------------------------------------------
// collectDefs extracts "$defs" and "definitions" into a flat lookup map.
func collectDefs(schema map[string]any) map[string]any {
defs := make(map[string]any)
for _, key := range []string{"$defs", "definitions"} {
if block, ok := schema[key].(map[string]any); ok {
for name, def := range block {
defs[name] = def
}
}
}
return defs
}
// resolveRefs inlines local $ref pointers from the defs map.
// visited tracks ref paths to break circular references.
func resolveRefs(schema map[string]any, defs map[string]any, visited map[string]bool, depth int) map[string]any {
if schema == nil || depth > maxSchemaDepth {
return schema
}
if ref, ok := schema["$ref"].(string); ok {
if visited[ref] {
return map[string]any{"type": "object", "description": "circular reference"}
}
name := refName(ref)
if resolved, ok := defs[name]; ok {
if m, ok := resolved.(map[string]any); ok {
next := copyVisited(visited)
next[ref] = true
out := resolveRefs(copySchema(m), defs, next, depth+1)
copyMeta(schema, out) // preserve parent description/title
return out
}
}
// Unresolvable ref — keep metadata only.
out := make(map[string]any)
copyMeta(schema, out)
return out
}
return walkSchema(schema, func(child map[string]any) map[string]any {
return resolveRefs(child, defs, visited, depth+1)
})
}
// ---------------------------------------------------------------------------
// Null variant stripping
// ---------------------------------------------------------------------------
// stripNullVariants simplifies anyOf/oneOf:[T, null] → T (recursive).
func stripNullVariants(schema map[string]any, depth int) map[string]any {
if schema == nil || depth > maxSchemaDepth {
return schema
}
for _, key := range []string{"anyOf", "oneOf"} {
variants, ok := schema[key].([]any)
if !ok || len(variants) == 0 {
continue
}
nonNull := make([]any, 0, len(variants))
for _, v := range variants {
if m, ok := v.(map[string]any); ok && isNullSchema(m) {
continue
}
nonNull = append(nonNull, v)
}
if len(nonNull) == len(variants) {
continue // nothing stripped
}
if len(nonNull) == 1 {
if m, ok := nonNull[0].(map[string]any); ok {
// Unwrap single non-null variant, preserving parent metadata.
merged := copySchema(m)
copyMeta(schema, merged)
return stripNullVariants(merged, depth+1)
}
}
schema[key] = nonNull
}
// Handle type:["string","null"] → type:"string"
if typeArr, ok := schema["type"].([]any); ok {
filtered := make([]any, 0, len(typeArr))
for _, t := range typeArr {
if s, ok := t.(string); ok && s == "null" {
continue
}
filtered = append(filtered, t)
}
if len(filtered) == 1 {
schema["type"] = filtered[0]
} else if len(filtered) != len(typeArr) {
schema["type"] = filtered
}
}
return walkSchema(schema, func(child map[string]any) map[string]any {
return stripNullVariants(child, depth+1)
})
}