diff --git a/schema/message.go b/schema/message.go index 3746244bb..1ec71dcb8 100644 --- a/schema/message.go +++ b/schema/message.go @@ -1438,6 +1438,8 @@ func mergeToolTextParts(group []ToolOutputPart) (ToolOutputPart, error) { func concatToolCalls(chunks []ToolCall) ([]ToolCall, error) { var merged []ToolCall + + // First pass: group chunks by Index. m := make(map[int][]int) for i := range chunks { index := chunks[i].Index @@ -1448,58 +1450,122 @@ func concatToolCalls(chunks []ToolCall) ([]ToolCall, error) { } } - var args strings.Builder + // Second pass: within each Index group, detect whether multiple distinct + // non-empty IDs exist. Some OpenAI-compatible providers (e.g. qwen, deepseek) + // reuse the same Index for different tool calls, distinguished only by ID. + // When this happens, split the group into sub-groups by ID so each tool call + // is merged independently, instead of returning an error. + type subGroup struct { + indices []int + } + type indexGroup struct { + originalIndex int + subs []subGroup + } + var groups []indexGroup for k, v := range m { - index := k - toolCall := ToolCall{Index: &index} - if len(v) > 0 { - toolCall = chunks[v[0]] + distinctIDs := make(map[string]bool) + for _, n := range v { + if id := chunks[n].ID; id != "" { + distinctIDs[id] = true + } } - args.Reset() - toolID, toolType, toolName := "", "", "" // these field will output atomically in any chunk - - for _, n := range v { - chunk := chunks[n] - if chunk.ID != "" { - if toolID == "" { - toolID = chunk.ID - } else if toolID != chunk.ID { - return nil, fmt.Errorf("cannot concat ToolCalls with different tool id: '%s' '%s'", toolID, chunk.ID) + if len(distinctIDs) <= 1 { + // Normal case: all chunks share the same ID (or have no ID). + groups = append(groups, indexGroup{originalIndex: k, subs: []subGroup{{indices: v}}}) + } else { + // Multiple distinct IDs at the same Index — split by ID. + // Chunks with empty ID are assigned to the most recently seen non-empty ID + // (streaming order: ID appears in the first chunk, subsequent chunks may omit it). + idToSub := make(map[string]int) // id -> index in subs slice + var subs []subGroup + lastID := "" + for _, n := range v { + id := chunks[n].ID + if id != "" { + lastID = id + } + effectiveID := lastID + if effectiveID == "" { + effectiveID = "__no_id__" + } + si, ok := idToSub[effectiveID] + if !ok { + si = len(subs) + idToSub[effectiveID] = si + subs = append(subs, subGroup{}) } + subs[si].indices = append(subs[si].indices, n) + } + groups = append(groups, indexGroup{originalIndex: k, subs: subs}) + } + } + // Sort groups by original index for deterministic output. + sort.Slice(groups, func(i, j int) bool { + return groups[i].originalIndex < groups[j].originalIndex + }) + + // Assign new sequential indexes to all sub-groups. + nextIndex := 0 + var args strings.Builder + for _, g := range groups { + for _, sg := range g.subs { + v := sg.indices + newIndex := nextIndex + nextIndex++ + toolCall := ToolCall{Index: &newIndex} + if len(v) > 0 { + toolCall = chunks[v[0]] + toolCall.Index = &newIndex } - if chunk.Type != "" { - if toolType == "" { - toolType = chunk.Type - } else if toolType != chunk.Type { - return nil, fmt.Errorf("cannot concat ToolCalls with different tool type: '%s' '%s'", toolType, chunk.Type) + args.Reset() + toolID, toolType, toolName := "", "", "" // these field will output atomically in any chunk + + for _, n := range v { + chunk := chunks[n] + if chunk.ID != "" { + if toolID == "" { + toolID = chunk.ID + } else if toolID != chunk.ID { + return nil, fmt.Errorf("cannot concat ToolCalls with different tool id: '%s' '%s'", toolID, chunk.ID) + } + } - } - if chunk.Function.Name != "" { - if toolName == "" { - toolName = chunk.Function.Name - } else if toolName != chunk.Function.Name { - return nil, fmt.Errorf("cannot concat ToolCalls with different tool name: '%s' '%s'", toolName, chunk.Function.Name) + if chunk.Type != "" { + if toolType == "" { + toolType = chunk.Type + } else if toolType != chunk.Type { + return nil, fmt.Errorf("cannot concat ToolCalls with different tool type: '%s' '%s'", toolType, chunk.Type) + } } - } - if chunk.Function.Arguments != "" { - _, err := args.WriteString(chunk.Function.Arguments) - if err != nil { - return nil, err + if chunk.Function.Name != "" { + if toolName == "" { + toolName = chunk.Function.Name + } else if toolName != chunk.Function.Name { + return nil, fmt.Errorf("cannot concat ToolCalls with different tool name: '%s' '%s'", toolName, chunk.Function.Name) + } + } + + if chunk.Function.Arguments != "" { + _, err := args.WriteString(chunk.Function.Arguments) + if err != nil { + return nil, err + } } } - } - toolCall.ID = toolID - toolCall.Type = toolType - toolCall.Function.Name = toolName - toolCall.Function.Arguments = args.String() + toolCall.ID = toolID + toolCall.Type = toolType + toolCall.Function.Name = toolName + toolCall.Function.Arguments = args.String() - merged = append(merged, toolCall) + merged = append(merged, toolCall) + } } if len(merged) > 1 { diff --git a/schema/message_test.go b/schema/message_test.go index bb086b8c6..baa3f7de4 100644 --- a/schema/message_test.go +++ b/schema/message_test.go @@ -1064,14 +1064,18 @@ func TestConcatToolCalls(t *testing.T) { assert.EqualValues(t, expectedToolCall, tc[0]) }) - t.Run("different_tool_id", func(t *testing.T) { + t.Run("different_tool_id_same_index_split_into_separate_calls", func(t *testing.T) { + // Some OpenAI-compatible providers (e.g. qwen, deepseek) reuse the same + // Index for different tool calls, distinguished only by ID. + // concatToolCalls should treat them as separate tool calls instead of erroring. givenToolCalls := []ToolCall{ { Index: generic.PtrOf(0), ID: "tool_call_id", Type: "function", Function: FunctionCall{ - Name: "tool_name", + Name: "tool_a", + Arguments: `{"arg":"val_a"}`, }, }, { @@ -1079,14 +1083,21 @@ func TestConcatToolCalls(t *testing.T) { ID: "tool_call_id_1", Type: "function", Function: FunctionCall{ - Name: "tool_name", - Arguments: "call me please", + Name: "tool_b", + Arguments: `{"arg":"val_b"}`, }, }, } - _, err := concatToolCalls(givenToolCalls) - assert.ErrorContains(t, err, "cannot concat ToolCalls with different tool id") + tc, err := concatToolCalls(givenToolCalls) + assert.NoError(t, err) + assert.Len(t, tc, 2) + assert.Equal(t, "tool_call_id", tc[0].ID) + assert.Equal(t, "tool_a", tc[0].Function.Name) + assert.Equal(t, `{"arg":"val_a"}`, tc[0].Function.Arguments) + assert.Equal(t, "tool_call_id_1", tc[1].ID) + assert.Equal(t, "tool_b", tc[1].Function.Name) + assert.Equal(t, `{"arg":"val_b"}`, tc[1].Function.Arguments) }) t.Run("different_tool_type", func(t *testing.T) {