diff --git a/dotnet/samples/GettingStarted/AgentSample.cs b/dotnet/samples/GettingStarted/AgentSample.cs
index 4194cc2b02..5d1e11f2ff 100644
--- a/dotnet/samples/GettingStarted/AgentSample.cs
+++ b/dotnet/samples/GettingStarted/AgentSample.cs
@@ -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);
diff --git a/dotnet/samples/GettingStarted/External/Azure.AI.Agents.Persistent/NewPersistentAgentsChatClient.cs b/dotnet/samples/GettingStarted/External/Azure.AI.Agents.Persistent/NewPersistentAgentsChatClient.cs
new file mode 100644
index 0000000000..87cad52921
--- /dev/null
+++ b/dotnet/samples/GettingStarted/External/Azure.AI.Agents.Persistent/NewPersistentAgentsChatClient.cs
@@ -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
+{
+ /// Represents an for an Azure.AI.Agents.Persistent .
+ public partial class NewPersistentAgentsChatClient : IChatClient
+ {
+ /// The name of the chat client provider.
+ private const string ProviderName = "azure";
+
+ /// The underlying .
+ private readonly PersistentAgentsClient? _client;
+
+ /// Metadata for the client.
+ private readonly ChatClientMetadata? _metadata;
+
+ /// The ID of the agent to use.
+ private readonly string? _agentId;
+
+ /// The thread ID to use if none is supplied in .
+ private readonly string? _defaultThreadId;
+
+ /// Lazily-retrieved agent instance. Used for its properties.
+ private PersistentAgent? _agent;
+
+ /// Initializes a new instance of the class for the specified .
+ 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() { }
+
+ ///
+ 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;
+
+ ///
+ public virtual Task GetResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
+ GetStreamingResponseAsync(messages, options, cancellationToken).ToChatResponseAsync(cancellationToken);
+
+ ///
+ public virtual async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable 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? 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 updates;
+ if (threadRun is not null &&
+ ConvertFunctionResultsToToolOutput(toolResults, out List? 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;
+ }
+ }
+ }
+
+ ///
+ public void Dispose() { }
+
+ ///
+ /// Creates the to use for the request and extracts any function result contents
+ /// that need to be submitted as tool results.
+ ///
+ private async ValueTask<(ThreadAndRunOptions RunOptions, List? ToolResults)> CreateRunOptionsAsync(
+ IEnumerable 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 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()
+ {
+ ["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? 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 messageContents = [];
+
+ if (chatMessage.Role == ChatRole.System ||
+ chatMessage.Role == new ChatRole("developer"))
+ {
+ instructions ??= new();
+ foreach (TextContent textContent in chatMessage.Contents.OfType())
+ {
+ _ = 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);
+ }
+
+ /// Convert instances to instances.
+ /// The tool results to process.
+ /// The generated list of tool outputs, if any could be created.
+ /// The run ID associated with the corresponding function call requests.
+ private static string? ConvertFunctionResultsToToolOutput(List? toolResults, out List? 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))]
+ private sealed partial class AgentsChatClientJsonContext : JsonSerializerContext;
+ }
+}
diff --git a/dotnet/samples/GettingStarted/External/MEAI.Abstractions/HostedFileContent.cs b/dotnet/samples/GettingStarted/External/MEAI.Abstractions/HostedFileContent.cs
new file mode 100644
index 0000000000..48fc89bcd6
--- /dev/null
+++ b/dotnet/samples/GettingStarted/External/MEAI.Abstractions/HostedFileContent.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// Represents a file that is hosted by the AI service.
+///
+///
+/// Unlike 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.
+///
+[DebuggerDisplay("FileId = {FileId}")]
+public sealed class HostedFileContent : AIContent
+{
+ private string _fileId;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ID of the hosted file.
+ /// is .
+ /// is empty or composed entirely of whitespace.
+ public HostedFileContent(string fileId)
+ {
+ _fileId = Throw.IfNullOrWhitespace(fileId);
+ }
+
+ ///
+ /// Gets or sets the ID of the hosted file.
+ ///
+ /// is .
+ /// is empty or composed entirely of whitespace.
+ public string FileId
+ {
+ get => _fileId;
+ set => _fileId = Throw.IfNullOrWhitespace(value);
+ }
+}
diff --git a/dotnet/samples/GettingStarted/External/MEAI.Abstractions/HostedVectorStoreContent.cs b/dotnet/samples/GettingStarted/External/MEAI.Abstractions/HostedVectorStoreContent.cs
new file mode 100644
index 0000000000..cdf132f390
--- /dev/null
+++ b/dotnet/samples/GettingStarted/External/MEAI.Abstractions/HostedVectorStoreContent.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// Represents a vector store that is hosted by the AI service.
+///
+///
+/// Unlike 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.
+///
+[DebuggerDisplay("VectorStoreId = {VectorStoreId}")]
+public sealed class HostedVectorStoreContent : AIContent
+{
+ private string? _vectorStoreId;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ID of the hosted vector store.
+ /// is .
+ /// is empty or composed entirely of whitespace.
+ public HostedVectorStoreContent(string vectorStoreId)
+ {
+ _vectorStoreId = Throw.IfNullOrWhitespace(vectorStoreId);
+ }
+
+ ///
+ /// Gets or sets the ID of the hosted vector store.
+ ///
+ /// is .
+ /// is empty or composed entirely of whitespace.
+ public string VectorStoreId
+ {
+ get => _vectorStoreId ?? string.Empty;
+ set => _vectorStoreId = Throw.IfNullOrWhitespace(value);
+ }
+}
diff --git a/dotnet/samples/GettingStarted/External/MEAI.Abstractions/NewHostedCodeInterpreterTool.cs b/dotnet/samples/GettingStarted/External/MEAI.Abstractions/NewHostedCodeInterpreterTool.cs
index 1c018593b8..e3b7bdb604 100644
--- a/dotnet/samples/GettingStarted/External/MEAI.Abstractions/NewHostedCodeInterpreterTool.cs
+++ b/dotnet/samples/GettingStarted/External/MEAI.Abstractions/NewHostedCodeInterpreterTool.cs
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
-using Microsoft.Extensions.AI;
-
-namespace OpenAI.Assistants;
+namespace Microsoft.Extensions.AI;
///
/// Proposal for abstraction updates based on the common code interpreter tool properties.
@@ -10,26 +8,11 @@ namespace OpenAI.Assistants;
///
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 _additionalProperties = [];
-
- /// Gets or sets the list of file IDs that the code interpreter tool can access.
- public IList 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();
- this._additionalProperties["fileIds"] = value;
- }
-
- return (IList)value;
- }
- }
-
- ///
- public override IReadOnlyDictionary AdditionalProperties => this._additionalProperties;
+ /// Gets or sets a collection of to be used as input to the code interpreter tool.
+ ///
+ /// Services support different varied kinds of inputs. Most support the IDs of files that are hosted by the service,
+ /// represented via . Some also support binary data, represented via .
+ /// Unsupported inputs will be ignored by the to which the tool is passed.
+ ///
+ public IList? Inputs { get; set; }
}
diff --git a/dotnet/samples/GettingStarted/External/MEAI.Abstractions/NewHostedFileSearchTool.cs b/dotnet/samples/GettingStarted/External/MEAI.Abstractions/NewHostedFileSearchTool.cs
new file mode 100644
index 0000000000..a2535fa053
--- /dev/null
+++ b/dotnet/samples/GettingStarted/External/MEAI.Abstractions/NewHostedFileSearchTool.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// Proposal for abstraction updates based on the common file search tool properties.
+/// This provides a standardized interface for file search functionality across providers.
+///
+public class NewHostedFileSearchTool : AITool
+{
+ /// Gets or sets a collection of to be used as input to the code interpreter tool.
+ ///
+ /// Services support different varied kinds of inputs. Most support the IDs of vector stores that are hosted by the service,
+ /// represented via . Some also support binary data, represented via .
+ /// Unsupported inputs will be ignored by the to which the tool is passed.
+ ///
+ public IList? Inputs { get; set; }
+}
diff --git a/dotnet/samples/GettingStarted/External/MEAI.OpenAI/NewOpenAIAssistantChatClient.cs b/dotnet/samples/GettingStarted/External/MEAI.OpenAI/NewOpenAIAssistantChatClient.cs
index 769eb74d74..7cf1a7d0f8 100644
--- a/dotnet/samples/GettingStarted/External/MEAI.OpenAI/NewOpenAIAssistantChatClient.cs
+++ b/dotnet/samples/GettingStarted/External/MEAI.OpenAI/NewOpenAIAssistantChatClient.cs
@@ -80,6 +80,74 @@ public sealed class NewOpenAIAssistantChatClient : IChatClient
IEnumerable 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;
+ }
+
///
public async IAsyncEnumerable GetStreamingResponseAsync(
IEnumerable 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 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);
diff --git a/dotnet/samples/GettingStarted/Resources/employees.pdf b/dotnet/samples/GettingStarted/Resources/employees.pdf
new file mode 100644
index 0000000000..bba45f80a9
Binary files /dev/null and b/dotnet/samples/GettingStarted/Resources/employees.pdf differ
diff --git a/dotnet/samples/GettingStarted/Steps/Step03_ChatClientAgent_UsingCodeInterpreterTools.cs b/dotnet/samples/GettingStarted/Steps/Step03_ChatClientAgent_UsingCodeInterpreterTools.cs
index dcd2250a8b..a968ac6ef7 100644
--- a/dotnet/samples/GettingStarted/Steps/Step03_ChatClientAgent_UsingCodeInterpreterTools.cs
+++ b/dotnet/samples/GettingStarted/Steps/Step03_ChatClientAgent_UsingCodeInterpreterTools.cs
@@ -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));
}
}
diff --git a/dotnet/samples/GettingStarted/Steps/Step04_ChatClientAgent_UsingFileSearchTools.cs b/dotnet/samples/GettingStarted/Steps/Step04_ChatClientAgent_UsingFileSearchTools.cs
new file mode 100644
index 0000000000..7cb8f78a1d
--- /dev/null
+++ b/dotnet/samples/GettingStarted/Steps/Step04_ChatClientAgent_UsingFileSearchTools.cs
@@ -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;
+
+///
+/// Demonstrates how to use 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.
+///
+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
+
+ ///
+ /// Uploads a file to the specified chat client provider and returns the file ID.
+ ///
+ /// Path to the file to be uploaded.
+ /// The chat client provider to use for uploading the file.
+ /// The ID of the uploaded file.
+ ///
+ private async Task 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 CreateVectorStoreAsync(IEnumerable 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 CreateVectorStoreOpenAIAssistantAsync(IEnumerable 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 CreateVectorStoreAzureAIAgentsPersistentAsync(IEnumerable fileIds)
+ {
+ var client = new PersistentAgentsClient(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential());
+ var vectorStore = await client.VectorStores.CreateVectorStoreAsync(fileIds);
+ return vectorStore.Value.Id;
+ }
+
+ #endregion
+}