diff --git a/go/adk/pkg/models/base.go b/go/adk/pkg/models/base.go index ea22a3d639..b4bddc45e7 100644 --- a/go/adk/pkg/models/base.go +++ b/go/adk/pkg/models/base.go @@ -116,6 +116,19 @@ func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } +// nonNilFunctionCallArgs returns args, substituting an empty map for nil. +// A no-argument FunctionCall reloads from the session store with nil Args +// (omitempty drops the empty map), and some providers reject null or "null". +// +// TODO: remove once the pinned go-genai fixes +// https://github.com/googleapis/go-genai/issues/920. +func nonNilFunctionCallArgs(args map[string]any) map[string]any { + if args == nil { + return map[string]any{} + } + return args +} + // parametersJsonSchemaToMap converts a genai.FunctionDeclaration.ParametersJsonSchema value // to map[string]any. ParametersJsonSchema is typed as `any` and can hold: // - map[string]any (rare — only if someone constructs it manually) diff --git a/go/adk/pkg/models/bedrock.go b/go/adk/pkg/models/bedrock.go index e7d8701753..049ff64a52 100644 --- a/go/adk/pkg/models/bedrock.go +++ b/go/adk/pkg/models/bedrock.go @@ -675,22 +675,10 @@ func convertGenaiContentsToBedrockMessages(contents []*genai.Content, nameMap ma if sanitized, ok := nameMap[callName]; ok { callName = sanitized } - // Bedrock requires toolUse.input to be a JSON object. A tool call - // with no arguments arrives here with a nil Args map (genai's - // FunctionCall.Args is `json:"args,omitempty"`, so an empty map is - // dropped when the event is persisted to the session store and - // reloaded as nil). NewLazyDocument(nil) serializes to `null`, which - // Bedrock rejects with "ValidationException: Malformed input request" - // ("The value at messages.N.content.M.toolUse.input is empty"). - // Coerce nil to an empty object so no-argument tool calls round-trip. - args := part.FunctionCall.Args - if args == nil { - args = map[string]any{} - } toolUse := types.ToolUseBlock{ ToolUseId: aws.String(sanitizeBedrockToolID(part.FunctionCall.ID, idMap, &idCounter)), Name: aws.String(callName), - Input: document.NewLazyDocument(args), + Input: document.NewLazyDocument(nonNilFunctionCallArgs(part.FunctionCall.Args)), } contentBlocks = append(contentBlocks, &types.ContentBlockMemberToolUse{ Value: toolUse, diff --git a/go/adk/pkg/models/openai_adk.go b/go/adk/pkg/models/openai_adk.go index 89665545f1..59040741e1 100644 --- a/go/adk/pkg/models/openai_adk.go +++ b/go/adk/pkg/models/openai_adk.go @@ -269,7 +269,7 @@ func genaiContentsToOpenAIMessages(contents []*genai.Content, config *genai.Gene toolCalls := make([]openai.ChatCompletionMessageToolCallUnionParam, 0, len(functionCalls)) var toolResponseMessages []openai.ChatCompletionMessageParamUnion for _, fc := range functionCalls { - argsJSON, _ := json.Marshal(fc.Args) + argsJSON, _ := json.Marshal(nonNilFunctionCallArgs(fc.Args)) toolCall := openai.ChatCompletionMessageFunctionToolCallParam{ ID: fc.ID, Type: constant.Function(openAIToolTypeFunction), diff --git a/go/adk/pkg/models/openai_adk_test.go b/go/adk/pkg/models/openai_adk_test.go index dde5959be8..7352c00ca5 100644 --- a/go/adk/pkg/models/openai_adk_test.go +++ b/go/adk/pkg/models/openai_adk_test.go @@ -220,6 +220,19 @@ func TestGenaiContentsToOpenAIMessages(t *testing.T) { t.Errorf("len(messages) = %d, want 1", len(msgs)) } }) + + t.Run("nil args (replay regression) encode as empty object", func(t *testing.T) { + contents := []*genai.Content{ + {Role: "model", Parts: []*genai.Part{{FunctionCall: &genai.FunctionCall{ID: "call_1", Name: "ping", Args: nil}}}}, + } + msgs, _ := genaiContentsToOpenAIMessages(contents, nil) + if len(msgs) == 0 || msgs[0].OfAssistant == nil || len(msgs[0].OfAssistant.ToolCalls) != 1 { + t.Fatalf("messages = %#v, want first message to be assistant with 1 tool call", msgs) + } + if got := msgs[0].OfAssistant.ToolCalls[0].GetFunction().Arguments; got != `{}` { + t.Errorf("arguments = %q, want {}", got) + } + }) } func TestApplyOpenAIConfig(t *testing.T) { diff --git a/go/adk/pkg/models/openai_responses.go b/go/adk/pkg/models/openai_responses.go index a39810dd58..9ef4eb45d0 100644 --- a/go/adk/pkg/models/openai_responses.go +++ b/go/adk/pkg/models/openai_responses.go @@ -124,7 +124,7 @@ func genaiContentsToResponsesInput(contents []*genai.Content, config *genai.Gene )) } for _, fc := range functionCalls { - argsJSON, _ := json.Marshal(fc.Args) + argsJSON, _ := json.Marshal(nonNilFunctionCallArgs(fc.Args)) input = append(input, responses.ResponseInputItemParamOfFunctionCall( string(argsJSON), fc.ID, diff --git a/go/adk/pkg/models/openai_responses_test.go b/go/adk/pkg/models/openai_responses_test.go index bbc9e1b0b4..920155084b 100644 --- a/go/adk/pkg/models/openai_responses_test.go +++ b/go/adk/pkg/models/openai_responses_test.go @@ -59,6 +59,19 @@ func TestGenaiContentsToResponsesInput(t *testing.T) { t.Fatalf("output = %q, want 3", got) } }) + + t.Run("nil args (replay regression) encode as empty object", func(t *testing.T) { + contents := []*genai.Content{ + {Role: "model", Parts: []*genai.Part{{FunctionCall: &genai.FunctionCall{ID: "call_1", Name: "ping", Args: nil}}}}, + } + input, _ := genaiContentsToResponsesInput(contents, nil) + if len(input) == 0 || input[0].OfFunctionCall == nil { + t.Fatalf("input = %#v, want function_call item", input) + } + if got := input[0].OfFunctionCall.Arguments; got != `{}` { + t.Errorf("arguments = %q, want {}", got) + } + }) } func TestGenaiToolsToResponsesTools(t *testing.T) { diff --git a/go/adk/pkg/models/sapaicore_adk.go b/go/adk/pkg/models/sapaicore_adk.go index 96d92c644b..6ed53d9965 100644 --- a/go/adk/pkg/models/sapaicore_adk.go +++ b/go/adk/pkg/models/sapaicore_adk.go @@ -211,7 +211,7 @@ func genaiContentsToOrchTemplate(contents []*genai.Content, config *genai.Genera toolCalls := make([]map[string]any, 0, len(functionCalls)) var toolResponses []map[string]any for _, fc := range functionCalls { - argsJSON, _ := json.Marshal(fc.Args) + argsJSON, _ := json.Marshal(nonNilFunctionCallArgs(fc.Args)) tc := map[string]any{ "type": "function", "function": map[string]any{ diff --git a/go/adk/pkg/models/sapaicore_adk_test.go b/go/adk/pkg/models/sapaicore_adk_test.go index 39a199ceb5..2f85887982 100644 --- a/go/adk/pkg/models/sapaicore_adk_test.go +++ b/go/adk/pkg/models/sapaicore_adk_test.go @@ -152,6 +152,27 @@ func TestGenaiContentsToOrchTemplate_ToolCall(t *testing.T) { if toolCalls[0]["id"] != "call_1" { t.Errorf("tool_calls[0].id = %v, want call_1", toolCalls[0]["id"]) } + + t.Run("nil args (replay regression) encode as empty object", func(t *testing.T) { + contents := []*genai.Content{ + {Role: "model", Parts: []*genai.Part{{FunctionCall: &genai.FunctionCall{ID: "call_1", Name: "ping", Args: nil}}}}, + } + msgs, _ := genaiContentsToOrchTemplate(contents, nil) + if len(msgs) == 0 { + t.Fatal("expected at least 1 message") + } + toolCalls, ok := msgs[0]["tool_calls"].([]map[string]any) + if !ok || len(toolCalls) == 0 { + t.Fatalf("tool_calls = %v, want non-empty slice", msgs[0]["tool_calls"]) + } + fn, ok := toolCalls[0]["function"].(map[string]any) + if !ok { + t.Fatalf("tool_calls[0].function = %v, want map", toolCalls[0]["function"]) + } + if fn["arguments"] != `{}` { + t.Errorf("tool_calls[0].function.arguments = %v, want {}", fn["arguments"]) + } + }) } func TestGenaiContentsToOrchTemplate_FunctionResponse(t *testing.T) {