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
1 change: 1 addition & 0 deletions internal/server/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func NewChatCompletionOrchestrator(
PromptProvider: promptService,
PromptProtecter: promptProtectionRuleService,
Middlewares: []pipeline.Middleware{
cc.FixMissingToolCalls(),
cc.StripBillingHeaderCCH(),
stream.EnsureUsage(),
},
Expand Down
67 changes: 67 additions & 0 deletions llm/pipeline/cc/fix_tool_calls.go
Original file line number Diff line number Diff line change
@@ -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
},
)
}
53 changes: 52 additions & 1 deletion llm/transformer/openai/responses/inbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions llm/transformer/openai/responses/inbound_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
Loading