Merge pull request #6593 from wucm667/fix/issue-6584-chat-tool-discovery

fix(apicompat): preserve discovered tools in chat fallback
This commit is contained in:
Wesley Liddick
2026-09-05 15:03:52 +08:00
committed by GitHub
3 changed files with 112 additions and 7 deletions
@@ -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--
@@ -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\"}"},
@@ -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 {