From fe7b1eefa9eb093ff0741827839fbb2749bf8e3d Mon Sep 17 00:00:00 2001 From: Blue-B Date: Sat, 7 Mar 2026 21:31:24 +0900 Subject: [PATCH 1/2] fix(gemini): infer missing type:"object" in tool schemas Add inferMissingObjectType to the Gemini schema cleanup pipeline. When a schema node has "properties" but no "type" field, Gemini API rejects it. This fix infers type:"object" for such nodes, preventing tool_use failures with Gemini models. --- internal/util/gemini_schema.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 8617b84637..d5ca5815c5 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -42,6 +42,7 @@ func cleanJSONSchema(jsonStr string, addPlaceholder bool) string { jsonStr = mergeAllOf(jsonStr) jsonStr = flattenAnyOfOneOf(jsonStr) jsonStr = flattenTypeArrays(jsonStr) + jsonStr = inferMissingObjectType(jsonStr) // Phase 3: Cleanup jsonStr = removeUnsupportedKeywords(jsonStr) @@ -495,6 +496,26 @@ func walkForExtensions(value gjson.Result, path string, paths *[]string) { } } +// inferMissingObjectType adds "type":"object" to any schema node that has +// "properties" but no "type" field. Gemini API rejects schemas where +// "properties" appears on a non-OBJECT type. +func inferMissingObjectType(jsonStr string) string { + paths := findPaths(jsonStr, "properties") + sortByDepth(paths) + + for _, p := range paths { + parentPath := trimSuffix(p, ".properties") + if isPropertyDefinition(parentPath) { + continue + } + typePath := joinPath(parentPath, "type") + if !gjson.Get(jsonStr, typePath).Exists() { + jsonStr, _ = sjson.Set(jsonStr, typePath, "object") + } + } + return jsonStr +} + func cleanupRequiredFields(jsonStr string) string { for _, p := range findPaths(jsonStr, "required") { parentPath := trimSuffix(p, ".required") From 277c44708d04a709b54669f6dae9de636ce4892c Mon Sep 17 00:00:00 2001 From: Blue-B Date: Mon, 9 Mar 2026 22:07:11 +0900 Subject: [PATCH 2/2] fix(gemini): handle field literally named 'properties' in inferMissingObjectType Replace isPropertyDefinition with isSchemaPropertiesKeyword that correctly distinguishes JSON Schema 'properties' keywords from fields named 'properties' by walking the path and tracking schema-vs-field depth. Add regression tests for both normal and edge-case schemas. --- internal/util/gemini_schema.go | 24 ++++- internal/util/gemini_schema_test.go | 135 ++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index d5ca5815c5..ea24b323eb 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -505,7 +505,7 @@ func inferMissingObjectType(jsonStr string) string { for _, p := range paths { parentPath := trimSuffix(p, ".properties") - if isPropertyDefinition(parentPath) { + if isSchemaPropertiesKeyword(parentPath) { continue } typePath := joinPath(parentPath, "type") @@ -516,6 +516,28 @@ func inferMissingObjectType(jsonStr string) string { return jsonStr } +// isSchemaPropertiesKeyword determines whether path points to a JSON Schema +// "properties" keyword (a map of field definitions) rather than a field that +// happens to be named "properties". It walks the path segments and tracks +// whether we are at schema level or inside a properties map (field-name level). +func isSchemaPropertiesKeyword(path string) bool { + if path == "" { + return false + } + parts := splitGJSONPath(path) + schemaLevel := true + for _, part := range parts { + if schemaLevel { + if part == "properties" { + schemaLevel = false + } + } else { + schemaLevel = true + } + } + return !schemaLevel +} + func cleanupRequiredFields(jsonStr string) string { for _, p := range findPaths(jsonStr, "required") { parentPath := trimSuffix(p, ".required") diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index bb06e95673..22fb863daa 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -1046,3 +1046,138 @@ func TestRemoveExtensionFields(t *testing.T) { }) } } + +func TestInferMissingObjectType(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "adds type:object when properties present but no type", + input: `{ + "properties": { + "name": { "type": "string" } + } + }`, + expected: `{ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }`, + }, + { + name: "does not overwrite existing type", + input: `{ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }`, + expected: `{ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }`, + }, + { + name: "adds type:object to nested schema missing type", + input: `{ + "type": "object", + "properties": { + "address": { + "properties": { + "street": { "type": "string" } + } + } + } + }`, + expected: `{ + "type": "object", + "properties": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string" } + } + } + } + }`, + }, + { + name: "does not modify field literally named properties", + input: `{ + "type": "object", + "properties": { + "properties": { + "type": "string", + "description": "a field named properties" + } + } + }`, + expected: `{ + "type": "object", + "properties": { + "properties": { + "type": "string", + "description": "a field named properties" + } + } + }`, + }, + { + name: "handles field named properties that itself has properties", + input: `{ + "type": "object", + "properties": { + "properties": { + "properties": { + "inner": { "type": "number" } + } + } + } + }`, + expected: `{ + "type": "object", + "properties": { + "properties": { + "type": "object", + "properties": { + "inner": { "type": "number" } + } + } + } + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := inferMissingObjectType(tt.input) + compareJSON(t, tt.expected, actual) + }) + } +} + +func TestCleanJSONSchemaForGemini_InferMissingObjectType(t *testing.T) { + input := `{ + "properties": { + "user": { + "properties": { + "name": { "type": "string" } + } + } + } + }` + + result := CleanJSONSchemaForGemini(input) + + if !gjson.Get(result, "type").Exists() || gjson.Get(result, "type").String() != "object" { + t.Errorf("root should have type:object, got: %s", result) + } + if !gjson.Get(result, "properties.user.type").Exists() || gjson.Get(result, "properties.user.type").String() != "object" { + t.Errorf("nested user should have type:object, got: %s", result) + } +}