Skip to content

Commit d29cba7

Browse files
committed
Add support for collection search results citations
1 parent bb68de2 commit d29cba7

File tree

6 files changed

+294
-111
lines changed

6 files changed

+294
-111
lines changed

readme.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,50 @@ var options = new ChatOptions
158158
};
159159
```
160160

161+
To receive the actual search results and file references, include `CollectionsSearchCallOutput` in the options:
162+
163+
```csharp
164+
var options = new GrokChatOptions
165+
{
166+
Include = [IncludeOption.CollectionsSearchCallOutput],
167+
Tools = [new HostedFileSearchTool {
168+
Inputs = [new HostedVectorStoreContent("[collection_id]")]
169+
}]
170+
};
171+
172+
var response = await grok.GetResponseAsync(messages, options);
173+
174+
// Access the search results with file references
175+
var results = response.Messages
176+
.SelectMany(x => x.Contents)
177+
.OfType<CollectionSearchToolResultContent>();
178+
179+
foreach (var result in results)
180+
{
181+
// Each result contains files that were found and referenced
182+
var files = result.Outputs?.OfType<HostedFileContent>();
183+
foreach (var file in files ?? [])
184+
{
185+
Console.WriteLine($"File: {file.Name} (ID: {file.FileId})");
186+
187+
// Files include citation annotations with snippets
188+
foreach (var citation in file.Annotations?.OfType<CitationAnnotation>() ?? [])
189+
{
190+
Console.WriteLine($" Title: {citation.Title}");
191+
Console.WriteLine($" Snippet: {citation.Snippet}");
192+
Console.WriteLine($" URL: {citation.Url}"); // collections://[collection_id]/files/[file_id]
193+
}
194+
}
195+
}
196+
```
197+
198+
Citations from collection search include:
199+
- **Title**: Extracted from the first line of the chunk content (if available), typically the file name or heading
200+
- **Snippet**: The relevant text excerpt from the document
201+
- **FileId**: Identifier of the source file in the collection
202+
- **Url**: A `collections://` URI pointing to the specific file within the collection
203+
- **ToolName**: Always set to `"collections_search"`
204+
161205
Learn more about [collection search](https://docs.x.ai/docs/guides/tools/collections-search-tool).
162206

163207
## Remote MCP

src/xAI.Tests/ChatClientTests.cs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,7 @@ public async Task GrokInvokesToolAndSearch()
9696
Assert.Equal(options.ModelId, response.ModelId);
9797

9898
var calls = response.Messages
99-
.SelectMany(x => x.Contents.OfType<HostedToolCallContent>())
100-
.Select(x => x.RawRepresentation as xAI.Protocol.ToolCall)
99+
.SelectMany(x => x.Contents.Select(x => x.RawRepresentation as xAI.Protocol.ToolCall))
101100
.Where(x => x is not null)
102101
.ToList();
103102

@@ -317,7 +316,6 @@ public async Task GrokInvokesHostedCollectionSearch()
317316

318317
var options = new GrokChatOptions
319318
{
320-
Include = { IncludeOption.CollectionsSearchCallOutput },
321319
Tools = [new HostedFileSearchTool {
322320
Inputs = [new HostedVectorStoreContent("collection_91559d9b-a55d-42fe-b2ad-ecf8904d9049")]
323321
}]
@@ -329,9 +327,21 @@ public async Task GrokInvokesHostedCollectionSearch()
329327
Assert.Contains("11,74", text);
330328
Assert.Contains(response.Messages
331329
.SelectMany(x => x.Contents)
332-
.OfType<HostedToolCallContent>()
330+
.OfType<CollectionSearchToolCallContent>()
333331
.Select(x => x.RawRepresentation as xAI.Protocol.ToolCall),
334332
x => x?.Type == xAI.Protocol.ToolCallType.CollectionsSearchTool);
333+
// No actual search results content since we didn't specify it in Include
334+
Assert.Empty(response.Messages.SelectMany(x => x.Contents).OfType<CollectionSearchToolResultContent>());
335+
336+
options.Include = [IncludeOption.CollectionsSearchCallOutput];
337+
response = await grok.GetResponseAsync(messages, options);
338+
339+
// Now it also contains the file reference as result content
340+
Assert.Contains(response.Messages
341+
.SelectMany(x => x.Contents)
342+
.OfType<CollectionSearchToolResultContent>()
343+
.SelectMany(x => (x.Outputs ?? []).OfType<HostedFileContent>()),
344+
x => x.Name == "LNS0004592.txt");
335345
}
336346

337347
[SecretsFact("XAI_API_KEY", "GITHUB_TOKEN")]
@@ -458,9 +468,8 @@ public async Task GrokStreamsUpdatesFromAllTools()
458468
.OfType<McpServerToolCallContent>());
459469

