From 40b6deff9675bf095a21c9e42b21fcc35c2781c6 Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 7 Nov 2025 09:42:11 -0800 Subject: [PATCH] .NET: [Feature Branch] Migrate state schema updates and support for agents as MCP tools (#1979) --- dotnet/Directory.Packages.props | 1 + dotnet/agent-framework-dotnet.slnx | 1 + .../AzureFunctions/01_SingleAgent/host.json | 2 +- .../07_AgentAsMcpTool.csproj | 42 +++ .../07_AgentAsMcpTool/Program.cs | 53 +++ .../07_AgentAsMcpTool/README.md | 93 +++++ .../07_AgentAsMcpTool/host.json | 19 + .../07_AgentAsMcpTool/local.settings.json | 10 + dotnet/samples/AzureFunctions/README.md | 1 + .../AgentEntity.cs | 19 +- .../AgentRunHandle.cs | 26 +- .../DefaultDurableAgentClient.cs | 15 +- .../DurableAIAgent.cs | 7 +- .../DurableAgentJsonUtilities.cs | 2 +- .../DurableAgentState.cs | 79 ----- .../DurableAgentStateEntry.cs | 79 ----- .../Microsoft.Agents.AI.DurableTask/Logs.cs | 18 + .../RunRequest.cs | 4 +- .../ServiceCollectionExtensions.cs | 13 +- .../State/DurableAgentState.cs | 27 ++ .../State/DurableAgentStateContent.cs | 60 ++++ .../State/DurableAgentStateData.cs | 25 ++ .../State/DurableAgentStateDataContent.cs | 45 +++ .../State/DurableAgentStateEntry.cs | 44 +++ .../State/DurableAgentStateErrorContent.cs | 59 ++++ .../DurableAgentStateFunctionCallContent.cs | 63 ++++ .../DurableAgentStateFunctionResultContent.cs | 49 +++ .../DurableAgentStateHostedFileContent.cs | 39 +++ ...rableAgentStateHostedVectorStoreContent.cs | 39 +++ .../State/DurableAgentStateJsonContext.cs | 42 +++ .../State/DurableAgentStateJsonConverter.cs | 71 ++++ .../State/DurableAgentStateMessage.cs | 76 ++++ .../State/DurableAgentStateRequest.cs | 50 +++ .../State/DurableAgentStateResponse.cs | 49 +++ .../State/DurableAgentStateTextContent.cs | 38 ++ .../DurableAgentStateTextReasoningContent.cs | 38 ++ .../State/DurableAgentStateUnknownContent.cs | 43 +++ .../State/DurableAgentStateUriContent.cs | 44 +++ .../State/DurableAgentStateUsage.cs | 71 ++++ .../State/DurableAgentStateUsageContent.cs | 37 ++ .../State/README.md | 147 ++++++++ .../BuiltInFunctionExecutor.cs | 53 ++- .../BuiltInFunctions.cs | 56 ++- .../DefaultFunctionsAgentOptionsProvider.cs | 45 +++ ...DurableAgentFunctionMetadataTransformer.cs | 64 +++- .../DurableAgentsOptionsExtensions.cs | 137 ++++++++ .../FunctionsAgentOptions.cs | 19 + .../FunctionsApplicationBuilderExtensions.cs | 8 +- .../HttpTriggerOptions.cs | 18 + .../IFunctionsAgentOptionsProvider.cs | 19 + .../Logs.cs | 20 ++ .../McpToolTriggerOptions.cs | 18 + ...ft.Agents.AI.Hosting.AzureFunctions.csproj | 18 + .../State/DurableAgentStateContentTests.cs | 324 ++++++++++++++++++ .../State/DurableAgentStateMessageTests.cs | 47 +++ .../State/DurableAgentStateTests.cs | 170 +++++++++ ...leAgentFunctionMetadataTransformerTests.cs | 141 ++++++-- .../TestAgent.cs | 33 ++ schemas/durable-agent-entity-state.json | 214 ++++++++++++ 59 files changed, 2786 insertions(+), 258 deletions(-) create mode 100644 dotnet/samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj create mode 100644 dotnet/samples/AzureFunctions/07_AgentAsMcpTool/Program.cs create mode 100644 dotnet/samples/AzureFunctions/07_AgentAsMcpTool/README.md create mode 100644 dotnet/samples/AzureFunctions/07_AgentAsMcpTool/host.json create mode 100644 dotnet/samples/AzureFunctions/07_AgentAsMcpTool/local.settings.json delete mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentState.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateEntry.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateDataContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedFileContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedVectorStoreContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextReasoningContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsageContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsAgentOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/HttpTriggerOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/IFunctionsAgentOptionsProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/McpToolTriggerOptions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs create mode 100644 schemas/durable-agent-entity-state.json diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 65a0e39f13..4ee9711594 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -105,6 +105,7 @@ + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 80660c78b3..15b86590df 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -27,6 +27,7 @@ + diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/host.json b/dotnet/samples/AzureFunctions/01_SingleAgent/host.json index 9bef705c04..9384a0a583 100644 --- a/dotnet/samples/AzureFunctions/01_SingleAgent/host.json +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/host.json @@ -17,4 +17,4 @@ } } } -} \ No newline at end of file +} diff --git a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj new file mode 100644 index 0000000000..8fa1f5f2e7 --- /dev/null +++ b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj @@ -0,0 +1,42 @@ + + + net9.0 + v4 + Exe + enable + enable + + AgentAsMcpTool + AgentAsMcpTool + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/Program.cs b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/Program.cs new file mode 100644 index 0000000000..40dffe21a0 --- /dev/null +++ b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/Program.cs @@ -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(); diff --git a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/README.md b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/README.md new file mode 100644 index 0000000000..11f1394644 --- /dev/null +++ b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/README.md @@ -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/) diff --git a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/host.json b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/host.json new file mode 100644 index 0000000000..aa36d82912 --- /dev/null +++ b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/host.json @@ -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" + } + } + } +} diff --git a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/local.settings.json b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/local.settings.json new file mode 100644 index 0000000000..54dfbb5664 --- /dev/null +++ b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/local.settings.json @@ -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_DEPLOYMENT": "" + } +} diff --git a/dotnet/samples/AzureFunctions/README.md b/dotnet/samples/AzureFunctions/README.md index eacae19306..83d26a53b7 100644 --- a/dotnet/samples/AzureFunctions/README.md +++ b/dotnet/samples/AzureFunctions/README.md @@ -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 diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs index 8c5c265f2d..166799a124 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentEntity.cs @@ -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 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; - } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs index 6df24d3832..e4fe08dbf2 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentRunHandle.cs @@ -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() + .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 diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs index e19b7ae742..2086a00ecb 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DefaultDurableAgentClient.cs @@ -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(); + private readonly DurableTaskClient _client = client ?? throw new ArgumentNullException(nameof(client)); + private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); public async Task 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); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs index 7134fcd16f..52907c5816 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs @@ -149,7 +149,12 @@ public sealed class DurableAIAgent : AIAgent AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return await this.RunAsync([new ChatMessage(ChatRole.User, message)], thread, serializerOptions, options, cancellationToken); + return await this.RunAsync( + messages: [new ChatMessage(ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }], + thread, + serializerOptions, + options, + cancellationToken); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs index dbe5cd76af..3d702ce000 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentJsonUtilities.cs @@ -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 diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentState.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentState.cs deleted file mode 100644 index 80a43d403f..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentState.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.DurableTask; - -/// -/// Represents the state of a durable agent, including its conversation history. -/// -public class DurableAgentState -{ - /// - /// Gets the ordered list of state entries representing the complete conversation history. - /// This includes both user messages and agent responses in chronological order. - /// - public List ConversationHistory { get; set; } = []; - - /// - /// Gets all chat messages from the conversation history. - /// - /// A collection of chat messages in chronological order. - public IEnumerable 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; - } - } - } - } - - /// - /// Gets an agent response from the conversation history. - /// - /// The correlation ID of the agent response to get. - /// The agent response if found, null otherwise. - /// True if the agent response was found, false otherwise. - 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; - } - - /// - /// Adds a chat message to the state. - /// - /// The chat message to add. - public void AddChatMessage(ChatMessage message) - { - this.ConversationHistory.Add(AgentStateEntry.CreateChatMessage(message)); - } - - /// - /// Adds an agent response to the state. - /// - /// The agent response to add. - /// The correlation ID for the agent response. - public void AddAgentResponse(AgentRunResponse response, string? correlationId) - { - this.ConversationHistory.Add(AgentStateEntry.CreateAgentResponse(response, correlationId)); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateEntry.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateEntry.cs deleted file mode 100644 index f0b6d8c3da..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentStateEntry.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.DurableTask; - -/// -/// 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. -/// -public sealed class AgentStateEntry -{ - /// - /// Initializes a new instance of the class. - /// - /// The chat message, if this entry represents a chat message. - /// The agent response, if this entry represents an agent response. - /// The correlation ID associated with the agent response, if any. - [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; - } - - /// - /// Gets the type of this state entry. - /// - public EntryType Type { get; } - - /// - /// Gets the correlation ID for this entry. - /// - public string? CorrelationId { get; } - - /// - /// Gets the chat message, if this entry is a chat message. - /// - public ChatMessage? ChatMessage { get; } - - /// - /// Gets the agent response, if this entry is an agent response. - /// - public AgentRunResponse? AgentResponse { get; } - - /// - /// Creates a chat message entry. - /// - /// The chat message. - /// A new chat message entry. - public static AgentStateEntry CreateChatMessage(ChatMessage message) => new(message, null, null); - - /// - /// Creates an agent response entry. - /// - /// The agent response. - /// The correlation ID for the agent response. - /// A new agent response entry. - public static AgentStateEntry CreateAgentResponse(AgentRunResponse response, string? correlationId) => new(null, response, correlationId); - - /// - /// Defines the types of entries that can be stored in the durable agent state. - /// - public enum EntryType - { - /// - /// A user chat message. - /// - ChatMessage, - - /// - /// An agent response. - /// - AgentResponse - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs index 139b073198..0bec1e149c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs @@ -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); } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs index f2dcc6832e..60e5a7f83c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs @@ -34,7 +34,7 @@ public record RunRequest /// Gets or sets the correlation ID for correlating this request with its response. /// [JsonInclude] - internal string? CorrelationId { get; set; } + internal string CorrelationId { get; set; } = Guid.NewGuid().ToString("N"); /// /// Initializes a new instance of the class for a single message. @@ -50,7 +50,7 @@ public record RunRequest ChatResponseFormat? responseFormat = null, bool enableToolCalls = true, IList? 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) { } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs index 9a910609f0..c611206d6a 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs @@ -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) { diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs new file mode 100644 index 0000000000..f0a12e4099 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents the state of a durable agent, including its conversation history. +/// +[JsonConverter(typeof(DurableAgentStateJsonConverter))] +internal sealed class DurableAgentState +{ + /// + /// Gets the data of the durable agent. + /// + [JsonPropertyName("data")] + public DurableAgentStateData Data { get; init; } = new(); + + /// + /// Gets the schema version of the durable agent state. + /// + /// + /// The version is specified in semver (i.e. "major.minor.patch") format. + /// + [JsonPropertyName("schemaVersion")] + public string SchemaVersion { get; init; } = "1.0.0"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs new file mode 100644 index 0000000000..62f9f18d60 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs @@ -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; + +/// +/// Base class for durable agent state content types. +/// +[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 +{ + /// + /// Gets any additional data found during deserialization that does not map to known properties. + /// + [JsonExtensionData] + public IDictionary? ExtensionData { get; set; } + + /// + /// Converts this durable agent state content to an . + /// + /// A converted instance. + public abstract AIContent ToAIContent(); + + /// + /// Creates a from an . + /// + /// The to convert. + /// A representing the original . + 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) + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs new file mode 100644 index 0000000000..f51820dcf5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateData.cs @@ -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; + +/// +/// Represents the data of a durable agent, including its conversation history. +/// +internal sealed class DurableAgentStateData +{ + /// + /// Gets the ordered list of state entries representing the complete conversation history. + /// This includes both user messages and agent responses in chronological order. + /// + [JsonPropertyName("conversationHistory")] + public IList ConversationHistory { get; init; } = []; + + /// + /// Gets any additional data found during deserialization that does not map to known properties. + /// + [JsonExtensionData] + public IDictionary? ExtensionData { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateDataContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateDataContent.cs new file mode 100644 index 0000000000..9954213bd7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateDataContent.cs @@ -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; + +/// +/// Represents a durable agent state content that contains data content. +/// +internal sealed class DurableAgentStateDataContent : DurableAgentStateContent +{ + /// + /// Gets the URI of the data content. + /// + [JsonPropertyName("uri")] + public required string Uri { get; init; } + + /// + /// Gets the media type of the data content. + /// + [JsonPropertyName("mediaType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MediaType { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original . + public static DurableAgentStateDataContent FromDataContent(DataContent content) + { + return new DurableAgentStateDataContent() + { + MediaType = content.MediaType, + Uri = content.Uri + }; + } + + /// + public override AIContent ToAIContent() + { + return new DataContent(this.Uri, this.MediaType); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs new file mode 100644 index 0000000000..2f04c90097 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateEntry.cs @@ -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; + +/// +/// Represents a single entry in the durable agent state, which can either be a +/// user/system request or agent response. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(DurableAgentStateRequest), "request")] +[JsonDerivedType(typeof(DurableAgentStateResponse), "response")] +internal abstract class DurableAgentStateEntry +{ + /// + /// Gets the correlation ID for this entry. + /// + /// + /// This ID is used to correlate back to its + /// . + /// + [JsonPropertyName("correlationId")] + public required string CorrelationId { get; init; } + + /// + /// Gets the timestamp when this entry was created. + /// + [JsonPropertyName("createdAt")] + public required DateTimeOffset CreatedAt { get; init; } + + /// + /// Gets the list of messages associated with this entry, in chronological order. + /// + [JsonPropertyName("messages")] + public IReadOnlyList Messages { get; init; } = []; + + /// + /// Gets any additional data found during deserialization that does not map to known properties. + /// + [JsonExtensionData] + public IDictionary? ExtensionData { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs new file mode 100644 index 0000000000..17e5fea75f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateErrorContent.cs @@ -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; + +/// +/// Represents durable agent state content that contains error content. +/// +internal sealed class DurableAgentStateErrorContent : DurableAgentStateContent +{ + /// + /// Gets the error message. + /// + [JsonPropertyName("message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; init; } + + /// + /// Gets the error code. + /// + [JsonPropertyName("errorCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ErrorCode { get; init; } + + /// + /// Gets the error details. + /// + [JsonPropertyName("details")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Details { get; init; } + + /// + /// Creates a from an . + /// + /// The to convert. + /// A representing the original + /// . + public static DurableAgentStateErrorContent FromErrorContent(ErrorContent content) + { + return new DurableAgentStateErrorContent() + { + Details = content.Details, + ErrorCode = content.ErrorCode, + Message = content.Message + }; + } + + /// + public override AIContent ToAIContent() + { + return new ErrorContent(this.Message) + { + Details = this.Details, + ErrorCode = this.ErrorCode + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs new file mode 100644 index 0000000000..babea0f4ff --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs @@ -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; + +/// +/// Durable agent state content representing a function call. +/// +internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateContent +{ + /// + /// The function call arguments. + /// + /// TODO: Consider ensuring that empty dictionaries are omitted from serialization. + [JsonPropertyName("arguments")] + public required IReadOnlyDictionary Arguments { get; init; } = + ImmutableDictionary.Empty; + + /// + /// Gets the function call identifier. + /// + /// + /// This is used to correlate this function call with its resulting + /// . + /// + [JsonPropertyName("callId")] + public required string CallId { get; init; } + + /// + /// Gets the function name. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// + /// A representing the original content. + /// + public static DurableAgentStateFunctionCallContent FromFunctionCallContent(FunctionCallContent content) + { + return new DurableAgentStateFunctionCallContent() + { + Arguments = content.Arguments?.ToImmutableDictionary() ?? ImmutableDictionary.Empty, + CallId = content.CallId, + Name = content.Name + }; + } + + /// + public override AIContent ToAIContent() + { + return new FunctionCallContent( + this.CallId, + this.Name, + new Dictionary(this.Arguments)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs new file mode 100644 index 0000000000..9237fdfa76 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs @@ -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; + +/// +/// Represents the function result content for a durable agent state response. +/// +internal sealed class DurableAgentStateFunctionResultContent : DurableAgentStateContent +{ + /// + /// Gets the function call identifier. + /// + /// + /// This is used to correlate this function result with its originating + /// . + /// + [JsonPropertyName("callId")] + public required string CallId { get; init; } + + /// + /// Gets the function result. + /// + [JsonPropertyName("result")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Result { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateFunctionResultContent FromFunctionResultContent(FunctionResultContent content) + { + return new DurableAgentStateFunctionResultContent() + { + CallId = content.CallId, + Result = content.Result + }; + } + + /// + public override AIContent ToAIContent() + { + return new FunctionResultContent(this.CallId, this.Result); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedFileContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedFileContent.cs new file mode 100644 index 0000000000..c6fc860ac0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedFileContent.cs @@ -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; + +/// +/// Represents durable agent state content that contains hosted file content. +/// +internal sealed class DurableAgentStateHostedFileContent : DurableAgentStateContent +{ + /// + /// Gets the file ID of the hosted file content. + /// + [JsonPropertyName("fileId")] + public required string FileId { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// + /// A representing the original . + /// + public static DurableAgentStateHostedFileContent FromHostedFileContent(HostedFileContent content) + { + return new DurableAgentStateHostedFileContent() + { + FileId = content.FileId + }; + } + + /// + public override AIContent ToAIContent() + { + return new HostedFileContent(this.FileId); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedVectorStoreContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedVectorStoreContent.cs new file mode 100644 index 0000000000..f7b615564b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateHostedVectorStoreContent.cs @@ -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; + +/// +/// Represents durable agent state content that contains hosted vector store content. +/// +internal sealed class DurableAgentStateHostedVectorStoreContent : DurableAgentStateContent +{ + /// + /// Gets the vector store ID of the hosted vector store content. + /// + [JsonPropertyName("vectorStoreId")] + public required string VectorStoreId { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// + /// A representing the original . + /// + public static DurableAgentStateHostedVectorStoreContent FromHostedVectorStoreContent(HostedVectorStoreContent content) + { + return new DurableAgentStateHostedVectorStoreContent() + { + VectorStoreId = content.VectorStoreId + }; + } + + /// + public override AIContent ToAIContent() + { + return new HostedVectorStoreContent(this.VectorStoreId); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs new file mode 100644 index 0000000000..2684fcd3e1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs @@ -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))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(JsonDocument))] +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(JsonNode))] +[JsonSerializable(typeof(JsonObject))] +[JsonSerializable(typeof(JsonValue))] +[JsonSerializable(typeof(JsonArray))] +[JsonSerializable(typeof(IEnumerable))] +[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 +{ +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs new file mode 100644 index 0000000000..4c7796b36c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonConverter.cs @@ -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; + +/// +/// JSON converter for which performs schema version checks before deserialization. +/// +internal sealed class DurableAgentStateJsonConverter : JsonConverter +{ + private const string SchemaVersionPropertyName = "schemaVersion"; + private const string DataPropertyName = "data"; + + /// + 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() + }; + } + + /// + 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(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs new file mode 100644 index 0000000000..294453c149 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateMessage.cs @@ -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; + +/// +/// Represents a single message within a durable agent state entry. +/// +internal sealed class DurableAgentStateMessage +{ + /// + /// Gets the name of the author of this message. + /// + [JsonPropertyName("authorName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AuthorName { get; init; } + + /// + /// Gets the timestamp when this message was created. + /// + [JsonPropertyName("createdAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CreatedAt { get; init; } + + /// + /// Gets the contents of this message. + /// + [JsonPropertyName("contents")] + public IReadOnlyList Contents { get; init; } = []; + + /// + /// Gets the role of the message sender (e.g., "user", "assistant", "system"). + /// + [JsonPropertyName("role")] + public required string Role { get; init; } + + /// + /// Gets any additional data found during deserialization that does not map to known properties. + /// + [JsonExtensionData] + public IDictionary? ExtensionData { get; set; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original message. + 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() + }; + } + + /// + /// Converts this to a . + /// + /// A representing this message. + public ChatMessage ToChatMessage() + { + return new ChatMessage() + { + CreatedAt = this.CreatedAt, + AuthorName = this.AuthorName, + Contents = this.Contents.Select(c => c.ToAIContent()).ToList(), + Role = new(this.Role) + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs new file mode 100644 index 0000000000..cb8f3c137c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs @@ -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; + +/// +/// Represents a user or system request entry in the durable agent state. +/// +internal sealed class DurableAgentStateRequest : DurableAgentStateEntry +{ + /// + /// Gets the expected response type for this request (e.g. "json" or "text"). + /// + /// + /// If omitted, the expectation is that the agent will respond in plain text. + /// + [JsonPropertyName("responseType")] + public string? ResponseType { get; init; } + + /// + /// Gets the expected response JSON schema for this request, if applicable. + /// + /// + /// This is only applicable when is "json". + /// If omitted, no specific schema is expected. + /// + [JsonPropertyName("responseSchema")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? ResponseSchema { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original request. + 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 + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs new file mode 100644 index 0000000000..216bb6e05c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.State; + +/// +/// Represents a durable agent state entry that is a response from the agent. +/// +internal sealed class DurableAgentStateResponse : DurableAgentStateEntry +{ + /// + /// Gets the usage details for this state response. + /// + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DurableAgentStateUsage? Usage { get; init; } + + /// + /// Creates a from an . + /// + /// The correlation ID linking this response to its request. + /// The to convert. + /// A representing the original response. + 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) + }; + } + + /// + /// Converts this back to an . + /// + /// A representing this response. + public AgentRunResponse ToRunResponse() + { + return new AgentRunResponse() + { + CreatedAt = this.CreatedAt, + Messages = this.Messages.Select(m => m.ToChatMessage()).ToList(), + Usage = this.Usage?.ToUsageDetails(), + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextContent.cs new file mode 100644 index 0000000000..0f3085465a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextContent.cs @@ -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; + +/// +/// Represents the text content for a durable agent state entry. +/// +internal sealed class DurableAgentStateTextContent : DurableAgentStateContent +{ + /// + /// Gets the text message content. + /// + [JsonPropertyName("text")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string? Text { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateTextContent FromTextContent(TextContent content) + { + return new DurableAgentStateTextContent() + { + Text = content.Text + }; + } + + /// + public override AIContent ToAIContent() + { + return new TextContent(this.Text); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextReasoningContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextReasoningContent.cs new file mode 100644 index 0000000000..9b5d6eba34 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateTextReasoningContent.cs @@ -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; + +/// +/// Represents the text reasoning content for a durable agent state entry. +/// +internal sealed class DurableAgentStateTextReasoningContent : DurableAgentStateContent +{ + /// + /// Gets the text reasoning content. + /// + [JsonPropertyName("text")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Text { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateTextReasoningContent FromTextReasoningContent(TextReasoningContent content) + { + return new DurableAgentStateTextReasoningContent() + { + Text = content.Text + }; + } + + /// + public override AIContent ToAIContent() + { + return new TextReasoningContent(this.Text); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs new file mode 100644 index 0000000000..00a180bba3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUnknownContent.cs @@ -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; + +/// +/// Represents the unknown content for a durable agent state entry. +/// +internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent +{ + /// + /// Gets the serialized unknown content. + /// + [JsonPropertyName("content")] + public required JsonElement Content { get; init; } + + /// + /// Creates a from an . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateUnknownContent FromUnknownContent(AIContent content) + { + return new DurableAgentStateUnknownContent() + { + Content = JsonSerializer.SerializeToElement( + value: content, + jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))) + }; + } + + /// + 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."); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs new file mode 100644 index 0000000000..8c6bbb8f24 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUriContent.cs @@ -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; + +/// +/// Represents URI content for a durable agent state message. +/// +internal sealed class DurableAgentStateUriContent : DurableAgentStateContent +{ + /// + /// Gets the URI of the content. + /// + [JsonPropertyName("uri")] + public required Uri Uri { get; init; } + + /// + /// Gets the media type of the content. + /// + [JsonPropertyName("mediaType")] + public required string MediaType { get; init; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateUriContent FromUriContent(UriContent uriContent) + { + return new DurableAgentStateUriContent() + { + MediaType = uriContent.MediaType, + Uri = uriContent.Uri + }; + } + + /// + public override AIContent ToAIContent() + { + return new UriContent(this.Uri, this.MediaType); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs new file mode 100644 index 0000000000..1b3714faca --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsage.cs @@ -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; + +/// +/// Represents the token usage details for a durable agent state response. +/// +internal sealed class DurableAgentStateUsage +{ + /// + /// Gets the number of input tokens used. + /// + [JsonPropertyName("inputTokenCount")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? InputTokenCount { get; init; } + + /// + /// Gets the number of output tokens used. + /// + [JsonPropertyName("outputTokenCount")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? OutputTokenCount { get; init; } + + /// + /// Gets the total number of tokens used. + /// + [JsonPropertyName("totalTokenCount")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? TotalTokenCount { get; init; } + + /// + /// Gets any additional data found during deserialization that does not map to known properties. + /// + [JsonExtensionData] + public IDictionary? ExtensionData { get; set; } + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original usage details. + [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; + + /// + /// Converts this back to a . + /// + /// A representing this usage. + public UsageDetails ToUsageDetails() + { + return new() + { + InputTokenCount = this.InputTokenCount, + OutputTokenCount = this.OutputTokenCount, + TotalTokenCount = this.TotalTokenCount + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsageContent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsageContent.cs new file mode 100644 index 0000000000..bdad860e62 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateUsageContent.cs @@ -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; + +/// +/// Represents the content for a durable agent state message. +/// +internal sealed class DurableAgentStateUsageContent : DurableAgentStateContent +{ + /// + /// Gets the usage details. + /// + [JsonPropertyName("usage")] + public DurableAgentStateUsage Usage { get; init; } = new(); + + /// + /// Creates a from a . + /// + /// The to convert. + /// A representing the original content. + public static DurableAgentStateUsageContent FromUsageContent(UsageContent content) + { + return new DurableAgentStateUsageContent() + { + Usage = DurableAgentStateUsage.FromUsage(content.Details) + }; + } + + /// + public override AIContent ToAIContent() + { + return new UsageContent(this.Usage.ToUsageDetails()); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md new file mode 100644 index 0000000000..09bb13c51e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/README.md @@ -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. `".."`) +- 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. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs index 55021172aa..291f042db5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs @@ -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}."); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index 80e2a2dfaa..5a13c0cf87 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -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 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..]; } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs new file mode 100644 index 0000000000..1039fb5aec --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// 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. +/// +internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary functionsAgentOptions) + : IFunctionsAgentOptionsProvider +{ + private readonly IReadOnlyDictionary _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 } + }; + + /// + /// Attempts to retrieve the options associated with the specified agent name. + /// If not found, a default options instance (with HTTP trigger enabled) is returned. + /// + /// The name of the agent whose options are to be retrieved. Cannot be null or empty. + /// The options for the specified agent. Will never be null. + /// Always true. Returns configured options if present; otherwise default fallback options. + 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; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs index a8cfd62053..cce8fbd1b0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs @@ -14,6 +14,8 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat { private readonly ILogger _logger; private readonly IReadOnlyDictionary> _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> agents, - ILogger logger) + ILogger 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 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> 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, + }; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs new file mode 100644 index 0000000000..ad21d8f4e1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Provides extension methods for registering and configuring AI agents in the context of the Azure Functions hosting environment. +/// +public static class DurableAgentsOptionsExtensions +{ + // Registry of agent options. + private static readonly Dictionary s_agentOptions = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds an AI agent to the specified DurableAgentsOptions instance and optionally configures agent-specific + /// options. + /// + /// The DurableAgentsOptions instance to which the AI agent will be added. + /// The AI agent to add. The agent's Name property must not be null or empty. + /// An optional delegate to configure agent-specific options. If null, default options are used. + /// The updated instance containing the added AI agent. + public static DurableAgentsOptions AddAIAgent( + this DurableAgentsOptions options, + AIAgent agent, + Action? 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; + } + + /// + /// Adds an AI agent to the specified options and configures trigger support for HTTP and MCP tool invocations. + /// + /// 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. + /// The options collection to which the AI agent will be added. Cannot be null. + /// The AI agent to add. The agent's Name property must not be null or empty. + /// true to enable an HTTP trigger for the agent; otherwise, false. + /// true to enable an MCP tool trigger for the agent; otherwise, false. + /// The updated instance with the specified AI agent and trigger configuration applied. + 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; + } + + /// + /// Registers an AI agent factory with the specified name and optional configuration in the provided + /// DurableAgentsOptions instance. + /// + /// 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. + /// The DurableAgentsOptions instance to which the AI agent factory will be added. Cannot be null. + /// The unique name used to identify the AI agent factory. Cannot be null. + /// A delegate that creates an AIAgent instance using the provided IServiceProvider. Cannot be null. + /// An optional action to configure FunctionsAgentOptions for the agent factory. If null, default options are used. + /// The updated DurableAgentsOptions instance containing the registered AI agent factory. + public static DurableAgentsOptions AddAIAgentFactory( + this DurableAgentsOptions options, + string name, + Func factory, + Action? 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; + } + + /// + /// Registers an AI agent factory with the specified name and configures trigger options for the agent. + /// + /// 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. + /// The options object to which the AI agent factory will be added. Cannot be null. + /// The unique name used to identify the AI agent factory. Cannot be null. + /// A delegate that creates an instance of the AI agent using the provided service provider. Cannot be null. + /// true to enable the HTTP trigger for the agent; otherwise, false. + /// true to enable the MCP tool trigger for the agent; otherwise, false. + /// The same DurableAgentsOptions instance, allowing for method chaining. + public static DurableAgentsOptions AddAIAgentFactory( + this DurableAgentsOptions options, + string name, + Func 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; + } + + /// + /// Builds the agentOptions used for dependency injection (read-only copy). + /// + internal static IReadOnlyDictionary GetAgentOptionsSnapshot() + { + return new Dictionary(s_agentOptions, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsAgentOptions.cs new file mode 100644 index 0000000000..6ead7d8be5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsAgentOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Provides configuration options for enabling and customizing function triggers for an agent. +/// +public sealed class FunctionsAgentOptions +{ + /// + /// Gets or sets the configuration options for the HTTP trigger endpoint. + /// + public HttpTriggerOptions HttpTrigger { get; set; } = new(false); + + /// + /// Gets or sets the options used to configure the MCP tool trigger behavior. + /// + public McpToolTriggerOptions McpToolTrigger { get; set; } = new(false); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index 614b530dc5..e13c6008ea 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -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(); + builder.Services.TryAddSingleton(_ => + new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot())); - // Handling of built-in function execution for Agent HTTP or Entity invocations. + builder.Services.AddSingleton(); + + // Handling of built-in function execution for Agent HTTP, MCP tool, or Entity invocations. builder.UseWhen(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(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/HttpTriggerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/HttpTriggerOptions.cs new file mode 100644 index 0000000000..2a750c3ae5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/HttpTriggerOptions.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Represents configuration options for the HTTP trigger for an agent. +/// +/// +/// Initializes a new instance of the class. +/// +/// Indicates whether the HTTP trigger is enabled for the agent. +public sealed class HttpTriggerOptions(bool isEnabled) +{ + /// + /// Gets or sets a value indicating whether the HTTP trigger is enabled for the agent. + /// + public bool IsEnabled { get; set; } = isEnabled; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/IFunctionsAgentOptionsProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/IFunctionsAgentOptionsProvider.cs new file mode 100644 index 0000000000..347b4242a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/IFunctionsAgentOptionsProvider.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Provides access to function trigger options for agents in the Azure Functions hosting environment. +/// +internal interface IFunctionsAgentOptionsProvider +{ + /// + /// Attempts to get trigger options for the specified agent. + /// + /// The agent name. + /// The resulting options if found. + /// True if options exist; otherwise false. + bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs new file mode 100644 index 0000000000..c49d2b39df --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs @@ -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); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/McpToolTriggerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/McpToolTriggerOptions.cs new file mode 100644 index 0000000000..8e729f6840 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/McpToolTriggerOptions.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// This class provides configuration options for the MCP tool trigger for an agent. +/// +/// +/// A value indicating whether the MCP tool trigger is enabled for the agent. +/// Set to to enable the trigger; otherwise, . +/// +public sealed class McpToolTriggerOptions(bool isEnabled) +{ + /// + /// Gets or sets a value indicating whether MCP tool trigger is enabled for the agent. + /// + public bool IsEnabled { get; set; } = isEnabled; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj index 7f11ef8fc5..4835039fbd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj @@ -29,6 +29,7 @@ + @@ -40,4 +41,21 @@ + + + + + <_Parameter1>Microsoft.Azure.Functions.Extensions.Mcp + <_Parameter2>1.0.0 + + <_Parameter3>true + <_Parameter3_IsLiteral>true + + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs new file mode 100644 index 0000000000..2fda1178e1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateContentTests.cs @@ -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(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(convertedContent); + + Assert.Equal(textContent.Text, convertedTextContent.Text); + } + + [Fact] + public void FunctionCallContentSerializationDeserialization() + { + // Arrange + FunctionCallContent functionCallContent = new( + "call-123", + "MyFunction", + new Dictionary + { + { "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(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(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(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(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(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(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(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(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(convertedContent); + + Assert.Equal(originalContent.Text, convertedTextContent.Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs new file mode 100644 index 0000000000..343644d911 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateMessageTests.cs @@ -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(convertedContent); + + Assert.Equal(textContent.Text, convertedTextContent.Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs new file mode 100644 index 0000000000..f8ce5c6dec --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateTests.cs @@ -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( + () => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void BreakingVersion() + { + // Arrange + const string JsonText = """ + { + "schemaVersion": "2.0.0" + } + """; + + // Act & Assert + Assert.Throws( + () => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState)); + } + + [Fact] + public void MissingData() + { + // Arrange + const string JsonText = """ + { + "schemaVersion": "1.0.0" + } + """; + + // Act & Assert + Assert.Throws( + () => 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(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(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(content); + Assert.Equal("Hello, agent!", textContent.Text); + }, + entry => + { + Assert.IsType(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(content); + Assert.Equal("Hi user!", textContent.Text); + }); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs index 9a924c1bfd..c9a13d7298 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs @@ -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> 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 + { + { "testAgent", options } + }); + List metadataList = BuildFunctionMetadataList(initialMetadataEntryCount); + DurableAgentFunctionMetadataTransformer transformer = new( + agents, + NullLogger.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(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(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(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(metadataList[mcpIndex]); + Assert.Equal("mcptool-testAgent", mcpToolTrigger.Name); + Assert.Contains("mcpToolTrigger", mcpToolTrigger.RawBindings![0]); + } } [Fact] public void Transform_AddsTriggers_ForMultipleAgents() { + // Arrange Dictionary> 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 functionsAgentOptions = new() + { + { "agentA", agentOptionsA }, + { "agentB", agentOptionsB }, + { "agentC", agentOptionsC } + }; + + IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(functionsAgentOptions); + DurableAgentFunctionMetadataTransformer transformer = new( + agents, + NullLogger.Instance, + new FakeServiceProvider(), + agentOptionsProvider); + const int InitialMetadataEntryCount = 2; List 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( - 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( - 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 GetTestLogger() => new(); + private sealed class FakeServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private sealed class FakeOptionsProvider : IFunctionsAgentOptionsProvider + { + private readonly Dictionary _map; + + public FakeOptionsProvider(Dictionary 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); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs new file mode 100644 index 0000000000..b0ad7ec0fe --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs @@ -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 RunAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => Task.FromResult(new AgentRunResponse([.. messages])); + + public override IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + private sealed class DummyAgentThread : AgentThread; +} diff --git a/schemas/durable-agent-entity-state.json b/schemas/durable-agent-entity-state.json new file mode 100644 index 0000000000..7ec195f89f --- /dev/null +++ b/schemas/durable-agent-entity-state.json @@ -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"] +}