diff --git a/pkg/model/provider/gemini/client.go b/pkg/model/provider/gemini/client.go index e1747a0826..e22336826d 100644 --- a/pkg/model/provider/gemini/client.go +++ b/pkg/model/provider/gemini/client.go @@ -563,6 +563,7 @@ func ConvertParametersToSchema(params any) (*genai.Schema, error) { return nil, err } + normalizeBooleanSchemas(m) normalizeTypeFields(m) normalizeEnumValues(m) @@ -574,6 +575,60 @@ func ConvertParametersToSchema(params any) (*genai.Schema, error) { return schema, nil } +// normalizeBooleanSchemas recursively replaces JSON Schema boolean sub-schemas +// with their object-form equivalent. JSON Schema (draft 2020-12) allows any +// sub-schema position — a "properties" value, "items", an "anyOf" entry, etc. — +// to be a bare boolean: true means "any value validates", false means "nothing +// validates". Gemini's Schema type (an OpenAPI 3.0 subset) has no such form: +// genai unmarshals every sub-schema into a *genai.Schema object and hard-fails +// on a JSON boolean ("cannot unmarshal bool into Go struct field +// Schema.properties of type genai.Schema"). +// +// A Go any/interface{} field in an MCP tool's input struct is the common source. +// Schema generators such as google/jsonschema-go render an unconstrained field +// as the boolean `true` schema, so any MCP server exposing such a tool would +// otherwise be unusable with Gemini. Coercing `true` to an empty object schema +// {} preserves "any value" while staying parseable by Gemini. additionalProperties +// booleans are left untouched — genai has no such field and ignores the keyword. +func normalizeBooleanSchemas(m map[string]any) { + // Keywords whose value is a map of sub-schemas. + for _, key := range []string{"properties", "patternProperties", "$defs", "definitions"} { + if sub, ok := m[key].(map[string]any); ok { + for name, v := range sub { + sub[name] = coerceBooleanSchema(v) + } + } + } + // Keywords whose value is a list of sub-schemas. + for _, key := range []string{"allOf", "anyOf", "oneOf", "prefixItems"} { + if arr, ok := m[key].([]any); ok { + for i, v := range arr { + arr[i] = coerceBooleanSchema(v) + } + } + } + // Keywords whose value is a single sub-schema. + for _, key := range []string{"items", "not", "if", "then", "else", "contains", "propertyNames"} { + if v, ok := m[key]; ok { + m[key] = coerceBooleanSchema(v) + } + } +} + +// coerceBooleanSchema turns a boolean JSON-Schema node into an empty object +// schema ({}) and recurses into object nodes. A `false` schema (nothing +// validates) has no Gemini equivalent and is not meaningful for a tool input +// parameter, so it is also mapped to {} rather than dropped. +func coerceBooleanSchema(v any) any { + if _, ok := v.(bool); ok { + return map[string]any{} + } + if sub, ok := v.(map[string]any); ok { + normalizeBooleanSchemas(sub) + } + return v +} + // normalizeTypeFields recursively converts type arrays to single string values. // JSON Schema allows "type": ["string", "null"] but Gemini expects a single type. // This picks the first non-null type from arrays. diff --git a/pkg/model/provider/gemini/schema_boolean_test.go b/pkg/model/provider/gemini/schema_boolean_test.go new file mode 100644 index 0000000000..d1875d5949 --- /dev/null +++ b/pkg/model/provider/gemini/schema_boolean_test.go @@ -0,0 +1,77 @@ +package gemini + +import ( + "testing" +) + +// A tool input schema containing a boolean sub-schema — the shape a JSON Schema +// generator emits for a Go any/interface{} field — must convert without error. +// Before normalizeBooleanSchemas this failed with +// "cannot unmarshal bool into Go struct field Schema.properties of type genai.Schema". +func TestConvertParametersToSchema_BooleanSubSchema(t *testing.T) { + params := map[string]any{ + "type": "object", + "properties": map[string]any{ + "address": map[string]any{"type": "string"}, + // interface{} fields -> boolean "true" schema (any value). + "count": true, + "for_each": true, + "nested": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": true, // any value + }, + }, + }, + }, + "required": []any{"address"}, + } + + schema, err := ConvertParametersToSchema(params) + if err != nil { + t.Fatalf("ConvertParametersToSchema: %v", err) + } + if schema == nil { + t.Fatal("nil schema") + } + if _, ok := schema.Properties["count"]; !ok { + t.Errorf("count property dropped; got %v", schema.Properties) + } + if _, ok := schema.Properties["nested"]; !ok { + t.Errorf("nested property dropped; got %v", schema.Properties) + } +} + +func TestNormalizeBooleanSchemas(t *testing.T) { + m := map[string]any{ + "properties": map[string]any{ + "any": true, + "none": false, + "obj": map[string]any{ + "type": "array", + "items": true, + }, + }, + // additionalProperties booleans must be preserved: genai ignores the + // keyword, and false carries meaning for other clients. + "additionalProperties": false, + } + normalizeBooleanSchemas(m) + + props := m["properties"].(map[string]any) + if _, ok := props["any"].(map[string]any); !ok { + t.Errorf("true schema not coerced to object: %T", props["any"]) + } + if _, ok := props["none"].(map[string]any); !ok { + t.Errorf("false schema not coerced to object: %T", props["none"]) + } + obj := props["obj"].(map[string]any) + if _, ok := obj["items"].(map[string]any); !ok { + t.Errorf("items true schema not coerced: %T", obj["items"]) + } + if m["additionalProperties"] != false { + t.Errorf("additionalProperties changed: %v", m["additionalProperties"]) + } +}