tool/functiontool: raw-value validation rejects marshalable results and accepts unrepresentable ones
Description
functionTool.Run converts a tool result into map[string]any, and falls back to wrapping a non-map result into {"result": v} when that conversion fails, at tool/functiontool/function.go:231-246:
resp, err := typeutil.ConvertToWithJSONSchema[TResults, map[string]any](output, f.outputSchema)
if err == nil { // all good
return resp, nil
}
// Specs requires the result to be a map (dict in python). python impl allows basic types when building response event
// functions.py __build_response_event does the following
// if not isinstance(function_result, dict):
// function_result = {'result': function_result}
if f.outputSchema != nil {
if err1 := f.outputSchema.Validate(output); err1 != nil {
return resp, err // if it fails propagate original err.
}
}
wrappedOutput := map[string]any{"result": output}
return wrappedOutput, nilThe guard at :241 decides between wrapping the result and propagating the original error. What it validates is the raw Go value. Resolved.Validate walks that value through reflect.ValueOf and never marshals it: a reflect.Struct is refused outright at jsonschema/validate.go:449-451, and at jsonschema/util.go:262-266 a float is sorted into integer or number by whether its fractional part is zero, so NaN and ±Inf pass as number. For a schema inferred from TResults, what the guard detects is a Go struct on the value's path, which is unrelated to whether the result can be sent.
The guard is wrong in both directions.
Valid results are rejected. A tool returning []SomeStruct produces the shape the fallback exists for, yet Run returns json: cannot unmarshal array into Go value of type map[string]interface {}. At internal/llminternal/base_flow.go:1416 that error is written as {"error": ...}, so the model receives it instead of the data. time.Time is rejected the same way, since it is a named struct. The same tool returning []float64 is wrapped into {"result": [...]} as expected.
Unrepresentable results are let through. When a tool returns map[string]any with one value set to math.NaN(), the guard lets it through and Run returns the wrapped result with a nil error. google.golang.org/genai then calls InternalDeepMarshal at models.go:4472 without receiving the error it returns, leaving parameterMap empty, so InternalFormatMap at models.go:4506 renders the URL as :generateContent, the request body is empty, and the run ends in a 404 whose Message is empty. Also measured as let through: float64 returning +Inf or -Inf, float32 returning NaN, and map[string]any holding a chan or a func.
Suggested fix
Decide the fallback on whether encoding/json can represent the result rather than on schema validation: marshal once, return that error when it fails, and wrap otherwise.
Enforce only a schema the caller supplied through Config.OutputSchema. The inferred schema describes TResults, not what encoding/json emits for it, so validating the JSON form against it would start rejecting []byte, json.RawMessage and a *SomeStruct returning nil, all three of which work on main.
Environment
The first direction reproduces without a model call: build a tool returning []SomeStruct inside the tool/functiontool package and call Run to get the error above. Both directions were run against Gemini API and Vertex AI, with the same behavior.
- ADK Library Version: main @
11521a4 - OS: Windows
- Go Version: 1.26.6
- Model Information:
gemini-3.8-flash, Gemini API and Vertex AI
Source: google/adk-go