460470
Assert.Contains(response.Messages
461-
.SelectMany(x => x.Contents)
462-
.OfType<HostedToolCallContent>()
463-
.Select(x => x.RawRepresentation as xAI.Protocol.ToolCall),
471+
.SelectMany(x => x.Contents.Select(x => x.RawRepresentation as xAI.Protocol.ToolCall))
472+
.Where(x => x != null),
464473
x => x?.Type == xAI.Protocol.ToolCallType.WebSearchTool);
465474

466475
Assert.Equal(1, getDateCalls);

src/xAI/HostedToolCallContent.cs renamed to src/xAI/CollectionSearchToolCallContent.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
using System.Diagnostics.CodeAnalysis;
2-
using Microsoft.Extensions.AI;
1+
using Microsoft.Extensions.AI;
32

43
namespace xAI;
54

65
/// <summary>Represents a hosted tool agentic call.</summary>
7-
[Experimental("xAI001")]
8-
public class HostedToolCallContent : AIContent
6+
public class CollectionSearchToolCallContent : AIContent
97
{
108
/// <summary>Gets or sets the tool call ID.</summary>
119
public virtual string? CallId { get; set; }

src/xAI/HostedToolResultContent.cs renamed to src/xAI/CollectionSearchToolResultContent.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
1-
using System.Diagnostics;
2-
using System.Diagnostics.CodeAnalysis;
3-
using Microsoft.Extensions.AI;
1+
using Microsoft.Extensions.AI;
42

53
namespace xAI;
64

75
/// <summary>Represents a hosted tool agentic call.</summary>
8-
[DebuggerDisplay("{DebuggerDisplay,nq}")]
9-
[Experimental("xAI001")]
10-
public class HostedToolResultContent : AIContent
6+
public class CollectionSearchToolResultContent : AIContent
117
{
128
/// <summary>Gets or sets the tool call ID.</summary>
139
public virtual string? CallId { get; set; }

src/xAI/GrokChatClient.cs

Lines changed: 23 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Text.Json;
2+
using Google.Protobuf;
23
using Grpc.Core;
34
using Grpc.Net.Client;
45
using Microsoft.Extensions.AI;
@@ -39,82 +40,23 @@ public async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messag
3940
var response = await client.GetCompletionAsync(request, cancellationToken: cancellationToken);
4041
var lastOutput = response.Outputs.OrderByDescending(x => x.Index).FirstOrDefault();
4142

42-
if (lastOutput == null)
43-
{
44-
return new ChatResponse()
45-
{
46-
ResponseId = response.Id,
47-
ModelId = response.Model,
48-
CreatedAt = response.Created.ToDateTimeOffset(),
49-
Usage = MapToUsage(response.Usage),
50-
};
51-
}
52-
53-
var message = new ChatMessage(MapRole(lastOutput.Message.Role), default(string));
54-
var citations = response.Citations?.Distinct().Select(MapCitation).ToList<AIAnnotation>();
55-
56-
foreach (var output in response.Outputs.OrderBy(x => x.Index))
57-
{
58-
if (output.Message.Content is { Length: > 0 } text)
59-
{
60-
// Special-case output from tools
61-
if (output.Message.Role == MessageRole.RoleTool &&
62-
output.Message.ToolCalls.Count == 1 &&
63-
output.Message.ToolCalls[0] is { } toolCall)
64-
{
65-
if (toolCall.Type == ToolCallType.McpTool)
66-
{
67-
message.Contents.Add(new McpServerToolCallContent(toolCall.Id, toolCall.Function.Name, null)
68-
{
69-
RawRepresentation = toolCall
70-
});
71-
message.Contents.Add(new McpServerToolResultContent(toolCall.Id)
72-
{
73-
RawRepresentation = toolCall,
74-
Output = [new TextContent(text)]
75-
});
76-
continue;
77-
}
78-
else if (toolCall.Type == ToolCallType.CodeExecutionTool)
79-
{
80-
message.Contents.Add(new CodeInterpreterToolCallContent()
81-
{
82-
CallId = toolCall.Id,
83-
RawRepresentation = toolCall
84-
});
85-
message.Contents.Add(new CodeInterpreterToolResultContent()
86-
{
87-
CallId = toolCall.Id,
88-
RawRepresentation = toolCall,
89-
Outputs = [new TextContent(text)]
90-
});
91-
continue;
92-
}
93-
}
94-
95-
var content = new TextContent(text) { Annotations = citations };
96-
97-
foreach (var citation in output.Message.Citations)
98-
(content.Annotations ??= []).Add(MapInlineCitation(citation));
99-
100-
message.Contents.Add(content);
101-
}
102-
103-
foreach (var toolCall in output.Message.ToolCalls)
104-
message.Contents.Add(MapToolCall(toolCall));
105-
}
106-
107-
return new ChatResponse(message)
43+
var result = new ChatResponse()
10844
{
10945
ResponseId = response.Id,
11046
ModelId = response.Model,
11147
CreatedAt = response.Created?.ToDateTimeOffset(),
11248
FinishReason = lastOutput != null ? MapFinishReason(lastOutput.FinishReason) : null,
11349
Usage = MapToUsage(response.Usage),
11450
};
51+
52+
var citations = response.Citations?.Distinct().Select(MapCitation).ToList<AIAnnotation>();
53+
54+
((List<ChatMessage>)result.Messages).AddRange(response.Outputs.AsChatMessages(citations));
55+
56+
return result;
11557
}
11658

117-
AIContent MapToolCall(ToolCall toolCall) => toolCall.Type switch
59+
AIContent? MapToolCall(ToolCall toolCall) => toolCall.Type switch
11860
{
11961
ToolCallType.ClientSideTool => new FunctionCallContent(
12062
toolCall.Id,
@@ -134,11 +76,12 @@ public async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messag
13476
CallId = toolCall.Id,
13577
RawRepresentation = toolCall
13678
},
137-
_ => new HostedToolCallContent()
79+
ToolCallType.CollectionsSearchTool => new CollectionSearchToolCallContent()
13880
{
13981
CallId = toolCall.Id,
14082
RawRepresentation = toolCall
141-
}
83+
},
84+
_ => null
14285
};
14386

