.Net: FileSearch Tool Sample + API Proposal Updates for MEAI.Abstractions/Azure.AI.Agents (#210)

* Wip, bringing persistent chat client back

* Bring back code interpreter resource logic

* Add file search samples and proposal for new OpenAIAssistantChatClient and AgentPersistantChatCLient

* Update dotnet/samples/GettingStarted/External/MEAI.Abstractions/NewHostedFileSearchTool.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Steps/Step04_ChatClientAgent_UsingFileSearchTools.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/External/Azure.AI.Agents.Persistent/NewPersistentAgentsChatClient.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update MEAI abstractions to reflect latest discussions, updated underlying chatclients

* Remove unneeded comments

* Address PR feedback

* Address PR feedback

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Roger Barreto
2025-07-29 11:41:35 +01:00
committed by GitHub
Unverified
parent dff700e469
commit 02814a4fc0
10 changed files with 881 additions and 39 deletions
+2 -3
View File
@@ -112,9 +112,8 @@ public class AgentSample(ITestOutputHelper output) : BaseSample(output)
=> new OpenAIResponseClient(TestConfiguration.OpenAI.ChatModelId, TestConfiguration.OpenAI.ApiKey)
.AsIChatClient();
private IChatClient GetAzureAIAgentPersistentClient(ChatClientAgentOptions options)
=> new PersistentAgentsClient(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential())
.AsIChatClient(options.Id!);
private NewPersistentAgentsChatClient GetAzureAIAgentPersistentClient(ChatClientAgentOptions options)
=> new(new PersistentAgentsClient(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential()), options.Id!);
private NewOpenAIAssistantChatClient GetOpenAIAssistantChatClient(ChatClientAgentOptions options)
=> new(new(TestConfiguration.OpenAI.ApiKey), options.Id!, null);
@@ -0,0 +1,520 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CA1852 // Use sealed class
#pragma warning disable IDE0161 // Convert to file-scoped namespace
#pragma warning disable CA1063 // Implement IDisposable Correctly
#pragma warning disable CA1816 // Implement IDisposable Correctly
// Proposal for a new Persistent Agents Chat Client code based on the Azure.AI.Agents.Persistent library.
// Source: https://raw.githubusercontent.com/Azure/azure-sdk-for-net/0497c087147/sdk/ai/Azure.AI.Agents.Persistent/src/Custom/PersistentAgentsChatClient.cs
#nullable enable
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Azure.AI.Agents.Persistent
{
/// <summary>Represents an <see cref="IChatClient"/> for an Azure.AI.Agents.Persistent <see cref="PersistentAgentsClient"/>.</summary>
public partial class NewPersistentAgentsChatClient : IChatClient
{
/// <summary>The name of the chat client provider.</summary>
private const string ProviderName = "azure";
/// <summary>The underlying <see cref="PersistentAgentsClient" />.</summary>
private readonly PersistentAgentsClient? _client;
/// <summary>Metadata for the client.</summary>
private readonly ChatClientMetadata? _metadata;
/// <summary>The ID of the agent to use.</summary>
private readonly string? _agentId;
/// <summary>The thread ID to use if none is supplied in <see cref="ChatOptions.ConversationId"/>.</summary>
private readonly string? _defaultThreadId;
/// <summary>Lazily-retrieved agent instance. Used for its properties.</summary>
private PersistentAgent? _agent;
/// <summary>Initializes a new instance of the <see cref="PersistentAgentsChatClient"/> class for the specified <see cref="PersistentAgentsClient"/>.</summary>
public NewPersistentAgentsChatClient(PersistentAgentsClient client, string agentId, string? defaultThreadId = null)
{
if (client is null)
{
throw new ArgumentNullException(nameof(client));
}
if (string.IsNullOrWhiteSpace(agentId))
{
throw new ArgumentException("Value cannot be empty or contain only white-space characters.", nameof(agentId));
}
_client = client;
_agentId = agentId;
_defaultThreadId = defaultThreadId;
_metadata = new(ProviderName);
}
public NewPersistentAgentsChatClient() { }
/// <inheritdoc />
public virtual object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) :
serviceKey is not null ? null :
serviceType == typeof(ChatClientMetadata) ? _metadata :
serviceType == typeof(PersistentAgentsClient) ? _client :
serviceType.IsInstanceOfType(this) ? this :
null;
/// <inheritdoc />
public virtual Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
GetStreamingResponseAsync(messages, options, cancellationToken).ToChatResponseAsync(cancellationToken);
/// <inheritdoc />
public virtual async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (messages is null)
{
throw new ArgumentNullException(nameof(messages));
}
// Extract necessary state from messages and options.
(ThreadAndRunOptions runOptions, List<FunctionResultContent>? toolResults) =
await CreateRunOptionsAsync(messages, options, cancellationToken).ConfigureAwait(false);
// Get the thread ID.
string? threadId = options?.ConversationId ?? _defaultThreadId;
if (threadId is null && toolResults is not null)
{
throw new ArgumentException("No thread ID was provided, but chat messages includes tool results.", nameof(messages));
}
// Get any active run ID for this thread.
ThreadRun? threadRun = null;
if (threadId is not null)
{
await foreach (ThreadRun? run in _client!.Runs.GetRunsAsync(threadId, limit: 1, ListSortOrder.Descending, cancellationToken: cancellationToken).ConfigureAwait(false))
{
if (run.Status != RunStatus.Completed && run.Status != RunStatus.Cancelled && run.Status != RunStatus.Failed && run.Status != RunStatus.Expired)
{
threadRun = run;
break;
}
}
}
// Submit the request.
IAsyncEnumerable<StreamingUpdate> updates;
if (threadRun is not null &&
ConvertFunctionResultsToToolOutput(toolResults, out List<ToolOutput>? toolOutputs) is { } toolRunId &&
toolRunId == threadRun.Id)
{
// There's an active run and we have tool results to submit, so submit the results and continue streaming.
// This is going to ignore any additional messages in the run options, as we are only submitting tool outputs,
// but there doesn't appear to be a way to submit additional messages, and having such additional messages is rare.
updates = _client!.Runs.SubmitToolOutputsToStreamAsync(threadRun, toolOutputs, cancellationToken);
}
else
{
if (threadId is null)
{
// No thread ID was provided, so create a new thread.
PersistentAgentThread thread = await _client!.Threads.CreateThreadAsync(runOptions.ThreadOptions.Messages, runOptions.ToolResources, runOptions.Metadata, cancellationToken).ConfigureAwait(false);
runOptions.ThreadOptions.Messages.Clear();
threadId = thread.Id;
}
else if (threadRun is not null)
{
// There was an active run; we need to cancel it before starting a new run.
await _client!.Runs.CancelRunAsync(threadId, threadRun.Id, cancellationToken).ConfigureAwait(false);
threadRun = null;
}
// Now create a new run and stream the results.
updates = _client!.Runs.CreateRunStreamingAsync(
threadId: threadId,
agentId: _agentId,
overrideModelName: runOptions.OverrideModelName,
overrideInstructions: runOptions.OverrideInstructions,
additionalInstructions: null,
additionalMessages: runOptions.ThreadOptions.Messages,
overrideTools: runOptions.OverrideTools,
temperature: runOptions.Temperature,
topP: runOptions.TopP,
maxPromptTokens: runOptions.MaxPromptTokens,
maxCompletionTokens: runOptions.MaxCompletionTokens,
truncationStrategy: runOptions.TruncationStrategy,
toolChoice: runOptions.ToolChoice,
responseFormat: runOptions.ResponseFormat,
parallelToolCalls: runOptions.ParallelToolCalls,
metadata: runOptions.Metadata,
cancellationToken);
}
// Process each update.
string? responseId = null;
await foreach (StreamingUpdate? update in updates.ConfigureAwait(false))
{
switch (update)
{
case ThreadUpdate tu:
threadId ??= tu.Value.Id;
goto default;
case RunUpdate ru:
threadId ??= ru.Value.ThreadId;
responseId ??= ru.Value.Id;
ChatResponseUpdate ruUpdate = new()
{
AuthorName = ru.Value.AssistantId,
ConversationId = threadId,
CreatedAt = ru.Value.CreatedAt,
MessageId = responseId,
ModelId = ru.Value.Model,
RawRepresentation = ru,
ResponseId = responseId,
Role = ChatRole.Assistant,
};
if (ru.Value.Usage is { } usage)
{
ruUpdate.Contents.Add(new UsageContent(new()
{
InputTokenCount = usage.PromptTokens,
OutputTokenCount = usage.CompletionTokens,
TotalTokenCount = usage.TotalTokens,
}));
}
if (ru is RequiredActionUpdate rau && rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName)
{
ruUpdate.Contents.Add(
new FunctionCallContent(
JsonSerializer.Serialize([ru.Value.Id, toolCallId], AgentsChatClientJsonContext.Default.StringArray),
functionName,
JsonSerializer.Deserialize(rau.FunctionArguments, AgentsChatClientJsonContext.Default.IDictionaryStringObject)!));
}
yield return ruUpdate;
break;
case MessageContentUpdate mcu:
yield return new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text)
{
ConversationId = threadId,
MessageId = responseId,
RawRepresentation = mcu,
ResponseId = responseId,
};
break;
default:
yield return new ChatResponseUpdate
{
ConversationId = threadId,
MessageId = responseId,
RawRepresentation = update,
ResponseId = responseId,
Role = ChatRole.Assistant,
};
break;
}
}
}
/// <inheritdoc />
public void Dispose() { }
/// <summary>
/// Creates the <see cref="ThreadAndRunOptions"/> to use for the request and extracts any function result contents
/// that need to be submitted as tool results.
/// </summary>
private async ValueTask<(ThreadAndRunOptions RunOptions, List<FunctionResultContent>? ToolResults)> CreateRunOptionsAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options, CancellationToken cancellationToken)
{
// Create the options instance to populate, either a fresh or using one the caller provides.
ThreadAndRunOptions runOptions =
options?.RawRepresentationFactory?.Invoke(this) as ThreadAndRunOptions ??
new();
// Load details about the agent if not already loaded.
if (_agent is null)
{
PersistentAgent agent = await _client!.Administration.GetAgentAsync(_agentId, cancellationToken).ConfigureAwait(false);
Interlocked.CompareExchange(ref _agent, agent, null);
}
// Populate the run options from the ChatOptions, if provided.
if (options is not null)
{
runOptions.MaxCompletionTokens ??= options.MaxOutputTokens;
runOptions.OverrideModelName ??= options.ModelId;
runOptions.TopP ??= options.TopP;
runOptions.Temperature ??= options.Temperature;
runOptions.ParallelToolCalls ??= options.AllowMultipleToolCalls;
// Ignored: options.TopK, options.FrequencyPenalty, options.Seed, options.StopSequences
if (options.Tools is { Count: > 0 } tools)
{
List<ToolDefinition> toolDefinitions = [];
ToolResources? toolResources = null;
// If the caller has provided any tool overrides, we'll assume they don't want to use the agent's tools.
// But if they haven't, the only way we can provide our tools is via an override, whereas we'd really like to
// just add them. To handle that, we'll get all of the agent's tools and add them to the override list
// along with our tools.
if (runOptions.OverrideTools is null || !runOptions.OverrideTools.Any())
{
toolDefinitions.AddRange(_agent.Tools);
}
// The caller can provide tools in the supplied ThreadAndRunOptions.
if (runOptions.OverrideTools is not null)
{
toolDefinitions.AddRange(runOptions.OverrideTools);
}
// Now add the tools from ChatOptions.Tools.
foreach (AITool tool in tools)
{
switch (tool)
{
case AIFunction aiFunction:
toolDefinitions.Add(new FunctionToolDefinition(
aiFunction.Name,
aiFunction.Description,
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(aiFunction.JsonSchema, AgentsChatClientJsonContext.Default.JsonElement))));
break;
case NewHostedCodeInterpreterTool codeTool:
toolDefinitions.Add(new CodeInterpreterToolDefinition());
if (codeTool.Inputs is { Count: > 0 })
{
foreach (var input in codeTool.Inputs)
{
switch (input)
{
case HostedFileContent hostedFile:
// If the input is a HostedFileContent, we can use its ID directly.
(toolResources ??= new() { CodeInterpreter = new() }).CodeInterpreter.FileIds.Add(hostedFile.FileId);
break;
}
}
}
break;
case NewHostedFileSearchTool fileSearchTool:
toolDefinitions.Add(new FileSearchToolDefinition());
if (fileSearchTool.Inputs is { Count: > 0 })
{
foreach (var input in fileSearchTool.Inputs)
{
switch (input)
{
case HostedVectorStoreContent hostedVectorStore:
// If the input is a HostedFileContent, we can use its ID directly.
(toolResources ??= new() { FileSearch = new() }).FileSearch.VectorStoreIds.Add(hostedVectorStore.VectorStoreId);
break;
}
}
}
break;
case HostedWebSearchTool webSearch when webSearch.AdditionalProperties?.TryGetValue("connectionId", out object? connectionId) is true:
toolDefinitions.Add(new BingGroundingToolDefinition(new BingGroundingSearchToolParameters([new BingGroundingSearchConfiguration(connectionId!.ToString())])));
break;
}
}
if (toolDefinitions.Count > 0)
{
runOptions.OverrideTools = toolDefinitions;
}
if (toolResources is not null)
{
runOptions.ToolResources = toolResources;
}
}
// Store the tool mode, if relevant.
if (runOptions.ToolChoice is null)
{
switch (options.ToolMode)
{
case NoneChatToolMode:
runOptions.ToolChoice = BinaryData.FromString("none");
break;
case RequiredChatToolMode required:
runOptions.ToolChoice = required.RequiredFunctionName is string functionName ?
BinaryData.FromString($$"""{"type": "function", "function": {"name": "{{functionName}}"} }""") :
BinaryData.FromString("required");
break;
}
}
// Store the response format, if relevant.
if (runOptions.ResponseFormat is null)
{
if (options.ResponseFormat is ChatResponseFormatJson jsonFormat)
{
runOptions.ResponseFormat = jsonFormat.Schema is { } schema ?
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(new Dictionary<string, object?>()
{
["type"] = "json_schema",
["json_schema"] = JsonSerializer.SerializeToNode(schema, AgentsChatClientJsonContext.Default.JsonNode),
}, AgentsChatClientJsonContext.Default.JsonObject)) :
BinaryData.FromString("""{ "type": "json_object" }""");
}
}
}
// Process ChatMessages. System messages are turned into additional instructions.
// All other messages are added 1:1, treating assistant messages as agent messages
// and everything else as user messages.
StringBuilder? instructions = null;
List<FunctionResultContent>? functionResults = null;
runOptions.ThreadOptions ??= new();
bool treatInstructionsAsOverride = false;
if (runOptions.OverrideInstructions is not null)
{
treatInstructionsAsOverride = true;
(instructions ??= new()).Append(runOptions.OverrideInstructions);
}
if (options?.Instructions is not null)
{
(instructions ??= new()).Append(options.Instructions);
}
foreach (ChatMessage chatMessage in messages)
{
List<MessageInputContentBlock> messageContents = [];
if (chatMessage.Role == ChatRole.System ||
chatMessage.Role == new ChatRole("developer"))
{
instructions ??= new();
foreach (TextContent textContent in chatMessage.Contents.OfType<TextContent>())
{
_ = instructions.Append(textContent);
}
continue;
}
foreach (AIContent content in chatMessage.Contents)
{
switch (content)
{
case TextContent text:
messageContents.Add(new MessageInputTextBlock(text.Text));
break;
case DataContent image when image.HasTopLevelMediaType("image"):
messageContents.Add(new MessageInputImageUriBlock(new MessageImageUriParam(image.Uri)));
break;
case UriContent image when image.HasTopLevelMediaType("image"):
messageContents.Add(new MessageInputImageUriBlock(new MessageImageUriParam(image.Uri.AbsoluteUri)));
break;
case FunctionResultContent result:
(functionResults ??= []).Add(result);
break;
default:
if (content.RawRepresentation is MessageInputContentBlock rawContent)
{
messageContents.Add(rawContent);
}
break;
}
}
if (messageContents.Count > 0)
{
runOptions.ThreadOptions.Messages.Add(new ThreadMessageOptions(
chatMessage.Role == ChatRole.Assistant ? MessageRole.Agent : MessageRole.User,
messageContents));
}
}
if (instructions is not null)
{
// If runOptions.OverrideInstructions was set by the caller, then all instructions are treated
// as an override. Otherwise, we want all of the instructions to augment the agent's instructions,
// so insert the agent's at the beginning.
if (!treatInstructionsAsOverride && !string.IsNullOrEmpty(_agent.Instructions))
{
instructions.Insert(0, _agent.Instructions);
}
runOptions.OverrideInstructions = instructions.ToString();
}
return (runOptions, functionResults);
}
/// <summary>Convert <see cref="FunctionResultContent"/> instances to <see cref="ToolOutput"/> instances.</summary>
/// <param name="toolResults">The tool results to process.</param>
/// <param name="toolOutputs">The generated list of tool outputs, if any could be created.</param>
/// <returns>The run ID associated with the corresponding function call requests.</returns>
private static string? ConvertFunctionResultsToToolOutput(List<FunctionResultContent>? toolResults, out List<ToolOutput>? toolOutputs)
{
string? runId = null;
toolOutputs = null;
if (toolResults?.Count > 0)
{
foreach (FunctionResultContent frc in toolResults)
{
// When creating the FunctionCallContext, we created it with a CallId == [runId, callId].
// We need to extract the run ID and ensure that the ToolOutput we send back to Azure
// is only the call ID.
string[]? runAndCallIDs;
try
{
runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, AgentsChatClientJsonContext.Default.StringArray);
}
catch
{
continue;
}
if (runAndCallIDs is null ||
runAndCallIDs.Length != 2 ||
string.IsNullOrWhiteSpace(runAndCallIDs[0]) || // run ID
string.IsNullOrWhiteSpace(runAndCallIDs[1]) || // call ID
(runId is not null && runId != runAndCallIDs[0]))
{
continue;
}
runId = runAndCallIDs[0];
(toolOutputs ??= []).Add(new(runAndCallIDs[1], frc.Result?.ToString() ?? string.Empty));
}
}
return runId;
}
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(JsonObject))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(IDictionary<string, object>))]
private sealed partial class AgentsChatClientJsonContext : JsonSerializerContext;
}
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Represents a file that is hosted by the AI service.
/// </summary>
/// <remarks>
/// Unlike <see cref="DataContent"/> which contains the data for a file or blob, this class represents a file that is hosted
/// by the AI service and referenced by an identifier. Such identifiers are specific to the provider.
/// </remarks>
[DebuggerDisplay("FileId = {FileId}")]
public sealed class HostedFileContent : AIContent
{
private string _fileId;
/// <summary>
/// Initializes a new instance of the <see cref="HostedFileContent"/> class.
/// </summary>
/// <param name="fileId">The ID of the hosted file.</param>
/// <exception cref="ArgumentNullException"><paramref name="fileId"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="fileId"/> is empty or composed entirely of whitespace.</exception>
public HostedFileContent(string fileId)
{
_fileId = Throw.IfNullOrWhitespace(fileId);
}
/// <summary>
/// Gets or sets the ID of the hosted file.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="value"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="value"/> is empty or composed entirely of whitespace.</exception>
public string FileId
{
get => _fileId;
set => _fileId = Throw.IfNullOrWhitespace(value);
}
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Represents a vector store that is hosted by the AI service.
/// </summary>
/// <remarks>
/// Unlike <see cref="DataContent"/> which contains the data for a file or blob, this class represents a vector store that is hosted
/// by the AI service and referenced by an identifier. Such identifiers are specific to the provider.
/// </remarks>
[DebuggerDisplay("VectorStoreId = {VectorStoreId}")]
public sealed class HostedVectorStoreContent : AIContent
{
private string? _vectorStoreId;
/// <summary>
/// Initializes a new instance of the <see cref="HostedVectorStoreContent"/> class.
/// </summary>
/// <param name="vectorStoreId">The ID of the hosted vector store.</param>
/// <exception cref="ArgumentNullException"><paramref name="vectorStoreId"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="vectorStoreId"/> is empty or composed entirely of whitespace.</exception>
public HostedVectorStoreContent(string vectorStoreId)
{
_vectorStoreId = Throw.IfNullOrWhitespace(vectorStoreId);
}
/// <summary>
/// Gets or sets the ID of the hosted vector store.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="value"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="value"/> is empty or composed entirely of whitespace.</exception>
public string VectorStoreId
{
get => _vectorStoreId ?? string.Empty;
set => _vectorStoreId = Throw.IfNullOrWhitespace(value);
}
}
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace OpenAI.Assistants;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Proposal for abstraction updates based on the common code interpreter tool properties.
@@ -10,26 +8,11 @@ namespace OpenAI.Assistants;
/// </summary>
public class NewHostedCodeInterpreterTool : HostedCodeInterpreterTool
{
// Usage of an internal dictionary is temporary and only used here because the MEAI.Abstractions does not have this specialization yet and the
// ChatClients must rely on the AdditionalProperties to check and set correctly the Code Interpreter Resource avoiding a customized RawRepresentationFactory implementation.
private readonly Dictionary<string, object?> _additionalProperties = [];
/// <summary>Gets or sets the list of file IDs that the code interpreter tool can access.</summary>
public IList<string> FileIds
{
get
{
// Only create the property in the dictionary when it is actually used
if (!this._additionalProperties.TryGetValue("fileIds", out var value) || value is null)
{
value = new List<string>();
this._additionalProperties["fileIds"] = value;
}
return (IList<string>)value;
}
}
/// <inheritdoc/>
public override IReadOnlyDictionary<string, object?> AdditionalProperties => this._additionalProperties;
/// <summary>Gets or sets a collection of <see cref="AIContent"/> to be used as input to the code interpreter tool.</summary>
/// <remarks>
/// Services support different varied kinds of inputs. Most support the IDs of files that are hosted by the service,
/// represented via <see cref="HostedFileContent"/>. Some also support binary data, represented via <see cref="DataContent"/>.
/// Unsupported inputs will be ignored by the <see cref="IChatClient"/> to which the tool is passed.
/// </remarks>
public IList<AIContent>? Inputs { get; set; }
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI;
/// <summary>
/// Proposal for abstraction updates based on the common file search tool properties.
/// This provides a standardized interface for file search functionality across providers.
/// </summary>
public class NewHostedFileSearchTool : AITool
{
/// <summary>Gets or sets a collection of <see cref="AIContent"/> to be used as input to the code interpreter tool.</summary>
/// <remarks>
/// Services support different varied kinds of inputs. Most support the IDs of vector stores that are hosted by the service,
/// represented via <see cref="HostedVectorStoreContent"/>. Some also support binary data, represented via <see cref="DataContent"/>.
/// Unsupported inputs will be ignored by the <see cref="IChatClient"/> to which the tool is passed.
/// </remarks>
public IList<AIContent>? Inputs { get; set; }
}
@@ -80,6 +80,74 @@ public sealed class NewOpenAIAssistantChatClient : IChatClient
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
GetStreamingResponseAsync(messages, options, cancellationToken).ToChatResponseAsync(cancellationToken);
private ToolResources? CreateToolResources(ChatOptions? options)
{
if (options is null)
{
return null;
}
if (options.Tools is { Count: > 0 } tools)
{
FileSearchToolResources? fileSearchResources = null;
CodeInterpreterToolResources? codeInterpreterResources = null;
// The caller can provide tools in the supplied ThreadAndRunOptions. Augment it with any supplied via ChatOptions.Tools.
foreach (AITool tool in tools)
{
switch (tool)
{
case NewHostedCodeInterpreterTool codeTool:
if (codeTool.Inputs is { Count: > 0 })
{
codeInterpreterResources ??= new();
foreach (var input in codeTool.Inputs)
{
switch (input)
{
case HostedFileContent fileContent:
// Use the file ID from the HostedFileContent.
codeInterpreterResources.FileIds.Add(fileContent.FileId);
break;
}
}
}
break;
case NewHostedFileSearchTool fileSearchTool:
// Handle file IDs for file search tool
if (fileSearchTool.Inputs is { Count: > 0 })
{
fileSearchResources ??= new();
foreach (var input in fileSearchTool.Inputs)
{
switch (input)
{
case HostedVectorStoreContent vectorStoreContent:
// Use the vector store ID from the HostedVectorStoreContent.
fileSearchResources.VectorStoreIds.Add(vectorStoreContent.VectorStoreId);
break;
}
}
}
break;
}
}
return new ToolResources
{
CodeInterpreter = codeInterpreterResources,
FileSearch = fileSearchResources,
};
}
return null;
}
/// <inheritdoc />
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
@@ -132,7 +200,11 @@ public sealed class NewOpenAIAssistantChatClient : IChatClient
if (threadId is null)
{
// No thread ID was provided, so create a new thread.
ThreadCreationOptions threadCreationOptions = new();
ThreadCreationOptions threadCreationOptions = new()
{
ToolResources = CreateToolResources(options)
};
foreach (var message in runOptions.AdditionalMessages)
{
threadCreationOptions.InitialMessages.Add(message);
@@ -303,18 +375,48 @@ public sealed class NewOpenAIAssistantChatClient : IChatClient
runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options));
break;
case HostedCodeInterpreterTool:
case NewHostedCodeInterpreterTool codeTool:
var codeInterpreterToolDefinition = new CodeInterpreterToolDefinition();
runOptions.ToolsOverride.Add(codeInterpreterToolDefinition);
// Once available, HostedCodeInterpreterTool.FileIds property will be used instead of the AdditionalProperties.
if (tool.AdditionalProperties.TryGetValue("fileIds", out object? fileIdsObject) && fileIdsObject is IEnumerable<string> fileIds)
if (codeTool.Inputs is { Count: > 0 })
{
var threadInitializationMessage = new ThreadInitializationMessage(OpenAI.Assistants.MessageRole.User, [OpenAI.Assistants.MessageContent.FromText("attachments")]);
foreach (var fileId in fileIds)
foreach (var input in codeTool.Inputs)
{
threadInitializationMessage.Attachments.Add(new(fileId, [codeInterpreterToolDefinition]));
switch (input)
{
case HostedFileContent fileContent:
// Use the file ID from the HostedFileContent.
threadInitializationMessage.Attachments.Add(new(fileContent.FileId, [codeInterpreterToolDefinition]));
break;
}
}
runOptions.AdditionalMessages.Add(threadInitializationMessage);
}
break;
case NewHostedFileSearchTool fileSearchTool:
var fileSearchToolDefinition = new FileSearchToolDefinition();
runOptions.ToolsOverride.Add(fileSearchToolDefinition);
// Handle file IDs for file search tool
if (fileSearchTool.Inputs is { Count: > 0 })
{
var threadInitializationMessage = new ThreadInitializationMessage(OpenAI.Assistants.MessageRole.User, [OpenAI.Assistants.MessageContent.FromText("file search attachments")]);
foreach (var input in fileSearchTool.Inputs)
{
switch (input)
{
case HostedFileContent fileContent:
// Use the file ID from the HostedFileContent.
threadInitializationMessage.Attachments.Add(new(fileContent.FileId, [fileSearchToolDefinition]));
break;
}
}
runOptions.AdditionalMessages.Add(threadInitializationMessage);
@@ -3,9 +3,9 @@
using System.Text;
using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Shared.Samples;
using OpenAI.Assistants;
using OpenAI.Files;
namespace Steps;
@@ -21,8 +21,10 @@ public sealed class Step03_ChatClientAgent_UsingCodeInterpreterTools(ITestOutput
[InlineData(ChatClientProviders.OpenAIAssistant)]
public async Task RunningWithFileReferenceAsync(ChatClientProviders provider)
{
var codeInterpreterTool = new NewHostedCodeInterpreterTool();
codeInterpreterTool.FileIds.Add(await UploadFileAsync("Resources/groceries.txt", provider));
var codeInterpreterTool = new NewHostedCodeInterpreterTool()
{
Inputs = [new HostedFileContent(await UploadFileAsync("Resources/groceries.txt", provider))]
};
var agentOptions = new ChatClientAgentOptions(
name: "HelpfulAssistant",
@@ -51,9 +53,9 @@ public sealed class Step03_ChatClientAgent_UsingCodeInterpreterTools(ITestOutput
assistantOutput.Append(update.Text);
}
if (update.RawRepresentation is not null)
if (update.RawRepresentation is ChatResponseUpdate chatUpdate && chatUpdate.RawRepresentation is not null)
{
codeInterpreterOutput.Append(GetCodeInterpreterOutput(update.RawRepresentation, provider));
codeInterpreterOutput.Append(GetCodeInterpreterOutput(chatUpdate.RawRepresentation, provider));
}
}
@@ -0,0 +1,136 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Shared.Samples;
using OpenAI.Files;
using OpenAI.VectorStores;
namespace Steps;
/// <summary>
/// Demonstrates how to use <see cref="ChatClientAgent"/> with file search tools and file references.
/// Shows uploading files to different providers and using them with file search capabilities to retrieve and analyze information from documents.
/// </summary>
public sealed class Step04_ChatClientAgent_UsingFileSearchTools(ITestOutputHelper output) : AgentSample(output)
{
[Theory]
[InlineData(ChatClientProviders.AzureAIAgentsPersistent)]
[InlineData(ChatClientProviders.OpenAIAssistant)]
public async Task RunningWithFileReferenceAsync(ChatClientProviders provider)
{
// Upload a file to the specified provider.
var fileId = await UploadFileAsync("Resources/employees.pdf", provider);
// Create a vector store for the uploaded file to enable file search capabilities.
var vectorStoreId = await CreateVectorStoreAsync([fileId], provider);
// Create a file search tool that can access the vector store.
var fileSearchTool = new NewHostedFileSearchTool()
{
Inputs = [new HostedVectorStoreContent(vectorStoreId)],
};
var agentOptions = new ChatClientAgentOptions
{
Name = "FileSearchAssistant",
Instructions = "You are a helpful assistant that can search through uploaded documents to answer questions. Use the file search tool to find relevant information from the uploaded files.",
ChatOptions = new() { Tools = [fileSearchTool] }
};
// Create the server-side agent Id when applicable (depending on the provider).
agentOptions.Id = await base.AgentCreateAsync(provider, agentOptions);
using var chatClient = base.GetChatClient(provider, agentOptions);
ChatClientAgent agent = new(chatClient, agentOptions);
var thread = agent.GetNewThread();
// Prompt which allows to verify that the file search functionality works correctly with the uploaded document.
const string Prompt = "Who is the youngest employee?";
var assistantOutput = new StringBuilder();
await foreach (var update in agent.RunStreamingAsync(Prompt, thread))
{
if (!string.IsNullOrWhiteSpace(update.Text))
{
assistantOutput.Append(update.Text);
}
}
Console.WriteLine("Assistant Output:");
Console.WriteLine(assistantOutput.ToString());
// Clean up the server-side agent after use when applicable (depending on the provider).
await base.AgentCleanUpAsync(provider, agent, thread);
}
#region private
/// <summary>
/// Uploads a file to the specified chat client provider and returns the file ID.
/// </summary>
/// <param name="filePath">Path to the file to be uploaded.</param>
/// <param name="provider">The chat client provider to use for uploading the file.</param>
/// <returns>The ID of the uploaded file.</returns>
/// <exception cref="NotSupportedException"></exception>
private async Task<string> UploadFileAsync(string filePath, ChatClientProviders provider)
{
switch (provider)
{
case ChatClientProviders.OpenAIAssistant:
var fileClient = new OpenAIFileClient(TestConfiguration.OpenAI.ApiKey);
OpenAIFile openAIFileInfo = await fileClient.UploadFileAsync(filePath, FileUploadPurpose.Assistants);
return openAIFileInfo.Id;
case ChatClientProviders.AzureAIAgentsPersistent:
var persistentAgentsClient = new PersistentAgentsClient(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential());
PersistentAgentFileInfo persistentAgentFileInfo = await persistentAgentsClient.Files.UploadFileAsync(filePath, PersistentAgentFilePurpose.Agents);
return persistentAgentFileInfo.Id;
default:
throw new NotSupportedException($"Client provider {provider} is not supported.");
}
}
private Task<string> CreateVectorStoreAsync(IEnumerable<string> fileIds, ChatClientProviders provider)
{
switch (provider)
{
case ChatClientProviders.OpenAIAssistant:
return CreateVectorStoreOpenAIAssistantAsync(fileIds);
case ChatClientProviders.AzureAIAgentsPersistent:
return CreateVectorStoreAzureAIAgentsPersistentAsync(fileIds);
default:
throw new NotSupportedException($"Client provider {provider} is not supported.");
}
}
private async Task<string> CreateVectorStoreOpenAIAssistantAsync(IEnumerable<string> fileIds)
{
var vectorStoreClient = new VectorStoreClient(TestConfiguration.OpenAI.ApiKey);
VectorStoreCreationOptions options = new();
foreach (var fileId in fileIds)
{
options.FileIds.Add(fileId);
}
var vectorStore = await vectorStoreClient.CreateVectorStoreAsync(waitUntilCompleted: true, options);
return vectorStore.VectorStoreId;
}
private async Task<string> CreateVectorStoreAzureAIAgentsPersistentAsync(IEnumerable<string> fileIds)
{
var client = new PersistentAgentsClient(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential());
var vectorStore = await client.VectorStores.CreateVectorStoreAsync(fileIds);
return vectorStore.Value.Id;
}
#endregion
}