Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 103 additions & 37 deletions schema/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
23 changes: 17 additions & 6 deletions schema/message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1064,29 +1064,40 @@ 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"}`,
},
},
{
Index: generic.PtrOf(0),
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) {
Expand Down