14487
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
@@ -161,44 +104,30 @@ async IAsyncEnumerable<ChatResponseUpdate> CompleteChatStreamingCore(IEnumerable
161104
ResponseId = chunk.Id,
162105
ModelId = chunk.Model,
163106
CreatedAt = chunk.Created?.ToDateTimeOffset(),
107+
RawRepresentation = chunk,
164108
FinishReason = output.FinishReason != FinishReason.ReasonInvalid ? MapFinishReason(output.FinishReason) : null,
165109
};
166110

167-
if (chunk.Citations is { Count: > 0 } citations)
111+
var citations = chunk.Citations?.Distinct().Select(MapCitation).ToList<AIAnnotation>();
112+
if (citations?.Count > 0)
168113
{
169114
var textContent = update.Contents.OfType<TextContent>().FirstOrDefault();
170115
if (textContent == null)
171116
{
172117
textContent = new TextContent(string.Empty);
173118
update.Contents.Add(textContent);
174119
}
175-
176-
foreach (var citation in citations.Distinct())
177-
(textContent.Annotations ??= []).Add(MapCitation(citation));
120+
((List<AIAnnotation>)(textContent.Annotations ??= [])).AddRange(citations);
178121
}
179122

180-
foreach (var toolCall in output.Delta.ToolCalls)
181-
update.Contents.Add(MapToolCall(toolCall));
123+
((List<AIContent>)update.Contents).AddRange(output.Delta.ToolCalls.AsContents(text, citations));
182124

183125
if (update.Contents.Any())
184126
yield return update;
185127
}
186128
}
187129
}
188130

189-
static CitationAnnotation MapInlineCitation(InlineCitation citation) => citation.CitationCase switch
190-
{
191-
InlineCitation.CitationOneofCase.WebCitation => new CitationAnnotation { Url = new(citation.WebCitation.Url) },
192-
InlineCitation.CitationOneofCase.XCitation => new CitationAnnotation { Url = new(citation.XCitation.Url) },
193-
InlineCitation.CitationOneofCase.CollectionsCitation => new CitationAnnotation
194-
{
195-
FileId = citation.CollectionsCitation.FileId,
196-
Snippet = citation.CollectionsCitation.ChunkContent,
197-
ToolName = "file_search",
198-
},
199-
_ => new CitationAnnotation()
200-
};
201-
202131
static CitationAnnotation MapCitation(string citation)
203132
{
204133
var url = new Uri(citation);
@@ -210,12 +139,13 @@ static CitationAnnotation MapCitation(string citation)
210139
var file = url.AbsolutePath[7..];
211140
return new CitationAnnotation
212141
{
213-
ToolName = "collections_search",
214-
FileId = file,
215142
AdditionalProperties = new AdditionalPropertiesDictionary
216-
{
217-
{ "collection_id", collection }
218-
}
143+
{
144+
{ "collection_id", collection }
145+
},
146+
FileId = file,
147+
ToolName = "collections_search",
148+
Url = new Uri($"collections://{collection}/files/{file}"),
219149
};
220150
}
221151

0 commit comments

Comments
 (0)