fix: address tool calls with empty arguments in copilot (#156)

Martin and Andrey Nering created

Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>

Change summary

providers/openai/language_model.go | 22 +++++++++
providers/openai/openai_test.go    | 78 ++++++++++++++++++++++++++++++++
2 files changed, 100 insertions(+)

Detailed changes

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)

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()