diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs index 88a2b1d610..448f0f16af 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs @@ -11,6 +11,10 @@ Env.TraversePath().Load(); var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; // Create the agent via the AI project client using the Responses API. @@ -23,7 +27,7 @@ AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential( providing explanations, brainstorming ideas, and offering guidance. Be concise, clear, and helpful in your responses. """, - name: "simple-agent", + name: agentName, description: "A simple general-purpose AI assistant"); // Host the agent as a Foundry Hosted Agent using the Responses API. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Properties/launchSettings.json index b7f2c658ac..cc21f3dd2e 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Properties/launchSettings.json +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Properties/launchSettings.json @@ -2,7 +2,6 @@ "profiles": { "Hosted-ChatClientAgent": { "commandName": "Project", - "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Properties/launchSettings.json index a7047d02a1..b4c4e005d3 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Properties/launchSettings.json +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Properties/launchSettings.json @@ -1,8 +1,7 @@ { "profiles": { - "simple-agent-foundry": { + "HostedFoundryAgent": { "commandName": "Project", - "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs index 27e0a404d7..af5b4be275 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs @@ -1,33 +1,38 @@ // Copyright (c) Microsoft. All rights reserved. using System.ClientModel.Primitives; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Azure.Identity; using DotNetEnv; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; // Load .env file if present (for local development) Env.TraversePath().Load(); -string agentEndpoint = Environment.GetEnvironmentVariable("AGENT_ENDPOINT") ?? "http://localhost:8088"; +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); // ── Create an agent-framework agent backed by the remote agent endpoint ────── -var endpointUri = new Uri(agentEndpoint); var options = new AIProjectClientOptions(); -// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy -// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right -// before the request hits the wire. -Uri clientEndpoint = endpointUri; -if (endpointUri.Scheme == "http") +if (agentEndpoint.Scheme == "http") { - clientEndpoint = new UriBuilder(endpointUri) { Scheme = "https" }.Uri; + // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy + // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right + // before the request hits the wire. + + agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); } -var aiProjectClient = new AIProjectClient(clientEndpoint, new AzureCliCredential(), options); -var agent = aiProjectClient.AsAIAgent(); +var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); +FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); AgentSession session = await agent.CreateSessionAsync(); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs index f429221c15..1f0f0a6e5f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs @@ -229,7 +229,8 @@ public static partial class AzureAIProjectChatClientExtensions { Throw.IfNull(aiProjectClient); Throw.IfNull(agentOptions); - agentOptions.ChatOptions ??= new(); + Throw.IfNull(agentOptions.ChatOptions); + Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); IChatClient chatClient = aiProjectClient .GetProjectOpenAIClient() diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs index 40ad435382..87bfd1fc75 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs @@ -47,8 +47,20 @@ public class AgentFrameworkResponseHandler : ResponseHandler { // 1. Resolve agent var agent = this.ResolveAgent(request); + var sessionStore = this.ResolveSessionStore(request); - // 2. Create the SDK event stream builder + // 2. Load or create a new session from the interaction + var sessionConversationId = request.GetConversationId() ?? Guid.NewGuid().ToString(); + + var chatClientAgent = agent.GetService(); + + AgentSession? session = !string.IsNullOrEmpty(sessionConversationId) + ? await sessionStore.GetSessionAsync(agent, sessionConversationId, cancellationToken).ConfigureAwait(false) + : chatClientAgent is not null + ? await chatClientAgent.CreateSessionAsync(sessionConversationId, cancellationToken).ConfigureAwait(false) + : await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + + // 3. Create the SDK event stream builder var stream = new ResponseEventStream(context, request); // 3. Emit lifecycle events @@ -87,7 +99,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler // and inside catch blocks. We use a flag to defer the yield to outside the try/catch. bool emittedTerminal = false; var enumerator = OutputConverter.ConvertUpdatesToEventsAsync( - agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken), + agent.RunStreamingAsync(messages, session, options: options, cancellationToken: cancellationToken), stream, cancellationToken).GetAsyncEnumerator(cancellationToken); try @@ -151,6 +163,12 @@ public class AgentFrameworkResponseHandler : ResponseHandler finally { await enumerator.DisposeAsync().ConfigureAwait(false); + + // Persist session after streaming completes (successful or not) + if (session is not null && !string.IsNullOrEmpty(sessionConversationId)) + { + await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, CancellationToken.None).ConfigureAwait(false); + } } } @@ -191,6 +209,43 @@ public class AgentFrameworkResponseHandler : ResponseHandler throw new InvalidOperationException(errorMessage); } + /// + /// Resolves an from the request. + /// Tries agent.name first, then falls back to metadata["entity_id"]. + /// If neither is present, attempts to resolve a default (non-keyed) . + /// + private AgentSessionStore ResolveSessionStore(CreateResponse request) + { + var agentName = GetAgentName(request); + + if (!string.IsNullOrEmpty(agentName)) + { + var sessionStore = this._serviceProvider.GetKeyedService(agentName); + if (sessionStore is not null) + { + return sessionStore; + } + + if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning("SessionStore for agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName); + } + } + + // Try non-keyed default + var defaultSessionStore = this._serviceProvider.GetService(); + if (defaultSessionStore is not null) + { + return defaultSessionStore; + } + + var errorMessage = string.IsNullOrEmpty(agentName) + ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered." + : $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore."; + + throw new InvalidOperationException(errorMessage); + } + private static string? GetAgentName(CreateResponse request) { // Try agent.name from AgentReference diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentSessionStore.cs new file mode 100644 index 0000000000..6aef3269b4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentSessionStore.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Defines the contract for storing and retrieving agent conversation sessions. +/// +/// +/// Implementations of this interface enable persistent storage of conversation sessions, +/// allowing conversations to be resumed across HTTP requests, application restarts, +/// or different service instances in hosted scenarios. +/// +public abstract class AgentSessionStore +{ + /// + /// Saves a serialized agent session to persistent storage. + /// + /// The agent that owns this session. + /// The unique identifier for the conversation/session. + /// The session to save. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous save operation. + public abstract ValueTask SaveSessionAsync( + AIAgent agent, + string conversationId, + AgentSession session, + CancellationToken cancellationToken = default); + + /// + /// Retrieves a serialized agent session from persistent storage. + /// + /// The agent that owns this session. + /// The unique identifier for the conversation/session to retrieve. + /// The to monitor for cancellation requests. + /// + /// A task that represents the asynchronous retrieval operation. + /// The task result contains the session, or a new session if not found. + /// + public abstract ValueTask GetSessionAsync( + AIAgent agent, + string conversationId, + CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InMemoryAgentSessionStore.cs new file mode 100644 index 0000000000..4ae94ed4fe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InMemoryAgentSessionStore.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Provides an in-memory implementation of for development and testing scenarios. +/// +/// +/// +/// This implementation stores sessions in memory using a concurrent dictionary and is suitable for: +/// +/// Single-instance development scenarios +/// Testing and prototyping +/// Scenarios where session persistence across restarts is not required +/// +/// +/// +/// Warning: All stored sessions will be lost when the application restarts. +/// For production use with multiple instances or persistence across restarts, use a durable storage implementation +/// such as Redis, SQL Server, or Azure Cosmos DB. +/// +/// +public sealed class InMemoryAgentSessionStore : AgentSessionStore +{ + private readonly ConcurrentDictionary _sessions = new(); + + /// + public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + { + var key = GetKey(conversationId, agent.Id); + this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + { + var key = GetKey(conversationId, agent.Id); + JsonElement? sessionContent = this._sessions.TryGetValue(key, out var existingSession) ? existingSession : null; + + return sessionContent switch + { + null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false), + _ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false), + }; + } + + private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs index 7f01e5904c..bf3b3421f6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs @@ -45,6 +45,7 @@ public static class FoundryHostingExtensions { ArgumentNullException.ThrowIfNull(services); services.AddResponsesServer(); + services.TryAddSingleton(); services.TryAddSingleton(); return services; } @@ -71,14 +72,27 @@ public static class FoundryHostingExtensions /// /// The service collection. /// The agent instance to register. + /// The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used. /// The service collection for chaining. - public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent) + public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(agent); services.AddResponsesServer(); - services.TryAddSingleton(agent); + agentSessionStore ??= new InMemoryAgentSessionStore(); + + if (!string.IsNullOrWhiteSpace(agent.Name)) + { + services.TryAddKeyedSingleton(agent.Name, agent); + services.TryAddKeyedSingleton(agent.Name, agentSessionStore); + } + else + { + services.TryAddSingleton(agent); + services.TryAddSingleton(agentSessionStore); + } + services.TryAddSingleton(); return services; }