From 3ebc0e88d7139816e5040cbcd60d140ef7a4d21d Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:38:32 +0100 Subject: [PATCH 1/9] Let the container choose who stores a hosted turn, and say so when it is stored twice Turning storage off downstream was unconditional and silent. It is now a container choice, and a deployment that ends up storing anyway is reported instead of quietly recording the conversation in two places nothing reconciles. FoundryResponsesOptions, passed through AddFoundryResponses, carries two settings. AllowStoredOutputEnabled defaults to false, which is when hosting turns storage off for every run and checks the result. Setting it to true leaves the agent's own configuration exactly as the container built it, and nothing is checked, overridden, or refused. IncludeReasoningEncryptedContent applies while storage is off, asking for the encrypted form of the reasoning tokens so reasoning survives between turns, mirroring AsIChatClientWithStoredOutputDisabled. Two checks replace the 400 that used to refuse a session carrying a conversation id. The readiness probe runs each registered agent with its chat client swapped for one that calls nothing, so the request the agent builds on its own is visible without leaving the container, and an agent asking for its responses to be stored keeps the container out of rotation. Per request, a conversation id on the session after the run means the agent's own service kept the turn, which fails with 501 and leaves the session unsaved so later turns do not resume onto it. A misconfigured container is a server problem, not a bad request, hence 5xx. Only a confirmed "this asks to be stored" fails either check. An agent that is not a ChatClientAgent, a request shape carrying no such setting, and a run that could not be completed all pass: this package cannot tell what those would do. --- .../AgentFrameworkResponseHandler.cs | 67 +++++-- .../FoundryResponsesOptions.cs | 53 ++++++ .../HostedStoredOutputCompatibility.cs | 129 ++++++++++++++ .../HostedStoredOutputHealthCheck.cs | 153 ++++++++++++++++ .../InputConverter.cs | 55 +++--- .../ServiceCollectionExtensions.cs | 53 +++++- .../StoredOutputProbeChatClient.cs | 73 ++++++++ .../AgentFrameworkResponseHandlerTests.cs | 143 +++++++++++++-- .../HostedStoredOutputHealthCheckTests.cs | 163 ++++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 4 +- 10 files changed, 824 insertions(+), 69 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryResponsesOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/StoredOutputProbeChatClient.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedStoredOutputHealthCheckTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 0472fcb38ff..7f735a1e369 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -169,19 +170,6 @@ public override async IAsyncEnumerable CreateAsync( } } - // A hosted agent's conversation is recorded by the AgentServer SDK's own storage provider. A - // conversation id on the session means the service behind the agent's chat client is recording - // a second one, which nothing here reads and which no one reconciles with the first. Refuse - // before any work is done, as a plain bad request rather than a failure part way through. - if (session is ChatClientAgentSession { ConversationId: not null }) - { - throw new ResponsesApiException( - new Error( - "service_managed_chat_history_not_supported", - "Chat history is managed by the hosted agent service, therefore using a ChatClientAgent with its own service storage is not supported. Configure the agent's chat client so the underlying service does not store responses."), - 400); - } - // 3. Create the SDK event stream builder var stream = new ResponseEventStream(context, request); @@ -219,15 +207,27 @@ public override async IAsyncEnumerable CreateAsync( } // 5. Build chat options - var chatOptions = InputConverter.ConvertToChatOptions(request, agentOptions?.ChatOptions?.RawRepresentationFactory); + var hostingOptions = this._serviceProvider.GetService>()?.Value; + var allowStoredOutputEnabled = hostingOptions?.AllowStoredOutputEnabled ?? false; + var chatOptions = InputConverter.ConvertToChatOptions( + request, + agentOptions?.ChatOptions?.RawRepresentationFactory, + hostingOptions); chatOptions.Instructions = request.Instructions; // Everything the agent needs for this turn is already in the input, so the provider it would // otherwise run is replaced for the duration by one that keeps its messages in memory and is // dropped when the run ends. Serving from a longer-lived one would deliver the conversation // twice, and storing into it would leave a copy the hosting service never sees. - chatOptions.AdditionalProperties ??= []; - chatOptions.AdditionalProperties.Add(new VolatileChatHistoryProvider()); + // + // A container that allows its own service to keep the conversation has taken history over, so + // nothing is put in its way: the agent already stands its own provider down when that service + // hands back a conversation id, and an override here would only collide with it. + if (!allowStoredOutputEnabled) + { + chatOptions.AdditionalProperties ??= []; + chatOptions.AdditionalProperties.Add(new VolatileChatHistoryProvider()); + } // Inject Foundry Toolbox tools when the toolbox service is available. // @@ -385,6 +385,13 @@ await this._toolboxService // NOTE: C# forbids 'yield return' inside a try block that has a catch clause, // and inside catch blocks. We use a flag to defer the yield to outside the try/catch. bool emittedTerminal = false; + bool agentKeptItsOwnConversation = false; + + // The session picks up the id of any conversation the agent's own service kept, at the end of + // the run and before the agent reports anything else about it. + bool AgentKeptItsOwnConversation() => + !allowStoredOutputEnabled && session is ChatClientAgentSession { ConversationId: not null }; + var enumerator = OutputConverter.ConvertUpdatesToEventsAsync( agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token), stream, @@ -453,6 +460,16 @@ await this._toolboxService if (failedEvent is not null) { + // The run may have failed precisely because the agent's own service kept the + // conversation: the session picks up that id before the agent goes on to complain + // about having two history managers. Report the deployment problem that caused it + // rather than the confusing symptom. + if (AgentKeptItsOwnConversation()) + { + agentKeptItsOwnConversation = true; + throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError(); + } + yield return failedEvent; yield break; } @@ -478,13 +495,29 @@ await this._toolboxService { await enumerator.DisposeAsync().ConfigureAwait(false); + // The run is over, so the session now carries whatever the agent's own service handed back. + // A conversation id there means that service kept this turn, which is a second recording of + // a conversation the hosting service already recorded. + agentKeptItsOwnConversation |= AgentKeptItsOwnConversation(); + // Persist session after streaming completes (successful or not). The user id partitions the // persisted session per end user, mirroring the load above so multi-turn continuity is preserved. - if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId)) + // A session pointing at a conversation the agent's own service kept is never persisted: every + // later turn would resume onto that conversation and keep the double recording going. + if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId) && !agentKeptItsOwnConversation) { await sessionStore.SaveSessionAsync(agent, agentSessionId, session, resolvedUserId, cancellationToken).ConfigureAwait(false); } } + + if (agentKeptItsOwnConversation) + { + this._logger.LogError( + "Agent '{AgentName}' had this response stored by the service behind its chat client, so the conversation is being recorded twice.", + agent.Name); + + throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError(); + } } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryResponsesOptions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryResponsesOptions.cs new file mode 100644 index 00000000000..d326ce230ac --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryResponsesOptions.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Options for hosting agents behind the Foundry Responses API. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FoundryResponsesOptions +{ + /// + /// Gets or sets a value indicating whether the agent's own chat client is allowed to store the + /// responses it produces. + /// + /// + /// + /// A hosted turn is already recorded by the storage provider that runs around this handler, and + /// that record is the conversation the caller reads back. When the service behind the agent's chat + /// client also stores the turn, the same exchange is written a second time onto a trail of its own, + /// which nothing here reads and no one reconciles with the first. + /// + /// + /// While this is , hosting turns that storage off for every run (the "store" + /// property in the JSON representation), and the readiness probe reports an agent whose + /// configuration would keep it on. Set it to to leave the agent's own + /// setting exactly as the container configured it, in which case hosting neither changes it nor + /// checks it. + /// + /// + /// + /// Default is . + /// + public bool AllowStoredOutputEnabled { get; set; } + + /// + /// Gets or sets a value indicating whether to include an encrypted version of reasoning tokens in + /// reasoning item outputs. + /// + /// + /// This enables reasoning items to be used in multi-turn conversations when using the Responses API + /// statelessly (like when the store parameter is set to false, or when an organization is enrolled + /// in the zero data retention program). It applies only while + /// is , because that is when hosting + /// turns storage off and the reasoning items would otherwise be lost between turns. + /// + /// + /// Default is . + /// + public bool IncludeReasoningEncryptedContent { get; set; } = true; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs new file mode 100644 index 00000000000..03e30c396ba --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using ChatCompletionOptions = OpenAI.Chat.ChatCompletionOptions; +using CreateResponseOptions = OpenAI.Responses.CreateResponseOptions; +using IncludedResponseProperty = OpenAI.Responses.IncludedResponseProperty; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Keeps the service behind a hosted agent's chat client from storing the responses it produces, and +/// reports the deployment that ends up storing them anyway. +/// +/// +/// +/// A hosted turn is already recorded by the AgentServer SDK's storage provider, which runs around the +/// handler, and that record is the conversation the caller reads back. A service that also stores the +/// turn writes the same exchange a second time onto a trail of its own, which nothing here reads and +/// no one reconciles with the first. +/// +/// +/// Turning storage off is a container concern, so a deployment that still stores is a server-side +/// misconfiguration rather than a bad request, and is reported as such. +/// +/// +internal static class HostedStoredOutputCompatibility +{ + /// + /// HTTP status returned when the agent's own service stored the turn. 501 Not Implemented + /// is a server-side classification, because the deployment, not the caller, is misconfigured; it is + /// also non-retryable and distinct from the generic 500 so it stands out in telemetry. + /// + internal const int MisconfiguredAgentStatusCode = 501; + + /// + /// Stable error code emitted in the response body so callers and tooling can match the condition. + /// + internal const string MisconfiguredAgentErrorCode = "agent_stored_output_not_disabled"; + + /// + /// Message shared by the readiness probe and the per-request check, naming both the cause and the fix. + /// + internal const string MisconfiguredAgentMessage = + "The service behind the agent's chat client stored this response, so the conversation is being recorded twice: once by the hosted agent service and once by that service. Build the agent's chat client so it does not store responses (for example with AsIChatClientWithStoredOutputDisabled), or set FoundryResponsesOptions.AllowStoredOutputEnabled to true to keep the second recording on purpose."; + + /// + /// Returns the error to throw when the agent's own service kept the turn. + /// + internal static ResponsesApiException CreateMisconfiguredAgentError() => + new(new Error(MisconfiguredAgentErrorCode, MisconfiguredAgentMessage), MisconfiguredAgentStatusCode); + + /// + /// Installs a factory on that turns storage off on the request the agent's + /// chat client is about to build. + /// + /// The chat options for this run. + /// + /// The factory the agent carries on its own , if any. It is invoked here and + /// its result is what gets the setting, because ChatClientAgent chains the two by taking the + /// agent's only when the request's returns null. A request factory that always answers would + /// otherwise drop whatever the container configured. + /// + /// + /// Whether to ask for the encrypted form of the reasoning tokens, which is what keeps reasoning + /// usable across turns while storage is off. + /// + /// + /// Both OpenAI request shapes carry the setting, so a chat client speaking either protocol is + /// covered. Anything else is a request type with no notion of storing a response, and is handed back + /// untouched. + /// + internal static void DisableStoredOutput( + ChatOptions options, + Func? agentRawRepresentationFactory, + bool includeReasoningEncryptedContent) + { + options.RawRepresentationFactory = chatClient => + { + switch (agentRawRepresentationFactory?.Invoke(chatClient)) + { + case CreateResponseOptions responseOptions: + return DisableStoredOutput(responseOptions, includeReasoningEncryptedContent); + + case ChatCompletionOptions completionOptions: + completionOptions.StoredOutputEnabled = false; + return completionOptions; + + case { } configuredByTheAgent: + return configuredByTheAgent; + + default: + return DisableStoredOutput(new CreateResponseOptions(), includeReasoningEncryptedContent); + } + }; + } + + /// + /// Reads whether a request the agent's chat client would send asks for the response to be stored. + /// Returns when the request shape carries no such setting, which is a request + /// type this package has nothing to say about. + /// + internal static bool? ReadsAsStoringResponses(object? rawRepresentation) => rawRepresentation switch + { + CreateResponseOptions responseOptions => responseOptions.StoredOutputEnabled, + ChatCompletionOptions completionOptions => completionOptions.StoredOutputEnabled, + _ => null, + }; + + /// + /// Turns storage off on a Responses request, and keeps reasoning usable across turns while it is off + /// by asking for the encrypted form of the reasoning tokens. Mirrors what + /// AsIChatClientWithStoredOutputDisabled does. + /// + private static CreateResponseOptions DisableStoredOutput(CreateResponseOptions responseOptions, bool includeReasoningEncryptedContent) + { + responseOptions.StoredOutputEnabled = false; + + if (includeReasoningEncryptedContent && + !responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent)) + { + responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent); + } + + return responseOptions; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs new file mode 100644 index 00000000000..6495a7a48cd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Reports, on the GET /readiness probe, a registered agent configured to have its own service +/// store the responses it produces, so a container that would record the conversation twice is caught +/// before it takes any traffic. +/// +/// +/// +/// Each agent is run for real, with its chat client replaced for that run by +/// , which answers without calling anything. The run therefore +/// builds the very request the agent would have sent, and the probe reads the store setting off it. +/// Nothing hosting adds per request is applied here, so what the probe sees is how the container +/// configured its agent. Nothing leaves the container either. +/// +/// +/// Only a confirmed "this asks to be stored" fails the probe. An agent that is not a +/// , a request that carries no such setting, and a run that could not be +/// completed are all reported as healthy: this package cannot tell what those would do, and a +/// readiness probe is the wrong place to turn an uncertainty into an outage. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class HostedStoredOutputHealthCheck : IHealthCheck +{ + private readonly IServiceProvider _serviceProvider; + private readonly FoundryResponsesOptions _hostingOptions; + private readonly ILogger? _logger; + + public HostedStoredOutputHealthCheck( + IServiceProvider serviceProvider, + IOptions? hostingOptions = null, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + + this._serviceProvider = serviceProvider; + this._hostingOptions = hostingOptions?.Value ?? new FoundryResponsesOptions(); + this._logger = logger; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (this._hostingOptions.AllowStoredOutputEnabled) + { + return HealthCheckResult.Healthy( + "Stored output: the container allows the agent's own service to store responses, so its configuration is left alone."); + } + + List storingAgents = []; + var checkedAgents = 0; + + foreach (var agent in this.ResolveAgents()) + { + if (agent.GetService() is null) + { + // Hosting only reaches the store setting through ChatClientAgent's chat options, so any + // other agent runs untouched and there is nothing to report. + continue; + } + + checkedAgents++; + if (await this.StoresItsOwnResponsesAsync(agent, cancellationToken).ConfigureAwait(false)) + { + storingAgents.Add(agent.Name ?? agent.Id); + } + } + + if (storingAgents.Count > 0) + { + return new HealthCheckResult( + status: context.Registration.FailureStatus, + description: string.Create( + CultureInfo.InvariantCulture, + $"Stored output: {storingAgents.Count} registered agent(s) ask their own service to store the responses they produce. {HostedStoredOutputCompatibility.MisconfiguredAgentMessage}"), + data: new Dictionary(StringComparer.Ordinal) { ["storingAgents"] = storingAgents }); + } + + return HealthCheckResult.Healthy( + string.Create(CultureInfo.InvariantCulture, $"Stored output: {checkedAgents} agent(s) checked, none asking to store responses of their own.")); + } + + /// + /// Runs the agent with its chat client replaced by one that calls nothing, and reports whether the + /// request the agent built asks for the response to be stored. + /// + /// + /// The run carries no chat options of its own, so the agent's own configuration is what reaches the + /// probe. Overriding the setting here, the way the request handler does per turn, would only show + /// the override back. + /// + private async Task StoresItsOwnResponsesAsync(AIAgent agent, CancellationToken cancellationToken) + { + var probe = new StoredOutputProbeChatClient(); + var runOptions = new ChatClientAgentRunOptions { ChatClientFactory = _ => probe }; + + try + { + await agent.RunAsync([], options: runOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The agent could not complete a run it was never really asked to answer. That says nothing + // about how it stores responses, so it is not held against it. + if (this._logger?.IsEnabled(LogLevel.Debug) is true) + { + this._logger.LogDebug(ex, "Could not probe the stored output setting for agent '{AgentName}'.", agent.Name); + } + + return false; + } + + if (probe.StoredOutputEnabled is null && this._logger?.IsEnabled(LogLevel.Debug) is true) + { + this._logger.LogDebug( + "Agent '{AgentName}' builds a request that carries no stored output setting, so whether its service would store responses could not be determined.", + agent.Name); + } + + return probe.StoredOutputEnabled is true; + } + + /// + /// Every agent this container can serve: the ones registered under a name, plus the default. + /// + private List ResolveAgents() + { + var agents = new List(this._serviceProvider.GetKeyedServices(KeyedService.AnyKey)); + + if (this._serviceProvider.GetService() is { } defaultAgent && !agents.Contains(defaultAgent)) + { + agents.Add(defaultAgent); + } + + return agents; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs index 3a4e95b041c..cf0779fa274 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs @@ -7,8 +7,6 @@ using System.Text.Json; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Extensions.AI; -using ChatCompletionOptions = OpenAI.Chat.ChatCompletionOptions; -using CreateResponseOptions = OpenAI.Responses.CreateResponseOptions; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; using SdkTextContent = Azure.AI.AgentServer.Responses.Models.TextContent; @@ -93,8 +91,15 @@ public static List ConvertOutputItemsToMessages(IReadOnlyList, if any, so a request that has /// to set one of its own can run it rather than replace it. /// + /// + /// How this container was configured. When it allows the agent's own service to store responses, + /// the setting is left exactly as the container configured it. + /// /// A configured instance. - public static ChatOptions ConvertToChatOptions(CreateResponse request, Func? agentRawRepresentationFactory = null) + public static ChatOptions ConvertToChatOptions( + CreateResponse request, + Func? agentRawRepresentationFactory = null, + FoundryResponsesOptions? hostingOptions = null) { var options = new ChatOptions { @@ -107,40 +112,20 @@ public static ChatOptions ConvertToChatOptions(CreateResponse request, Func - { - switch (agentRawRepresentationFactory?.Invoke(chatClient)) - { - case CreateResponseOptions responseOptions: - responseOptions.StoredOutputEnabled = false; - return responseOptions; - - case ChatCompletionOptions completionOptions: - completionOptions.StoredOutputEnabled = false; - return completionOptions; - - case { } configuredByTheAgent: - return configuredByTheAgent; - - default: - return new CreateResponseOptions { StoredOutputEnabled = false }; - } - }; + HostedStoredOutputCompatibility.DisableStoredOutput( + options, + agentRawRepresentationFactory, + hostingOptions?.IncludeReasoningEncryptedContent ?? true); return options; } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index 8bd86abee5e..28152362cb4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -51,13 +51,18 @@ public static class FoundryHostingExtensions /// /// /// The service collection. + /// + /// Optional callback to configure , for example to allow the + /// agent's own service to store the responses it produces. + /// /// The service collection for chaining. - public static IServiceCollection AddFoundryResponses(this IServiceCollection services) + public static IServiceCollection AddFoundryResponses(this IServiceCollection services, Action? configure = null) { ArgumentNullException.ThrowIfNull(services); services.AddResponsesServer(); services.AddHealthChecks(); ConfigureFoundryListenPort(services); + ConfigureFoundryResponsesOptions(services, configure); services.TryAddSingleton(_ => FileSystemAgentSessionStore.CreateDefault()); services.TryAddSingleton(); return services; @@ -86,8 +91,16 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser /// The service collection. /// The agent instance to register. /// The agent session store to use for managing agent sessions server-side. If null, a file-system session store is used, rooted at /.checkpoints when running in a Foundry hosted environment and {cwd}/.checkpoints locally. + /// + /// Optional callback to configure , for example to allow the + /// agent's own service to store the responses it produces. + /// /// The service collection for chaining. - public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null) + public static IServiceCollection AddFoundryResponses( + this IServiceCollection services, + AIAgent agent, + AgentSessionStore? agentSessionStore = null, + Action? configure = null) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(agent); @@ -95,6 +108,7 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser services.AddResponsesServer(); services.AddHealthChecks(); ConfigureFoundryListenPort(services); + ConfigureFoundryResponsesOptions(services, configure); agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault(); if (!string.IsNullOrWhiteSpace(agent.Name)) @@ -112,6 +126,41 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser return services; } + /// + /// Applies the caller's and registers the readiness check that + /// reports an agent configured to have its own service store the responses it produces. + /// + /// + /// The check is registered on the same /readiness pipeline that + /// maps, so a container that would record the conversation twice never takes traffic. + /// AddCheck does not dedupe by name, so a repeated registration is guarded here. + /// + private static void ConfigureFoundryResponsesOptions(IServiceCollection services, Action? configure) + { + if (configure is not null) + { + services.Configure(configure); + } + + const string HealthCheckName = "foundry-stored-output"; + services.Configure(opts => + { + foreach (var existing in opts.Registrations) + { + if (string.Equals(existing.Name, HealthCheckName, StringComparison.Ordinal)) + { + return; + } + } + + opts.Registrations.Add(new HealthCheckRegistration( + name: HealthCheckName, + factory: sp => ActivatorUtilities.CreateInstance(sp), + failureStatus: HealthStatus.Unhealthy, + tags: ["foundry", "responses", "readiness"])); + }); + } + /// /// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolboxes /// MCP proxy at startup and provides MCP tools to . diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/StoredOutputProbeChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/StoredOutputProbeChatClient.cs new file mode 100644 index 00000000000..152f0fe6029 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/StoredOutputProbeChatClient.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// A chat client that answers without calling anything, and records whether the request it was handed +/// asks for the response to be stored. +/// +/// +/// +/// Used by to run an agent for real, through +/// ChatClientAgentRunOptions.ChatClientFactory, and see the request that agent builds on its own. +/// Nothing hosting would add later is applied here, so what this observes is the container's own +/// configuration. Nothing leaves the process either. +/// +/// +/// The reply carries no conversation id, so the agent is not led to believe a service kept the +/// conversation. +/// +/// +internal sealed class StoredOutputProbeChatClient : IChatClient +{ + /// + /// Whether the observed request asked for the response to be stored, or when + /// the run never reached the client or the request carries no such setting. + /// + public bool? StoredOutputEnabled { get; private set; } + + /// + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + this.Observe(options); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, string.Empty))); + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask.ConfigureAwait(false); + this.Observe(options); + yield return new ChatResponseUpdate(ChatRole.Assistant, string.Empty); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceKey is null && serviceType?.IsInstanceOfType(this) is true ? this : null; + + /// + public void Dispose() + { + } + + private void Observe(ChatOptions? options) + { + // Building the request is what the agent's chat client would do next, so running the factory + // here shows the very setting that would have gone out. + var rawRepresentation = options?.RawRepresentationFactory?.Invoke(this); + this.StoredOutputEnabled = HostedStoredOutputCompatibility.ReadsAsStoringResponses(rawRepresentation); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs index ae711b6902b..7e0a8014e1e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -14,9 +14,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Moq; using ChatCompletionOptions = OpenAI.Chat.ChatCompletionOptions; using CreateResponseOptions = OpenAI.Responses.CreateResponseOptions; +using IncludedResponseProperty = OpenAI.Responses.IncludedResponseProperty; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; @@ -1044,7 +1046,7 @@ await DrainEventsAsync(handler.CreateAsync( } [Fact] - public async Task CreateAsync_AgentWhoseChatClientReportsAConversationId_IsRejectedAsync() + public async Task CreateAsync_AgentWhoseChatClientStoredTheTurn_FailsTheRequestAsync() { // Arrange: a chat client whose underlying service keeps the conversation and says so on every // answer, whatever the host asks of it. @@ -1057,22 +1059,49 @@ public async Task CreateAsync_AgentWhoseChatClientReportsAConversationId_IsRejec var agent = new ChatClientAgent(client.Object); var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); - await DrainEventsAsync(handler.CreateAsync( + // Act + Assert: the hosting service already recorded this turn, so a second recording held by + // the service behind the chat client has no owner and no way to stay in step. The container was + // deployed wrong, which is nothing the caller can fix, so the very first turn fails as a server + // error rather than quietly building a conversation nobody can reconcile. + var failure = await Assert.ThrowsAsync(() => DrainEventsAsync(handler.CreateAsync( NewConversationRequest("conv-rejected", "first question", store: true), NewContextServing("resp_" + new string('3', 45) + "0", []), - CancellationToken.None)); - - // Act + Assert: a hosted agent's conversation is recorded by the AgentServer SDK's storage - // provider, so a second one held by the service behind the chat client has no owner and no way - // to stay in step. The next turn is refused as a plain bad request rather than run against a - // conversation nobody can reconcile. - var failure = await Assert.ThrowsAsync(() => DrainEventsAsync(handler.CreateAsync( - NewConversationRequest("conv-rejected", "second question", store: true), - NewContextServing("resp_" + new string('3', 45) + "1", []), CancellationToken.None))); - Assert.Equal("service_managed_chat_history_not_supported", failure.Error.Code); - Assert.Equal(400, failure.StatusCode); + Assert.Equal("agent_stored_output_not_disabled", failure.Error.Code); + Assert.Equal(501, failure.StatusCode); + } + + [Fact] + public async Task CreateAsync_AgentWhoseChatClientStoredTheTurn_AndStoringIsAllowed_SucceedsAsync() + { + // Arrange: the same agent, in a container that opted into keeping its own recording. + var client = new Mock(); + client.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(() => ToAsyncEnumerableUpdatesAsync( + new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1", ConversationId = "conv-downstream" })); + + var agent = new ChatClientAgent(client.Object); + var handler = BuildHandlerWith( + agent, + new FakeHostedSessionIsolationKeyProvider(), + new InMemoryAgentSessionStore(), + hostingOptions: new FoundryResponsesOptions { AllowStoredOutputEnabled = true }); + + // Act + var names = new List(); + await foreach (var evt in handler.CreateAsync( + NewConversationRequest("conv-allowed", "first question", store: true), + NewContextServing("resp_" + new string('4', 45) + "0", []), + CancellationToken.None)) + { + names.Add(evt.GetType().Name); + } + + // Assert: nothing is checked and nothing is refused. The agent is left to run against its own + // service exactly as the container built it. + Assert.DoesNotContain("ResponseFailedEvent", names); } [Fact] @@ -1130,6 +1159,83 @@ await DrainEventsAsync(handler.CreateAsync( Assert.NotNull(sentToTheClient?.RawRepresentationFactory); var raw = Assert.IsType(sentToTheClient!.RawRepresentationFactory!(client.Object)); Assert.False(raw.StoredOutputEnabled); + + // And because nothing is stored, reasoning would be lost between turns unless its encrypted + // form is asked for, which is what AsIChatClientWithStoredOutputDisabled does too. + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, raw.IncludedProperties); + } + + [Fact] + public async Task CreateAsync_ReasoningEncryptedContentTurnedOff_IsNotAskedForAsync() + { + // Arrange: a container that does not want the encrypted reasoning tokens asked for. + ChatOptions? sentToTheClient = null; + var client = new Mock(); + client.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns((IEnumerable _, ChatOptions? options, CancellationToken _) => + { + sentToTheClient = options; + return ToAsyncEnumerableUpdatesAsync(new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1" }); + }); + + var agent = new ChatClientAgent(client.Object); + var handler = BuildHandlerWith( + agent, + new FakeHostedSessionIsolationKeyProvider(), + new InMemoryAgentSessionStore(), + hostingOptions: new FoundryResponsesOptions { IncludeReasoningEncryptedContent = false }); + + // Act + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-no-reasoning", "a question", store: true), + NewContextServing("resp_" + new string('6', 45) + "0", []), + CancellationToken.None)); + + // Assert: storing is still turned off, but nothing else is added to the request. + var raw = Assert.IsType(sentToTheClient!.RawRepresentationFactory!(client.Object)); + Assert.False(raw.StoredOutputEnabled); + Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, raw.IncludedProperties); + } + + [Fact] + public async Task CreateAsync_StoringIsAllowed_LeavesTheAgentsOwnSettingAloneAsync() + { + // Arrange: a container that opted into keeping its own recording, with an agent that asks for + // its responses to be stored. + ChatOptions? sentToTheClient = null; + var client = new Mock(); + client.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns((IEnumerable _, ChatOptions? options, CancellationToken _) => + { + sentToTheClient = options; + return ToAsyncEnumerableUpdatesAsync(new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1" }); + }); + + var agent = new ChatClientAgent(client.Object, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = true }, + }, + }); + + var handler = BuildHandlerWith( + agent, + new FakeHostedSessionIsolationKeyProvider(), + new InMemoryAgentSessionStore(), + hostingOptions: new FoundryResponsesOptions { AllowStoredOutputEnabled = true }); + + // Act + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-allowed-setting", "a question", store: true), + NewContextServing("resp_" + new string('8', 45) + "0", []), + CancellationToken.None)); + + // Assert: what the container configured is what goes out, untouched. + var raw = Assert.IsType(sentToTheClient!.RawRepresentationFactory!(client.Object)); + Assert.True(raw.StoredOutputEnabled); } [Fact] @@ -1767,12 +1873,21 @@ private static (CreateResponse Request, Mock Context) BuildUser return (request, ctx); } - private static AgentFrameworkResponseHandler BuildHandlerWith(AIAgent agent, HostedSessionIsolationKeyProvider provider, AgentSessionStore store) + private static AgentFrameworkResponseHandler BuildHandlerWith( + AIAgent agent, + HostedSessionIsolationKeyProvider provider, + AgentSessionStore store, + FoundryResponsesOptions? hostingOptions = null) { var services = new ServiceCollection(); services.AddSingleton(store); services.AddSingleton(agent); services.AddSingleton(provider); + if (hostingOptions is not null) + { + services.AddSingleton(Options.Create(hostingOptions)); + } + return new AgentFrameworkResponseHandler(services.BuildServiceProvider(), NullLogger.Instance); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedStoredOutputHealthCheckTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedStoredOutputHealthCheckTests.cs new file mode 100644 index 00000000000..b44b5f96cc1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedStoredOutputHealthCheckTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Moq; +using CreateResponseOptions = OpenAI.Responses.CreateResponseOptions; + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +/// +/// Covers the readiness check that reports an agent configured to have its own service store the +/// responses it produces, so a container recording the conversation twice never takes traffic. +/// +public class HostedStoredOutputHealthCheckTests +{ + [Fact] + public async Task CheckHealthAsync_AgentThatAsksNotToStore_IsHealthyAsync() + { + // Arrange: an agent built the way a hosted container should build one. + var agent = new ChatClientAgent( + NewSilentChatClient(), + new ChatClientAgentOptions + { + Name = "asks-not-to-store", + ChatOptions = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }, + }, + }); + + var check = BuildCheckFor(agent); + + // Act + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + // Assert + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task CheckHealthAsync_AgentThatAsksToStore_IsUnhealthyAsync() + { + // Arrange: an agent that asks its own service to keep the responses it produces, which is a + // second recording of a conversation the hosting service already keeps. + var agent = new ChatClientAgent( + NewSilentChatClient(), + new ChatClientAgentOptions + { + Name = "asks-to-store", + ChatOptions = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = true }, + }, + }); + + var check = BuildCheckFor(agent); + + // Act + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + // Assert: the deployment is reported, and the agent named, before it can take traffic. + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("asks-to-store", (List)result.Data["storingAgents"]); + } + + [Fact] + public async Task CheckHealthAsync_StoringIsAllowed_SkipsTheCheckAsync() + { + // Arrange: the same agent, in a container that opted into keeping its own recording. + var agent = new ChatClientAgent( + NewSilentChatClient(), + new ChatClientAgentOptions + { + Name = "asks-to-store", + ChatOptions = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = true }, + }, + }); + + var check = BuildCheckFor(agent, new FoundryResponsesOptions { AllowStoredOutputEnabled = true }); + + // Act + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + // Assert: the container's own choice is not second-guessed. + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task CheckHealthAsync_AgentWithNoSettingOfItsOwn_IsHealthyAsync() + { + // Arrange: an agent that builds no request of its own, so there is nothing to read. Hosting + // turns storing off per run anyway, and an unknown is not worth an outage. + var check = BuildCheckFor(new ChatClientAgent(NewSilentChatClient(), new ChatClientAgentOptions { Name = "says-nothing" })); + + // Act + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + // Assert + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task CheckHealthAsync_AgentThatIsNotAChatClientAgent_IsHealthyAsync() + { + // Arrange: hosting only reaches the setting through a ChatClientAgent's chat options, so any + // other agent runs untouched and there is nothing to report about it. + var agent = new Mock(); + agent.Setup(a => a.GetService(It.IsAny(), It.IsAny())).Returns(null!); + + var check = BuildCheckFor(agent.Object); + + // Act + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + // Assert + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + private static HostedStoredOutputHealthCheck BuildCheckFor(AIAgent agent, FoundryResponsesOptions? hostingOptions = null) + { + var services = new ServiceCollection(); + services.AddSingleton(agent); + return new HostedStoredOutputHealthCheck( + services.BuildServiceProvider(), + Options.Create(hostingOptions ?? new FoundryResponsesOptions())); + } + + private static HealthCheckContext NewContext() => new() + { + Registration = new HealthCheckRegistration( + "foundry-stored-output", + _ => new Mock().Object, + HealthStatus.Unhealthy, + tags: null), + }; + + /// A chat client that answers without calling anything and keeps no conversation. + private static IChatClient NewSilentChatClient() + { + var client = new Mock(); + client.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(() => OneUpdateAsync()); + client.Setup(c => c.GetResponseAsync( + It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + return client.Object; + } + + private static async IAsyncEnumerable OneUpdateAsync() + { + await Task.CompletedTask; + yield return new ChatResponseUpdate(ChatRole.Assistant, "ok"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs index f49c669f66d..472f7f2b4e3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs @@ -74,8 +74,10 @@ public void AddFoundryResponses_WithAgent_RegistersAgentAndHandler() public void AddFoundryResponses_WithNullAgent_ThrowsArgumentNullException() { var services = new ServiceCollection(); + // Cast to bind the agent overload: the parameterless overload also accepts a single null + // (as its optional configure callback), so the cast keeps this test targeting the agent path. Assert.Throws( - () => services.AddFoundryResponses(null!)); + () => services.AddFoundryResponses((AIAgent)null!)); } [Fact] From 4eb4c9c21830539792e63cd668d18bf511ec0530 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:58:55 +0100 Subject: [PATCH 2/9] Rename the stored-session flag to say what it means --- .../AgentFrameworkResponseHandler.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 7f735a1e369..d69f7022006 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -385,11 +385,11 @@ await this._toolboxService // NOTE: C# forbids 'yield return' inside a try block that has a catch clause, // and inside catch blocks. We use a flag to defer the yield to outside the try/catch. bool emittedTerminal = false; - bool agentKeptItsOwnConversation = false; + bool notAllowedAgentSessionStoredInTheService = false; // The session picks up the id of any conversation the agent's own service kept, at the end of // the run and before the agent reports anything else about it. - bool AgentKeptItsOwnConversation() => + bool NotAllowedAgentSessionStoredInTheService() => !allowStoredOutputEnabled && session is ChatClientAgentSession { ConversationId: not null }; var enumerator = OutputConverter.ConvertUpdatesToEventsAsync( @@ -464,9 +464,9 @@ bool AgentKeptItsOwnConversation() => // conversation: the session picks up that id before the agent goes on to complain // about having two history managers. Report the deployment problem that caused it // rather than the confusing symptom. - if (AgentKeptItsOwnConversation()) + if (NotAllowedAgentSessionStoredInTheService()) { - agentKeptItsOwnConversation = true; + notAllowedAgentSessionStoredInTheService = true; throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError(); } @@ -498,19 +498,19 @@ bool AgentKeptItsOwnConversation() => // The run is over, so the session now carries whatever the agent's own service handed back. // A conversation id there means that service kept this turn, which is a second recording of // a conversation the hosting service already recorded. - agentKeptItsOwnConversation |= AgentKeptItsOwnConversation(); + notAllowedAgentSessionStoredInTheService |= NotAllowedAgentSessionStoredInTheService(); // Persist session after streaming completes (successful or not). The user id partitions the // persisted session per end user, mirroring the load above so multi-turn continuity is preserved. // A session pointing at a conversation the agent's own service kept is never persisted: every // later turn would resume onto that conversation and keep the double recording going. - if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId) && !agentKeptItsOwnConversation) + if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId) && !notAllowedAgentSessionStoredInTheService) { await sessionStore.SaveSessionAsync(agent, agentSessionId, session, resolvedUserId, cancellationToken).ConfigureAwait(false); } } - if (agentKeptItsOwnConversation) + if (notAllowedAgentSessionStoredInTheService) { this._logger.LogError( "Agent '{AgentName}' had this response stored by the service behind its chat client, so the conversation is being recorded twice.", From 8de6b5d7ace4528b72e22234b3f721248c48b518 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:17:52 +0100 Subject: [PATCH 3/9] Say plainly what server-side storage does to a hosted turn --- .../HostedStoredOutputCompatibility.cs | 11 +++++++++-- .../HostedStoredOutputHealthCheck.cs | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs index 03e30c396ba..65a3dab72a4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs @@ -41,10 +41,17 @@ internal static class HostedStoredOutputCompatibility internal const string MisconfiguredAgentErrorCode = "agent_stored_output_not_disabled"; /// - /// Message shared by the readiness probe and the per-request check, naming both the cause and the fix. + /// What goes wrong and how to fix it, shared by the readiness probe and the per-request check so + /// both name the same cause and the same two ways out. + /// + internal const string MisconfiguredAgentExplanation = + "Server-side storage must be off for a hosted agent. With it on, the agent's own service records a separate conversation and response that nothing tracks, while the hosted agent service records its own conversation for the same request, so one exchange is written twice into two places nobody reconciles. Build the agent's chat client so it does not store responses (for example with AsIChatClientWithStoredOutputDisabled), or set FoundryResponsesOptions.AllowStoredOutputEnabled to true to keep that second recording on purpose."; + + /// + /// Message carried by the error raised when a turn was stored by the agent's own service. /// internal const string MisconfiguredAgentMessage = - "The service behind the agent's chat client stored this response, so the conversation is being recorded twice: once by the hosted agent service and once by that service. Build the agent's chat client so it does not store responses (for example with AsIChatClientWithStoredOutputDisabled), or set FoundryResponsesOptions.AllowStoredOutputEnabled to true to keep the second recording on purpose."; + "The service behind the agent's chat client stored this response. " + MisconfiguredAgentExplanation; /// /// Returns the error to throw when the agent's own service kept the turn. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs index 6495a7a48cd..17b70f11bd9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs @@ -88,7 +88,7 @@ public async Task CheckHealthAsync(HealthCheckContext context status: context.Registration.FailureStatus, description: string.Create( CultureInfo.InvariantCulture, - $"Stored output: {storingAgents.Count} registered agent(s) ask their own service to store the responses they produce. {HostedStoredOutputCompatibility.MisconfiguredAgentMessage}"), + $"Stored output: {storingAgents.Count} registered agent(s) have server-side storage enabled. {HostedStoredOutputCompatibility.MisconfiguredAgentExplanation}"), data: new Dictionary(StringComparer.Ordinal) { ["storingAgents"] = storingAgents }); } From fb518e4f2c84838372639cbbb2b8c4bafafb3bb1 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:25:20 +0100 Subject: [PATCH 4/9] Read the store gate as an allow, and align the messages The flag that decides whether the session may be saved reads as an allow at every use, while the test it comes from keeps saying what is not allowed, so neither side has to be read inside out. The wording now matches what the readiness probe says: server side storage must be off, because with it on the agent's own service records a conversation and response nothing tracks while the hosted agent records its own for the same request. The message the readiness probe raises no longer travels through a shared constant, since each check says its own thing. --- .../AgentFrameworkResponseHandler.cs | 12 ++++++------ .../HostedStoredOutputCompatibility.cs | 19 +++++-------------- .../HostedStoredOutputHealthCheck.cs | 2 +- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index d69f7022006..8bed58260a6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -385,7 +385,7 @@ await this._toolboxService // NOTE: C# forbids 'yield return' inside a try block that has a catch clause, // and inside catch blocks. We use a flag to defer the yield to outside the try/catch. bool emittedTerminal = false; - bool notAllowedAgentSessionStoredInTheService = false; + bool allowAgentSessionStoreInTheService = true; // The session picks up the id of any conversation the agent's own service kept, at the end of // the run and before the agent reports anything else about it. @@ -466,7 +466,7 @@ bool NotAllowedAgentSessionStoredInTheService() => // rather than the confusing symptom. if (NotAllowedAgentSessionStoredInTheService()) { - notAllowedAgentSessionStoredInTheService = true; + allowAgentSessionStoreInTheService = false; throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError(); } @@ -498,22 +498,22 @@ bool NotAllowedAgentSessionStoredInTheService() => // The run is over, so the session now carries whatever the agent's own service handed back. // A conversation id there means that service kept this turn, which is a second recording of // a conversation the hosting service already recorded. - notAllowedAgentSessionStoredInTheService |= NotAllowedAgentSessionStoredInTheService(); + allowAgentSessionStoreInTheService &= !NotAllowedAgentSessionStoredInTheService(); // Persist session after streaming completes (successful or not). The user id partitions the // persisted session per end user, mirroring the load above so multi-turn continuity is preserved. // A session pointing at a conversation the agent's own service kept is never persisted: every // later turn would resume onto that conversation and keep the double recording going. - if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId) && !notAllowedAgentSessionStoredInTheService) + if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId) && allowAgentSessionStoreInTheService) { await sessionStore.SaveSessionAsync(agent, agentSessionId, session, resolvedUserId, cancellationToken).ConfigureAwait(false); } } - if (notAllowedAgentSessionStoredInTheService) + if (!allowAgentSessionStoreInTheService) { this._logger.LogError( - "Agent '{AgentName}' had this response stored by the service behind its chat client, so the conversation is being recorded twice.", + "Agent '{AgentName}' should not have server side storage enabled. This produced a new untracked conversation/response in the server while the hosted agent also generated a conversation for the request of the agent.", agent.Name); throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError(); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs index 65a3dab72a4..2b1b78447fe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs @@ -40,24 +40,15 @@ internal static class HostedStoredOutputCompatibility /// internal const string MisconfiguredAgentErrorCode = "agent_stored_output_not_disabled"; - /// - /// What goes wrong and how to fix it, shared by the readiness probe and the per-request check so - /// both name the same cause and the same two ways out. - /// - internal const string MisconfiguredAgentExplanation = - "Server-side storage must be off for a hosted agent. With it on, the agent's own service records a separate conversation and response that nothing tracks, while the hosted agent service records its own conversation for the same request, so one exchange is written twice into two places nobody reconciles. Build the agent's chat client so it does not store responses (for example with AsIChatClientWithStoredOutputDisabled), or set FoundryResponsesOptions.AllowStoredOutputEnabled to true to keep that second recording on purpose."; - - /// - /// Message carried by the error raised when a turn was stored by the agent's own service. - /// - internal const string MisconfiguredAgentMessage = - "The service behind the agent's chat client stored this response. " + MisconfiguredAgentExplanation; - /// /// Returns the error to throw when the agent's own service kept the turn. /// internal static ResponsesApiException CreateMisconfiguredAgentError() => - new(new Error(MisconfiguredAgentErrorCode, MisconfiguredAgentMessage), MisconfiguredAgentStatusCode); + new( + new Error( + MisconfiguredAgentErrorCode, + "The agent should not have server side storage enabled. This produced a new untracked conversation/response in the server while the hosted agent also generated a conversation for the request of the agent. This setting is only allowed when enabling the FoundryResponsesOptions.AllowStoredOutputEnabled flag, which leaves the agent's own storage setting untouched and keeps that second recording on purpose."), + MisconfiguredAgentStatusCode); /// /// Installs a factory on that turns storage off on the request the agent's diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs index 17b70f11bd9..47db5e256fa 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs @@ -88,7 +88,7 @@ public async Task CheckHealthAsync(HealthCheckContext context status: context.Registration.FailureStatus, description: string.Create( CultureInfo.InvariantCulture, - $"Stored output: {storingAgents.Count} registered agent(s) have server-side storage enabled. {HostedStoredOutputCompatibility.MisconfiguredAgentExplanation}"), + $"Stored output: {storingAgents.Count} registered agent(s) should not have server side storage enabled. This will produce a new untracked conversation/response in the server while the hosted agent will also generate a conversation for the request of the agent. This setting is only allowed when enabling the FoundryResponsesOptions.AllowStoredOutputEnabled flag, which leaves the agent's own storage setting untouched and keeps that second recording on purpose."), data: new Dictionary(StringComparer.Ordinal) { ["storingAgents"] = storingAgents }); } From 43a77210fdd6cd113bddefc2ee20c0f63fb47f12 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:30:42 +0100 Subject: [PATCH 5/9] Address the review comments left open on the merged PR Five points raised on #7525 were marked resolved without a code change, and the code they pointed at was still there. A hosted workflow session is now recognised by its full type name, so a session of the same short name from another namespace is not mistaken for one. The test double moves into the namespace it stands in for, otherwise it would no longer exercise the check. The per-run chat history provider is handed over on AgentRunOptions.AdditionalProperties, which ChatClientAgent copies onto the chat options with precedence, rather than being written onto the chat options here. The test that pins down who supplies the history said the agent's own provider is used, while it asserts the opposite, so it is renamed after what it checks. Reading a response back in the hosted integration tests no longer swallows every failure: only "not stored" and "not readable through this endpoint" are, so an expired token or a server fault cannot be mistaken for an absent response and pass the test. Also fills in the readiness message for the case where storing is explicitly allowed. --- .../AgentFrameworkResponseHandler.cs | 34 +++++++++---------- .../HostedStoredOutputHealthCheck.cs | 2 +- .../Fixtures/HostedAgentFixture.cs | 11 ++++-- .../AgentFrameworkResponseHandlerTests.cs | 17 ++++------ .../WorkflowSession.cs | 16 +++++++++ 5 files changed, 49 insertions(+), 31 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/WorkflowSession.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 8bed58260a6..128568772e6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -41,9 +41,10 @@ public class AgentFrameworkResponseHandler : ResponseHandler /// /// The session type a hosted workflow runs with. It is internal to Microsoft.Agents.AI.Workflows, /// so it is recognised by name: taking a reference to it would mean opening that package's internals, - /// which cannot be done here because both packages compile the same shared source files. + /// which cannot be done here because both packages compile the same shared source files. The full name + /// is matched so a session of the same short name from another namespace is not mistaken for it. /// - private const string WorkflowSessionTypeName = "WorkflowSession"; + private const string WorkflowSessionTypeName = "Microsoft.Agents.AI.Workflows.WorkflowSession"; /// /// Initializes a new instance of the class @@ -183,7 +184,7 @@ public override async IAsyncEnumerable CreateAsync( // Add the chat history to the request. Workflow sessions accumulate previous turns and must not // get the full history again; their types are internal, hence the check on the type name. if (sessionLoadedFromStore is null - || !string.Equals(sessionLoadedFromStore.GetType().Name, WorkflowSessionTypeName, StringComparison.Ordinal)) + || !string.Equals(sessionLoadedFromStore.GetType().FullName, WorkflowSessionTypeName, StringComparison.Ordinal)) { var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); if (history.Count > 0) @@ -215,20 +216,6 @@ public override async IAsyncEnumerable CreateAsync( hostingOptions); chatOptions.Instructions = request.Instructions; - // Everything the agent needs for this turn is already in the input, so the provider it would - // otherwise run is replaced for the duration by one that keeps its messages in memory and is - // dropped when the run ends. Serving from a longer-lived one would deliver the conversation - // twice, and storing into it would leave a copy the hosting service never sees. - // - // A container that allows its own service to keep the conversation has taken history over, so - // nothing is put in its way: the agent already stands its own provider down when that service - // hands back a conversation id, and an override here would only collide with it. - if (!allowStoredOutputEnabled) - { - chatOptions.AdditionalProperties ??= []; - chatOptions.AdditionalProperties.Add(new VolatileChatHistoryProvider()); - } - // Inject Foundry Toolbox tools when the toolbox service is available. // // Two sources are considered: @@ -371,7 +358,20 @@ await this._toolboxService } } + // Everything the agent needs for this turn is already in the input, so the provider it would + // otherwise run is replaced for the duration by one that keeps its messages in memory and is + // dropped when the run ends. Serving from a longer-lived one would deliver the conversation + // twice, and storing into it would leave a copy the hosting service never sees. + // + // A container that allows its own service to keep the conversation has taken history over, so + // nothing is put in its way: the agent already stands its own provider down when that service + // hands back a conversation id, and an override here would only collide with it. var options = new ChatClientAgentRunOptions(chatOptions); + if (!allowStoredOutputEnabled) + { + options.AdditionalProperties ??= []; + options.AdditionalProperties.Add(new VolatileChatHistoryProvider()); + } // 6. Set up consent context for -32006 OAuth consent interception. // We create a linked CTS so the consent-aware tool wrapper can cancel the agent diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs index 47db5e256fa..ee2377ff6f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs @@ -60,7 +60,7 @@ public async Task CheckHealthAsync(HealthCheckContext context if (this._hostingOptions.AllowStoredOutputEnabled) { return HealthCheckResult.Healthy( - "Stored output: the container allows the agent's own service to store responses, so its configuration is left alone."); + "The hosted agent backend storage usage was detected and the stored output enabled setting is explicitly allowing it."); } List storingAgents = []; diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs index 5b895d69643..71541c87a90 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; using System.Linq; @@ -200,6 +201,12 @@ public async Task CountConversationItemsAsync(string conversationId) /// are tried, because a response created inside the container is not necessarily reachable through /// the same endpoint as one created for the caller. /// + /// + /// Only "not there, or not through this endpoint" is swallowed: 404 for a response that was never + /// stored, and 403 session_not_accessible for one the project-level client may not read. + /// Anything else, an expired token or a server fault for instance, is left to surface, because a + /// caller reading this as "nothing is stored" would turn a broken run into a passing test. + /// public async Task TryReadResponseAsync(string responseId) { foreach (var responses in new[] @@ -216,9 +223,9 @@ public async Task CountConversationItemsAsync(string conversationId) return response.Value; } } - catch + catch (ClientResultException ex) when (ex.Status is 404 or 403) { - // Not readable through this endpoint; try the next one. + // Not stored, or not readable through this endpoint; try the next one. } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs index 7e0a8014e1e..8baaa0a5879 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -870,9 +870,9 @@ private static ResponseContext NewServingContext(string responseId, IReadOnlyLis // regression tests for the behaviour this region replaced: the handler used to fetch the platform // history and prepend it to the input of every turn, while a ChatClientAgent independently ran its // own ChatHistoryProvider. Against that older handler these three fail: - // - DoesNotCopyPlatformHistoryIntoTheSession (the service's turns ended up in the session) - // - DoesNotAskItToStorePlatformHistory (and in a custom provider's own database) - // - UsesThatProviderInsteadOfThePlatform (both sources reached the model at once) + // - DoesNotCopyPlatformHistoryIntoTheSession (the service's turns ended up in the session) + // - DoesNotAskItToStorePlatformHistory (and in a custom provider's own database) + // - TakesThePlatformHistoryInsteadOfThatProvider (both sources reached the model at once) [Fact] public async Task CreateAsync_AgentWithoutProviderPipeline_ReceivesPlatformHistoryInInputAsync() @@ -961,7 +961,7 @@ public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_DoesNotCopyP } [Fact] - public async Task CreateAsync_ChatClientAgentWithHistoryProvider_UsesThatProviderInsteadOfThePlatformAsync() + public async Task CreateAsync_ChatClientAgentWithHistoryProvider_TakesThePlatformHistoryInsteadOfThatProviderAsync() { // Arrange: the agent was created with its own chat history provider. var captured = new List(); @@ -1547,7 +1547,7 @@ protected override Task RunCoreAsync( protected override ValueTask CreateSessionCoreAsync( CancellationToken cancellationToken = default) => - new(new WorkflowSession()); + new(new Workflows.WorkflowSession()); protected override ValueTask SerializeSessionCoreAsync( AgentSession session, @@ -1559,12 +1559,7 @@ protected override ValueTask DeserializeSessionCoreAsync( JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) => - new(new WorkflowSession()); - } - - /// Carries the name the handler looks for; the real one is internal to its own package. - private sealed class WorkflowSession : AgentSession - { + new(new Workflows.WorkflowSession()); } private sealed class CancellationCheckingAgent : AIAgent diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/WorkflowSession.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/WorkflowSession.cs new file mode 100644 index 00000000000..aebca80e886 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/WorkflowSession.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Stands in for the session a hosted workflow runs with, which the handler recognises by its full +/// type name because the real one is internal to its own package. +/// +/// +/// Declared in the real type's namespace on purpose: the handler matches the full name, so a double +/// declared anywhere else would not exercise the check. The real type is internal to another assembly, +/// so nothing here is ambiguous with it. +/// +internal sealed class WorkflowSession : AgentSession +{ +} From cfba2b3812618c66d0ec98694c9b844e547ca242 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:11:58 +0100 Subject: [PATCH 6/9] Address the review on #7572 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all real, all in code this branch introduced. A container that allows its own service to keep the conversation was still being handed the platform history on every turn. That service replays the earlier turns itself, so the model was getting each of them twice, which is the very thing this work exists to prevent. The history now goes in only while nothing else holds it: the first turn of such a conversation still gets it, and the service takes over from there. A turn that fails for storing downstream was announcing itself as completed first and only then failing, leaving the caller with two different answers for the same turn. The completed event is now held back until the run is wound up and the session can be read, because the id of any conversation the agent's service kept only lands there at the very end. The readiness probe replaced the chat client but left the agent's chat history provider running, so a provider backed by a database was reading and writing on every probe, and adding the probe's empty turn to a real conversation. It is stood down for that run now. The probe also treated any cancellation as the health check's own, so a timeout inside an agent could fail readiness. Only a cancellation of the health check's token is left to propagate. Fixing the completed event turned up a latent problem: the terminal event types are named the same in two namespaces this file pulls in, and the short name binds to the ones the response stream never produces, so vt is ResponseCompletedEvent was quietly always false. The three terminal types are now named explicitly. --- .../AgentFrameworkResponseHandler.cs | 51 +++++++++++-- .../HostedStoredOutputHealthCheck.cs | 16 +++- .../AgentFrameworkResponseHandlerTests.cs | 76 +++++++++++++++++++ 3 files changed, 133 insertions(+), 10 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 128568772e6..6f8b6fe2444 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -15,6 +15,13 @@ using Microsoft.Extensions.Options; using Microsoft.Shared.DiagnosticIds; +// The terminal stream events are named the same in two namespaces this file pulls in, and the short +// name binds to the one the event objects are not. Naming them here keeps `is` checks against the +// types the response stream actually produces. +using ResponseCompletedEvent = Azure.AI.AgentServer.Responses.Models.ResponseCompletedEvent; +using ResponseFailedEvent = Azure.AI.AgentServer.Responses.Models.ResponseFailedEvent; +using ResponseIncompleteEvent = Azure.AI.AgentServer.Responses.Models.ResponseIncompleteEvent; + namespace Microsoft.Agents.AI.Foundry.Hosting; /// @@ -129,6 +136,8 @@ public override async IAsyncEnumerable CreateAsync( conversationId, request.PreviousResponseId, context.ResponseId); var agentOptions = agent.GetService(); + var hostingOptions = this._serviceProvider.GetService>()?.Value; + var allowStoredOutputEnabled = hostingOptions?.AllowStoredOutputEnabled ?? false; // Load an existing session when there is a conversation key. The store returns null when // nothing is persisted for it, which is the authoritative "this is a resume" signal: a @@ -181,10 +190,21 @@ public override async IAsyncEnumerable CreateAsync( // 4. Convert input: history + current input → ChatMessage[] var messages = new List(); - // Add the chat history to the request. Workflow sessions accumulate previous turns and must not - // get the full history again; their types are internal, hence the check on the type name. - if (sessionLoadedFromStore is null - || !string.Equals(sessionLoadedFromStore.GetType().FullName, WorkflowSessionTypeName, StringComparison.Ordinal)) + // Add the chat history to the request, unless something else already holds it. + // + // A workflow session accumulates previous turns of its own, so handing it the full history + // again would replay them; its type is internal, hence the check on the type name. + // + // A session carrying a conversation id means the service behind the agent's chat client is + // holding the conversation, which only happens when the container allows it. That service adds + // its own history to the run, so adding the platform's as well would send every earlier turn + // twice. The first turn of such a conversation has no id yet, so it still gets the history and + // the service picks it up from there. + var somethingElseHoldsTheHistory = + string.Equals(sessionLoadedFromStore?.GetType().FullName, WorkflowSessionTypeName, StringComparison.Ordinal) + || (allowStoredOutputEnabled && session is ChatClientAgentSession { ConversationId: not null }); + + if (!somethingElseHoldsTheHistory) { var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); if (history.Count > 0) @@ -208,8 +228,6 @@ public override async IAsyncEnumerable CreateAsync( } // 5. Build chat options - var hostingOptions = this._serviceProvider.GetService>()?.Value; - var allowStoredOutputEnabled = hostingOptions?.AllowStoredOutputEnabled ?? false; var chatOptions = InputConverter.ConvertToChatOptions( request, agentOptions?.ChatOptions?.RawRepresentationFactory, @@ -387,6 +405,9 @@ await this._toolboxService bool emittedTerminal = false; bool allowAgentSessionStoreInTheService = true; + // A successful terminal event, held until the run is wound up and the session can be checked. + ResponseStreamEvent? completedEvent = null; + // The session picks up the id of any conversation the agent's own service kept, at the end of // the run and before the agent reports anything else about it. bool NotAllowedAgentSessionStoredInTheService() => @@ -482,10 +503,21 @@ bool NotAllowedAgentSessionStoredInTheService() => yield break; } + // A completed event is held back rather than sent straight out. The id of any + // conversation the agent's own service kept only lands on the session once the run is + // fully wound up, which is after this point, so sending the event now could tell the + // caller the turn finished and then hand them a failure for the very same turn. + if (evt is ResponseCompletedEvent) + { + completedEvent = evt; + emittedTerminal = true; + continue; + } + // yield is in the outer try (finally-only) — allowed by C# yield return evt!; - if (evt is ResponseCompletedEvent or ResponseFailedEvent or ResponseIncompleteEvent) + if (evt is ResponseFailedEvent or ResponseIncompleteEvent) { emittedTerminal = true; } @@ -518,6 +550,11 @@ bool NotAllowedAgentSessionStoredInTheService() => throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError(); } + + if (completedEvent is not null) + { + yield return completedEvent; + } } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs index ee2377ff6f4..6a3bee14ff1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs @@ -104,20 +104,30 @@ public async Task CheckHealthAsync(HealthCheckContext context /// The run carries no chat options of its own, so the agent's own configuration is what reaches the /// probe. Overriding the setting here, the way the request handler does per turn, would only show /// the override back. + /// + /// The agent's chat history provider is stood down for this run, because it would otherwise read + /// and write its own store on every readiness probe. A provider backed by a database would then be + /// doing external calls, and adding this probe's empty turn to a real conversation, for a run that + /// asks the agent nothing. + /// /// private async Task StoresItsOwnResponsesAsync(AIAgent agent, CancellationToken cancellationToken) { var probe = new StoredOutputProbeChatClient(); var runOptions = new ChatClientAgentRunOptions { ChatClientFactory = _ => probe }; + runOptions.AdditionalProperties ??= []; + runOptions.AdditionalProperties.Add(new VolatileChatHistoryProvider()); try { await agent.RunAsync([], options: runOptions, cancellationToken: cancellationToken).ConfigureAwait(false); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) { - // The agent could not complete a run it was never really asked to answer. That says nothing - // about how it stores responses, so it is not held against it. + // The agent could not complete a run it was never really asked to answer, which says nothing + // about how it stores responses and is not held against it. A cancellation of its own, a + // timeout inside the agent for instance, lands here too; only the health check's own + // cancellation is left to propagate. if (this._logger?.IsEnabled(LogLevel.Debug) is true) { this._logger.LogDebug(ex, "Could not probe the stored output setting for agent '{AgentName}'.", agent.Name); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs index 8baaa0a5879..d5ec266f712 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -1072,6 +1072,45 @@ public async Task CreateAsync_AgentWhoseChatClientStoredTheTurn_FailsTheRequestA Assert.Equal(501, failure.StatusCode); } + [Fact] + public async Task CreateAsync_AgentWhoseChatClientStoredTheTurn_NeverReportsTheTurnCompletedAsync() + { + // Arrange: a chat client whose service keeps the conversation, on an agent that does not object + // to being handed a second history manager. Its run therefore succeeds and the session comes + // back carrying the id, which is the case where the turn looks fine right up to the end. + var client = new Mock(); + client.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(() => ToAsyncEnumerableUpdatesAsync( + new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1", ConversationId = "conv-downstream" })); + + var agent = new ChatClientAgent(client.Object, new ChatClientAgentOptions + { + ThrowOnChatHistoryProviderConflict = false, + WarnOnChatHistoryProviderConflict = false, + }); + + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + + // Act: collect whatever reaches the caller before the failure. + var seen = new List(); + await Assert.ThrowsAsync(async () => + { + await foreach (var evt in handler.CreateAsync( + NewConversationRequest("conv-no-completed", "first question", store: true), + NewContextServing("resp_" + new string('7', 45) + "0", []), + CancellationToken.None)) + { + seen.Add(evt.GetType().Name); + } + }); + + // Assert: a turn this container will not stand behind is never announced as completed first. + // Telling the caller it finished and then dropping the connection leaves two different answers + // for the same turn. + Assert.DoesNotContain("ResponseCompletedEvent", seen); + } + [Fact] public async Task CreateAsync_AgentWhoseChatClientStoredTheTurn_AndStoringIsAllowed_SucceedsAsync() { @@ -1104,6 +1143,43 @@ public async Task CreateAsync_AgentWhoseChatClientStoredTheTurn_AndStoringIsAllo Assert.DoesNotContain("ResponseFailedEvent", names); } + [Fact] + public async Task CreateAsync_StoringIsAllowed_ResumedTurnDoesNotAlsoGetThePlatformHistoryAsync() + { + // Arrange: a container that allows its own service to keep the conversation. Once that service + // holds it, it adds the earlier turns to the run itself. + var captured = new List(); + var agent = new ChatClientAgent( + CreateCapturingChatClient(captured, conversationId: "conv-downstream"), + new ChatClientAgentOptions { Name = "keeps-its-own" }); + + var store = new InMemoryAgentSessionStore(); + var handler = BuildHandlerWith( + agent, + new FakeHostedSessionIsolationKeyProvider(), + store, + hostingOptions: new FoundryResponsesOptions { AllowStoredOutputEnabled = true }); + + // First turn: nothing holds the conversation yet, so the platform history is what seeds it. + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-owned", "first question", store: true), + NewContextServing("resp_" + new string('b', 45) + "0", []), + CancellationToken.None)); + + // Act: a second turn, for which the platform now reports the first one as history. + captured.Clear(); + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-owned", "second question", store: true), + NewContextServing("resp_" + new string('b', 45) + "1", [NewHistoryMessageItem("msg_hist_1", "first question")]), + CancellationToken.None)); + + // Assert: only this turn's input goes in. The service holding the conversation replays the + // earlier turns on its own, so sending the platform's copy as well would hand the model every + // earlier turn twice. + Assert.DoesNotContain(captured, m => m.Text.Contains("first question", StringComparison.Ordinal)); + Assert.Contains(captured, m => m.Text.Contains("second question", StringComparison.Ordinal)); + } + [Fact] public async Task CreateAsync_ChatClientAgent_TakesTheWholeConversationFromTheHostingServiceAsync() { From 88e388e78599bd14452d55f1253707b3fdedb7ea Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:00:10 +0100 Subject: [PATCH 7/9] Let the chat history provider carry the conversation The handler used to read the hosting service's record of the conversation and prepend it to the input of every run, then work out who should not get it: a resumed workflow by the name of its session type, and a container whose own service already holds the conversation. Two exceptions, a type name matched as a string, and a shape where the same turns could arrive from two directions. An agent that reads its history through a provider is now given one, seeded with that record, for the length of the run. The turns arrive the way the agent expects them rather than as fresh input, so nothing is stored back as if it had just been said, and the provider is dropped when the run ends. Only the new input is passed to the run now. Everything else supplies its own history and is left alone: an agent built with a provider keeps using it, an agent whose service keeps the conversation reads it from there, and an agent that is not a ChatClientAgent, a hosted workflow for instance, carries the conversation in its own session state and wants only the new input. The workflow session type name check is gone with it. The session is saved on every turn again. It was being withheld when the agent's own service had kept the turn, which is a decision about that service, not about the session; nothing this handler adds for a turn reaches the session anyway. --- .../AgentFrameworkResponseHandler.cs | 93 +++++++------------ .../VolatileChatHistoryProvider.cs | 30 +++--- .../AgentFrameworkResponseHandlerTests.cs | 49 +++++----- 3 files changed, 77 insertions(+), 95 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 6f8b6fe2444..e9fff4338c1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Threading; @@ -42,17 +41,6 @@ public class AgentFrameworkResponseHandler : ResponseHandler /// private static readonly HostedSessionIsolationKeyProvider s_defaultIsolationKeyProvider = new PlatformHostedSessionIsolationKeyProvider(); - /// Identifies the handler as the source of chat history messages it passes as input. - private const string HistorySourceId = "Microsoft.Agents.AI.Foundry.Hosting.AgentFrameworkResponseHandler"; - - /// - /// The session type a hosted workflow runs with. It is internal to Microsoft.Agents.AI.Workflows, - /// so it is recognised by name: taking a reference to it would mean opening that package's internals, - /// which cannot be done here because both packages compile the same shared source files. The full name - /// is matched so a session of the same short name from another namespace is not mistaken for it. - /// - private const string WorkflowSessionTypeName = "Microsoft.Agents.AI.Workflows.WorkflowSession"; - /// /// Initializes a new instance of the class /// that resolves agents from keyed DI services. @@ -139,16 +127,12 @@ public override async IAsyncEnumerable CreateAsync( var hostingOptions = this._serviceProvider.GetService>()?.Value; var allowStoredOutputEnabled = hostingOptions?.AllowStoredOutputEnabled ?? false; - // Load an existing session when there is a conversation key. The store returns null when - // nothing is persisted for it, which is the authoritative "this is a resume" signal: a - // non-null result means a prior turn saved this session. Whether loaded or created, the - // handler owns creating a fresh session when none exists, so the resume signal does not - // depend on inspecting the session for state the handler itself also writes to. - AgentSession? sessionLoadedFromStore = !string.IsNullOrWhiteSpace(agentSessionId) - ? await sessionStore.GetSessionAsync(agent, agentSessionId, resolvedUserId, cancellationToken).ConfigureAwait(false) - : null; - - AgentSession? session = sessionLoadedFromStore ?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + // Load the session for this conversation, or start a new one. The store returns null when + // nothing is persisted for the key, so a fresh conversation and a resumed one both end up with + // a session to run against. + AgentSession? session = !string.IsNullOrWhiteSpace(agentSessionId) + ? await sessionStore.GetOrCreateSessionAsync(agent, agentSessionId, resolvedUserId, cancellationToken).ConfigureAwait(false) + : await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); // Capture the platform per-request call id (x-agent-foundry-call-id, protocol 2.0.0 only). // It is re-applied to the ambient HostedCallContext immediately before each outbound egress @@ -187,34 +171,10 @@ public override async IAsyncEnumerable CreateAsync( yield return stream.EmitCreated(); yield return stream.EmitInProgress(); - // 4. Convert input: history + current input → ChatMessage[] + // 4. Convert input: the current input items become the run's messages. Earlier turns are not + // added here; whatever holds the history for this agent supplies them, see step 5. var messages = new List(); - // Add the chat history to the request, unless something else already holds it. - // - // A workflow session accumulates previous turns of its own, so handing it the full history - // again would replay them; its type is internal, hence the check on the type name. - // - // A session carrying a conversation id means the service behind the agent's chat client is - // holding the conversation, which only happens when the container allows it. That service adds - // its own history to the run, so adding the platform's as well would send every earlier turn - // twice. The first turn of such a conversation has no id yet, so it still gets the history and - // the service picks it up from there. - var somethingElseHoldsTheHistory = - string.Equals(sessionLoadedFromStore?.GetType().FullName, WorkflowSessionTypeName, StringComparison.Ordinal) - || (allowStoredOutputEnabled && session is ChatClientAgentSession { ConversationId: not null }); - - if (!somethingElseHoldsTheHistory) - { - var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); - if (history.Count > 0) - { - messages.AddRange(InputConverter - .ConvertOutputItemsToMessages(history, session?.StateBag) - .Select(m => m.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, HistorySourceId))); - } - } - // Load and convert current input items var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); if (inputItems.Count > 0) @@ -376,19 +336,30 @@ await this._toolboxService } } - // Everything the agent needs for this turn is already in the input, so the provider it would - // otherwise run is replaced for the duration by one that keeps its messages in memory and is - // dropped when the run ends. Serving from a longer-lived one would deliver the conversation - // twice, and storing into it would leave a copy the hosting service never sees. + // 5b. Decide who supplies the earlier turns of this conversation. // - // A container that allows its own service to keep the conversation has taken history over, so - // nothing is put in its way: the agent already stands its own provider down when that service - // hands back a conversation id, and an override here would only collide with it. + // A ChatClientAgent with no provider of its own has nothing to remember the conversation with, + // so the hosting service's own record is handed to it, through a provider that lives for this + // run and is dropped at the end. Passing those turns as input instead would have the agent + // store them again as if they were new. + // + // Everything else is left alone and supplies its own history: an agent given a provider uses + // it, an agent whose service keeps the conversation gets it from there, and an agent that is + // not a ChatClientAgent, a hosted workflow for instance, carries the conversation in its own + // session state and only wants the new input. var options = new ChatClientAgentRunOptions(chatOptions); - if (!allowStoredOutputEnabled) + var useVolatileChatHistoryProvider = + !allowStoredOutputEnabled + && agent.GetService() is not null + && agentOptions?.ChatHistoryProvider is null; + + if (useVolatileChatHistoryProvider) { + var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); + options.AdditionalProperties ??= []; - options.AdditionalProperties.Add(new VolatileChatHistoryProvider()); + options.AdditionalProperties.Add( + new VolatileChatHistoryProvider(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag))); } // 6. Set up consent context for -32006 OAuth consent interception. @@ -533,10 +504,10 @@ bool NotAllowedAgentSessionStoredInTheService() => allowAgentSessionStoreInTheService &= !NotAllowedAgentSessionStoredInTheService(); // Persist session after streaming completes (successful or not). The user id partitions the - // persisted session per end user, mirroring the load above so multi-turn continuity is preserved. - // A session pointing at a conversation the agent's own service kept is never persisted: every - // later turn would resume onto that conversation and keep the double recording going. - if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId) && allowAgentSessionStoreInTheService) + // persisted session per end user, mirroring the load above so multi-turn continuity is + // preserved. Nothing this handler adds for the turn reaches the session: the provider it + // supplies keeps its messages in a field and is dropped when the run ends. + if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId)) { await sessionStore.SaveSessionAsync(agent, agentSessionId, session, resolvedUserId, cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/VolatileChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/VolatileChatHistoryProvider.cs index 3fc912665df..be878fe631a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/VolatileChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/VolatileChatHistoryProvider.cs @@ -8,17 +8,17 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// -/// A that holds the turn's messages in a field, for the lifetime of -/// one request and no longer. +/// A that holds a conversation in a field, for the lifetime of one +/// request and no longer. /// /// /// /// A hosted agent's conversation is recorded by the AgentServer SDK's own storage provider, which /// writes every turn the caller asked it to store and serves it back through /// . That happens around -/// the handler, not through it. The handler reads the conversation from there and passes it in as the -/// run's input, so nothing has to be carried between requests, and a provider storing anything of its -/// own would only add a copy the storage provider never sees. +/// the handler, not through it. An agent with no provider of its own is given that record here, so it +/// reads the conversation the way it reads any other history, and anything it stores back is dropped +/// with this instance rather than kept somewhere the storage provider never sees. /// /// /// Within a single run the provider still does its ordinary work: an agent calling tools goes back to @@ -26,14 +26,24 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// Those live here until the run ends and the instance is dropped. /// /// -/// Supplied as a run-scoped override through , so it takes -/// the place of the agent's own provider for the turn without changing the agent. An agent that does not -/// read its history through a provider ignores it. +/// Supplied as a run-scoped override through , so it +/// serves the turn without changing the agent. An agent that does not read its history through a +/// provider ignores it. /// /// internal sealed class VolatileChatHistoryProvider : ChatHistoryProvider { - private readonly List _messages = []; + private readonly List _messages; + + /// + /// Initializes a new instance of the class holding the + /// conversation so far. + /// + /// The turns of this conversation the hosting service has recorded. + public VolatileChatHistoryProvider(IEnumerable? history = null) + { + this._messages = history is null ? [] : [.. history]; + } /// protected override ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) @@ -42,8 +52,6 @@ protected override ValueTask> ProvideChatHistoryAsync(I /// protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) { - // Only what this run produced arrives here: the base class filters out everything already marked - // as chat history, which covers the turns the handler took from the storage provider. this._messages.AddRange(context.RequestMessages); if (context.ResponseMessages is not null) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs index d5ec266f712..6ebc65e32bd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -383,7 +383,7 @@ public async Task CreateAsync_AgentResolvedBeforeEmitCreated_ExceptionHasNoEvent } [Fact] - public async Task CreateAsync_WithHistory_PrependsHistoryToMessagesAsync() + public async Task CreateAsync_WithHistory_LeavesTheNewInputAloneAsync() { // Arrange var agent = new CapturingAgent(); @@ -425,10 +425,11 @@ public async Task CreateAsync_WithHistory_PrependsHistoryToMessagesAsync() } // Assert + // Assert: this agent supplies its own history, so only the new input reaches it. Assert.NotNull(agent.CapturedMessages); var messages = agent.CapturedMessages.ToList(); - Assert.True(messages.Count >= 2); - Assert.Equal(ChatRole.Assistant, messages[0].Role); + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); } [Fact] @@ -723,7 +724,8 @@ public async Task CreateAsync_FirstTurnOfAKnownConversation_StillReceivesTheServ // Arrange: the first turn this container serves for a conversation the service already holds // history for. Nothing has been persisted for it yet, so this is not a resume: the history has // to be handed to the agent, otherwise it answers knowing nothing of the conversation. - var agent = new CapturingAgent(); + var captured = new List(); + var agent = new ChatClientAgent(CreateCapturingChatClient(captured), new ChatClientAgentOptions { Name = "keeps-nothing" }); var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); var request = new CreateResponse { Model = "test" }; request.Conversation = BinaryData.FromString("\"conv-known\""); @@ -746,8 +748,7 @@ public async Task CreateAsync_FirstTurnOfAKnownConversation_StillReceivesTheServ // freshly created session already carries state and reading that as "it has run before" made the // first turn of every conversation look like a resume, dropping its history. It only showed up // when hosted, because there is no identity to write locally. - Assert.NotNull(agent.CapturedMessages); - Assert.Contains(agent.CapturedMessages!, m => m.Text.Contains("earlier turn", StringComparison.Ordinal)); + Assert.Contains(captured, m => m.Text.Contains("earlier turn", StringComparison.Ordinal)); } [Fact] @@ -782,7 +783,8 @@ public async Task CreateAsync_SecondTurnOfAnAgentThatKeepsNothing_StillReceivesT // Arrange: an agent written outside this repo that runs no chat history provider and keeps // nothing in its session, with a first turn that persists one anyway. const string ConversationId = "conv-keeps-nothing"; - var agent = new CapturingAgent(); + var captured = new List(); + var agent = new ChatClientAgent(CreateCapturingChatClient(captured), new ChatClientAgentOptions { Name = "keeps-nothing" }); var store = new InMemoryAgentSessionStore(); var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), store); await DrainEventsAsync(handler.CreateAsync( @@ -791,16 +793,16 @@ await DrainEventsAsync(handler.CreateAsync( CancellationToken.None)); // Act: a second turn of the same conversation, for which the service now reports history. + captured.Clear(); await DrainEventsAsync(handler.CreateAsync( NewConversationTurn(ConversationId, "second question"), NewServingContext("resp_" + new string('8', 46), [NewHistoryMessageItem("msg_hist_1", "first question")]), CancellationToken.None)); // Assert: a persisted session says a prior turn ran here, not that the conversation is inside it. - // Only a workflow keeps its messages that way; anything else starts each turn with nothing, so - // withholding the history would leave it answering blind. - Assert.NotNull(agent.CapturedMessages); - Assert.Contains(agent.CapturedMessages!, m => m.Text.Contains("first question", StringComparison.Ordinal)); + // An agent with nothing of its own to remember it with starts each turn empty, so withholding + // the history would leave it answering blind. + Assert.Contains(captured, m => m.Text.Contains("first question", StringComparison.Ordinal)); } [Fact] @@ -875,10 +877,10 @@ private static ResponseContext NewServingContext(string responseId, IReadOnlyLis // - TakesThePlatformHistoryInsteadOfThatProvider (both sources reached the model at once) [Fact] - public async Task CreateAsync_AgentWithoutProviderPipeline_ReceivesPlatformHistoryInInputAsync() + public async Task CreateAsync_AgentThatIsNotAChatClientAgent_ReceivesOnlyTheNewInputAsync() { - // Arrange: a plain AIAgent (a hosted workflow, for example) has no ChatHistoryProvider - // pipeline, so the handler is the only thing that can hand it the platform history. + // Arrange: a plain AIAgent, a hosted workflow for instance, carries the conversation in its own + // session state and picks up where it left off. var agent = new CapturingAgent(); var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); var (request, ctx) = BuildChainRequest("resp_" + new string('1', 46), callId: null); @@ -888,9 +890,11 @@ public async Task CreateAsync_AgentWithoutProviderPipeline_ReceivesPlatformHisto // Act await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); - // Assert + // Assert: only this turn's input goes in. Replaying the earlier turns would re-drive steps such + // an agent has already run. Assert.NotNull(agent.CapturedMessages); - Assert.Contains(agent.CapturedMessages!, m => m.Text.Contains("earlier turn", StringComparison.Ordinal)); + Assert.DoesNotContain(agent.CapturedMessages!, m => m.Text.Contains("earlier turn", StringComparison.Ordinal)); + Assert.Contains(agent.CapturedMessages!, m => m.Text.Contains("Hello", StringComparison.Ordinal)); } [Fact] @@ -961,7 +965,7 @@ public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_DoesNotCopyP } [Fact] - public async Task CreateAsync_ChatClientAgentWithHistoryProvider_TakesThePlatformHistoryInsteadOfThatProviderAsync() + public async Task CreateAsync_ChatClientAgentWithHistoryProvider_LeavesThatProviderToSupplyTheHistoryAsync() { // Arrange: the agent was created with its own chat history provider. var captured = new List(); @@ -976,12 +980,11 @@ public async Task CreateAsync_ChatClientAgentWithHistoryProvider_TakesThePlatfor // Act await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); - // Assert: one source only, and hosted it is the one the AgentServer SDK's storage provider - // records and serves back. A provider storing a second copy inside the container would add a - // conversation that storage provider never sees, so the agent's provider is stood down for the - // turn rather than mixed in. - Assert.Contains(captured, m => m.Text.Contains("from the platform", StringComparison.Ordinal)); - Assert.DoesNotContain(captured, m => m.Text.Contains("from my own store", StringComparison.Ordinal)); + // Assert: an agent given a provider keeps it, and hosting adds nothing of its own. Handing it + // the hosting service's copy as well would put the same conversation in front of the model + // twice and leave the provider's own store holding turns it never took. + Assert.Contains(captured, m => m.Text.Contains("from my own store", StringComparison.Ordinal)); + Assert.DoesNotContain(captured, m => m.Text.Contains("from the platform", StringComparison.Ordinal)); } [Fact] From d5b5bb238dee2c917674b0cb1e2d678da49fbb15 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:08:24 +0100 Subject: [PATCH 8/9] Fail a turn to skip its session, and name the store check after what it detects The session was being withheld from the store on a condition about the agent's own service rather than about the turn, and guarded by an emptiness check on a key that is never empty. A turn that is being failed now says so, and only that skips the save. A turn that ends incomplete, waiting on OAuth consent or interrupted by a shutdown, is not a failure: the caller comes back for it and needs the state built up so far, the tool approval ids among it. The session key is resolved once as a value that always exists, so both the load and the save use it without asking again whether it is there. CheckNotAllowedStoreUsage and notAllowedStoreUsageDetected now read as what they are: a check for an agent storing when it should not, and the flag saying it was seen. --- .../AgentFrameworkResponseHandler.cs | 63 +++++++++---------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index e9fff4338c1..fe2889b444b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -336,23 +336,16 @@ await this._toolboxService } } - // 5b. Decide who supplies the earlier turns of this conversation. - // - // A ChatClientAgent with no provider of its own has nothing to remember the conversation with, - // so the hosting service's own record is handed to it, through a provider that lives for this - // run and is dropped at the end. Passing those turns as input instead would have the agent - // store them again as if they were new. - // - // Everything else is left alone and supplies its own history: an agent given a provider uses - // it, an agent whose service keeps the conversation gets it from there, and an agent that is - // not a ChatClientAgent, a hosted workflow for instance, carries the conversation in its own - // session state and only wants the new input. var options = new ChatClientAgentRunOptions(chatOptions); + + // We only use a volatile provider for the conversation history if the agent is a ChatClientAgent and the allow setting is not intentionally set or not custom chat history provider is intentionally supplied. var useVolatileChatHistoryProvider = !allowStoredOutputEnabled && agent.GetService() is not null && agentOptions?.ChatHistoryProvider is null; + // This will create a temporary in-memory provider for the conversation history, which will be dropped at the end of this run. + // This is used to avoid storing the conversation history as the SDK will by default do the same via the (InMemory/Foundry)ResponsesProvider internal implementation. if (useVolatileChatHistoryProvider) { var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); @@ -374,14 +367,20 @@ await this._toolboxService // NOTE: C# forbids 'yield return' inside a try block that has a catch clause, // and inside catch blocks. We use a flag to defer the yield to outside the try/catch. bool emittedTerminal = false; - bool allowAgentSessionStoreInTheService = true; + bool notAllowedStoreUsageDetected = false; + + // Set when this turn is being failed, so its session is not kept. A turn that ends incomplete, + // waiting on OAuth consent or interrupted by a shutdown, is not a failure: the caller comes back + // for it and needs the state that was built up, the tool approval ids among it. + bool turnFailed = false; // A successful terminal event, held until the run is wound up and the session can be checked. ResponseStreamEvent? completedEvent = null; - // The session picks up the id of any conversation the agent's own service kept, at the end of - // the run and before the agent reports anything else about it. - bool NotAllowedAgentSessionStoredInTheService() => + // Check whenever the agent is storing messages when it should not. + bool CheckNotAllowedStoreUsage() => + // For IChatClients implementations when the backend is set to not store (store = false) the returned responseMessage.ConversationId comes null. + // If for any reason this property is set it means that the storage setting was enabled when it shouldn't. !allowStoredOutputEnabled && session is ChatClientAgentSession { ConversationId: not null }; var enumerator = OutputConverter.ConvertUpdatesToEventsAsync( @@ -452,16 +451,17 @@ bool NotAllowedAgentSessionStoredInTheService() => if (failedEvent is not null) { - // The run may have failed precisely because the agent's own service kept the - // conversation: the session picks up that id before the agent goes on to complain - // about having two history managers. Report the deployment problem that caused it - // rather than the confusing symptom. - if (NotAllowedAgentSessionStoredInTheService()) + // The run may have failed precisely because the agent stored the turn: the session + // picks up that conversation id before the agent goes on to complain about having + // two history managers. Report the cause rather than the symptom. + if (CheckNotAllowedStoreUsage()) { - allowAgentSessionStoreInTheService = false; + notAllowedStoreUsageDetected = true; + turnFailed = true; throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError(); } + turnFailed = true; yield return failedEvent; yield break; } @@ -498,22 +498,21 @@ bool NotAllowedAgentSessionStoredInTheService() => { await enumerator.DisposeAsync().ConfigureAwait(false); - // The run is over, so the session now carries whatever the agent's own service handed back. - // A conversation id there means that service kept this turn, which is a second recording of - // a conversation the hosting service already recorded. - allowAgentSessionStoreInTheService &= !NotAllowedAgentSessionStoredInTheService(); + // Only after the the agent ran when can check precisely if the session had been used to store messages in the backend for validation. + if (CheckNotAllowedStoreUsage()) + { + notAllowedStoreUsageDetected = true; + turnFailed = true; + } - // Persist session after streaming completes (successful or not). The user id partitions the - // persisted session per end user, mirroring the load above so multi-turn continuity is - // preserved. Nothing this handler adds for the turn reaches the session: the provider it - // supplies keeps its messages in a field and is dropped when the run ends. - if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId)) + // Persist the session for the next turn of this conversation, unless this one is being failed. + if (session is not null && !turnFailed) { - await sessionStore.SaveSessionAsync(agent, agentSessionId, session, resolvedUserId, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, agentSessionId!, session, resolvedUserId, cancellationToken).ConfigureAwait(false); } } - if (!allowAgentSessionStoreInTheService) + if (notAllowedStoreUsageDetected) { this._logger.LogError( "Agent '{AgentName}' should not have server side storage enabled. This produced a new untracked conversation/response in the server while the hosted agent also generated a conversation for the request of the agent.", From 91653335e6485f890a77615571613f5cf71816c7 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:35:07 +0100 Subject: [PATCH 9/9] Read a hosted response through the agent client, and only forgive a 404 Reading a response back tried the project-level client first and then the per-agent one, swallowing 403 as well as 404 to get past the first. The project-level client cannot see a hosted agent's responses at all, so that attempt only ever produced the 403 the catch then had to forgive, and any other 403, an authorization failure for instance, was read as "nothing is stored" and passed the test. Only the per-agent client is used now, and only a 404 counts as not stored. Verified against the service: a well-formed id it has no response for answers 404 invalid_request_error "Response '...' not found", the same id through the project-level client answers 403 session_not_accessible, and a malformed id answers 400. Everything but the 404 now surfaces. --- .../Fixtures/HostedAgentFixture.cs | 37 ++++++------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs index 71541c87a90..c44f642830f 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs @@ -197,39 +197,26 @@ public async Task CountConversationItemsAsync(string conversationId) /// /// Tries to read a response back off the service by id, returning when - /// nothing is stored under it. Both the project-wide client and this scenario's per-agent client - /// are tried, because a response created inside the container is not necessarily reachable through - /// the same endpoint as one created for the caller. + /// nothing is stored under it. /// /// - /// Only "not there, or not through this endpoint" is swallowed: 404 for a response that was never - /// stored, and 403 session_not_accessible for one the project-level client may not read. - /// Anything else, an expired token or a server fault for instance, is left to surface, because a - /// caller reading this as "nothing is stored" would turn a broken run into a passing test. + /// Reads go through this scenario's per-agent client, which is the one that can see a hosted + /// agent's responses; the project-level client answers 403 session_not_accessible for them. + /// Only a 404 is taken as "nothing is stored": that is what the service answers for a well-formed + /// id it has no response for. Anything else surfaces, because a caller reading a 403 or a server + /// fault as "nothing is stored" would turn a broken run into a passing test. /// public async Task TryReadResponseAsync(string responseId) { - foreach (var responses in new[] + try { - this.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient(), - this.AgentOpenAIClient.GetProjectResponsesClient(), - }) + var response = await this.AgentOpenAIClient.GetProjectResponsesClient().GetResponseAsync(responseId).ConfigureAwait(false); + return response?.Value; + } + catch (ClientResultException ex) when (ex.Status is 404) { - try - { - var response = await responses.GetResponseAsync(responseId).ConfigureAwait(false); - if (response?.Value is not null) - { - return response.Value; - } - } - catch (ClientResultException ex) when (ex.Status is 404 or 403) - { - // Not stored, or not readable through this endpoint; try the next one. - } + return null; } - - return null; } public async ValueTask InitializeAsync()