mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: [Feature Branch] Migrate state schema updates and support for agents as MCP tools (#1979)
This commit is contained in:
committed by
GitHub
Unverified
parent
754491cdd3
commit
40b6deff96
@@ -105,6 +105,7 @@
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" Version="1.0.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.5" />
|
||||
<!-- Community -->
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<Project Path="samples/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj" />
|
||||
<Project Path="samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
|
||||
<Project Path="samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj" />
|
||||
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/">
|
||||
<File Path="samples/GettingStarted/README.md" />
|
||||
|
||||
@@ -17,4 +17,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||
<AssemblyName>AgentAsMcpTool</AssemblyName>
|
||||
<RootNamespace>AgentAsMcpTool</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Azure Functions packages -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to configure AI agents to be accessible as MCP tools.
|
||||
// When using AddAIAgent and enabling MCP tool triggers, the Functions host will automatically
|
||||
// generate a remote MCP endpoint for the app at /runtime/webhooks/mcp with a agent-specific
|
||||
// query tool name.
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OpenAI;
|
||||
|
||||
// Get the Azure OpenAI endpoint and deployment name from environment variables.
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
|
||||
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
// Define three AI agents we are going to use in this application.
|
||||
AIAgent agent1 = client.GetChatClient(deploymentName).CreateAIAgent("You are good at telling jokes.", "Joker");
|
||||
|
||||
AIAgent agent2 = client.GetChatClient(deploymentName)
|
||||
.CreateAIAgent("Check stock prices.", "StockAdvisor");
|
||||
|
||||
AIAgent agent3 = client.GetChatClient(deploymentName)
|
||||
.CreateAIAgent("Recommend plants.", "PlantAdvisor", description: "Get plant recommendations.");
|
||||
|
||||
using IHost app = FunctionsApplication
|
||||
.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableAgents(options =>
|
||||
{
|
||||
options
|
||||
.AddAIAgent(agent1) // Enables HTTP trigger by default.
|
||||
.AddAIAgent(agent2, enableHttpTrigger: false, enableMcpToolTrigger: true) // Disable HTTP trigger, enable MCP Tool trigger.
|
||||
.AddAIAgent(agent3, agentOptions =>
|
||||
{
|
||||
agentOptions.McpToolTrigger.IsEnabled = true; // Enable MCP Tool trigger.
|
||||
});
|
||||
})
|
||||
.Build();
|
||||
app.Run();
|
||||
@@ -0,0 +1,93 @@
|
||||
# Agent as MCP Tool Sample
|
||||
|
||||
This sample demonstrates how to configure AI agents to be accessible as both HTTP endpoints and [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) tools, enabling flexible integration patterns for AI agent consumption.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **Multi-trigger Agent Configuration**: Configure agents to support HTTP triggers, MCP tool triggers, or both
|
||||
- **Microsoft Agent Framework Integration**: Use the framework to define AI agents with specific roles and capabilities
|
||||
- **Flexible Agent Registration**: Register agents with customizable trigger configurations
|
||||
- **MCP Server Hosting**: Expose agents as MCP tools for consumption by MCP-compatible clients
|
||||
|
||||
## Sample Architecture
|
||||
|
||||
This sample creates three agents with different trigger configurations:
|
||||
|
||||
| Agent | Role | HTTP Trigger | MCP Tool Trigger | Description |
|
||||
|-------|------|--------------|------------------|-------------|
|
||||
| **Joker** | Comedy specialist | ✅ Enabled | ❌ Disabled | Accessible only via HTTP requests |
|
||||
| **StockAdvisor** | Financial data | ❌ Disabled | ✅ Enabled | Accessible only as MCP tool |
|
||||
| **PlantAdvisor** | Indoor plant recommendations | ✅ Enabled | ✅ Enabled | Accessible via both HTTP and MCP |
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for complete setup instructions, including:
|
||||
|
||||
- Prerequisites installation
|
||||
- Azure OpenAI configuration
|
||||
- Durable Task Scheduler setup
|
||||
- Storage emulator configuration
|
||||
|
||||
For this sample, you'll also need to install [node.js](https://nodejs.org/en/download) in order to use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) tool.
|
||||
|
||||
## Configuration
|
||||
|
||||
Update your `local.settings.json` with your Azure OpenAI credentials:
|
||||
|
||||
```json
|
||||
{
|
||||
"Values": {
|
||||
"AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/",
|
||||
"AZURE_OPENAI_DEPLOYMENT": "your-deployment-name",
|
||||
"AZURE_OPENAI_KEY": "your-api-key-if-not-using-rbac"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
1. **Start the Function App**:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/AzureFunctions/07_AgentAsMcpTool
|
||||
func start
|
||||
```
|
||||
|
||||
2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output. It will look like:
|
||||
|
||||
```text
|
||||
MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp
|
||||
```
|
||||
|
||||
## Testing MCP Tool Integration
|
||||
|
||||
Any MCP-compatible client can connect to the server endpoint and utilize the exposed agent tools. The agents will appear as callable tools within the MCP protocol.
|
||||
|
||||
### Using MCP Inspector
|
||||
|
||||
1. Run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) from the command line:
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
1. Connect using the MCP server endpoint from your terminal output
|
||||
|
||||
- For **Transport Type**, select **"Streamable HTTP"**
|
||||
- For **URL**, enter the MCP server endpoint `http://localhost:7071/runtime/webhooks/mcp`
|
||||
- Click the **Connect** button
|
||||
|
||||
1. Click the **List Tools** button to see the available MCP tools. You should see the `StockAdvisor` and `PlantAdvisor` tools.
|
||||
|
||||
1. Test the available MCP tools:
|
||||
|
||||
- **StockAdvisor** - Set "MSFT ATH" (ATH is "all time high") as the query and click the **Run Tool** button.
|
||||
- **PlantAdvisor** - Set "Low light in Seattle" as the query and click the **Run Tool** button.
|
||||
|
||||
You'll see the results of the tool calls in the MCP Inspector interface under the **Tool Results** section. You should also see the results in the terminal where you ran the `func start` command.
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Model Context Protocol Documentation](https://modelcontextprotocol.io/)
|
||||
- [Microsoft Agent Framework Documentation](https://github.com/Azure/durable-agent-framework)
|
||||
- [Azure Functions Documentation](https://learn.microsoft.com/azure/azure-functions/)
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"Microsoft.Azure.Functions.DurableAgents": "Information",
|
||||
"DurableTask": "Information",
|
||||
"Microsoft.DurableTask": "Information"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "default",
|
||||
"storageProvider": {
|
||||
"type": "AzureManaged",
|
||||
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
||||
"AZURE_OPENAI_DEPLOYMENT": "<AZURE_OPENAI_DEPLOYMENT>"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ This directory contains samples for Azure Functions.
|
||||
- **[04_AgentOrchestration_Conditionals](04_AgentOrchestration_Conditionals)**: A sample that demonstrates how to host multiple agents in an Azure Functions app and run them sequentially using a durable orchestration with conditionals.
|
||||
- **[05_AgentOrchestration_HITL](05_AgentOrchestration_HITL)**: A sample that demonstrates how to implement a human-in-the-loop workflow using durable orchestration, including external event handling for human approval.
|
||||
- **[06_LongRunningTools](06_LongRunningTools)**: A sample that demonstrates how agents can start and interact with durable orchestrations from tool calls to enable long-running tool scenarios.
|
||||
- **[07_AgentAsMcpTool](07_AgentAsMcpTool)**: A sample that demonstrates how to configure durable AI agents to be accessible as Model Context Protocol (MCP) tools.
|
||||
|
||||
## Running the Samples
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -40,13 +41,11 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
logger.LogInformation("Ignoring empty request");
|
||||
}
|
||||
|
||||
// TODO: Get state from optional state store
|
||||
DurableAgentState state = this.State;
|
||||
this.State.Data.ConversationHistory.Add(DurableAgentStateRequest.FromRunRequest(request));
|
||||
|
||||
foreach (ChatMessage msg in request.Messages)
|
||||
{
|
||||
logger.LogAgentRequest(sessionId, msg.Role, msg.Text);
|
||||
state.AddChatMessage(msg);
|
||||
}
|
||||
|
||||
// Set the current agent context for the duration of the agent run. This will be exposed
|
||||
@@ -62,7 +61,7 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
{
|
||||
// Start the agent response stream
|
||||
IAsyncEnumerable<AgentRunResponseUpdate> responseStream = agentWrapper.RunStreamingAsync(
|
||||
state.EnumerateChatMessages(),
|
||||
this.State.Data.ConversationHistory.SelectMany(e => e.Messages).Select(m => m.ToChatMessage()),
|
||||
agentWrapper.GetNewThread(),
|
||||
options: null,
|
||||
this._cancellationToken);
|
||||
@@ -98,7 +97,8 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
}
|
||||
|
||||
// Persist the agent response to the entity state for client polling
|
||||
state.AddAgentResponse(response, request.CorrelationId);
|
||||
this.State.Data.ConversationHistory.Add(
|
||||
DurableAgentStateResponse.FromRunResponse(request.CorrelationId, response));
|
||||
|
||||
string responseText = response.Text;
|
||||
|
||||
@@ -113,8 +113,6 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
response.Usage?.TotalTokenCount);
|
||||
}
|
||||
|
||||
this.UpdateEntityState(state);
|
||||
|
||||
return response;
|
||||
}
|
||||
finally
|
||||
@@ -123,11 +121,4 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
DurableAgentContext.ClearCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEntityState(DurableAgentState state)
|
||||
{
|
||||
// This method is called to update the state of the entity.
|
||||
// It can be used to persist the state to a durable store if needed.
|
||||
this.State = state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
@@ -11,10 +13,16 @@ namespace Microsoft.Agents.AI.DurableTask;
|
||||
internal sealed class AgentRunHandle
|
||||
{
|
||||
private readonly DurableTaskClient _client;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
internal AgentRunHandle(DurableTaskClient client, AgentSessionId sessionId, string correlationId)
|
||||
internal AgentRunHandle(
|
||||
DurableTaskClient client,
|
||||
ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
string correlationId)
|
||||
{
|
||||
this._client = client;
|
||||
this._logger = logger;
|
||||
this.SessionId = sessionId;
|
||||
this.CorrelationId = correlationId;
|
||||
}
|
||||
@@ -41,6 +49,8 @@ internal sealed class AgentRunHandle
|
||||
TimeSpan pollInterval = TimeSpan.FromMilliseconds(50); // Start with 50ms
|
||||
TimeSpan maxPollInterval = TimeSpan.FromSeconds(3); // Maximum 3 seconds
|
||||
|
||||
this._logger.LogStartPollingForResponse(this.SessionId, this.CorrelationId);
|
||||
|
||||
while (true)
|
||||
{
|
||||
// Poll the entity state for responses
|
||||
@@ -49,10 +59,18 @@ internal sealed class AgentRunHandle
|
||||
cancellation: cancellationToken);
|
||||
DurableAgentState? state = entityResponse?.State;
|
||||
|
||||
// Look for an agent response with matching CorrelationId
|
||||
if (state is not null && state.TryGetAgentResponse(this.CorrelationId, out AgentRunResponse? response))
|
||||
if (state?.Data.ConversationHistory is not null)
|
||||
{
|
||||
return response;
|
||||
// Look for an agent response with matching CorrelationId
|
||||
DurableAgentStateResponse? response = state.Data.ConversationHistory
|
||||
.OfType<DurableAgentStateResponse>()
|
||||
.FirstOrDefault(r => r.CorrelationId == this.CorrelationId);
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
this._logger.LogDonePollingForResponse(this.SessionId, this.CorrelationId);
|
||||
return response.ToRunResponse();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before polling again with exponential backoff
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
internal class DefaultDurableAgentClient(DurableTaskClient client, ILoggerFactory? loggerFactory = null) : IDurableAgentClient
|
||||
internal class DefaultDurableAgentClient(DurableTaskClient client, ILoggerFactory loggerFactory) : IDurableAgentClient
|
||||
{
|
||||
private readonly DurableTaskClient _client = client;
|
||||
private readonly ILogger? _logger = loggerFactory?.CreateLogger<DefaultDurableAgentClient>();
|
||||
private readonly DurableTaskClient _client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<DefaultDurableAgentClient>();
|
||||
|
||||
public async Task<AgentRunHandle> RunAgentAsync(
|
||||
AgentSessionId sessionId,
|
||||
@@ -17,11 +18,7 @@ internal class DefaultDurableAgentClient(DurableTaskClient client, ILoggerFactor
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// The correlation ID is used to fetch the correct response later.
|
||||
request.CorrelationId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// TODO: Use source generators to log the request
|
||||
this._logger?.LogInformation("Signalling agent with session ID '{SessionId}'", sessionId);
|
||||
this._logger.LogSignallingAgent(sessionId);
|
||||
|
||||
await this._client.Entities.SignalEntityAsync(
|
||||
sessionId,
|
||||
@@ -29,6 +26,6 @@ internal class DefaultDurableAgentClient(DurableTaskClient client, ILoggerFactor
|
||||
request,
|
||||
cancellation: cancellationToken);
|
||||
|
||||
return new AgentRunHandle(this._client, sessionId, request.CorrelationId);
|
||||
return new AgentRunHandle(this._client, this._logger, sessionId, request.CorrelationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,12 @@ public sealed class DurableAIAgent : AIAgent
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await this.RunAsync<T>([new ChatMessage(ChatRole.User, message)], thread, serializerOptions, options, cancellationToken);
|
||||
return await this.RunAsync<T>(
|
||||
messages: [new ChatMessage(ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }],
|
||||
thread,
|
||||
serializerOptions,
|
||||
options,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
@@ -73,7 +74,6 @@ internal static partial class DurableAgentJsonUtilities
|
||||
|
||||
// Durable Agent State Types
|
||||
[JsonSerializable(typeof(DurableAgentState))]
|
||||
[JsonSerializable(typeof(AgentStateEntry))]
|
||||
[JsonSerializable(typeof(DurableAgentThread))]
|
||||
|
||||
// Request Types
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a durable agent, including its conversation history.
|
||||
/// </summary>
|
||||
public class DurableAgentState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the ordered list of state entries representing the complete conversation history.
|
||||
/// This includes both user messages and agent responses in chronological order.
|
||||
/// </summary>
|
||||
public List<AgentStateEntry> ConversationHistory { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all chat messages from the conversation history.
|
||||
/// </summary>
|
||||
/// <returns>A collection of chat messages in chronological order.</returns>
|
||||
public IEnumerable<ChatMessage> EnumerateChatMessages()
|
||||
{
|
||||
foreach (AgentStateEntry entry in this.ConversationHistory)
|
||||
{
|
||||
if (entry.Type == AgentStateEntry.EntryType.ChatMessage)
|
||||
{
|
||||
yield return entry.ChatMessage!;
|
||||
}
|
||||
else if (entry.Type == AgentStateEntry.EntryType.AgentResponse)
|
||||
{
|
||||
foreach (ChatMessage message in entry.AgentResponse!.Messages)
|
||||
{
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an agent response from the conversation history.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID of the agent response to get.</param>
|
||||
/// <param name="response">The agent response if found, null otherwise.</param>
|
||||
/// <returns>True if the agent response was found, false otherwise.</returns>
|
||||
public bool TryGetAgentResponse(string correlationId, [NotNullWhen(true)] out AgentRunResponse? response)
|
||||
{
|
||||
foreach (AgentStateEntry entry in this.ConversationHistory.Where(
|
||||
entry => entry.Type == AgentStateEntry.EntryType.AgentResponse &&
|
||||
entry.CorrelationId == correlationId))
|
||||
{
|
||||
response = entry.AgentResponse!;
|
||||
return true;
|
||||
}
|
||||
|
||||
response = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a chat message to the state.
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message to add.</param>
|
||||
public void AddChatMessage(ChatMessage message)
|
||||
{
|
||||
this.ConversationHistory.Add(AgentStateEntry.CreateChatMessage(message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an agent response to the state.
|
||||
/// </summary>
|
||||
/// <param name="response">The agent response to add.</param>
|
||||
/// <param name="correlationId">The correlation ID for the agent response.</param>
|
||||
public void AddAgentResponse(AgentRunResponse response, string? correlationId)
|
||||
{
|
||||
this.ConversationHistory.Add(AgentStateEntry.CreateAgentResponse(response, correlationId));
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single entry in the durable agent state, which can either be a chat message or agent response.
|
||||
/// This maintains chronological order of all interactions while preserving strong typing.
|
||||
/// </summary>
|
||||
public sealed class AgentStateEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentStateEntry"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatMessage">The chat message, if this entry represents a chat message.</param>
|
||||
/// <param name="agentResponse">The agent response, if this entry represents an agent response.</param>
|
||||
/// <param name="correlationId">The correlation ID associated with the agent response, if any.</param>
|
||||
[JsonConstructor]
|
||||
public AgentStateEntry(ChatMessage? chatMessage, AgentRunResponse? agentResponse, string? correlationId)
|
||||
{
|
||||
this.ChatMessage = chatMessage;
|
||||
this.AgentResponse = agentResponse;
|
||||
this.Type = agentResponse is null ? EntryType.ChatMessage : EntryType.AgentResponse;
|
||||
this.CorrelationId = correlationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of this state entry.
|
||||
/// </summary>
|
||||
public EntryType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the correlation ID for this entry.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat message, if this entry is a chat message.
|
||||
/// </summary>
|
||||
public ChatMessage? ChatMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent response, if this entry is an agent response.
|
||||
/// </summary>
|
||||
public AgentRunResponse? AgentResponse { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a chat message entry.
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message.</param>
|
||||
/// <returns>A new chat message entry.</returns>
|
||||
public static AgentStateEntry CreateChatMessage(ChatMessage message) => new(message, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an agent response entry.
|
||||
/// </summary>
|
||||
/// <param name="response">The agent response.</param>
|
||||
/// <param name="correlationId">The correlation ID for the agent response.</param>
|
||||
/// <returns>A new agent response entry.</returns>
|
||||
public static AgentStateEntry CreateAgentResponse(AgentRunResponse response, string? correlationId) => new(null, response, correlationId);
|
||||
|
||||
/// <summary>
|
||||
/// Defines the types of entries that can be stored in the durable agent state.
|
||||
/// </summary>
|
||||
public enum EntryType
|
||||
{
|
||||
/// <summary>
|
||||
/// A user chat message.
|
||||
/// </summary>
|
||||
ChatMessage,
|
||||
|
||||
/// <summary>
|
||||
/// An agent response.
|
||||
/// </summary>
|
||||
AgentResponse
|
||||
}
|
||||
}
|
||||
@@ -28,4 +28,22 @@ internal static partial class Logs
|
||||
long? inputTokenCount,
|
||||
long? outputTokenCount,
|
||||
long? totalTokenCount);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 3,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Signalling agent with session ID '{SessionId}'")]
|
||||
public static partial void LogSignallingAgent(this ILogger logger, AgentSessionId sessionId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 4,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Polling agent with session ID '{SessionId}' for response with correlation ID '{CorrelationId}'")]
|
||||
public static partial void LogStartPollingForResponse(this ILogger logger, AgentSessionId sessionId, string correlationId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 5,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Found response for agent with session ID '{SessionId}' with correlation ID '{CorrelationId}'")]
|
||||
public static partial void LogDonePollingForResponse(this ILogger logger, AgentSessionId sessionId, string correlationId);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public record RunRequest
|
||||
/// Gets or sets the correlation ID for correlating this request with its response.
|
||||
/// </summary>
|
||||
[JsonInclude]
|
||||
internal string? CorrelationId { get; set; }
|
||||
internal string CorrelationId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RunRequest"/> class for a single message.
|
||||
@@ -50,7 +50,7 @@ public record RunRequest
|
||||
ChatResponseFormat? responseFormat = null,
|
||||
bool enableToolCalls = true,
|
||||
IList<string>? enableToolNames = null)
|
||||
: this([new ChatMessage(role ?? ChatRole.User, message)], responseFormat, enableToolCalls, enableToolNames)
|
||||
: this([new ChatMessage(role ?? ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }], responseFormat, enableToolCalls, enableToolNames)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
@@ -97,7 +98,7 @@ public static class ServiceCollectionExtensions
|
||||
return options;
|
||||
}
|
||||
|
||||
private class DefaultDataConverter : DataConverter
|
||||
private sealed class DefaultDataConverter : DataConverter
|
||||
{
|
||||
// Use durable agent options (web defaults + camel case by default) with case-insensitive matching.
|
||||
// We clone to apply naming/casing tweaks while retaining source-generated metadata where available.
|
||||
@@ -116,6 +117,11 @@ public static class ServiceCollectionExtensions
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetType == typeof(DurableAgentState))
|
||||
{
|
||||
return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
}
|
||||
|
||||
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType);
|
||||
if (typeInfo is JsonTypeInfo typedInfo)
|
||||
{
|
||||
@@ -136,6 +142,11 @@ public static class ServiceCollectionExtensions
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is DurableAgentState durableAgentState)
|
||||
{
|
||||
return JsonSerializer.Serialize(durableAgentState, DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
}
|
||||
|
||||
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType());
|
||||
if (typeInfo is JsonTypeInfo typedInfo)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a durable agent, including its conversation history.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(DurableAgentStateJsonConverter))]
|
||||
internal sealed class DurableAgentState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the data of the durable agent.
|
||||
/// </summary>
|
||||
[JsonPropertyName("data")]
|
||||
public DurableAgentStateData Data { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema version of the durable agent state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The version is specified in semver (i.e. "major.minor.patch") format.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for durable agent state content types.
|
||||
/// </summary>
|
||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateDataContent), "data")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateErrorContent), "error")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateFunctionCallContent), "functionCall")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateFunctionResultContent), "functionResult")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateHostedFileContent), "hostedFile")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateHostedVectorStoreContent), "hostedVectorStore")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateTextContent), "text")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateTextReasoningContent), "reasoning")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateUriContent), "uri")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateUsageContent), "usage")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateUnknownContent), "unknown")]
|
||||
internal abstract class DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets any additional data found during deserialization that does not map to known properties.
|
||||
/// </summary>
|
||||
[JsonExtensionData]
|
||||
public IDictionary<string, JsonElement>? ExtensionData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts this durable agent state content to an <see cref="AIContent"/>.
|
||||
/// </summary>
|
||||
/// <returns>A converted <see cref="AIContent"/> instance.</returns>
|
||||
public abstract AIContent ToAIContent();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateContent"/> from an <see cref="AIContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="content">The <see cref="AIContent"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateContent"/> representing the original <see cref="AIContent"/>.</returns>
|
||||
public static DurableAgentStateContent FromAIContent(AIContent content)
|
||||
{
|
||||
return content switch
|
||||
{
|
||||
DataContent dataContent => DurableAgentStateDataContent.FromDataContent(dataContent),
|
||||
ErrorContent errorContent => DurableAgentStateErrorContent.FromErrorContent(errorContent),
|
||||
FunctionCallContent functionCallContent => DurableAgentStateFunctionCallContent.FromFunctionCallContent(functionCallContent),
|
||||
FunctionResultContent functionResultContent => DurableAgentStateFunctionResultContent.FromFunctionResultContent(functionResultContent),
|
||||
HostedFileContent hostedFileContent => DurableAgentStateHostedFileContent.FromHostedFileContent(hostedFileContent),
|
||||
HostedVectorStoreContent hostedVectorStoreContent => DurableAgentStateHostedVectorStoreContent.FromHostedVectorStoreContent(hostedVectorStoreContent),
|
||||
TextContent textContent => DurableAgentStateTextContent.FromTextContent(textContent),
|
||||
TextReasoningContent textReasoningContent => DurableAgentStateTextReasoningContent.FromTextReasoningContent(textReasoningContent),
|
||||
UriContent uriContent => DurableAgentStateUriContent.FromUriContent(uriContent),
|
||||
UsageContent usageContent => DurableAgentStateUsageContent.FromUsageContent(usageContent),
|
||||
_ => DurableAgentStateUnknownContent.FromUnknownContent(content)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the data of a durable agent, including its conversation history.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the ordered list of state entries representing the complete conversation history.
|
||||
/// This includes both user messages and agent responses in chronological order.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conversationHistory")]
|
||||
public IList<DurableAgentStateEntry> ConversationHistory { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets any additional data found during deserialization that does not map to known properties.
|
||||
/// </summary>
|
||||
[JsonExtensionData]
|
||||
public IDictionary<string, JsonElement>? ExtensionData { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a durable agent state content that contains data content.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateDataContent : DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the URI of the data content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("uri")]
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the media type of the data content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateDataContent"/> from a <see cref="DataContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="content">The <see cref="DataContent"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateDataContent"/> representing the original <see cref="DataContent"/>.</returns>
|
||||
public static DurableAgentStateDataContent FromDataContent(DataContent content)
|
||||
{
|
||||
return new DurableAgentStateDataContent()
|
||||
{
|
||||
MediaType = content.MediaType,
|
||||
Uri = content.Uri
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AIContent ToAIContent()
|
||||
{
|
||||
return new DataContent(this.Uri, this.MediaType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single entry in the durable agent state, which can either be a
|
||||
/// user/system request or agent response.
|
||||
/// </summary>
|
||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateRequest), "request")]
|
||||
[JsonDerivedType(typeof(DurableAgentStateResponse), "response")]
|
||||
internal abstract class DurableAgentStateEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the correlation ID for this entry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This ID is used to correlate <see cref="DurableAgentStateResponse"/> back to its
|
||||
/// <see cref="DurableAgentStateRequest"/>.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("correlationId")]
|
||||
public required string CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when this entry was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of messages associated with this entry, in chronological order.
|
||||
/// </summary>
|
||||
[JsonPropertyName("messages")]
|
||||
public IReadOnlyList<DurableAgentStateMessage> Messages { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets any additional data found during deserialization that does not map to known properties.
|
||||
/// </summary>
|
||||
[JsonExtensionData]
|
||||
public IDictionary<string, JsonElement>? ExtensionData { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents durable agent state content that contains error content.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateErrorContent : DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the error message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error code.
|
||||
/// </summary>
|
||||
[JsonPropertyName("errorCode")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ErrorCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("details")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateErrorContent"/> from an <see cref="ErrorContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="content">The <see cref="ErrorContent"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateErrorContent"/> representing the original
|
||||
/// <see cref="ErrorContent"/>.</returns>
|
||||
public static DurableAgentStateErrorContent FromErrorContent(ErrorContent content)
|
||||
{
|
||||
return new DurableAgentStateErrorContent()
|
||||
{
|
||||
Details = content.Details,
|
||||
ErrorCode = content.ErrorCode,
|
||||
Message = content.Message
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AIContent ToAIContent()
|
||||
{
|
||||
return new ErrorContent(this.Message)
|
||||
{
|
||||
Details = this.Details,
|
||||
ErrorCode = this.ErrorCode
|
||||
};
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Durable agent state content representing a function call.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// The function call arguments.
|
||||
/// </summary>
|
||||
/// TODO: Consider ensuring that empty dictionaries are omitted from serialization.
|
||||
[JsonPropertyName("arguments")]
|
||||
public required IReadOnlyDictionary<string, object?> Arguments { get; init; } =
|
||||
ImmutableDictionary<string, object?>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function call identifier.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is used to correlate this function call with its resulting
|
||||
/// <see cref="DurableAgentStateFunctionResultContent"/>.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("callId")]
|
||||
public required string CallId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateFunctionCallContent"/> from a <see cref="FunctionCallContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="content">The <see cref="FunctionCallContent"/> to convert.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="DurableAgentStateFunctionCallContent"/> representing the original content.
|
||||
/// </returns>
|
||||
public static DurableAgentStateFunctionCallContent FromFunctionCallContent(FunctionCallContent content)
|
||||
{
|
||||
return new DurableAgentStateFunctionCallContent()
|
||||
{
|
||||
Arguments = content.Arguments?.ToImmutableDictionary() ?? ImmutableDictionary<string, object?>.Empty,
|
||||
CallId = content.CallId,
|
||||
Name = content.Name
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AIContent ToAIContent()
|
||||
{
|
||||
return new FunctionCallContent(
|
||||
this.CallId,
|
||||
this.Name,
|
||||
new Dictionary<string, object?>(this.Arguments));
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the function result content for a durable agent state response.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateFunctionResultContent : DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the function call identifier.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is used to correlate this function result with its originating
|
||||
/// <see cref="DurableAgentStateFunctionCallContent"/>.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("callId")]
|
||||
public required string CallId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("result")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public object? Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateFunctionResultContent"/> from a <see cref="FunctionResultContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="content">The <see cref="FunctionResultContent"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateFunctionResultContent"/> representing the original content.</returns>
|
||||
public static DurableAgentStateFunctionResultContent FromFunctionResultContent(FunctionResultContent content)
|
||||
{
|
||||
return new DurableAgentStateFunctionResultContent()
|
||||
{
|
||||
CallId = content.CallId,
|
||||
Result = content.Result
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AIContent ToAIContent()
|
||||
{
|
||||
return new FunctionResultContent(this.CallId, this.Result);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents durable agent state content that contains hosted file content.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateHostedFileContent : DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the file ID of the hosted file content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fileId")]
|
||||
public required string FileId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateHostedFileContent"/> from a <see cref="HostedFileContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="content">The <see cref="HostedFileContent"/> to convert.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="DurableAgentStateHostedFileContent"/> representing the original <see cref="HostedFileContent"/>.
|
||||
/// </returns>
|
||||
public static DurableAgentStateHostedFileContent FromHostedFileContent(HostedFileContent content)
|
||||
{
|
||||
return new DurableAgentStateHostedFileContent()
|
||||
{
|
||||
FileId = content.FileId
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AIContent ToAIContent()
|
||||
{
|
||||
return new HostedFileContent(this.FileId);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents durable agent state content that contains hosted vector store content.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateHostedVectorStoreContent : DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the vector store ID of the hosted vector store content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vectorStoreId")]
|
||||
public required string VectorStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateHostedVectorStoreContent"/> from a <see cref="HostedVectorStoreContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="content">The <see cref="HostedVectorStoreContent"/> to convert.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="DurableAgentStateHostedVectorStoreContent"/> representing the original <see cref="HostedVectorStoreContent"/>.
|
||||
/// </returns>
|
||||
public static DurableAgentStateHostedVectorStoreContent FromHostedVectorStoreContent(HostedVectorStoreContent content)
|
||||
{
|
||||
return new DurableAgentStateHostedVectorStoreContent()
|
||||
{
|
||||
VectorStoreId = content.VectorStoreId
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AIContent ToAIContent()
|
||||
{
|
||||
return new HostedVectorStoreContent(this.VectorStoreId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
[JsonSourceGenerationOptions(WriteIndented = false)]
|
||||
[JsonSerializable(typeof(DurableAgentState))]
|
||||
[JsonSerializable(typeof(DurableAgentStateContent))]
|
||||
[JsonSerializable(typeof(DurableAgentStateData))]
|
||||
[JsonSerializable(typeof(DurableAgentStateEntry))]
|
||||
[JsonSerializable(typeof(DurableAgentStateMessage))]
|
||||
// Function call and result content
|
||||
[JsonSerializable(typeof(Dictionary<string, object>))]
|
||||
[JsonSerializable(typeof(IDictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(JsonDocument))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
[JsonSerializable(typeof(JsonNode))]
|
||||
[JsonSerializable(typeof(JsonObject))]
|
||||
[JsonSerializable(typeof(JsonValue))]
|
||||
[JsonSerializable(typeof(JsonArray))]
|
||||
[JsonSerializable(typeof(IEnumerable<string>))]
|
||||
[JsonSerializable(typeof(char))]
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(int))]
|
||||
[JsonSerializable(typeof(short))]
|
||||
[JsonSerializable(typeof(long))]
|
||||
[JsonSerializable(typeof(uint))]
|
||||
[JsonSerializable(typeof(ushort))]
|
||||
[JsonSerializable(typeof(ulong))]
|
||||
[JsonSerializable(typeof(float))]
|
||||
[JsonSerializable(typeof(double))]
|
||||
[JsonSerializable(typeof(decimal))]
|
||||
[JsonSerializable(typeof(bool))]
|
||||
[JsonSerializable(typeof(TimeSpan))]
|
||||
[JsonSerializable(typeof(DateTime))]
|
||||
[JsonSerializable(typeof(DateTimeOffset))]
|
||||
internal sealed partial class DurableAgentStateJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for <see cref="DurableAgentState"/> which performs schema version checks before deserialization.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateJsonConverter : JsonConverter<DurableAgentState>
|
||||
{
|
||||
private const string SchemaVersionPropertyName = "schemaVersion";
|
||||
private const string DataPropertyName = "data";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override DurableAgentState? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
JsonElement? element = JsonSerializer.Deserialize(
|
||||
ref reader,
|
||||
DurableAgentStateJsonContext.Default.JsonElement);
|
||||
|
||||
if (element is null)
|
||||
{
|
||||
throw new JsonException("The durable agent state is not valid JSON.");
|
||||
}
|
||||
|
||||
if (!element.Value.TryGetProperty(SchemaVersionPropertyName, out JsonElement versionElement))
|
||||
{
|
||||
throw new InvalidOperationException("The durable agent state is missing the 'schemaVersion' property.");
|
||||
}
|
||||
|
||||
if (!Version.TryParse(versionElement.GetString(), out Version? schemaVersion))
|
||||
{
|
||||
throw new InvalidOperationException("The durable agent state has an invalid 'schemaVersion' property.");
|
||||
}
|
||||
|
||||
if (schemaVersion.Major != 1)
|
||||
{
|
||||
throw new InvalidOperationException($"The durable agent state schema version '{schemaVersion}' is not supported.");
|
||||
}
|
||||
|
||||
if (!element.Value.TryGetProperty(DataPropertyName, out JsonElement dataElement))
|
||||
{
|
||||
throw new InvalidOperationException("The durable agent state is missing the 'data' property.");
|
||||
}
|
||||
|
||||
DurableAgentStateData? data = dataElement.Deserialize(
|
||||
DurableAgentStateJsonContext.Default.DurableAgentStateData);
|
||||
|
||||
return new DurableAgentState
|
||||
{
|
||||
SchemaVersion = schemaVersion.ToString(),
|
||||
Data = data ?? new DurableAgentStateData()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Write(Utf8JsonWriter writer, DurableAgentState value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName(SchemaVersionPropertyName);
|
||||
writer.WriteStringValue(value.SchemaVersion);
|
||||
writer.WritePropertyName(DataPropertyName);
|
||||
JsonSerializer.Serialize(
|
||||
writer,
|
||||
value.Data,
|
||||
DurableAgentStateJsonContext.Default.DurableAgentStateData);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single message within a durable agent state entry.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the name of the author of this message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("authorName")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? AuthorName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when this message was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the contents of this message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("contents")]
|
||||
public IReadOnlyList<DurableAgentStateContent> Contents { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the role of the message sender (e.g., "user", "assistant", "system").
|
||||
/// </summary>
|
||||
[JsonPropertyName("role")]
|
||||
public required string Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any additional data found during deserialization that does not map to known properties.
|
||||
/// </summary>
|
||||
[JsonExtensionData]
|
||||
public IDictionary<string, JsonElement>? ExtensionData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateMessage"/> from a <see cref="ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="message">The <see cref="ChatMessage"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateMessage"/> representing the original message.</returns>
|
||||
public static DurableAgentStateMessage FromChatMessage(ChatMessage message)
|
||||
{
|
||||
return new DurableAgentStateMessage()
|
||||
{
|
||||
CreatedAt = message.CreatedAt,
|
||||
AuthorName = message.AuthorName,
|
||||
Role = message.Role.ToString(),
|
||||
Contents = message.Contents.Select(DurableAgentStateContent.FromAIContent).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts this <see cref="DurableAgentStateMessage"/> to a <see cref="ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ChatMessage"/> representing this message.</returns>
|
||||
public ChatMessage ToChatMessage()
|
||||
{
|
||||
return new ChatMessage()
|
||||
{
|
||||
CreatedAt = this.CreatedAt,
|
||||
AuthorName = this.AuthorName,
|
||||
Contents = this.Contents.Select(c => c.ToAIContent()).ToList(),
|
||||
Role = new(this.Role)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a user or system request entry in the durable agent state.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateRequest : DurableAgentStateEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the expected response type for this request (e.g. "json" or "text").
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If omitted, the expectation is that the agent will respond in plain text.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("responseType")]
|
||||
public string? ResponseType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the expected response JSON schema for this request, if applicable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is only applicable when <see cref="ResponseType"/> is "json".
|
||||
/// If omitted, no specific schema is expected.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("responseSchema")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public JsonElement? ResponseSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateRequest"/> from a <see cref="RunRequest"/>.
|
||||
/// </summary>
|
||||
/// <param name="request">The <see cref="RunRequest"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateRequest"/> representing the original request.</returns>
|
||||
public static DurableAgentStateRequest FromRunRequest(RunRequest request)
|
||||
{
|
||||
return new DurableAgentStateRequest()
|
||||
{
|
||||
CorrelationId = request.CorrelationId,
|
||||
Messages = request.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(),
|
||||
CreatedAt = request.Messages.Min(m => m.CreatedAt) ?? DateTimeOffset.UtcNow,
|
||||
ResponseType = request.ResponseFormat is ChatResponseFormatJson ? "json" : "text",
|
||||
ResponseSchema = (request.ResponseFormat as ChatResponseFormatJson)?.Schema
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a durable agent state entry that is a response from the agent.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateResponse : DurableAgentStateEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the usage details for this state response.
|
||||
/// </summary>
|
||||
[JsonPropertyName("usage")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DurableAgentStateUsage? Usage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateResponse"/> from an <see cref="AgentRunResponse"/>.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID linking this response to its request.</param>
|
||||
/// <param name="response">The <see cref="AgentRunResponse"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateResponse"/> representing the original response.</returns>
|
||||
public static DurableAgentStateResponse FromRunResponse(string correlationId, AgentRunResponse response)
|
||||
{
|
||||
return new DurableAgentStateResponse()
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow,
|
||||
Messages = response.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(),
|
||||
Usage = DurableAgentStateUsage.FromUsage(response.Usage)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts this <see cref="DurableAgentStateResponse"/> back to an <see cref="AgentRunResponse"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="AgentRunResponse"/> representing this response.</returns>
|
||||
public AgentRunResponse ToRunResponse()
|
||||
{
|
||||
return new AgentRunResponse()
|
||||
{
|
||||
CreatedAt = this.CreatedAt,
|
||||
Messages = this.Messages.Select(m => m.ToChatMessage()).ToList(),
|
||||
Usage = this.Usage?.ToUsageDetails(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the text content for a durable agent state entry.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateTextContent : DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the text message content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("text")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public required string? Text { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateTextContent"/> from a <see cref="TextContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="content">The <see cref="TextContent"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateTextContent"/> representing the original content.</returns>
|
||||
public static DurableAgentStateTextContent FromTextContent(TextContent content)
|
||||
{
|
||||
return new DurableAgentStateTextContent()
|
||||
{
|
||||
Text = content.Text
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AIContent ToAIContent()
|
||||
{
|
||||
return new TextContent(this.Text);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the text reasoning content for a durable agent state entry.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateTextReasoningContent : DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the text reasoning content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("text")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Text { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateTextReasoningContent"/> from a <see cref="TextReasoningContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="content">The <see cref="TextReasoningContent"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateTextReasoningContent"/> representing the original content.</returns>
|
||||
public static DurableAgentStateTextReasoningContent FromTextReasoningContent(TextReasoningContent content)
|
||||
{
|
||||
return new DurableAgentStateTextReasoningContent()
|
||||
{
|
||||
Text = content.Text
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AIContent ToAIContent()
|
||||
{
|
||||
return new TextReasoningContent(this.Text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the unknown content for a durable agent state entry.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the serialized unknown content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content")]
|
||||
public required JsonElement Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateUnknownContent"/> from an <see cref="AIContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="content">The <see cref="AIContent"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateUnknownContent"/> representing the original content.</returns>
|
||||
public static DurableAgentStateUnknownContent FromUnknownContent(AIContent content)
|
||||
{
|
||||
return new DurableAgentStateUnknownContent()
|
||||
{
|
||||
Content = JsonSerializer.SerializeToElement(
|
||||
value: content,
|
||||
jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent)))
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AIContent ToAIContent()
|
||||
{
|
||||
AIContent? content = this.Content.Deserialize(
|
||||
jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))) as AIContent;
|
||||
|
||||
return content ?? throw new InvalidOperationException($"The content '{this.Content}' is not valid AI content.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents URI content for a durable agent state message.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateUriContent : DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the URI of the content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("uri")]
|
||||
public required Uri Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the media type of the content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateUriContent"/> from a <see cref="UriContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="uriContent">The <see cref="UriContent"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateUriContent"/> representing the original content.</returns>
|
||||
public static DurableAgentStateUriContent FromUriContent(UriContent uriContent)
|
||||
{
|
||||
return new DurableAgentStateUriContent()
|
||||
{
|
||||
MediaType = uriContent.MediaType,
|
||||
Uri = uriContent.Uri
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AIContent ToAIContent()
|
||||
{
|
||||
return new UriContent(this.Uri, this.MediaType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the token usage details for a durable agent state response.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateUsage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the number of input tokens used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inputTokenCount")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? InputTokenCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of output tokens used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outputTokenCount")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? OutputTokenCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of tokens used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalTokenCount")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? TotalTokenCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any additional data found during deserialization that does not map to known properties.
|
||||
/// </summary>
|
||||
[JsonExtensionData]
|
||||
public IDictionary<string, JsonElement>? ExtensionData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateUsage"/> from a <see cref="UsageDetails"/>.
|
||||
/// </summary>
|
||||
/// <param name="usage">The <see cref="UsageDetails"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateUsage"/> representing the original usage details.</returns>
|
||||
[return: NotNullIfNotNull(nameof(usage))]
|
||||
public static DurableAgentStateUsage? FromUsage(UsageDetails? usage) =>
|
||||
usage is not null
|
||||
? new()
|
||||
{
|
||||
InputTokenCount = usage.InputTokenCount,
|
||||
OutputTokenCount = usage.OutputTokenCount,
|
||||
TotalTokenCount = usage.TotalTokenCount
|
||||
}
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Converts this <see cref="DurableAgentStateUsage"/> back to a <see cref="UsageDetails"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="UsageDetails"/> representing this usage.</returns>
|
||||
public UsageDetails ToUsageDetails()
|
||||
{
|
||||
return new()
|
||||
{
|
||||
InputTokenCount = this.InputTokenCount,
|
||||
OutputTokenCount = this.OutputTokenCount,
|
||||
TotalTokenCount = this.TotalTokenCount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the content for a durable agent state message.
|
||||
/// </summary>
|
||||
internal sealed class DurableAgentStateUsageContent : DurableAgentStateContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the usage details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("usage")]
|
||||
public DurableAgentStateUsage Usage { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DurableAgentStateUsageContent"/> from a <see cref="UsageContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="content">The <see cref="UsageContent"/> to convert.</param>
|
||||
/// <returns>A <see cref="DurableAgentStateUsageContent"/> representing the original content.</returns>
|
||||
public static DurableAgentStateUsageContent FromUsageContent(UsageContent content)
|
||||
{
|
||||
return new DurableAgentStateUsageContent()
|
||||
{
|
||||
Usage = DurableAgentStateUsage.FromUsage(content.Details)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AIContent ToAIContent()
|
||||
{
|
||||
return new UsageContent(this.Usage.ToUsageDetails());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
# Durable Agent State
|
||||
|
||||
Durable agents are represented as durable entities, with each session (i.e. thread) of conversation history stored as JSON-serialized state for an individual entity instance.
|
||||
|
||||
## State Schema
|
||||
|
||||
The [schema](../../../../schemas/durable-agent-entity-state.json) for durable agent state is a distillation of the prompt and response messages accumulated over the lifetime of a session. While these messages and content originate from Microsoft Agent Framework types (for .NET, see [ChatMessage](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs) and [AIContent](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs)), durable agent state uses its own, parallel, types in order to (1) better manage the versioning and compatibility of serialized state over time, (2) account for agent implementations across languages/platforms (e.g. .NET and Python), as well as (3) ensure consistency for external tools that make use of state data.
|
||||
|
||||
> When new AI content types are added to the Microsoft Agent Framework, equivalent types should be added to the entity state schema as well. The durable agent state "unknown" type can be used when an AI content type is encountered but no equivalent type exists.
|
||||
|
||||
## State Versioning
|
||||
|
||||
The serialized state contains a root `schemaVersion` property, which represents the version of the schema used to serialize data in that state (represented by the `data` property).
|
||||
|
||||
Some versioning considerations:
|
||||
|
||||
- Versions should use semver notation (e.g. `"<major>.<minor>.<patch>"`)
|
||||
- Durable agents should use the version property to determine how to deserialize that state and should not attempt to deserialize semver-incompatible versions
|
||||
- Newer versions of durable agents should strive to be compatible with older schema versions (e.g. new properties and objects should be optional)
|
||||
- Durable agents should preserve existing, but unrecognized, properties when serializing state
|
||||
|
||||
## Sample State
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"data": {
|
||||
"conversationHistory": [
|
||||
{
|
||||
"$type": "request",
|
||||
"responseType": "text",
|
||||
"correlationId": "c338f064f4b44b8d9c21a66e3cda41b2",
|
||||
"createdAt": "2025-11-04T19:33:05.245476+00:00",
|
||||
"messages": [
|
||||
{
|
||||
"contents": [
|
||||
{
|
||||
"$type": "text",
|
||||
"text": "Start the documentation generation workflow for the product \u0027Goldbrew Coffee\u0027"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$type": "response",
|
||||
"usage": {
|
||||
"inputTokenCount": 595,
|
||||
"outputTokenCount": 63,
|
||||
"totalTokenCount": 658
|
||||
},
|
||||
"correlationId": "c338f064f4b44b8d9c21a66e3cda41b2",
|
||||
"createdAt": "2025-11-04T19:33:10.47008+00:00",
|
||||
"messages": [
|
||||
{
|
||||
"authorName": "OrchestratorAgent",
|
||||
"createdAt": "2025-11-04T19:33:10+00:00",
|
||||
"contents": [
|
||||
{
|
||||
"$type": "functionCall",
|
||||
"arguments": {
|
||||
"productName": "Goldbrew Coffee"
|
||||
},
|
||||
"callId": "call_qWk9Ay4doKYrUBoADK8MBwHf",
|
||||
"name": "StartDocumentGeneration"
|
||||
}
|
||||
],
|
||||
"role": "assistant"
|
||||
},
|
||||
{
|
||||
"authorName": "OrchestratorAgent",
|
||||
"createdAt": "2025-11-04T19:33:10.47008+00:00",
|
||||
"contents": [
|
||||
{
|
||||
"$type": "functionResult",
|
||||
"callId": "call_qWk9Ay4doKYrUBoADK8MBwHf",
|
||||
"result": "8b835e8f2a6f40faabdba33bd8fd8c74"
|
||||
}
|
||||
],
|
||||
"role": "tool"
|
||||
},
|
||||
{
|
||||
"authorName": "OrchestratorAgent",
|
||||
"createdAt": "2025-11-04T19:33:10+00:00",
|
||||
"contents": [
|
||||
{
|
||||
"$type": "text",
|
||||
"text": "The documentation generation workflow for the product \u0022Goldbrew Coffee\u0022 has been started. You can request updates on its status or provide additional input anytime during the process. Let me know how you\u2019d like to proceed!"
|
||||
}
|
||||
],
|
||||
"role": "assistant"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$type": "request",
|
||||
"responseType": "text",
|
||||
"correlationId": "71f35b7add6b403fadd0db8a7c137b58",
|
||||
"createdAt": "2025-11-04T19:33:11.903413+00:00",
|
||||
"messages": [
|
||||
{
|
||||
"contents": [
|
||||
{
|
||||
"$type": "text",
|
||||
"text": "Tell the user that you\u0027re starting to gather information for product \u0027Goldbrew Coffee\u0027."
|
||||
}
|
||||
],
|
||||
"role": "system"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$type": "response",
|
||||
"usage": {
|
||||
"inputTokenCount": 396,
|
||||
"outputTokenCount": 48,
|
||||
"totalTokenCount": 444
|
||||
},
|
||||
"correlationId": "71f35b7add6b403fadd0db8a7c137b58",
|
||||
"createdAt": "2025-11-04T19:33:12+00:00",
|
||||
"messages": [
|
||||
{
|
||||
"authorName": "OrchestratorAgent",
|
||||
"createdAt": "2025-11-04T19:33:12+00:00",
|
||||
"contents": [
|
||||
{
|
||||
"$type": "text",
|
||||
"text": "I am starting to gather information to create product documentation for \u0027Goldbrew Coffee\u0027. If you have any specific details, key features, or requirements you\u0027d like included, please share them. Otherwise, I\u0027ll continue with the standard documentation process."
|
||||
}
|
||||
],
|
||||
"role": "assistant"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State Consumers
|
||||
|
||||
Additional tools may make use of durable agent state. Significant changes to the state schema may need corresponding changes to those applications.
|
||||
|
||||
### Durable Task Scheduler Dashboard
|
||||
|
||||
The [Durable Task Scheduler (DTS)](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) Dashboard, while providing general UX for management of durable orchestrations and entities, also has UX specific to the use of durable agents.
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Context.Features;
|
||||
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Azure.Functions.Worker.Invocation;
|
||||
using Microsoft.DurableTask.Client;
|
||||
@@ -36,6 +37,7 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
HttpRequestData? httpRequestData = null;
|
||||
TaskEntityDispatcher? dispatcher = null;
|
||||
DurableTaskClient? durableTaskClient = null;
|
||||
ToolInvocationContext? mcpToolInvocationContext = null;
|
||||
|
||||
foreach (var binding in values)
|
||||
{
|
||||
@@ -50,21 +52,25 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
case DurableTaskClient client:
|
||||
durableTaskClient = client;
|
||||
break;
|
||||
case ToolInvocationContext toolContext:
|
||||
mcpToolInvocationContext = toolContext;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool isAgentHttpInvocation = string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal);
|
||||
if (durableTaskClient is null)
|
||||
{
|
||||
// This is not expected to happen since all built-in functions are
|
||||
// expected to have a Durable Task client binding.
|
||||
throw new InvalidOperationException($"Durable Task client binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
if (isAgentHttpInvocation)
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentHttpFunctionEntryPoint)
|
||||
{
|
||||
if (httpRequestData == null)
|
||||
{
|
||||
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
if (durableTaskClient == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Durable Task client binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.RunAgentHttpAsync(
|
||||
httpRequestData,
|
||||
@@ -73,19 +79,32 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
return;
|
||||
}
|
||||
|
||||
// If not HTTP invocation, It will be entity invocation path.
|
||||
if (dispatcher == null)
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentEntityFunctionEntryPoint)
|
||||
{
|
||||
throw new InvalidOperationException($"Task entity dispatcher binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
if (durableTaskClient == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Durable Task client binding is missing for the invocation {context.InvocationId}.");
|
||||
if (dispatcher is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Task entity dispatcher binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
await BuiltInFunctions.InvokeAgentAsync(
|
||||
dispatcher,
|
||||
durableTaskClient,
|
||||
context);
|
||||
return;
|
||||
}
|
||||
|
||||
await BuiltInFunctions.InvokeAgentAsync(
|
||||
dispatcher,
|
||||
durableTaskClient,
|
||||
context);
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint)
|
||||
{
|
||||
if (mcpToolInvocationContext is null)
|
||||
{
|
||||
throw new InvalidOperationException($"MCP tool invocation context binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value =
|
||||
await BuiltInFunctions.RunMcpToolAsync(mcpToolInvocationContext, durableTaskClient, context);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System.Net;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -12,8 +13,12 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
internal static class BuiltInFunctions
|
||||
{
|
||||
internal const string HttpPrefix = "http-";
|
||||
internal const string McpToolPrefix = "mcptool-";
|
||||
|
||||
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
|
||||
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
|
||||
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
|
||||
|
||||
// Exposed as an entity trigger via AgentFunctionsProvider
|
||||
public static async Task InvokeAgentAsync(
|
||||
@@ -91,19 +96,52 @@ internal static class BuiltInFunctions
|
||||
return httpResponse;
|
||||
}
|
||||
|
||||
private static string GetAgentName(FunctionContext context)
|
||||
public static async Task<string?> RunMcpToolAsync(
|
||||
[McpToolTrigger("BuiltInMcpTool")] ToolInvocationContext context,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
FunctionContext functionContext)
|
||||
{
|
||||
// Remove the trailing _http from the function name
|
||||
string functionName = context.FunctionDefinition.Name;
|
||||
if (!functionName.EndsWith("_http", StringComparison.Ordinal))
|
||||
if (context.Arguments is null)
|
||||
{
|
||||
// This should never happen because the function metadata provider ensures
|
||||
// that the function name ends with '_http'.
|
||||
throw new InvalidOperationException(
|
||||
$"Built-in HTTP trigger function name '{functionName}' does not end with '_http'.");
|
||||
throw new ArgumentException("MCP Tool invocation is missing required arguments.");
|
||||
}
|
||||
|
||||
return functionName[..^5];
|
||||
if (!context.Arguments.TryGetValue("query", out object? queryObj) || queryObj is not string query)
|
||||
{
|
||||
throw new ArgumentException("MCP Tool invocation is missing required 'query' argument of type string.");
|
||||
}
|
||||
|
||||
string agentName = context.Name;
|
||||
|
||||
// Derive session id: try to parse provided threadId, otherwise create a new one.
|
||||
AgentSessionId sessionId = context.Arguments.TryGetValue("threadId", out object? threadObj) && threadObj is string threadId && !string.IsNullOrWhiteSpace(threadId)
|
||||
? AgentSessionId.Parse(threadId)
|
||||
: new AgentSessionId(agentName, functionContext.InvocationId);
|
||||
|
||||
AIAgent agentProxy = client.AsDurableAgentProxy(functionContext, agentName);
|
||||
|
||||
AgentRunResponse agentResponse = await agentProxy.RunAsync(
|
||||
message: new ChatMessage(ChatRole.User, query),
|
||||
thread: new DurableAgentThread(sessionId),
|
||||
options: null);
|
||||
|
||||
return agentResponse.Text;
|
||||
}
|
||||
|
||||
private static string GetAgentName(FunctionContext context)
|
||||
{
|
||||
// Check if the function name starts with the HttpPrefix
|
||||
string functionName = context.FunctionDefinition.Name;
|
||||
if (!functionName.StartsWith(HttpPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
// This should never happen because the function metadata provider ensures
|
||||
// that the function name starts with the HttpPrefix (http-).
|
||||
throw new InvalidOperationException(
|
||||
$"Built-in HTTP trigger function name '{functionName}' does not start with '{HttpPrefix}'.");
|
||||
}
|
||||
|
||||
// Remove the HttpPrefix from the function name to get the agent name.
|
||||
return functionName[HttpPrefix.Length..];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to agent-specific options for functions agents by name.
|
||||
/// Returns default options (HTTP trigger enabled, MCP tool disabled) when no explicit options were configured.
|
||||
/// </summary>
|
||||
internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary<string, FunctionsAgentOptions> functionsAgentOptions)
|
||||
: IFunctionsAgentOptionsProvider
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, FunctionsAgentOptions> _functionsAgentOptions =
|
||||
functionsAgentOptions ?? throw new ArgumentNullException(nameof(functionsAgentOptions));
|
||||
|
||||
// Default options. HTTP trigger enabled, MCP tool disabled.
|
||||
private static readonly FunctionsAgentOptions s_defaultOptions = new()
|
||||
{
|
||||
HttpTrigger = { IsEnabled = true },
|
||||
McpToolTrigger = { IsEnabled = false }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve the options associated with the specified agent name.
|
||||
/// If not found, a default options instance (with HTTP trigger enabled) is returned.
|
||||
/// </summary>
|
||||
/// <param name="agentName">The name of the agent whose options are to be retrieved. Cannot be null or empty.</param>
|
||||
/// <param name="options">The options for the specified agent. Will never be null.</param>
|
||||
/// <returns>Always true. Returns configured options if present; otherwise default fallback options.</returns>
|
||||
public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(agentName);
|
||||
|
||||
if (this._functionsAgentOptions.TryGetValue(agentName, out FunctionsAgentOptions? existing))
|
||||
{
|
||||
options = existing;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not defined, return default options.
|
||||
options = s_defaultOptions;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+50
-14
@@ -14,6 +14,8 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
|
||||
{
|
||||
private readonly ILogger<DurableAgentFunctionMetadataTransformer> _logger;
|
||||
private readonly IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> _agents;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IFunctionsAgentOptionsProvider _functionsAgentOptionsProvider;
|
||||
|
||||
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
|
||||
private static readonly string s_builtInFunctionsScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
|
||||
@@ -21,29 +23,45 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
|
||||
|
||||
public DurableAgentFunctionMetadataTransformer(
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents,
|
||||
ILogger<DurableAgentFunctionMetadataTransformer> logger)
|
||||
ILogger<DurableAgentFunctionMetadataTransformer> logger,
|
||||
IServiceProvider serviceProvider,
|
||||
IFunctionsAgentOptionsProvider functionsAgentOptionsProvider)
|
||||
{
|
||||
this._agents = agents ?? throw new ArgumentNullException(nameof(agents));
|
||||
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this._serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
this._functionsAgentOptionsProvider = functionsAgentOptionsProvider ?? throw new ArgumentNullException(nameof(functionsAgentOptionsProvider));
|
||||
}
|
||||
|
||||
public string Name => nameof(DurableAgentFunctionMetadataTransformer);
|
||||
|
||||
public void Transform(IList<IFunctionMetadata> original)
|
||||
{
|
||||
this._logger.LogInformation("Transforming function metadata to add durable agent functions. Initial function count: {FunctionCount}", original.Count);
|
||||
this._logger.LogTransformingFunctionMetadata(original.Count);
|
||||
|
||||
foreach (string agentName in this._agents.Keys)
|
||||
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> kvp in this._agents)
|
||||
{
|
||||
this._logger.LogInformation("Registering functions for agent: {AgentName}", agentName);
|
||||
string agentName = kvp.Key;
|
||||
|
||||
this._logger.LogRegisteringTriggerForAgent(agentName, "entity");
|
||||
|
||||
// Each agent type gets its own entity trigger function.
|
||||
// We do this 1:1 mapping for improved telemetry.
|
||||
original.Add(CreateAgentTrigger(agentName));
|
||||
|
||||
// Each agent type gets its own HTTP trigger function.
|
||||
// TODO: Put this behind a configuration option.
|
||||
original.Add(CreateHttpTrigger(agentName, $"agents/{agentName}/run", nameof(BuiltInFunctions.RunAgentHttpAsync)));
|
||||
if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions))
|
||||
{
|
||||
if (agentTriggerOptions.HttpTrigger.IsEnabled)
|
||||
{
|
||||
this._logger.LogRegisteringTriggerForAgent(agentName, "http");
|
||||
original.Add(CreateHttpTrigger(agentName, $"agents/{agentName}/run"));
|
||||
}
|
||||
|
||||
if (agentTriggerOptions.McpToolTrigger.IsEnabled)
|
||||
{
|
||||
AIAgent agent = kvp.Value(this._serviceProvider);
|
||||
this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool");
|
||||
original.Add(CreateMcpToolTrigger(agentName, agent.Description));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,20 +81,38 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
|
||||
};
|
||||
}
|
||||
|
||||
private static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string dotnetMethodName)
|
||||
private static DefaultFunctionMetadata CreateHttpTrigger(string name, string route)
|
||||
{
|
||||
return new DefaultFunctionMetadata()
|
||||
{
|
||||
Name = $"{name}_http",
|
||||
Name = $"{BuiltInFunctions.HttpPrefix}{name}",
|
||||
Language = "dotnet-isolated",
|
||||
RawBindings =
|
||||
[
|
||||
$$"""{"name":"req","type":"httpTrigger","direction":"In","authLevel":"function","methods": ["post"],"route":"{{route}}"}""",
|
||||
"""{"name":"$return","type":"http","direction":"Out"}""",
|
||||
"""{"name":"client","type":"durableClient","direction":"In"}"""
|
||||
$"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [\"post\"],\"route\":\"{route}\"}}",
|
||||
"{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}",
|
||||
"{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}"
|
||||
],
|
||||
EntryPoint = BuiltInFunctions.RunAgentHttpFunctionEntryPoint,
|
||||
ScriptFile = s_builtInFunctionsScriptFile,
|
||||
};
|
||||
}
|
||||
|
||||
private static DefaultFunctionMetadata CreateMcpToolTrigger(string agentName, string? description)
|
||||
{
|
||||
return new DefaultFunctionMetadata
|
||||
{
|
||||
Name = $"{BuiltInFunctions.McpToolPrefix}{agentName}",
|
||||
Language = "dotnet-isolated",
|
||||
RawBindings =
|
||||
[
|
||||
$$"""{"name":"context","type":"mcpToolTrigger","direction":"In","toolName":"{{agentName}}","description":"{{description}}","toolProperties":"[{\"propertyName\":\"query\",\"propertyType\":\"string\",\"description\":\"The query to send to the agent.\",\"isRequired\":true,\"isArray\":false},{\"propertyName\":\"threadId\",\"propertyType\":\"string\",\"description\":\"Optional thread identifier.\",\"isRequired\":false,\"isArray\":false}]"}""",
|
||||
"""{"name":"query","type":"mcpToolProperty","direction":"In","propertyName":"query","description":"The query to send to the agent","isRequired":true,"dataType":"String","propertyType":"string"}""",
|
||||
"""{"name":"threadId","type":"mcpToolProperty","direction":"In","propertyName":"threadId","description":"The thread identifier.","isRequired":false,"dataType":"String","propertyType":"string"}""",
|
||||
"""{"name":"client","type":"durableClient","direction":"In"}"""
|
||||
],
|
||||
EntryPoint = BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint,
|
||||
ScriptFile = s_builtInFunctionsScriptFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for registering and configuring AI agents in the context of the Azure Functions hosting environment.
|
||||
/// </summary>
|
||||
public static class DurableAgentsOptionsExtensions
|
||||
{
|
||||
// Registry of agent options.
|
||||
private static readonly Dictionary<string, FunctionsAgentOptions> s_agentOptions = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the specified DurableAgentsOptions instance and optionally configures agent-specific
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <param name="options">The DurableAgentsOptions instance to which the AI agent will be added.</param>
|
||||
/// <param name="agent">The AI agent to add. The agent's Name property must not be null or empty.</param>
|
||||
/// <param name="configure">An optional delegate to configure agent-specific options. If null, default options are used.</param>
|
||||
/// <returns>The updated <see cref="DurableAgentsOptions"/> instance containing the added AI agent.</returns>
|
||||
public static DurableAgentsOptions AddAIAgent(
|
||||
this DurableAgentsOptions options,
|
||||
AIAgent agent,
|
||||
Action<FunctionsAgentOptions>? configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrEmpty(agent.Name);
|
||||
|
||||
// Initialize with default behavior (HTTP trigger enabled)
|
||||
FunctionsAgentOptions agentOptions = new() { HttpTrigger = { IsEnabled = true } };
|
||||
configure?.Invoke(agentOptions);
|
||||
options.AddAIAgent(agent);
|
||||
s_agentOptions[agent.Name] = agentOptions;
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the specified options and configures trigger support for HTTP and MCP tool invocations.
|
||||
/// </summary>
|
||||
/// <remarks>If an agent with the same name already exists in the options, its configuration will be
|
||||
/// updated. Both triggers can be enabled independently. This method supports method chaining by returning the
|
||||
/// provided options instance.</remarks>
|
||||
/// <param name="options">The options collection to which the AI agent will be added. Cannot be null.</param>
|
||||
/// <param name="agent">The AI agent to add. The agent's Name property must not be null or empty.</param>
|
||||
/// <param name="enableHttpTrigger">true to enable an HTTP trigger for the agent; otherwise, false.</param>
|
||||
/// <param name="enableMcpToolTrigger">true to enable an MCP tool trigger for the agent; otherwise, false.</param>
|
||||
/// <returns>The updated <see cref="DurableAgentsOptions"/> instance with the specified AI agent and trigger configuration applied.</returns>
|
||||
public static DurableAgentsOptions AddAIAgent(
|
||||
this DurableAgentsOptions options,
|
||||
AIAgent agent,
|
||||
bool enableHttpTrigger,
|
||||
bool enableMcpToolTrigger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrEmpty(agent.Name);
|
||||
|
||||
FunctionsAgentOptions agentOptions = new();
|
||||
agentOptions.HttpTrigger.IsEnabled = enableHttpTrigger;
|
||||
agentOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger;
|
||||
|
||||
options.AddAIAgent(agent);
|
||||
s_agentOptions[agent.Name] = agentOptions;
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an AI agent factory with the specified name and optional configuration in the provided
|
||||
/// DurableAgentsOptions instance.
|
||||
/// </summary>
|
||||
/// <remarks>If an agent factory with the same name already exists, its configuration will be replaced.
|
||||
/// This method enables custom agent registration and configuration for use in durable agent scenarios.</remarks>
|
||||
/// <param name="options">The DurableAgentsOptions instance to which the AI agent factory will be added. Cannot be null.</param>
|
||||
/// <param name="name">The unique name used to identify the AI agent factory. Cannot be null.</param>
|
||||
/// <param name="factory">A delegate that creates an AIAgent instance using the provided IServiceProvider. Cannot be null.</param>
|
||||
/// <param name="configure">An optional action to configure FunctionsAgentOptions for the agent factory. If null, default options are used.</param>
|
||||
/// <returns>The updated DurableAgentsOptions instance containing the registered AI agent factory.</returns>
|
||||
public static DurableAgentsOptions AddAIAgentFactory(
|
||||
this DurableAgentsOptions options,
|
||||
string name,
|
||||
Func<IServiceProvider, AIAgent> factory,
|
||||
Action<FunctionsAgentOptions>? configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
|
||||
// Initialize with default behavior (HTTP trigger enabled)
|
||||
FunctionsAgentOptions agentOptions = new() { HttpTrigger = { IsEnabled = true } };
|
||||
configure?.Invoke(agentOptions);
|
||||
options.AddAIAgentFactory(name, factory);
|
||||
s_agentOptions[name] = agentOptions;
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an AI agent factory with the specified name and configures trigger options for the agent.
|
||||
/// </summary>
|
||||
/// <remarks>If both triggers are disabled, the agent will not be accessible via HTTP or MCP tool
|
||||
/// endpoints. This method can be used to register multiple agent factories with different configurations.</remarks>
|
||||
/// <param name="options">The options object to which the AI agent factory will be added. Cannot be null.</param>
|
||||
/// <param name="name">The unique name used to identify the AI agent factory. Cannot be null.</param>
|
||||
/// <param name="factory">A delegate that creates an instance of the AI agent using the provided service provider. Cannot be null.</param>
|
||||
/// <param name="enableHttpTrigger">true to enable the HTTP trigger for the agent; otherwise, false.</param>
|
||||
/// <param name="enableMcpToolTrigger">true to enable the MCP tool trigger for the agent; otherwise, false.</param>
|
||||
/// <returns>The same DurableAgentsOptions instance, allowing for method chaining.</returns>
|
||||
public static DurableAgentsOptions AddAIAgentFactory(
|
||||
this DurableAgentsOptions options,
|
||||
string name,
|
||||
Func<IServiceProvider, AIAgent> factory,
|
||||
bool enableHttpTrigger,
|
||||
bool enableMcpToolTrigger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
|
||||
FunctionsAgentOptions agentOptions = new();
|
||||
agentOptions.HttpTrigger.IsEnabled = enableHttpTrigger;
|
||||
agentOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger;
|
||||
|
||||
options.AddAIAgentFactory(name, factory);
|
||||
s_agentOptions[name] = agentOptions;
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the agentOptions used for dependency injection (read-only copy).
|
||||
/// </summary>
|
||||
internal static IReadOnlyDictionary<string, FunctionsAgentOptions> GetAgentOptionsSnapshot()
|
||||
{
|
||||
return new Dictionary<string, FunctionsAgentOptions>(s_agentOptions, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides configuration options for enabling and customizing function triggers for an agent.
|
||||
/// </summary>
|
||||
public sealed class FunctionsAgentOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the configuration options for the HTTP trigger endpoint.
|
||||
/// </summary>
|
||||
public HttpTriggerOptions HttpTrigger { get; set; } = new(false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the options used to configure the MCP tool trigger behavior.
|
||||
/// </summary>
|
||||
public McpToolTriggerOptions McpToolTrigger { get; set; } = new(false);
|
||||
}
|
||||
+6
-2
@@ -29,11 +29,15 @@ public static class FunctionsApplicationBuilderExtensions
|
||||
// The main agent services registration is done in Microsoft.DurableTask.Agents.
|
||||
builder.Services.ConfigureDurableAgents(configure);
|
||||
|
||||
builder.Services.TryAddSingleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>();
|
||||
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
|
||||
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
|
||||
|
||||
// Handling of built-in function execution for Agent HTTP or Entity invocations.
|
||||
builder.Services.AddSingleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>();
|
||||
|
||||
// Handling of built-in function execution for Agent HTTP, MCP tool, or Entity invocations.
|
||||
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal));
|
||||
builder.Services.AddSingleton<BuiltInFunctionExecutor>();
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Represents configuration options for the HTTP trigger for an agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="HttpTriggerOptions"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="isEnabled">Indicates whether the HTTP trigger is enabled for the agent.</param>
|
||||
public sealed class HttpTriggerOptions(bool isEnabled)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the HTTP trigger is enabled for the agent.
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = isEnabled;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to function trigger options for agents in the Azure Functions hosting environment.
|
||||
/// </summary>
|
||||
internal interface IFunctionsAgentOptionsProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to get trigger options for the specified agent.
|
||||
/// </summary>
|
||||
/// <param name="agentName">The agent name.</param>
|
||||
/// <param name="options">The resulting options if found.</param>
|
||||
/// <returns>True if options exist; otherwise false.</returns>
|
||||
bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
internal static partial class Logs
|
||||
{
|
||||
[LoggerMessage(
|
||||
EventId = 100,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Transforming function metadata to add durable agent functions. Initial function count: {FunctionCount}")]
|
||||
public static partial void LogTransformingFunctionMetadata(this ILogger logger, int functionCount);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 101,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Registering {TriggerType} function for agent '{AgentName}'")]
|
||||
public static partial void LogRegisteringTriggerForAgent(this ILogger logger, string agentName, string triggerType);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// This class provides configuration options for the MCP tool trigger for an agent.
|
||||
/// </summary>
|
||||
/// <param name="isEnabled">
|
||||
/// A value indicating whether the MCP tool trigger is enabled for the agent.
|
||||
/// Set to <see langword="true"/> to enable the trigger; otherwise, <see langword="false"/>.
|
||||
/// </param>
|
||||
public sealed class McpToolTriggerOptions(bool isEnabled)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether MCP tool trigger is enabled for the agent.
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = isEnabled;
|
||||
}
|
||||
+18
@@ -29,6 +29,7 @@
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Internals -->
|
||||
@@ -40,4 +41,21 @@
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="/" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
This attribute tells the Functions build process to restore the specified WebJobs extension package,
|
||||
making the MCP extension available to the Functions host.
|
||||
-->
|
||||
<AssemblyAttribute Include="Microsoft.Azure.Functions.Worker.Extensions.Abstractions.ExtensionInformationAttribute">
|
||||
<_Parameter1>Microsoft.Azure.Functions.Extensions.Mcp</_Parameter1>
|
||||
<_Parameter2>1.0.0</_Parameter2>
|
||||
<!--
|
||||
Force Azure Functions host to load the MCP extension automatically, even when
|
||||
the consuming application doesn't explicitly reference McpToolTrigger attributes
|
||||
-->
|
||||
<_Parameter3>true</_Parameter3>
|
||||
<_Parameter3_IsLiteral>true</_Parameter3_IsLiteral>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateContentTests
|
||||
{
|
||||
private static readonly JsonTypeInfo s_stateContentTypeInfo =
|
||||
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateContent))!;
|
||||
|
||||
[Fact]
|
||||
public void ErrorContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
ErrorContent errorContent = new("message")
|
||||
{
|
||||
Details = "details",
|
||||
ErrorCode = "code"
|
||||
};
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(errorContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
ErrorContent convertedErrorContent = Assert.IsType<ErrorContent>(convertedContent);
|
||||
|
||||
Assert.Equal(errorContent.Message, convertedErrorContent.Message);
|
||||
Assert.Equal(errorContent.Details, convertedErrorContent.Details);
|
||||
Assert.Equal(errorContent.ErrorCode, convertedErrorContent.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextContent textContent = new("Hello, world!");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(textContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
TextContent convertedTextContent = Assert.IsType<TextContent>(convertedContent);
|
||||
|
||||
Assert.Equal(textContent.Text, convertedTextContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FunctionCallContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
FunctionCallContent functionCallContent = new(
|
||||
"call-123",
|
||||
"MyFunction",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
{ "param1", 42 },
|
||||
{ "param2", "value" }
|
||||
});
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(functionCallContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
FunctionCallContent convertedFunctionCallContent = Assert.IsType<FunctionCallContent>(convertedContent);
|
||||
|
||||
Assert.Equal(functionCallContent.CallId, convertedFunctionCallContent.CallId);
|
||||
Assert.Equal(functionCallContent.Name, convertedFunctionCallContent.Name);
|
||||
|
||||
Assert.NotNull(functionCallContent.Arguments);
|
||||
Assert.NotNull(convertedFunctionCallContent.Arguments);
|
||||
Assert.Equal(functionCallContent.Arguments.Keys.Order(), convertedFunctionCallContent.Arguments.Keys.Order());
|
||||
|
||||
// NOTE: Deserialized dictionaries will have JSON element values rather than the original native types,
|
||||
// so we only check the keys here.
|
||||
foreach (string key in functionCallContent.Arguments.Keys)
|
||||
{
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(functionCallContent.Arguments[key]),
|
||||
JsonSerializer.Serialize(convertedFunctionCallContent.Arguments[key]));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FunctionResultContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
FunctionResultContent functionResultContent = new("call-123", "return value");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(functionResultContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
FunctionResultContent convertedFunctionResultContent = Assert.IsType<FunctionResultContent>(convertedContent);
|
||||
|
||||
Assert.Equal(functionResultContent.CallId, convertedFunctionResultContent.CallId);
|
||||
// NOTE: We serialize both results to JSON for comparison since deserialized objects will be
|
||||
// JSON elements rather than the original native types.
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(functionResultContent.Result),
|
||||
JsonSerializer.Serialize(convertedFunctionResultContent.Result));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==", null)] // Valid data URI containing media type; pass null for separate mediaType parameter.
|
||||
[InlineData("data:;base64,SGVsbG8sIFdvcmxkIQ==", "text/plain")] // Valid data URI without media type; pass media
|
||||
public void DataContentSerializationDeserialization(string dataUri, string? mediaType)
|
||||
{
|
||||
// Arrange
|
||||
DataContent dataContent = new(dataUri, mediaType);
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(dataContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
DataContent convertedDataContent = Assert.IsType<DataContent>(convertedContent);
|
||||
|
||||
Assert.Equal(dataContent.Uri, convertedDataContent.Uri);
|
||||
Assert.Equal(dataContent.MediaType, convertedDataContent.MediaType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostedFileContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
HostedFileContent hostedFileContent = new("file-123");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(hostedFileContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
HostedFileContent convertedHostedFileContent = Assert.IsType<HostedFileContent>(convertedContent);
|
||||
|
||||
Assert.Equal(hostedFileContent.FileId, convertedHostedFileContent.FileId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostedVectorStoreContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
HostedVectorStoreContent hostedVectorStoreContent = new("vs-123");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(hostedVectorStoreContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
HostedVectorStoreContent convertedHostedVectorStoreContent = Assert.IsType<HostedVectorStoreContent>(convertedContent);
|
||||
|
||||
Assert.Equal(hostedVectorStoreContent.VectorStoreId, convertedHostedVectorStoreContent.VectorStoreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextReasoningContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextReasoningContent textReasoningContent = new("Reasoning chain...");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(textReasoningContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
TextReasoningContent convertedTextReasoningContent = Assert.IsType<TextReasoningContent>(convertedContent);
|
||||
|
||||
Assert.Equal(textReasoningContent.Text, convertedTextReasoningContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UriContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
UriContent uriContent = new(new Uri("https://example.com"), "text/html");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(uriContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
UriContent convertedUriContent = Assert.IsType<UriContent>(convertedContent);
|
||||
|
||||
Assert.Equal(uriContent.Uri, convertedUriContent.Uri);
|
||||
Assert.Equal(uriContent.MediaType, convertedUriContent.MediaType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsageContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
UsageDetails usageDetails = new()
|
||||
{
|
||||
InputTokenCount = 10,
|
||||
OutputTokenCount = 5,
|
||||
TotalTokenCount = 15
|
||||
};
|
||||
|
||||
UsageContent usageContent = new(usageDetails);
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(usageContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
UsageContent convertedUsageContent = Assert.IsType<UsageContent>(convertedContent);
|
||||
|
||||
Assert.NotNull(convertedUsageContent.Details);
|
||||
Assert.Equal(usageDetails.InputTokenCount, convertedUsageContent.Details.InputTokenCount);
|
||||
Assert.Equal(usageDetails.OutputTokenCount, convertedUsageContent.Details.OutputTokenCount);
|
||||
Assert.Equal(usageDetails.TotalTokenCount, convertedUsageContent.Details.TotalTokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextContent originalContent = new("Some unknown content");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateUnknownContent.FromUnknownContent(originalContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
TextContent convertedTextContent = Assert.IsType<TextContent>(convertedContent);
|
||||
|
||||
Assert.Equal(originalContent.Text, convertedTextContent.Text);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateMessageTests
|
||||
{
|
||||
[Fact]
|
||||
public void MessageSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextContent textContent = new("Hello, world!");
|
||||
ChatMessage message = new(ChatRole.User, [textContent])
|
||||
{
|
||||
AuthorName = "User123",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
DurableAgentStateMessage durableMessage = DurableAgentStateMessage.FromChatMessage(message);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(
|
||||
durableMessage,
|
||||
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateMessage))!);
|
||||
|
||||
DurableAgentStateMessage? convertedJsonContent = (DurableAgentStateMessage?)JsonSerializer.Deserialize(
|
||||
jsonContent,
|
||||
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateMessage))!);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
ChatMessage convertedMessage = convertedJsonContent.ToChatMessage();
|
||||
|
||||
Assert.Equal(message.AuthorName, convertedMessage.AuthorName);
|
||||
Assert.Equal(message.CreatedAt, convertedMessage.CreatedAt);
|
||||
Assert.Equal(message.Role, convertedMessage.Role);
|
||||
|
||||
AIContent convertedContent = Assert.Single(convertedMessage.Contents);
|
||||
TextContent convertedTextContent = Assert.IsType<TextContent>(convertedContent);
|
||||
|
||||
Assert.Equal(textContent.Text, convertedTextContent.Text);
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void InvalidVersion()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "hello"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BreakingVersion()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "2.0.0"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingData()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "1.0.0"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtraData()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"data": {
|
||||
"conversationHistory": [],
|
||||
"extraField": "someValue"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
DurableAgentState? state = JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state?.Data?.ExtensionData);
|
||||
|
||||
Assert.True(state.Data.ExtensionData!.ContainsKey("extraField"));
|
||||
Assert.Equal("someValue", state.Data.ExtensionData["extraField"]!.ToString());
|
||||
|
||||
// Act
|
||||
string jsonState = JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
JsonDocument? jsonDocument = JsonSerializer.Deserialize<JsonDocument>(jsonState);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(jsonDocument);
|
||||
Assert.True(jsonDocument.RootElement.TryGetProperty("data", out JsonElement dataElement));
|
||||
Assert.True(dataElement.TryGetProperty("extraField", out JsonElement extraFieldElement));
|
||||
Assert.Equal("someValue", extraFieldElement.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicState()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"data": {
|
||||
"conversationHistory": [
|
||||
{
|
||||
"$type": "request",
|
||||
"correlationId": "12345",
|
||||
"createdAt": "2024-01-01T12:00:00Z",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"contents": [
|
||||
{
|
||||
"$type": "text",
|
||||
"text": "Hello, agent!"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$type": "response",
|
||||
"correlationId": "12345",
|
||||
"createdAt": "2024-01-01T12:01:00Z",
|
||||
"messages": [
|
||||
{
|
||||
"role": "agent",
|
||||
"contents": [
|
||||
{
|
||||
"$type": "text",
|
||||
"text": "Hi user!"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
DurableAgentState? state = JsonSerializer.Deserialize(
|
||||
JsonText,
|
||||
DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal("1.0.0", state.SchemaVersion);
|
||||
Assert.NotNull(state.Data);
|
||||
|
||||
Assert.Collection(state.Data.ConversationHistory,
|
||||
entry =>
|
||||
{
|
||||
Assert.IsType<DurableAgentStateRequest>(entry);
|
||||
Assert.Equal("12345", entry.CorrelationId);
|
||||
Assert.Equal(DateTimeOffset.Parse("2024-01-01T12:00:00Z"), entry.CreatedAt);
|
||||
Assert.Single(entry.Messages);
|
||||
Assert.Equal("user", entry.Messages[0].Role);
|
||||
DurableAgentStateContent content = Assert.Single(entry.Messages[0].Contents);
|
||||
DurableAgentStateTextContent textContent = Assert.IsType<DurableAgentStateTextContent>(content);
|
||||
Assert.Equal("Hello, agent!", textContent.Text);
|
||||
},
|
||||
entry =>
|
||||
{
|
||||
Assert.IsType<DurableAgentStateResponse>(entry);
|
||||
Assert.Equal("12345", entry.CorrelationId);
|
||||
Assert.Equal(DateTimeOffset.Parse("2024-01-01T12:01:00Z"), entry.CreatedAt);
|
||||
Assert.Single(entry.Messages);
|
||||
Assert.Equal("agent", entry.Messages[0].Role);
|
||||
Assert.Single(entry.Messages[0].Contents);
|
||||
DurableAgentStateContent content = Assert.Single(entry.Messages[0].Contents);
|
||||
DurableAgentStateTextContent textContent = Assert.IsType<DurableAgentStateTextContent>(content);
|
||||
Assert.Equal("Hi user!", textContent.Text);
|
||||
});
|
||||
}
|
||||
}
|
||||
+116
-25
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
@@ -8,65 +9,142 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests;
|
||||
public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)] // Empty original metadata list
|
||||
[InlineData(3)] // Non-empty original metadata list
|
||||
public void Transform_AddsAgentAndHttpTriggers_ForEachAgent(int initialMetadataEntryCount)
|
||||
[InlineData(0, false, false, 1)] // entity only
|
||||
[InlineData(0, true, false, 2)] // entity + http
|
||||
[InlineData(0, false, true, 2)] // entity + mcp tool
|
||||
[InlineData(0, true, true, 3)] // entity + http + mcp tool
|
||||
[InlineData(3, true, true, 3)] // entity + http + mcp tool added to existing
|
||||
public void Transform_AddsAgentAndHttpTriggers_ForEachAgent(
|
||||
int initialMetadataEntryCount,
|
||||
bool enableHttp,
|
||||
bool enableMcp,
|
||||
int expectedMetadataCount)
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
|
||||
{
|
||||
{ "testAgent", _ => null! }
|
||||
{ "testAgent", _ => new TestAgent("testAgent", "Test agent description") }
|
||||
};
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(agents, GetTestLogger());
|
||||
|
||||
FunctionsAgentOptions options = new();
|
||||
|
||||
options.HttpTrigger.IsEnabled = enableHttp;
|
||||
options.McpToolTrigger.IsEnabled = enableMcp;
|
||||
|
||||
IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(new Dictionary<string, FunctionsAgentOptions>
|
||||
{
|
||||
{ "testAgent", options }
|
||||
});
|
||||
|
||||
List<IFunctionMetadata> metadataList = BuildFunctionMetadataList(initialMetadataEntryCount);
|
||||
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(
|
||||
agents,
|
||||
NullLogger<DurableAgentFunctionMetadataTransformer>.Instance,
|
||||
new FakeServiceProvider(),
|
||||
agentOptionsProvider);
|
||||
|
||||
// Act
|
||||
transformer.Transform(metadataList);
|
||||
|
||||
Assert.Equal(initialMetadataEntryCount + 2, metadataList.Count); // each agent adds 2 functions (http + entity).
|
||||
// Assert
|
||||
Assert.Equal(initialMetadataEntryCount + expectedMetadataCount, metadataList.Count);
|
||||
|
||||
DefaultFunctionMetadata agentTrigger = Assert.IsType<DefaultFunctionMetadata>(metadataList[initialMetadataEntryCount]);
|
||||
Assert.Equal("dafx-testAgent", agentTrigger.Name);
|
||||
Assert.Equal("dotnet-isolated", agentTrigger.Language);
|
||||
Assert.Contains("type\":\"entityTrigger", agentTrigger.RawBindings![0]);
|
||||
Assert.Contains("entityTrigger", agentTrigger.RawBindings![0]);
|
||||
|
||||
DefaultFunctionMetadata httpTrigger = Assert.IsType<DefaultFunctionMetadata>(metadataList[initialMetadataEntryCount + 1]);
|
||||
Assert.Equal("testAgent_http", httpTrigger.Name);
|
||||
Assert.Equal("dotnet-isolated", httpTrigger.Language);
|
||||
Assert.Contains("type\":\"httpTrigger", httpTrigger.RawBindings![0]);
|
||||
Assert.Contains("route\":\"agents/testAgent/run", httpTrigger.RawBindings[0]);
|
||||
if (enableHttp)
|
||||
{
|
||||
DefaultFunctionMetadata httpTrigger = Assert.IsType<DefaultFunctionMetadata>(metadataList[initialMetadataEntryCount + 1]);
|
||||
Assert.Equal("http-testAgent", httpTrigger.Name);
|
||||
Assert.Contains("httpTrigger", httpTrigger.RawBindings![0]);
|
||||
}
|
||||
|
||||
if (enableMcp)
|
||||
{
|
||||
int mcpIndex = initialMetadataEntryCount + (enableHttp ? 2 : 1);
|
||||
DefaultFunctionMetadata mcpToolTrigger = Assert.IsType<DefaultFunctionMetadata>(metadataList[mcpIndex]);
|
||||
Assert.Equal("mcptool-testAgent", mcpToolTrigger.Name);
|
||||
Assert.Contains("mcpToolTrigger", mcpToolTrigger.RawBindings![0]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform_AddsTriggers_ForMultipleAgents()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
|
||||
{
|
||||
{ "agentA", _ => null! },
|
||||
{ "agentB", _ => null! },
|
||||
{ "agentC", _ => null! }
|
||||
{ "agentA", _ => new TestAgent("testAgentA", "Test agent description") },
|
||||
{ "agentB", _ => new TestAgent("testAgentB", "Test agent description") },
|
||||
{ "agentC", _ => new TestAgent("testAgentC", "Test agent description") }
|
||||
};
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(agents, GetTestLogger());
|
||||
|
||||
// Helper to create options with configurable triggers
|
||||
static FunctionsAgentOptions CreateFunctionsAgentOptions(bool httpEnabled, bool mcpEnabled)
|
||||
{
|
||||
FunctionsAgentOptions options = new();
|
||||
options.HttpTrigger.IsEnabled = httpEnabled;
|
||||
options.McpToolTrigger.IsEnabled = mcpEnabled;
|
||||
return options;
|
||||
}
|
||||
|
||||
FunctionsAgentOptions agentOptionsA = CreateFunctionsAgentOptions(true, false);
|
||||
FunctionsAgentOptions agentOptionsB = CreateFunctionsAgentOptions(true, true);
|
||||
FunctionsAgentOptions agentOptionsC = CreateFunctionsAgentOptions(true, true);
|
||||
|
||||
Dictionary<string, FunctionsAgentOptions> functionsAgentOptions = new()
|
||||
{
|
||||
{ "agentA", agentOptionsA },
|
||||
{ "agentB", agentOptionsB },
|
||||
{ "agentC", agentOptionsC }
|
||||
};
|
||||
|
||||
IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(functionsAgentOptions);
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(
|
||||
agents,
|
||||
NullLogger<DurableAgentFunctionMetadataTransformer>.Instance,
|
||||
new FakeServiceProvider(),
|
||||
agentOptionsProvider);
|
||||
|
||||
const int InitialMetadataEntryCount = 2;
|
||||
List<IFunctionMetadata> metadataList = BuildFunctionMetadataList(InitialMetadataEntryCount);
|
||||
|
||||
// Act
|
||||
transformer.Transform(metadataList);
|
||||
|
||||
Assert.Equal(InitialMetadataEntryCount + (agents.Count * 2), metadataList.Count);
|
||||
// Assert
|
||||
Assert.Equal(InitialMetadataEntryCount + (agents.Count * 2) + 2, metadataList.Count);
|
||||
|
||||
foreach (string agentName in agents.Keys)
|
||||
{
|
||||
// The agent's entity trigger name is prefixed with "dafx-"
|
||||
DefaultFunctionMetadata entityMeta =
|
||||
Assert.IsType<DefaultFunctionMetadata>(
|
||||
Assert.Single(metadataList, m => m.Name == "dafx-" + agentName));
|
||||
Assert.Single(metadataList, m => m.Name == $"dafx-{agentName}"));
|
||||
Assert.NotNull(entityMeta.RawBindings);
|
||||
Assert.Contains("entityTrigger", entityMeta.RawBindings[0]);
|
||||
|
||||
DefaultFunctionMetadata httpMeta =
|
||||
Assert.IsType<DefaultFunctionMetadata>(
|
||||
Assert.Single(metadataList, m => m.Name == agentName + "_http"));
|
||||
Assert.Single(metadataList, m => m.Name == $"http-{agentName}"));
|
||||
Assert.NotNull(httpMeta.RawBindings);
|
||||
Assert.Contains("httpTrigger", httpMeta.RawBindings[0]);
|
||||
Assert.Contains($"agents/{agentName}/run", httpMeta.RawBindings[0]);
|
||||
|
||||
// We expect 2 mcp tool triggers only for agentB and agentC
|
||||
if (agentName == "agentB" || agentName == "agentC")
|
||||
{
|
||||
DefaultFunctionMetadata? mcpToolMeta =
|
||||
Assert.Single(metadataList, m => m.Name == $"mcptool-{agentName}") as DefaultFunctionMetadata;
|
||||
Assert.NotNull(mcpToolMeta);
|
||||
Assert.NotNull(mcpToolMeta.RawBindings);
|
||||
Assert.Equal(4, mcpToolMeta.RawBindings.Count);
|
||||
Assert.Contains("mcpToolTrigger", mcpToolMeta.RawBindings[0]);
|
||||
Assert.Contains("mcpToolProperty", mcpToolMeta.RawBindings[1]); // We expect 2 tool property bindings
|
||||
Assert.Contains("mcpToolProperty", mcpToolMeta.RawBindings[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +158,7 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
Language = "dotnet-isolated",
|
||||
Name = $"SingleAgentOrchestration{i + 1}",
|
||||
EntryPoint = "MyApp.Functions.SingleAgentOrchestration",
|
||||
RawBindings =
|
||||
[
|
||||
"{\r\n \"name\": \"context\",\r\n \"direction\": \"In\",\r\n \"type\": \"orchestrationTrigger\",\r\n \"properties\": {}\r\n }"
|
||||
],
|
||||
RawBindings = ["{\r\n \"name\": \"context\",\r\n \"direction\": \"In\",\r\n \"type\": \"orchestrationTrigger\",\r\n \"properties\": {}\r\n }"],
|
||||
ScriptFile = "MyApp.dll"
|
||||
});
|
||||
}
|
||||
@@ -91,5 +166,21 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
return list;
|
||||
}
|
||||
|
||||
private static NullLogger<DurableAgentFunctionMetadataTransformer> GetTestLogger() => new();
|
||||
private sealed class FakeServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
private sealed class FakeOptionsProvider : IFunctionsAgentOptionsProvider
|
||||
{
|
||||
private readonly Dictionary<string, FunctionsAgentOptions> _map;
|
||||
|
||||
public FakeOptionsProvider(Dictionary<string, FunctionsAgentOptions> map)
|
||||
{
|
||||
this._map = map ?? throw new ArgumentNullException(nameof(map));
|
||||
}
|
||||
|
||||
public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options)
|
||||
=> this._map.TryGetValue(agentName, out options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests;
|
||||
|
||||
internal sealed class TestAgent(string name, string description) : AIAgent
|
||||
{
|
||||
public override string? Name => name;
|
||||
|
||||
public override string? Description => description;
|
||||
|
||||
public override AgentThread GetNewThread() => new DummyAgentThread();
|
||||
|
||||
public override AgentThread DeserializeThread(
|
||||
JsonElement serializedThread,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null) => new DummyAgentThread();
|
||||
|
||||
public override Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(new AgentRunResponse([.. messages]));
|
||||
|
||||
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
private sealed class DummyAgentThread : AgentThread;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/microsoft/agent-framework/schemas/durable-agent-entity-state.json",
|
||||
"$defs": {
|
||||
"usage": {
|
||||
"type": "object",
|
||||
"description": "Token usage statistics.",
|
||||
"properties": {
|
||||
"inputTokenCount": { "type": "integer" },
|
||||
"outputTokenCount": { "type": "integer" },
|
||||
"totalTokenCount": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"dataContent": {
|
||||
"type": "object",
|
||||
"description": "The content of a message exchanged with the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "data" },
|
||||
"uri": { "type": "string", "description": "The URI that comprises the data." },
|
||||
"mediaType": { "type": "string", "description": "The media type of the data." }
|
||||
},
|
||||
"required": ["$type", "uri"]
|
||||
},
|
||||
"errorContent": {
|
||||
"type": "object",
|
||||
"description": "The error content of a message exchanged with the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "error" },
|
||||
"message": { "type": "string", "description": "The error message." },
|
||||
"errorCode": { "type": "string", "description": "The error code." },
|
||||
"details": { "description": "Additional details about the error." }
|
||||
},
|
||||
"required": ["$type"]
|
||||
},
|
||||
"hostedFileContent": {
|
||||
"type": "object",
|
||||
"description": "The hosted file content of a message exchanged with the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "hostedFile" },
|
||||
"fileId": { "type": "string", "description": "The identifier of the hosted file." }
|
||||
},
|
||||
"required": ["$type", "fileId"]
|
||||
},
|
||||
"hostedVectorStoreContent": {
|
||||
"type": "object",
|
||||
"description": "The hosted vector store content of a message exchanged with the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "hostedVectorStore" },
|
||||
"vectorStoreId": { "type": "string", "description": "The identifier of the hosted vector store." }
|
||||
},
|
||||
"required": ["$type", "vectorStoreId"]
|
||||
},
|
||||
"textReasoningContent": {
|
||||
"type": "object",
|
||||
"description": "The reasoning content of a message exchanged with the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "reasoning" },
|
||||
"text": { "type": "string", "description": "The reasoning text." }
|
||||
},
|
||||
"required": ["$type"]
|
||||
},
|
||||
"uriContent": {
|
||||
"type": "object",
|
||||
"description": "The URI content of a message exchanged with the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "uri" },
|
||||
"uri": { "type": "string", "description": "The URI." },
|
||||
"mediaType": { "type": "string", "description": "The media type of the URI." }
|
||||
},
|
||||
"required": ["$type", "uri", "mediaType"]
|
||||
},
|
||||
"usageContent": {
|
||||
"type": "object",
|
||||
"description": "The usage content of a message exchanged with the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "usage" },
|
||||
"usage": { "$ref": "#/$defs/usage" }
|
||||
},
|
||||
"required": ["$type", "usage"]
|
||||
},
|
||||
"textContent": {
|
||||
"type": "object",
|
||||
"description": "The text content of a message exchanged with the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "text" },
|
||||
"text": { "type": "string", "description": "The text content of the message." }
|
||||
},
|
||||
"required": ["$type", "text"]
|
||||
},
|
||||
"functionCallContent": {
|
||||
"type": "object",
|
||||
"description": "The function call content of a message exchanged with the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "functionCall" },
|
||||
"callId": { "type": "string", "description": "The identifier of the function being called." },
|
||||
"name": { "type": "string", "description": "The name of the function being called." },
|
||||
"arguments": { "type": "object", "description": "The arguments provided to the function call." }
|
||||
},
|
||||
"required": ["$type", "callId", "name"]
|
||||
},
|
||||
"functionResultContent": {
|
||||
"type": "object",
|
||||
"description": "The function result content of a message exchanged with the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "functionResult" },
|
||||
"callId": { "type": "string", "description": "The identifier of the function being called." },
|
||||
"result": { "description": "The result returned by the function call." }
|
||||
},
|
||||
"required": ["$type", "callId"]
|
||||
},
|
||||
"unknownContent": {
|
||||
"type": "object",
|
||||
"description": "The unknown content of a message exchanged with the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "unknown" },
|
||||
"content": { "description": "The unknown message content serialized as JSON." }
|
||||
},
|
||||
"required": ["$type", "content"]
|
||||
},
|
||||
"chatContentItem": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/dataContent" },
|
||||
{ "$ref": "#/$defs/errorContent" },
|
||||
{ "$ref": "#/$defs/functionCallContent" },
|
||||
{ "$ref": "#/$defs/functionResultContent" },
|
||||
{ "$ref": "#/$defs/hostedFileContent" },
|
||||
{ "$ref": "#/$defs/hostedVectorStoreContent" },
|
||||
{ "$ref": "#/$defs/usageContent" },
|
||||
{ "$ref": "#/$defs/textContent" },
|
||||
{ "$ref": "#/$defs/textReasoningContent" },
|
||||
{ "$ref": "#/$defs/uriContent" },
|
||||
{ "$ref": "#/$defs/unknownContent" }
|
||||
]
|
||||
},
|
||||
"chatMessage": {
|
||||
"type": "object",
|
||||
"description": "Single chat message exchanged with the agent.",
|
||||
"properties": {
|
||||
"authorName": { "type": "string", "description": "The name of the author of the message." },
|
||||
"role": { "type": "string", "enum": ["user", "assistant", "system", "tool"] },
|
||||
"contents": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/chatContentItem" }
|
||||
},
|
||||
"createdAt": { "type": "string", "format": "date-time", "description": "When this message was created (RFC 3339)." }
|
||||
},
|
||||
"required": ["role", "createdAt"]
|
||||
},
|
||||
"chatMessages": {
|
||||
"type": "array",
|
||||
"description": "Ordered list of chat messages.",
|
||||
"items": { "$ref": "#/$defs/chatMessage" }
|
||||
},
|
||||
"conversationEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": { "type": "string", "format": "date-time", "description": "When this exchange was created (RFC 3339)." },
|
||||
"correlationId": { "type": "string", "description": "An optional correlation ID to group related exchanges." },
|
||||
"messages": { "$ref": "#/$defs/chatMessages" }
|
||||
},
|
||||
"required": ["createdAt"]
|
||||
},
|
||||
"agentRequest": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/$defs/conversationEntry" }
|
||||
],
|
||||
"description": "The request (i.e. prompt) sent to the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "request" },
|
||||
"responseSchema": {
|
||||
"type": "object",
|
||||
"description": "If the expected response type is JSON, this schema defines the expected structure of the response."
|
||||
},
|
||||
"responseType": {
|
||||
"type": "string",
|
||||
"description": "The expected type of the response (e.g., 'text', 'json')."
|
||||
}
|
||||
}
|
||||
},
|
||||
"agentResponse": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/$defs/conversationEntry" }
|
||||
],
|
||||
"description": "The response received from the agent.",
|
||||
"properties": {
|
||||
"$type": { "type": "string", "const": "response" },
|
||||
"usage": {
|
||||
"$ref": "#/$defs/usage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "The durable agent's state data.",
|
||||
"properties": {
|
||||
"conversationHistory": {
|
||||
"type": "array",
|
||||
"description": "Ordered list of conversation entries.",
|
||||
"items": { "$ref": "#/$defs/conversationEntry" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "string",
|
||||
"description": "Semantic version of this state schema. By convention, this should be the first property.",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"data": { "$ref": "#/$defs/data" }
|
||||
},
|
||||
"required": ["schemaVersion", "data"]
|
||||
}
|
||||
Reference in New Issue
Block a user