mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.Net: Added Azure AI Persistent Agents (#81)
* Added PersistentAgentsChatClient * Added integration tests * Small fixes * Added sample * Added TODO for tools * Small rename * Removed user-secrets id * Renamed project * Fixed warning * Fixed warning * More fixes * More fixes
This commit is contained in:
committed by
GitHub
Unverified
parent
d1d69af482
commit
47dcad6b49
@@ -6,6 +6,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.1.0-beta.2" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.2.0-beta.4" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
|
||||
<!-- System.* -->
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" Id="e91d4745-7ccd-4de8-9bc0-31081f540038">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" Id="a35b6971-6f27-4904-a168-8e12b229cced">
|
||||
<BuildType Solution="Publish|*" Project="Release" />
|
||||
</Project>
|
||||
@@ -52,6 +55,7 @@
|
||||
<File Path="src/Shared/Throw/Throw.cs" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/src/Shared/IntegrationTests/">
|
||||
<File Path="src/Shared/IntegrationTests/AzureAIConfiguration.cs" />
|
||||
<File Path="src/Shared/IntegrationTests/OpenAIConfiguration.cs" />
|
||||
<File Path="src/Shared/IntegrationTests/README.md" />
|
||||
</Folder>
|
||||
@@ -100,6 +104,7 @@
|
||||
</Folder>
|
||||
<Project Path="src/Microsoft.Agents.Abstractions/Microsoft.Agents.Abstractions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents/Microsoft.Agents.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.AzureAIAgentsPersistent/Microsoft.Extensions.AI.AzureAIAgentsPersistent.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.Abstractions.UnitTests/Microsoft.Agents.Abstractions.UnitTests.csproj">
|
||||
<BuildType Solution="Publish|*" Project="Debug" />
|
||||
</Project>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents\Microsoft.Agents.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.AzureAIAgentsPersistent\Microsoft.Extensions.AI.AzureAIAgentsPersistent.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.Agents.Persistent;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.AzureAIAgentsPersistent;
|
||||
using Microsoft.Shared.Samples;
|
||||
|
||||
namespace Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Shows how to use <see cref="ChatClientAgent"/> with Azure AI Persistent Agents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Running "az login" command in terminal is required for authentication with Azure AI service.
|
||||
/// </remarks>
|
||||
public sealed class ChatClientAgent_With_AzureAIAgentsPersistent(ITestOutputHelper output) : AgentSample(output)
|
||||
{
|
||||
private const string JokerName = "Joker";
|
||||
private const string JokerInstructions = "You are good at telling jokes.";
|
||||
|
||||
[Fact]
|
||||
public async Task RunWithAzureAIAgentsPersistent()
|
||||
{
|
||||
// Get a client to create server side agents with.
|
||||
var persistentAgentsClient = new PersistentAgentsClient(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential());
|
||||
|
||||
// Create a server side agent to work with.
|
||||
var persistentAgentResponse = await persistentAgentsClient.Administration.CreateAgentAsync(
|
||||
model: TestConfiguration.AzureAI.DeploymentName,
|
||||
name: JokerName,
|
||||
instructions: JokerInstructions);
|
||||
|
||||
var persistentAgent = persistentAgentResponse.Value;
|
||||
|
||||
// Get the chat client to use for the agent.
|
||||
using var chatClient = persistentAgentsClient.AsIChatClient(persistentAgent.Id);
|
||||
|
||||
// Define the agent
|
||||
ChatClientAgent agent = new(chatClient);
|
||||
|
||||
// Start a new thread for the agent conversation.
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
// Respond to user input
|
||||
await RunAgentAsync("Tell me a joke about a pirate.");
|
||||
await RunAgentAsync("Now add some emojis to the joke.");
|
||||
|
||||
// Local function to run agent and display the conversation messages for the thread.
|
||||
async Task RunAgentAsync(string input)
|
||||
{
|
||||
this.WriteUserMessage(input);
|
||||
|
||||
var response = await agent.RunAsync(input, thread);
|
||||
|
||||
this.WriteResponseOutput(response);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await persistentAgentsClient.Threads.DeleteThreadAsync(thread.Id);
|
||||
await persistentAgentsClient.Administration.DeleteAgentAsync(persistentAgent.Id);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Agents.Persistent" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft.Extensions.AI.AzureAIAgentsPersistent</Title>
|
||||
<Description>Implementation of generative AI abstractions for Azure AI Persistent Agents.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Agents.Persistent;
|
||||
|
||||
namespace Microsoft.Extensions.AI.AzureAIAgentsPersistent;
|
||||
|
||||
/// <summary>Represents an <see cref="IChatClient"/> for an Azure.AI.Agents.Persistent <see cref="PersistentAgentsClient"/>.</summary>
|
||||
public sealed partial class PersistentAgentsChatClient : 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? _threadId;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="PersistentAgentsChatClient"/> class for the specified <see cref="PersistentAgentsClient"/>.</summary>
|
||||
public PersistentAgentsChatClient(PersistentAgentsClient client, string agentId, string? threadId)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or be comprised of only whitespace.", nameof(agentId));
|
||||
}
|
||||
|
||||
this._client = client;
|
||||
this._agentId = agentId;
|
||||
this._threadId = threadId;
|
||||
|
||||
this._metadata = new(ProviderName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) =>
|
||||
serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) :
|
||||
serviceKey is not null ? null :
|
||||
serviceType == typeof(ChatClientMetadata) ? this._metadata :
|
||||
serviceType == typeof(PersistentAgentsClient) ? this._client :
|
||||
serviceType.IsInstanceOfType(this) ? this :
|
||||
null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
this.GetStreamingResponseAsync(messages, options, cancellationToken).ToChatResponseAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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) = this.CreateRunOptions(messages, options);
|
||||
|
||||
// Get the thread ID.
|
||||
string? threadId = options?.ConversationId ?? this._threadId;
|
||||
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 (var run in this._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 = this._client.Runs.SubmitToolOutputsToStreamAsync(threadRun, toolOutputs, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (threadId is null)
|
||||
{
|
||||
// No thread ID was provided, so create a new thread.
|
||||
PersistentAgentThread thread = await this._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 this._client.Runs.CancelRunAsync(threadId, threadRun.Id, cancellationToken).ConfigureAwait(false);
|
||||
threadRun = null;
|
||||
}
|
||||
|
||||
// Now create a new run and stream the results.
|
||||
updates = this._client.Runs.CreateRunStreamingAsync(
|
||||
threadId: threadId,
|
||||
agentId: this._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 (var 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 (ThreadAndRunOptions RunOptions, List<FunctionResultContent>? ToolResults) CreateRunOptions(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options)
|
||||
{
|
||||
// Create the options instance to populate, either a fresh or using one the caller provides.
|
||||
ThreadAndRunOptions runOptions =
|
||||
options?.RawRepresentationFactory?.Invoke(this) as ThreadAndRunOptions ??
|
||||
new();
|
||||
|
||||
// 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
|
||||
|
||||
// TODO: When moved to Azure.AI.Agents.Persistent, merge agent tools with override tools, in similar way like here:
|
||||
// https://github.com/dotnet/extensions/blob/694b95ef75c6bd9de00ef761dadae4e70ee8739f/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs#L263-L279
|
||||
if (options.Tools is { Count: > 0 } tools)
|
||||
{
|
||||
// The caller can provide tools in the supplied ThreadAndRunOptions. Augment it with any supplied via ChatOptions.Tools.
|
||||
IList<ToolDefinition> toolDefinitions = runOptions.OverrideTools is not null ? [.. runOptions.OverrideTools] : [];
|
||||
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 HostedCodeInterpreterTool:
|
||||
toolDefinitions.Add(new CodeInterpreterToolDefinition());
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
{
|
||||
["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();
|
||||
|
||||
foreach (var chatMessage in messages)
|
||||
{
|
||||
List<MessageInputContentBlock> messageContents = [];
|
||||
|
||||
if (chatMessage.Role == ChatRole.System ||
|
||||
chatMessage.Role == new ChatRole("developer"))
|
||||
{
|
||||
instructions ??= new();
|
||||
foreach (var 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.ToString())));
|
||||
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)
|
||||
{
|
||||
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 (var 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);
|
||||
}
|
||||
#pragma warning disable CA1031 // Do not catch general exception types
|
||||
catch
|
||||
#pragma warning restore CA1031 // Do not catch general exception types
|
||||
{
|
||||
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;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.Agents.Persistent;
|
||||
|
||||
namespace Microsoft.Extensions.AI.AzureAIAgentsPersistent;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="PersistentAgentsClient"/>.
|
||||
/// </summary>
|
||||
public static class PersistentAgentsClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an <see cref="IChatClient"/> for a <see cref="PersistentAgentsClient"/> client for interacting with a specific agent.
|
||||
/// </summary>
|
||||
/// <param name="client">The <see cref="PersistentAgentsClient"/> instance to be accessed as an <see cref="IChatClient"/>.</param>
|
||||
/// <param name="agentId">The unique identifier of the agent with which to interact.</param>
|
||||
/// <param name="threadId">
|
||||
/// An optional existing thread identifier for the chat session. This serves as a default, and may be overridden per call to
|
||||
/// <see cref="IChatClient.GetResponseAsync"/> or <see cref="IChatClient.GetStreamingResponseAsync"/> via the <see cref="ChatOptions.ConversationId"/>
|
||||
/// property. If not thread ID is provided via either mechanism, a new thread will be created for the request.
|
||||
/// </param>
|
||||
/// <returns>An <see cref="IChatClient"/> instance configured to interact with the specified agent and thread.</returns>
|
||||
public static IChatClient AsIChatClient(this PersistentAgentsClient client, string agentId, string? threadId = null) =>
|
||||
new PersistentAgentsChatClient(client, agentId, threadId);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Shared.IntegrationTests;
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
|
||||
|
||||
internal sealed class AzureAIConfiguration
|
||||
{
|
||||
public string Endpoint { get; set; }
|
||||
|
||||
public string DeploymentName { get; set; }
|
||||
}
|
||||
@@ -43,6 +43,16 @@ public sealed class TestConfiguration
|
||||
public string? ApiKey { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Represents the configuration settings required to interact with the Azure AI service.</summary>
|
||||
public sealed class AzureAIConfig
|
||||
{
|
||||
/// <summary>Gets or sets the endpoint of Azure AI Foundry project.</summary>
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the name of the model deployment.</summary>
|
||||
public string? DeploymentName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the configuration system with the specified configuration root.
|
||||
/// </summary>
|
||||
@@ -53,6 +63,7 @@ public sealed class TestConfiguration
|
||||
}
|
||||
|
||||
#region Private Members
|
||||
|
||||
private readonly IConfigurationRoot _configRoot;
|
||||
private static TestConfiguration? s_instance;
|
||||
|
||||
@@ -66,6 +77,11 @@ public sealed class TestConfiguration
|
||||
/// </summary>
|
||||
private static IConfigurationRoot? ConfigurationRoot => s_instance?._configRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration settings for the AzureAI integration.
|
||||
/// </summary>
|
||||
public static AzureAIConfig AzureAI => LoadSection<AzureAIConfig>();
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a configuration section based on the specified key.
|
||||
/// </summary>
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.AzureAIAgentsPersistent\Microsoft.Extensions.AI.AzureAIAgentsPersistent.csproj" />
|
||||
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using AgentConformanceTests;
|
||||
using Azure;
|
||||
using Azure.AI.Agents.Persistent;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.AzureAIAgentsPersistent;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace AzureAIAgentsPersistent.IntegrationTests;
|
||||
|
||||
public class AzureAIAgentsPersistentFixture : AgentFixture
|
||||
{
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
private Agent _agent;
|
||||
private PersistentAgentsClient _persistentAgentsClient;
|
||||
private PersistentAgent _persistentAgent;
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
|
||||
public override Agent Agent => this._agent;
|
||||
|
||||
public override async Task<List<ChatMessage>> GetChatHistoryAsync(AgentThread thread)
|
||||
{
|
||||
if (thread is not ChatClientAgentThread chatClientThread)
|
||||
{
|
||||
throw new InvalidOperationException($"The thread must be of type {nameof(ChatClientAgentThread)} to retrieve chat history.");
|
||||
}
|
||||
|
||||
List<ChatMessage> messages = [];
|
||||
|
||||
AsyncPageable<PersistentThreadMessage> threadMessages = this._persistentAgentsClient.Messages.GetMessagesAsync(threadId: thread.Id, order: ListSortOrder.Ascending);
|
||||
|
||||
await foreach (var threadMessage in threadMessages)
|
||||
{
|
||||
var message = new ChatMessage
|
||||
{
|
||||
Role = threadMessage.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant
|
||||
};
|
||||
|
||||
foreach (var content in threadMessage.ContentItems)
|
||||
{
|
||||
if (content is MessageTextContent textContent)
|
||||
{
|
||||
message.Contents.Add(new TextContent(textContent.Text));
|
||||
}
|
||||
}
|
||||
|
||||
messages.Add(message);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
public override Task DeleteThreadAsync(AgentThread thread)
|
||||
{
|
||||
if (thread?.Id is not null)
|
||||
{
|
||||
return this._persistentAgentsClient.Threads.DeleteThreadAsync(thread.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task DisposeAsync()
|
||||
{
|
||||
if (this._persistentAgentsClient is not null && this._persistentAgent is not null)
|
||||
{
|
||||
return this._persistentAgentsClient.Administration.DeleteAgentAsync(this._persistentAgent.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override async Task InitializeAsync()
|
||||
{
|
||||
var config = TestConfiguration.LoadSection<AzureAIConfiguration>();
|
||||
|
||||
this._persistentAgentsClient = new(config.Endpoint, new AzureCliCredential());
|
||||
|
||||
var persistentAgentResponse = await this._persistentAgentsClient.Administration.CreateAgentAsync(
|
||||
model: config.DeploymentName,
|
||||
name: "HelpfulAssistant",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
this._persistentAgent = persistentAgentResponse.Value;
|
||||
|
||||
var chatClient = this._persistentAgentsClient.AsIChatClient(this._persistentAgent.Id);
|
||||
|
||||
this._agent = new ChatClientAgent(chatClient);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AzureAIAgentsPersistent.IntegrationTests;
|
||||
|
||||
public class AzureAIAgentsPersistentInvokeStreamingTests() : RunStreamingAsyncTests<AzureAIAgentsPersistentFixture>(() => new())
|
||||
{
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AzureAIAgentsPersistent.IntegrationTests;
|
||||
|
||||
public class AzureAIAgentsPersistentInvokeTests() : RunAsyncTests<AzureAIAgentsPersistentFixture>(() => new())
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user