From 3fb595f438b53ca7fda1a9c6bc46a03531090616 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 24 Jul 2026 09:12:23 +0530 Subject: [PATCH 1/2] Surface FileSearch tool-call output in the Responses provider Attaching a *hostedtool.FileSearch tool never requested file_search_call.results, and responsesProcessResponse had no case for the file_search_call output item, so the queries and retrieved chunks were silently dropped and only file_citation annotations survived. Request responses.ResponseIncludableFileSearchCallResults (guarded like the reasoning-encrypted-content include so it is not duplicated) and map the ResponseFileSearchToolCall output item to TextContent carrying a CitationAnnotation (file ID, filename and snippet) in both the non-streaming and streaming paths, matching how the .NET/Python SDKs surface FileSearch results. --- provider/openaiprovider/responses.go | 34 ++++ provider/openaiprovider/responses_test.go | 216 ++++++++++++++++++++++ 2 files changed, 250 insertions(+) diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 1b712031..8cb06bb1 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -389,6 +389,11 @@ func responsesBuildCompletionParams(config AgentConfig, messages []*message.Mess params.Tools = append(params.Tools, responses.ToolUnionParam{ OfFileSearch: &variant, }) + // Request the retrieved chunks so the file_search_call output item + // carries its results, mirroring the reasoning-encrypted-content path. + if !slices.Contains(params.Include, responses.ResponseIncludableFileSearchCallResults) { + params.Include = append(params.Include, responses.ResponseIncludableFileSearchCallResults) + } case *hostedtool.CodeInterpreter: var variant responses.ToolCodeInterpreterParam hosted := make([]string, 0, len(tl.Inputs)) @@ -900,6 +905,9 @@ func responsesProcessResponse(resp *responses.Response, seqNum int64, yield func } currentUpdate.Contents = append(currentUpdate.Contents, &output) + case responses.ResponseFileSearchToolCall: + currentUpdate.Contents = append(currentUpdate.Contents, fileSearchToolCallContents(out)...) + case responses.ResponseOutputItemMcpApprovalRequest: currentUpdate.Contents = append(currentUpdate.Contents, mcpApprovalRequestContent(out)) @@ -1139,6 +1147,8 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, content := &message.TextContent{Text: outputText.String()} content.RawRepresentation = item u.Contents = []message.Content{content} + case responses.ResponseFileSearchToolCall: + u.Contents = fileSearchToolCallContents(item) case responses.ResponseOutputItemMcpApprovalRequest: u.Contents = []message.Content{mcpApprovalRequestContent(item)} case responses.ResponseOutputItemImageGenerationCall: @@ -1169,6 +1179,30 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, return u, nil } +// fileSearchToolCallContents surfaces a file_search_call output item as message +// content. Each retrieved chunk becomes a TextContent carrying a CitationAnnotation +// (file ID, filename and snippet), so the queries/results are no longer dropped and +// only the file_citation annotations survive. The raw item (queries + status) is kept +// on RawRepresentation, matching the .NET/Python surfacing of FileSearch results. +func fileSearchToolCallContents(item responses.ResponseFileSearchToolCall) []message.Content { + contents := make([]message.Content, 0, len(item.Results)) + for _, res := range item.Results { + textContent := &message.TextContent{ + ContentHeader: message.ContentHeader{RawRepresentation: item}, + Text: res.Text, + } + textContent.Annotations = append(textContent.Annotations, &message.CitationAnnotation{ + FileID: res.FileID, + Title: res.Filename, + Snippet: res.Text, + ToolName: "file_search", + RawRepresentation: res, + }) + contents = append(contents, textContent) + } + return contents +} + func mcpApprovalRequestContent(item responses.ResponseOutputItemMcpApprovalRequest) *message.ToolApprovalRequestContent { return &message.ToolApprovalRequestContent{ ContentHeader: message.ContentHeader{RawRepresentation: item}, diff --git a/provider/openaiprovider/responses_test.go b/provider/openaiprovider/responses_test.go index 4f060310..4fe43da1 100644 --- a/provider/openaiprovider/responses_test.go +++ b/provider/openaiprovider/responses_test.go @@ -2484,6 +2484,222 @@ data: {"type":"response.completed","response":{"id":"resp_002","object":"respons } } +func TestResponsesFileSearchTool_NonStreaming(t *testing.T) { + const input = ` + { + "model":"gpt-4o-mini", + "input":[{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"What does the doc say?"}] + }], + "include":["file_search_call.results"], + "tools":[{ + "type":"file_search", + "vector_store_ids":["vs_abc"] + }] + } + ` + + const output = ` + { + "id":"resp_fs_001", + "object":"response", + "created_at":1761309813, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "id":"fs_001", + "type":"file_search_call", + "status":"completed", + "queries":["what does the doc say"], + "results":[ + { + "file_id":"file_abc", + "filename":"doc.txt", + "score":0.87, + "text":"The doc says hello." + } + ] + }, + { + "id":"msg_fs_001", + "type":"message", + "status":"completed", + "content":[{"type":"output_text","annotations":[],"text":"It says hello."}], + "role":"assistant" + } + ], + "usage":{"input_tokens":20,"output_tokens":5,"total_tokens":25} + } + ` + + server := newTestResponsesServer(t, input, output) + defer server.Close() + + a := newTestResponsesClient(server, "gpt-4o-mini") + + resp, err := a.RunText(t.Context(), "What does the doc say?", + agent.WithTool(&hostedtool.FileSearch{ + Inputs: []message.Content{&message.HostedVectorStoreContent{VectorStoreID: "vs_abc"}}, + }), + ).Collect() + if err != nil { + t.Fatalf("error = %v", err) + } + + if len(resp.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(resp.Messages)) + } + + // Locate the TextContent surfaced from the file_search_call results. + var found *message.TextContent + for _, c := range resp.Messages[0].Contents { + tc, ok := c.(*message.TextContent) + if !ok { + continue + } + for _, ann := range tc.Annotations { + if ca, ok := ann.(*message.CitationAnnotation); ok && ca.FileID == "file_abc" { + found = tc + break + } + } + if found != nil { + break + } + } + if found == nil { + t.Fatalf("expected a TextContent surfacing the file_search_call result, got contents %+v", resp.Messages[0].Contents) + } + if found.Text != "The doc says hello." { + t.Errorf("expected retrieved text to be surfaced, got %q", found.Text) + } + + citation, ok := found.Annotations[0].(*message.CitationAnnotation) + if !ok { + t.Fatalf("expected CitationAnnotation, got %T", found.Annotations[0]) + } + if citation.FileID != "file_abc" { + t.Errorf("expected FileID file_abc, got %q", citation.FileID) + } + if citation.Title != "doc.txt" { + t.Errorf("expected Title doc.txt, got %q", citation.Title) + } +} + +func TestResponsesFileSearchTool_Streaming(t *testing.T) { + const input = ` + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"search"}]}], + "include":["file_search_call.results"], + "tools":[{"type":"file_search","vector_store_ids":["vs_abc"]}], + "stream":true + } + ` + + const output = `event: response.created +data: {"type":"response.created","response":{"id":"resp_fs_str","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + +event: response.output_item.done +data: {"type":"response.output_item.done","response_id":"resp_fs_str","output_index":0,"item":{"type":"file_search_call","id":"fs_str","status":"completed","queries":["search"],"results":[{"file_id":"file_str","filename":"notes.md","score":0.9,"text":"streamed chunk"}]}} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_fs_str","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[{"type":"file_search_call","id":"fs_str","status":"completed","queries":["search"],"results":[{"file_id":"file_str","filename":"notes.md","score":0.9,"text":"streamed chunk"}]}]}} + +` + + server := newTestResponsesServerStreaming(t, input, output) + defer server.Close() + + a := newTestResponsesClient(server, "gpt-4o-mini") + + var found *message.TextContent + for update, err := range a.RunText(t.Context(), "search", + agent.Stream(true), + agent.WithTool(&hostedtool.FileSearch{ + Inputs: []message.Content{&message.HostedVectorStoreContent{VectorStoreID: "vs_abc"}}, + }), + ) { + if err != nil { + t.Fatalf("error = %v", err) + } + for _, c := range update.Contents { + tc, ok := c.(*message.TextContent) + if !ok { + continue + } + for _, ann := range tc.Annotations { + if ca, ok := ann.(*message.CitationAnnotation); ok && ca.FileID == "file_str" { + found = tc + } + } + } + } + if found == nil { + t.Fatal("expected the streamed file_search_call result to be surfaced") + } + if found.Text != "streamed chunk" { + t.Errorf("expected retrieved text 'streamed chunk', got %q", found.Text) + } +} + +func TestResponsesFileSearchTool_DoesNotDuplicateInclude(t *testing.T) { + // The include is requested by the FileSearch tool; if the caller already set it + // via ResponsesNewParams, it must not be appended twice. + const input = ` + { + "model":"gpt-4o-mini", + "input":[{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hi"}] + }], + "include":["file_search_call.results"], + "tools":[{ + "type":"file_search", + "vector_store_ids":["vs_abc"] + }] + } + ` + + const output = ` + { + "id":"resp_fs_002", + "object":"response", + "created_at":1761309813, + "status":"completed", + "model":"gpt-4o-mini", + "output":[{ + "type":"message", + "id":"msg_fs_002", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","annotations":[],"text":"hi"}] + }] + } + ` + + server := newTestResponsesServer(t, input, output) + defer server.Close() + + a := newTestResponsesClient(server, "gpt-4o-mini") + + _, err := a.RunText(t.Context(), "hi", + openaiprovider.ResponsesNewParams(responses.ResponseNewParams{ + Include: []responses.ResponseIncludable{responses.ResponseIncludableFileSearchCallResults}, + }), + agent.WithTool(&hostedtool.FileSearch{ + Inputs: []message.Content{&message.HostedVectorStoreContent{VectorStoreID: "vs_abc"}}, + }), + ).Collect() + if err != nil { + t.Fatalf("error = %v", err) + } +} + func TestResponsesStreamingResponseWithIncompleteUpdate_HandlesCorrectly(t *testing.T) { const input = ` { From 42190070240fbb8b14b0e7172545133f691ebb67 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 24 Jul 2026 14:55:59 +0530 Subject: [PATCH 2/2] Surface empty file_search_call and clarify helper doc --- provider/openaiprovider/responses.go | 19 ++++- provider/openaiprovider/responses_test.go | 90 +++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 8cb06bb1..e9fd0265 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -1181,10 +1181,23 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, // fileSearchToolCallContents surfaces a file_search_call output item as message // content. Each retrieved chunk becomes a TextContent carrying a CitationAnnotation -// (file ID, filename and snippet), so the queries/results are no longer dropped and -// only the file_citation annotations survive. The raw item (queries + status) is kept -// on RawRepresentation, matching the .NET/Python surfacing of FileSearch results. +// (file ID, filename and snippet). Previously the queries and retrieved chunks were +// dropped and only the file_citation annotations survived; surfacing them here keeps +// the raw item (queries + status) on RawRepresentation, matching the .NET/Python +// surfacing of FileSearch results. When the call returns no results, a single empty +// annotated TextContent is emitted so the call is still surfaced and, being annotated, +// is not coalesced away (which would strip its RawRepresentation). func fileSearchToolCallContents(item responses.ResponseFileSearchToolCall) []message.Content { + if len(item.Results) == 0 { + textContent := &message.TextContent{ + ContentHeader: message.ContentHeader{RawRepresentation: item}, + } + textContent.Annotations = append(textContent.Annotations, &message.CitationAnnotation{ + ToolName: "file_search", + RawRepresentation: item, + }) + return []message.Content{textContent} + } contents := make([]message.Content, 0, len(item.Results)) for _, res := range item.Results { textContent := &message.TextContent{ diff --git a/provider/openaiprovider/responses_test.go b/provider/openaiprovider/responses_test.go index 4fe43da1..8e452980 100644 --- a/provider/openaiprovider/responses_test.go +++ b/provider/openaiprovider/responses_test.go @@ -2700,6 +2700,96 @@ func TestResponsesFileSearchTool_DoesNotDuplicateInclude(t *testing.T) { } } +func TestResponsesFileSearchTool_NoResultsStillSurfaced(t *testing.T) { + // A file_search_call with zero results must still be surfaced as an annotated + // TextContent so the call (queries + status on RawRepresentation) is not dropped + // and, being annotated, is not coalesced away. + const input = ` + { + "model":"gpt-4o-mini", + "input":[{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"anything?"}] + }], + "include":["file_search_call.results"], + "tools":[{ + "type":"file_search", + "vector_store_ids":["vs_abc"] + }] + } + ` + + const output = ` + { + "id":"resp_fs_003", + "object":"response", + "created_at":1761309813, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "id":"fs_003", + "type":"file_search_call", + "status":"completed", + "queries":["anything"], + "results":[] + }, + { + "id":"msg_fs_003", + "type":"message", + "status":"completed", + "content":[{"type":"output_text","annotations":[],"text":"Nothing found."}], + "role":"assistant" + } + ], + "usage":{"input_tokens":20,"output_tokens":5,"total_tokens":25} + } + ` + + server := newTestResponsesServer(t, input, output) + defer server.Close() + + a := newTestResponsesClient(server, "gpt-4o-mini") + + resp, err := a.RunText(t.Context(), "anything?", + agent.WithTool(&hostedtool.FileSearch{ + Inputs: []message.Content{&message.HostedVectorStoreContent{VectorStoreID: "vs_abc"}}, + }), + ).Collect() + if err != nil { + t.Fatalf("error = %v", err) + } + + if len(resp.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(resp.Messages)) + } + + // Find the annotated TextContent surfacing the (empty) file_search_call. + var found *message.TextContent + for _, c := range resp.Messages[0].Contents { + tc, ok := c.(*message.TextContent) + if !ok { + continue + } + for _, ann := range tc.Annotations { + if ca, ok := ann.(*message.CitationAnnotation); ok && ca.ToolName == "file_search" { + found = tc + break + } + } + if found != nil { + break + } + } + if found == nil { + t.Fatalf("expected an annotated TextContent surfacing the empty file_search_call, got contents %+v", resp.Messages[0].Contents) + } + if found.RawRepresentation == nil { + t.Error("expected the empty file_search_call to retain its RawRepresentation") + } +} + func TestResponsesStreamingResponseWithIncompleteUpdate_HandlesCorrectly(t *testing.T) { const input = ` {