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 +}