diff --git a/internal/server/orchestrator/orchestrator.go b/internal/server/orchestrator/orchestrator.go index b8a6284cc..b2d5e4a96 100644 --- a/internal/server/orchestrator/orchestrator.go +++ b/internal/server/orchestrator/orchestrator.go @@ -75,6 +75,7 @@ func NewChatCompletionOrchestrator( PromptProvider: promptService, PromptProtecter: promptProtectionRuleService, Middlewares: []pipeline.Middleware{ + cc.FixMissingToolCalls(), cc.StripBillingHeaderCCH(), stream.EnsureUsage(), }, diff --git a/llm/pipeline/cc/fix_tool_calls.go b/llm/pipeline/cc/fix_tool_calls.go new file mode 100644 index 000000000..4d44d33a5 --- /dev/null +++ b/llm/pipeline/cc/fix_tool_calls.go @@ -0,0 +1,67 @@ +package cc + +import ( + "context" + + "github.com/looplj/axonhub/llm" + "github.com/looplj/axonhub/llm/pipeline" + "github.com/looplj/axonhub/llm/transformer/openai/responses" +) + +// FixMissingToolCalls returns a pipeline middleware that: +// 1. Inserts synthetic tool messages for any assistant tool_calls that lack a +// corresponding tool response. +// 2. Adds dummy tool definitions for any function name referenced in tool_calls +// that is missing from the request's tools array. +// +// This prevents downstream providers from rejecting requests due to incomplete +// tool call cycles or unknown tool definitions (e.g., Codex-specific tools like +// exec_command that are present in conversation history but not in the client's +// tool definitions). +func FixMissingToolCalls() pipeline.Middleware { + return pipeline.OnLlmRequest("FixMissingToolCalls", + func(ctx context.Context, request *llm.Request) (*llm.Request, error) { + if request == nil || len(request.Messages) == 0 { + return request, nil + } + + // Step 1: Fix missing tool responses + request.Messages = responses.FixMissingToolCallOutputs(request.Messages) + + // Step 2: Ensure all tool names in conversation history have + // matching tool definitions. Downstream providers reject tool + // calls whose function name is not in the tools array. + knownTools := make(map[string]bool, len(request.Tools)) + for _, t := range request.Tools { + if t.Type == "function" && t.Function.Name != "" { + knownTools[t.Function.Name] = true + } + } + + for _, msg := range request.Messages { + if msg.Role != "assistant" || len(msg.ToolCalls) == 0 { + continue + } + for _, tc := range msg.ToolCalls { + name := tc.Function.Name + if name == "" || knownTools[name] { + continue + } + // Add a minimal tool definition so downstream + // providers don't reject the request. + request.Tools = append(request.Tools, llm.Tool{ + Type: "function", + Function: llm.Function{ + Name: name, + Description: name, + Parameters: []byte(`{"type":"object","properties":{}}`), + }, + }) + knownTools[name] = true + } + } + + return request, nil + }, + ) +} diff --git a/llm/transformer/openai/responses/inbound.go b/llm/transformer/openai/responses/inbound.go index 695317003..a288e825f 100644 --- a/llm/transformer/openai/responses/inbound.go +++ b/llm/transformer/openai/responses/inbound.go @@ -320,6 +320,57 @@ func convertToolChoiceToLLM(src *ToolChoice) *llm.ToolChoice { return result } +// FixMissingToolCallOutputs inserts synthetic tool messages for any tool call +// that lacks a corresponding tool response. This prevents downstream providers +// from rejecting requests due to incomplete tool call cycles. +// +// Each assistant message is checked independently against immediately-following +// tool messages: when the same tool-call ID appears in multiple assistant turns, +// an earlier output is not treated as the response for a later occurrence. +func FixMissingToolCallOutputs(messages []llm.Message) []llm.Message { + fixed := make([]llm.Message, 0, len(messages)) + + for i := 0; i < len(messages); i++ { + msg := messages[i] + fixed = append(fixed, msg) + + if msg.Role != "assistant" || len(msg.ToolCalls) == 0 { + continue + } + + // Collect IDs from this assistant message that need responses + missing := make(map[string]bool, len(msg.ToolCalls)) + for _, tc := range msg.ToolCalls { + if tc.ID != "" { + missing[tc.ID] = true + } + } + + // Look ahead at immediately-following tool messages and remove + // covered IDs. Stop at the first non-tool message — tool responses + // for this turn must appear before the next user/assistant message. + for j := i + 1; j < len(messages) && messages[j].Role == "tool"; j++ { + if messages[j].ToolCallID != nil { + delete(missing, *messages[j].ToolCallID) + } + } + + // Insert synthetic tool messages for any uncovered IDs + for id := range missing { + toolCallID := id + fixed = append(fixed, llm.Message{ + Role: "tool", + ToolCallID: &toolCallID, + Content: llm.MessageContent{ + Content: lo.ToPtr(""), + }, + }) + } + } + + return fixed +} + // convertInputToMessages converts Responses API input to llm.Message slice. // It handles merging reasoning items with subsequent function_call items into a single assistant message. func convertInputToMessages(input *Input) ([]llm.Message, error) { @@ -375,7 +426,7 @@ func convertInputToMessages(input *Input) ([]llm.Message, error) { i++ } - return messages, nil +return FixMissingToolCallOutputs(messages), nil } // convertReasoningWithFollowing converts a reasoning item and merges it with subsequent diff --git a/llm/transformer/openai/responses/inbound_test.go b/llm/transformer/openai/responses/inbound_test.go index 004672948..d4d77d64c 100644 --- a/llm/transformer/openai/responses/inbound_test.go +++ b/llm/transformer/openai/responses/inbound_test.go @@ -1681,3 +1681,135 @@ func TestInboundTransformer_TransformResponse_WithReasoning(t *testing.T) { }) } } + +func TestFixMissingToolCallOutputs(t *testing.T) { + tests := []struct { + name string + input []llm.Message + validate func(t *testing.T, result []llm.Message) + }{ + { + name: "empty messages unchanged", + input: []llm.Message{}, + validate: func(t *testing.T, result []llm.Message) { + require.Empty(t, result) + }, + }, + { + name: "all tool calls have responses — unchanged", + input: []llm.Message{ + {Role: "assistant", ToolCalls: []llm.ToolCall{ + {ID: "call_1", Type: "function", Function: llm.FunctionCall{Name: "get_weather", Arguments: `{}`}}, + }}, + {Role: "tool", ToolCallID: lo.ToPtr("call_1"), Content: llm.MessageContent{Content: lo.ToPtr("sunny")}}, + }, + validate: func(t *testing.T, result []llm.Message) { + require.Len(t, result, 2) + }, + }, + { + name: "missing tool response — inserts synthetic tool message", + input: []llm.Message{ + {Role: "assistant", ToolCalls: []llm.ToolCall{ + {ID: "exec_command:10", Type: "function", Function: llm.FunctionCall{Name: "exec_command", Arguments: `{}`}}, + }}, + {Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("next")}}, + }, + validate: func(t *testing.T, result []llm.Message) { + require.Len(t, result, 3) + require.Equal(t, "assistant", result[0].Role) + require.Equal(t, "tool", result[1].Role) + require.Equal(t, "exec_command:10", *result[1].ToolCallID) + require.Equal(t, "user", result[2].Role) + }, + }, + { + name: "multiple tool calls with partial responses — inserts only for missing", + input: []llm.Message{ + {Role: "assistant", ToolCalls: []llm.ToolCall{ + {ID: "exec_command:10", Type: "function", Function: llm.FunctionCall{Name: "exec_command", Arguments: `{}`}}, + {ID: "call_abc", Type: "function", Function: llm.FunctionCall{Name: "read_file", Arguments: `{}`}}, + }}, + {Role: "tool", ToolCallID: lo.ToPtr("call_abc"), Content: llm.MessageContent{Content: lo.ToPtr("file content")}}, + {Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("next")}}, + }, + validate: func(t *testing.T, result []llm.Message) { + require.Len(t, result, 4) + require.Equal(t, "assistant", result[0].Role) + require.Len(t, result[0].ToolCalls, 2) + require.Equal(t, "tool", result[1].Role) + require.Equal(t, "exec_command:10", *result[1].ToolCallID) + require.Equal(t, "tool", result[2].Role) + require.Equal(t, "call_abc", *result[2].ToolCallID) + }, + }, + { + name: "last message is assistant with tool_calls — inserts after it", + input: []llm.Message{ + {Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("hello")}}, + {Role: "assistant", ToolCalls: []llm.ToolCall{ + {ID: "call_last", Type: "function", Function: llm.FunctionCall{Name: "search", Arguments: `{}`}}, + }}, + }, + validate: func(t *testing.T, result []llm.Message) { + require.Len(t, result, 3) + require.Equal(t, "assistant", result[1].Role) + require.Equal(t, "tool", result[2].Role) + require.Equal(t, "call_last", *result[2].ToolCallID) + }, + }, + { + name: "same ID across two assistant turns — each checked independently", + input: []llm.Message{ + {Role: "assistant", ToolCalls: []llm.ToolCall{ + {ID: "call_x", Type: "function", Function: llm.FunctionCall{Name: "f", Arguments: `{}`}}, + }}, + {Role: "tool", ToolCallID: lo.ToPtr("call_x"), Content: llm.MessageContent{Content: lo.ToPtr("result 1")}}, + {Role: "assistant", ToolCalls: []llm.ToolCall{ + {ID: "call_x", Type: "function", Function: llm.FunctionCall{Name: "f", Arguments: `{}`}}, + }}, + {Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("next")}}, + }, + validate: func(t *testing.T, result []llm.Message) { + require.Len(t, result, 5) + require.Equal(t, "assistant", result[0].Role) + require.Equal(t, "tool", result[1].Role) + require.NotEmpty(t, *result[1].Content.Content) // real + require.Equal(t, "assistant", result[2].Role) + require.Equal(t, "tool", result[3].Role) + require.Empty(t, *result[3].Content.Content) // synthetic + require.Equal(t, "user", result[4].Role) + }, + }, + { + name: "tool call with empty ID skipped", + input: []llm.Message{ + {Role: "assistant", ToolCalls: []llm.ToolCall{ + {ID: "", Type: "function", Function: llm.FunctionCall{Name: "no_id", Arguments: `{}`}}, + }}, + {Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("next")}}, + }, + validate: func(t *testing.T, result []llm.Message) { + require.Len(t, result, 2) + }, + }, + { + name: "no change when no tool_calls", + input: []llm.Message{ + {Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("hello")}}, + {Role: "assistant", Content: llm.MessageContent{Content: lo.ToPtr("hi")}}, + {Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("how are you")}}, + }, + validate: func(t *testing.T, result []llm.Message) { + require.Len(t, result, 3) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FixMissingToolCallOutputs(tt.input) + tt.validate(t, result) + }) + } +}