From 238e34d3113a4b2cb4f8b72c09c46dbe42fd295f Mon Sep 17 00:00:00 2001 From: Martin <1224973+mavaa@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:27:29 +0100 Subject: [PATCH] fix: address tool calls with empty arguments in copilot (#156) Co-authored-by: Andrey Nering --- providers/openai/language_model.go | 22 +++++++++ providers/openai/openai_test.go | 78 ++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/providers/openai/language_model.go b/providers/openai/language_model.go index 0445a610455c0a8225206077e0fd231bb504aa25..96b4514b5902f1b30ea179b6abbd6261ebc86f3c 100644 --- a/providers/openai/language_model.go +++ b/providers/openai/language_model.go @@ -514,6 +514,28 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S } } + // Handle tool calls that finish with empty arguments (e.g., Copilot). + // Normalize empty args to "{}" and emit the tool call if valid. + for idx, tc := range toolCalls { + if tc.hasFinished { + continue + } + if tc.arguments == "" { + tc.arguments = "{}" + toolCalls[idx] = tc + } + if xjson.IsValid(tc.arguments) { + if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeToolInputEnd, ID: tc.id}) { + return + } + if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeToolCall, ID: tc.id, ToolCallName: tc.name, ToolCallInput: tc.arguments}) { + return + } + tc.hasFinished = true + toolCalls[idx] = tc + } + } + if len(acc.Choices) > 0 { choice := acc.Choices[0] providerMetadata = o.streamProviderMetadataFunc(choice, providerMetadata) diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index f89a6b9d43b405c6d20aef485b57aa5629f606ae..9cf3d4bfac1959e143180e0f73884e73366068fc 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -2259,6 +2259,18 @@ func (sms *streamingMockServer) prepareErrorStreamResponse() { sms.chunks = chunks } +func (sms *streamingMockServer) prepareToolStreamResponseWithEmptyArgs() { + chunks := []string{ + // Tool call start with empty arguments (like Copilot sometimes does) + `data: {"id":"chatcmpl-emptyargs","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_empty_args","type":"function","function":{"name":"test-tool","arguments":""}}]},"logprobs":null,"finish_reason":null}]}` + "\n\n", + // Finish without any argument deltas + `data: {"id":"chatcmpl-emptyargs","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]}` + "\n\n", + `data: {"id":"chatcmpl-emptyargs","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":53,"completion_tokens":17,"total_tokens":70}}` + "\n\n", + "data: [DONE]\n\n", + } + sms.chunks = chunks +} + func collectStreamParts(stream fantasy.StreamResponse) ([]fantasy.StreamPart, error) { var parts []fantasy.StreamPart for part := range stream { @@ -2416,6 +2428,72 @@ func TestDoStream(t *testing.T) { require.Equal(t, `{"value":"Sparkle Day"}`, fullInput.String()) }) + t.Run("should handle tool calls with empty arguments", func(t *testing.T) { + t.Parallel() + + server := newStreamingMockServer() + defer server.close() + + server.prepareToolStreamResponseWithEmptyArgs() + + provider, err := New( + WithAPIKey("test-api-key"), + WithBaseURL(server.server.URL), + ) + require.NoError(t, err) + model, _ := provider.LanguageModel(t.Context(), "gpt-3.5-turbo") + + stream, err := model.Stream(context.Background(), fantasy.Call{ + Prompt: testPrompt, + Tools: []fantasy.Tool{ + fantasy.FunctionTool{ + Name: "test-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": map[string]any{ + "type": "string", + }, + }, + "required": []string{"value"}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }, + }, + }, + }) + + require.NoError(t, err) + + parts, err := collectStreamParts(stream) + require.NoError(t, err) + + // Find tool-related parts + toolInputStart, toolInputEnd, toolCall := -1, -1, -1 + + for i, part := range parts { + switch part.Type { + case fantasy.StreamPartTypeToolInputStart: + toolInputStart = i + require.Equal(t, "call_empty_args", part.ID) + require.Equal(t, "test-tool", part.ToolCallName) + case fantasy.StreamPartTypeToolInputEnd: + toolInputEnd = i + require.Equal(t, "call_empty_args", part.ID) + case fantasy.StreamPartTypeToolCall: + toolCall = i + require.Equal(t, "call_empty_args", part.ID) + require.Equal(t, "test-tool", part.ToolCallName) + // Empty arguments should be normalized to "{}" + require.Equal(t, "{}", part.ToolCallInput) + } + } + + require.NotEqual(t, -1, toolInputStart, "expected ToolInputStart part") + require.NotEqual(t, -1, toolInputEnd, "expected ToolInputEnd part") + require.NotEqual(t, -1, toolCall, "expected ToolCall part") + }) + t.Run("should stream annotations/citations", func(t *testing.T) { t.Parallel()