diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 0897f858ab..e9f7ff0354 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -141,6 +141,36 @@ func EffectiveResponsesTools(req *ResponsesRequest) ([]ResponsesTool, error) { } tools = append(tools, item.Tools...) } + + // A completed client tool search can introduce tools for the remainder of + // the turn. Promote them before lowering to ChatCompletions, using the same + // dedupe, conflict, namespace, and custom-tool rules as the native adapter. + toolsRaw, err := json.Marshal(tools) + if err != nil { + return nil, fmt.Errorf("encode responses tools for discovery promotion: %w", err) + } + var rawTools, rawInput []any + if err := json.Unmarshal(toolsRaw, &rawTools); err != nil { + return nil, fmt.Errorf("decode responses tools for discovery promotion: %w", err) + } + if err := json.Unmarshal(inputRaw, &rawInput); err != nil { + return nil, fmt.Errorf("parse responses input for discovery promotion: %w", err) + } + promoted, err := promotedResponsesToolSearchDiscoveries(rawTools, rawInput) + if err != nil { + return nil, err + } + if len(promoted) > 0 { + promotedRaw, err := json.Marshal(promoted) + if err != nil { + return nil, fmt.Errorf("encode promoted responses tools: %w", err) + } + var discovered []ResponsesTool + if err := json.Unmarshal(promotedRaw, &discovered); err != nil { + return nil, fmt.Errorf("decode promoted responses tools: %w", err) + } + tools = append(tools, discovered...) + } return tools, nil } @@ -456,6 +486,11 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa continue case "function_call_output", "custom_tool_call_output", "tool_search_output": outputRaw := bytesTrimSpace(item["output"]) + if itemType == "tool_search_output" && (len(outputRaw) == 0 || string(outputRaw) == "null") { + // Newer clients return discoveries in tools[] without a separate + // output field. Keep that useful result in Chat tool history. + outputRaw = bytesTrimSpace(item["tools"]) + } callID := rawString(item["call_id"]) if callID == "" && invalidEmptyFunctionCallOutputs > 0 { invalidEmptyFunctionCallOutputs-- diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go index ebf23fd9f9..bd56729545 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -597,6 +597,65 @@ func TestResponsesInputToChatMessages_ToolSearchCallHistory(t *testing.T) { assert.JSONEq(t, `"{\"groups\":[\"gmail\"]}"`, string(messages[2].Content)) } +func TestResponsesToChatCompletionsRequest_PromotesCompletedToolSearchDiscoveries(t *testing.T) { + var req ResponsesRequest + require.NoError(t, json.Unmarshal([]byte(`{ + "model":"gpt-5","tools":[ + {"type":"tool_search"}, + {"type":"function","name":"inspect","parameters":{"type":"object"}} + ],"input":[ + {"type":"tool_search_call","call_id":"search_1","arguments":{"query":"workspace"}}, + {"type":"tool_search_output","call_id":"search_1","status":"completed","execution":"client","tools":[ + {"type":"function","name":"inspect","parameters":{"type":"object"}}, + {"type":"custom","name":"exec"}, + {"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]} + ]} + ]}`), &req)) + + effective, err := EffectiveResponsesTools(&req) + require.NoError(t, err) + require.Len(t, effective, 4, "the identical static discovery must be deduplicated") + assert.True(t, CustomToolNames(effective)["exec"]) + namespaces := NamespaceToolNames(effective) + assert.Equal(t, NamespacedToolName{Namespace: "collaboration", Name: "spawn_agent"}, namespaces["collaboration__spawn_agent"]) + + chatReq, err := ResponsesToChatCompletionsRequest(&req) + require.NoError(t, err) + require.Len(t, chatReq.Tools, 4) + assert.Equal(t, []string{"tool_search", "inspect", "exec", "collaboration__spawn_agent"}, []string{ + chatReq.Tools[0].Function.Name, chatReq.Tools[1].Function.Name, + chatReq.Tools[2].Function.Name, chatReq.Tools[3].Function.Name, + }) + require.Len(t, chatReq.Messages, 2) + assert.Equal(t, "tool", chatReq.Messages[1].Role) + var discoveryPayload []map[string]any + var serialized string + require.NoError(t, json.Unmarshal(chatReq.Messages[1].Content, &serialized)) + require.NoError(t, json.Unmarshal([]byte(serialized), &discoveryPayload)) + require.Len(t, discoveryPayload, 3) + + responses := ChatCompletionsResponseToResponses(&ChatCompletionsResponse{Choices: []ChatChoice{{ + Message: ChatMessage{Role: "assistant", ToolCalls: []ChatToolCall{ + {ID: "call_1", Type: "function", Function: ChatFunctionCall{Name: "collaboration__spawn_agent", Arguments: `{}`}}, + {ID: "call_2", Type: "function", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input":"pwd"}`}}, + }}, + }}}, req.Model, CustomToolNames(effective), FunctionToolNames(effective), HasToolSearchTool(effective), namespaces) + require.Len(t, responses.Output, 2) + assert.Equal(t, "function_call", responses.Output[0].Type) + assert.Equal(t, "spawn_agent", responses.Output[0].Name) + assert.Equal(t, "collaboration", responses.Output[0].Namespace) + assert.Equal(t, "custom_tool_call", responses.Output[1].Type) + assert.Equal(t, "exec", responses.Output[1].Name) + assert.Equal(t, "pwd", responses.Output[1].Input) +} + +func TestEffectiveResponsesTools_RejectsDiscoveredConflict(t *testing.T) { + var req ResponsesRequest + require.NoError(t, json.Unmarshal([]byte(`{"tools":[{"type":"tool_search"},{"type":"function","name":"inspect","parameters":{"type":"object"}}],"input":[{"type":"tool_search_output","status":"completed","tools":[{"type":"function","name":"inspect","parameters":{"type":"string"}}]}]}`), &req)) + _, err := EffectiveResponsesTools(&req) + require.ErrorContains(t, err, "conflicts") +} + func TestResponsesInputToChatMessages_NamespacedFunctionCallHistory(t *testing.T) { input := json.RawMessage(`[ {"type":"function_call","call_id":"call_n","name":"send","namespace":"gmail","arguments":"{\"to\":\"a\"}"}, diff --git a/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go b/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go index 2617d5f940..55298c0eef 100644 --- a/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go +++ b/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go @@ -27,6 +27,21 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) { if !ok || len(input) == 0 { return false, nil } + promoted, err := promotedResponsesToolSearchDiscoveries(tools, input) + if err != nil { + return false, err + } + if len(promoted) == 0 { + return false, nil + } + req["tools"] = append(tools, promoted...) + return true, nil +} + +func promotedResponsesToolSearchDiscoveries(tools, input []any) ([]any, error) { + if len(tools) == 0 || !hasResponsesToolSearchDeclaration(tools) || len(input) == 0 { + return nil, nil + } known := make(map[string]responsesDiscoveredToolIdentity) for _, raw := range tools { @@ -60,7 +75,7 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) { } appendTool, err := admitResponsesDiscoveredTool(known, identity.name, identity) if err != nil { - return false, err + return nil, err } if appendTool { promoted = append(promoted, copy) @@ -74,7 +89,7 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) { for _, candidate := range identities { appendTool, err := admitResponsesDiscoveredTool(known, candidate.flat, candidate.identity) if err != nil { - return false, err + return nil, err } if appendTool { children = append(children, candidate.child) @@ -88,11 +103,7 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) { } } } - if len(promoted) == 0 { - return false, nil - } - req["tools"] = append(tools, promoted...) - return true, nil + return promoted, nil } func hasResponsesToolSearchDeclaration(tools []any) bool {