diff --git a/provider/openaiprovider/chat.go b/provider/openaiprovider/chat.go index 11529aba..36d5188b 100644 --- a/provider/openaiprovider/chat.go +++ b/provider/openaiprovider/chat.go @@ -500,10 +500,8 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara ret := funcResult.Result if funcResult.Error != nil { ret = funcResult.Error - } else if b, ok := ret.(json.RawMessage); ok { - ret = string(b) } - messages = append(messages, openai.ToolMessage(fmt.Sprintf("%v", ret), funcResult.CallID)) + messages = append(messages, openai.ToolMessage(toolResultText(ret), funcResult.CallID)) } } return messages, nil @@ -513,6 +511,29 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara } } +// toolResultText renders a function-tool result for the OpenAI wire format. A +// non-string, non-raw result (e.g. a struct or map returned by a typed +// functool) is JSON-encoded rather than rendered with Go's %v, which would send +// an unparseable Go representation like "{Paris 20}" to the model. +func toolResultText(ret any) string { + switch v := ret.(type) { + case nil: + return "" + case string: + return v + case json.RawMessage: + return string(v) + case []byte: + return string(v) + case error: + return v.Error() + } + if b, err := json.Marshal(ret); err == nil { + return string(b) + } + return fmt.Sprintf("%v", ret) +} + func addUsage(contents []message.Content, usage openai.CompletionUsage) []message.Content { details := message.UsageDetails{ InputTokenCount: usage.PromptTokens, diff --git a/provider/openaiprovider/chat_test.go b/provider/openaiprovider/chat_test.go index 6a978dab..953aaed1 100644 --- a/provider/openaiprovider/chat_test.go +++ b/provider/openaiprovider/chat_test.go @@ -1551,3 +1551,42 @@ func TestChatEmptyChoices_NonStreaming(t *testing.T) { t.Errorf("expected usage input=12 total=12 to be surfaced, got %+v", usage) } } + +// A structured (non-string) function-tool result — e.g. a struct returned by a +// typed functool — must be JSON-encoded in the request, not rendered with Go's +// %v, which would send an unparseable representation like "{Paris 20}" to the model. +func TestChatToolResult_StructSerializedAsJSON_NonStreaming(t *testing.T) { + capturedCh := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + capturedCh <- string(b) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"x","object":"chat.completion","created":1,"model":"gpt-4o-mini","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`) + })) + defer server.Close() + a := newTestClient(server) + + type weather struct { + City string `json:"city"` + TempC int `json:"temp_c"` + } + messages := []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "weather?"}}}, + {Role: message.RoleAssistant, Contents: []message.Content{ + &message.FunctionCallContent{CallID: "c1", Name: "GetWeather", Arguments: "{}"}, + }}, + {Role: message.RoleTool, Contents: []message.Content{ + &message.FunctionResultContent{CallID: "c1", Result: weather{City: "Paris", TempC: 20}}, + }}, + } + if _, err := a.Run(t.Context(), messages).Collect(); err != nil { + t.Fatalf("run: %v", err) + } + captured := <-capturedCh + if strings.Contains(captured, "{Paris 20}") { + t.Errorf("tool result rendered with Go %%v instead of JSON:\n%s", captured) + } + if !strings.Contains(captured, "temp_c") { + t.Errorf("tool result was not JSON-encoded (missing field temp_c):\n%s", captured) + } +} diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 371e0801..04b1ccad 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -746,10 +746,11 @@ func responsesBuildMessageParam(msg *message.Message, resp responses.ResponseInp outputContent, )) } else { - // Default case - convert to string + // Default case - convert to string (JSON-encode structured + // results rather than rendering them with Go's %v). resp = append(resp, responses.ResponseInputItemParamOfFunctionCallOutput( funcResult.CallID, - fmt.Sprintf("%v", ret), + toolResultText(ret), )) } diff --git a/provider/openaiprovider/responses_test.go b/provider/openaiprovider/responses_test.go index 4f060310..ef74acc4 100644 --- a/provider/openaiprovider/responses_test.go +++ b/provider/openaiprovider/responses_test.go @@ -292,7 +292,8 @@ func TestResponsesBasicRequestResponse_NonStreaming(t *testing.T) { a := newTestResponsesClient(server, "gpt-4o-mini") - resp, err := a.RunText(t.Context(), "hello", + resp, err := a.RunText( + t.Context(), "hello", openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ MaxOutputTokens: openai.Int(20), Temperature: openai.Float(0.5), @@ -384,7 +385,8 @@ data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952 a := newTestResponsesClient(server, "gpt-4o-mini") var updates []*agent.ResponseUpdate - for update, err := range a.RunText(t.Context(), "hello", agent.Stream(true), + for update, err := range a.RunText( + t.Context(), "hello", agent.Stream(true), openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ MaxOutputTokens: openai.Int(20), Temperature: openai.Float(0.5), @@ -676,7 +678,8 @@ func TestResponsesChatOptions_Model_OverridesClientModel_NonStreaming(t *testing a := newTestResponsesClient(server, "gpt-4o-mini") // Override with gpt-4o in options - resp, err := a.RunText(t.Context(), "hello", + resp, err := a.RunText( + t.Context(), "hello", openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ Model: "gpt-4o", MaxOutputTokens: openai.Int(10), @@ -780,7 +783,8 @@ func TestResponsesMultipleMessages_NonStreaming(t *testing.T) { {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "i'm good. how are you?"}}}, } - resp, err := a.Run(t.Context(), messages, + resp, err := a.Run( + t.Context(), messages, openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ Temperature: openai.Float(0.25), }), @@ -1162,7 +1166,8 @@ data: {"type":"response.completed","response":{"id":"resp_streaming123","object" var updates []*agent.ResponseUpdate // Override with gpt-4o in options - for update, err := range a.RunText(t.Context(), "hello", agent.Stream(true), + for update, err := range a.RunText( + t.Context(), "hello", agent.Stream(true), openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ Model: "gpt-4o", MaxOutputTokens: openai.Int(20), @@ -1290,7 +1295,8 @@ func TestResponsesMultipleOutputItems_NonStreaming(t *testing.T) { a := newTestResponsesClient(server, "gpt-4o-mini") - resp, err := a.RunText(t.Context(), "hello", + resp, err := a.RunText( + t.Context(), "hello", openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ MaxOutputTokens: openai.Int(20), Temperature: openai.Float(0.5), @@ -1491,7 +1497,8 @@ func TestResponsesFunctionCallWithResult_NonStreaming(t *testing.T) { Description: "Get the current weather", }, getWeather) - resp1, err := a1.RunText(t.Context(), "What's the weather in Seattle?", + resp1, err := a1.RunText( + t.Context(), "What's the weather in Seattle?", agent.WithTool(tool), ).Collect() if err != nil { @@ -1542,7 +1549,8 @@ func TestResponsesFunctionCallWithResult_NonStreaming(t *testing.T) { }}, } - resp2, err := a2.Run(t.Context(), messages, + resp2, err := a2.Run( + t.Context(), messages, agent.WithTool(tool), ).Collect() if err != nil { @@ -1686,7 +1694,8 @@ func TestResponsesFunctionCall_UsesCallIDWhenDifferentFromID(t *testing.T) { Description: "Get the current weather", }, getWeather) - resp1, err := a1.RunText(t.Context(), "What's the weather in Amsterdam?", + resp1, err := a1.RunText( + t.Context(), "What's the weather in Amsterdam?", agent.WithTool(weatherTool), ).Collect() if err != nil { @@ -1733,7 +1742,8 @@ func TestResponsesFunctionCall_UsesCallIDWhenDifferentFromID(t *testing.T) { }}, } - resp2, err := a2.Run(t.Context(), messages, + resp2, err := a2.Run( + t.Context(), messages, agent.WithTool(weatherTool), ).Collect() if err != nil { @@ -2345,7 +2355,8 @@ func TestResponsesCodeInterpreterTool_NonStreaming(t *testing.T) { a := newTestResponsesClient(server, "gpt-4o-mini") - resp, err := a.RunText(t.Context(), "Calculate the sum of numbers from 1 to 5", + resp, err := a.RunText( + t.Context(), "Calculate the sum of numbers from 1 to 5", agent.WithTool(&hostedtool.CodeInterpreter{}), ).Collect() if err != nil { @@ -2453,7 +2464,8 @@ data: {"type":"response.completed","response":{"id":"resp_002","object":"respons var updates []*agent.ResponseUpdate var allText strings.Builder - for update, err := range a.RunText(t.Context(), "Calculate 3+3", agent.Stream(true), + for update, err := range a.RunText( + t.Context(), "Calculate 3+3", agent.Stream(true), agent.WithTool(&hostedtool.CodeInterpreter{}), ) { if err != nil { @@ -4411,7 +4423,8 @@ func TestResponsesConversationId_AsResponseId_NonStreaming(t *testing.T) { t.Fatal(err) } - _, err = a.RunText(t.Context(), "hello", + _, err = a.RunText( + t.Context(), "hello", openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ MaxOutputTokens: openai.Int(20), Temperature: openai.Float(0.5), @@ -4483,7 +4496,8 @@ func TestDisableStoreOutputDoesNotUseOrUpdateResponseID(t *testing.T) { t.Fatal(err) } - _, err = a.RunText(t.Context(), "hello", + _, err = a.RunText( + t.Context(), "hello", agent.WithSession(session), ).Collect() if err != nil { @@ -4549,7 +4563,8 @@ func TestResponsesNewParamsStoreOverridesDisableStoreOutput(t *testing.T) { t.Fatal(err) } - _, err = a.RunText(t.Context(), "hello", + _, err = a.RunText( + t.Context(), "hello", agent.WithSession(session), openaiprovider.ResponsesNewParams(responses.ResponseNewParams{Store: openai.Bool(true)}), ).Collect() @@ -4616,7 +4631,8 @@ func TestResponsesNewParamsStoreFalseDoesNotUpdateResponseID(t *testing.T) { t.Fatal(err) } - _, err = a.RunText(t.Context(), "hello", + _, err = a.RunText( + t.Context(), "hello", agent.WithSession(session), openaiprovider.ResponsesNewParams(responses.ResponseNewParams{Store: openai.Bool(false)}), ).Collect() @@ -4671,7 +4687,8 @@ func TestResponsesNewParamsStoreFalseDoesNotDuplicateReasoningInclude(t *testing }, ) - _, err := a.RunText(t.Context(), "hello", + _, err := a.RunText( + t.Context(), "hello", openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ Store: openai.Bool(false), Include: []responses.ResponseIncludable{responses.ResponseIncludableReasoningEncryptedContent}, @@ -4731,7 +4748,8 @@ func TestResponsesConversationId_AsConversationId_NonStreaming(t *testing.T) { t.Fatal(err) } - _, err = a.RunText(t.Context(), "hello", + _, err = a.RunText( + t.Context(), "hello", openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ MaxOutputTokens: openai.Int(20), Temperature: openai.Float(0.5), @@ -4803,7 +4821,8 @@ data: {"type":"response.completed","response":{"id":"resp_67890","object":"respo } var updates []*agent.ResponseUpdate - for update, err := range a.RunText(t.Context(), "hello", agent.Stream(true), + for update, err := range a.RunText( + t.Context(), "hello", agent.Stream(true), openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ MaxOutputTokens: openai.Int(20), Temperature: openai.Float(0.5), @@ -4882,7 +4901,8 @@ data: {"type":"response.completed","response":{"id":"resp_67890","object":"respo } var updates []*agent.ResponseUpdate - for update, err := range a.RunText(t.Context(), "hello", agent.Stream(true), + for update, err := range a.RunText( + t.Context(), "hello", agent.Stream(true), openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ MaxOutputTokens: openai.Int(20), Temperature: openai.Float(0.5), @@ -4942,7 +4962,8 @@ func TestResponsesBackgroundResponses_FirstCall(t *testing.T) { t.Fatal(err) } - resp, err := a.RunText(t.Context(), "hello", + resp, err := a.RunText( + t.Context(), "hello", openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ MaxOutputTokens: openai.Int(20), Temperature: openai.Float(0.5), @@ -5025,7 +5046,8 @@ func testResponsesBackgroundPolling(t *testing.T, status string) { } ctJSON, _ := json.Marshal(ct) - resp, err := a.Run(t.Context(), nil, + resp, err := a.Run( + t.Context(), nil, agent.WithContinuationToken(agenttest.NewContinuationToken(t, string(ctJSON))), agent.AllowBackgroundResponses(true), agent.WithSession(session), @@ -5146,7 +5168,8 @@ data: {"type":"response.completed","sequence_number":17,"response":{"id":"resp_6 var updates []*agent.ResponseUpdate var allText strings.Builder - for update, err := range a.RunText(t.Context(), "hello", agent.Stream(true), + for update, err := range a.RunText( + t.Context(), "hello", agent.Stream(true), agent.AllowBackgroundResponses(true), agent.WithSession(session), ) { @@ -5249,7 +5272,8 @@ data: {"type":"response.completed","sequence_number":17,"response":{"truncation" } var updates []*agent.ResponseUpdate - for update, err := range a.Run(t.Context(), []*message.Message{}, agent.Stream(true), + for update, err := range a.Run( + t.Context(), []*message.Message{}, agent.Stream(true), agent.AllowBackgroundResponses(true), agent.WithContinuationToken(token), agent.WithSession(session), @@ -5310,7 +5334,8 @@ func TestResponsesGetContinuationToken_WithMessages_ThrowsException(t *testing.T token := agenttest.NewContinuationToken(t, `{"response_id":"resp_123","sequence_number":0}`) // Attempt to use continuation token with messages should error - _, err := a.RunText(t.Context(), "test", + _, err := a.RunText( + t.Context(), "test", agent.WithContinuationToken(token), ).Collect() @@ -5335,7 +5360,8 @@ func TestResponsesBackgroundResponses_PollingCall_WithMessages(t *testing.T) { token := agenttest.NewContinuationToken(t, `{"response_id":"resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8","sequence_number":0}`) // A try to update a background response with new messages should fail - _, err = a.RunText(t.Context(), "Please book hotel as well", + _, err = a.RunText( + t.Context(), "Please book hotel as well", agent.WithSession(session), agent.WithContinuationToken(token), agent.AllowBackgroundResponses(true), @@ -5511,7 +5537,8 @@ func TestResponsesMultipleRequiredFunctions(t *testing.T) { Description: "Get the current time for a location", }, getTime) - resp, err := a.RunText(t.Context(), "What's the weather and time in Seattle?", + resp, err := a.RunText( + t.Context(), "What's the weather and time in Seattle?", agent.WithTool(weatherTool), agent.WithTool(timeTool), agent.WithToolMode(tool.RequireTools("GetWeather", "GetTime")), @@ -5546,3 +5573,42 @@ func responsesBodyEqual(t *testing.T, got string, want string) { t.Errorf("body\ngot %s\nwant %s", gotOut, wantOut) } } + +// TestResponsesToolResult_StructSerializedAsJSON verifies that a structured +// FunctionResultContent.Result is JSON-encoded in the Responses API request +// rather than rendered with Go's %v (matching the chat path). +func TestResponsesToolResult_StructSerializedAsJSON(t *testing.T) { + capturedCh := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + capturedCh <- string(b) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"resp_test","object":"response","created_at":1741891428,"status":"completed","error":null,"incomplete_details":null,"model":"gpt-4o-mini","output":[{"type":"message","id":"msg_test","status":"completed","role":"assistant","content":[{"type":"output_text","text":"ok","annotations":[]}]}]}`) + })) + defer server.Close() + a := newTestResponsesClient(server, "gpt-4o-mini") + + type weather struct { + City string `json:"city"` + TempC int `json:"temp_c"` + } + messages := []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "weather?"}}}, + {Role: message.RoleAssistant, Contents: []message.Content{ + &message.FunctionCallContent{CallID: "c1", Name: "GetWeather", Arguments: "{}"}, + }}, + {Role: message.RoleTool, Contents: []message.Content{ + &message.FunctionResultContent{CallID: "c1", Result: weather{City: "Paris", TempC: 20}}, + }}, + } + if _, err := a.Run(t.Context(), messages).Collect(); err != nil { + t.Fatalf("run: %v", err) + } + captured := <-capturedCh + if strings.Contains(captured, "{Paris 20}") { + t.Errorf("tool result rendered with Go %%v instead of JSON:\n%s", captured) + } + if !strings.Contains(captured, "temp_c") { + t.Errorf("tool result was not JSON-encoded (missing field temp_c):\n%s", captured) + } +}