diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
index 0472fcb38f..fe2889b444 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;
@@ -12,8 +11,16 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+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;
///
@@ -34,16 +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.
- ///
- private const string WorkflowSessionTypeName = "WorkflowSession";
-
///
/// Initializes a new instance of the class
/// that resolves agents from keyed DI services.
@@ -127,17 +124,15 @@ 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
- // 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
@@ -169,19 +164,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);
@@ -189,23 +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. 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))
- {
- 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)
@@ -219,16 +188,12 @@ public override async IAsyncEnumerable CreateAsync(
}
// 5. Build chat options
- var chatOptions = InputConverter.ConvertToChatOptions(request, agentOptions?.ChatOptions?.RawRepresentationFactory);
+ 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());
-
// Inject Foundry Toolbox tools when the toolbox service is available.
//
// Two sources are considered:
@@ -373,6 +338,23 @@ await this._toolboxService
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);
+
+ options.AdditionalProperties ??= [];
+ options.AdditionalProperties.Add(
+ new VolatileChatHistoryProvider(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag)));
+ }
+
// 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
// run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState
@@ -385,6 +367,22 @@ 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 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;
+
+ // 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(
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
stream,
@@ -453,6 +451,17 @@ await this._toolboxService
if (failedEvent is not null)
{
+ // 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())
+ {
+ notAllowedStoreUsageDetected = true;
+ turnFailed = true;
+ throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError();
+ }
+
+ turnFailed = true;
yield return failedEvent;
yield break;
}
@@ -465,10 +474,21 @@ await this._toolboxService
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;
}
@@ -478,12 +498,32 @@ await this._toolboxService
{
await enumerator.DisposeAsync().ConfigureAwait(false);
- // 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))
+ // 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())
{
- await sessionStore.SaveSessionAsync(agent, agentSessionId, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
+ notAllowedStoreUsageDetected = true;
+ turnFailed = true;
}
+
+ // 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);
+ }
+ }
+
+ 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.",
+ agent.Name);
+
+ throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError();
+ }
+
+ if (completedEvent is not null)
+ {
+ yield return completedEvent;
}
}
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 0000000000..d326ce230a
--- /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 0000000000..2b1b78447f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputCompatibility.cs
@@ -0,0 +1,127 @@
+// 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";
+
+ ///
+ /// Returns the error to throw when the agent's own service kept the turn.
+ ///
+ internal static ResponsesApiException CreateMisconfiguredAgentError() =>
+ 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
+ /// 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 0000000000..6a3bee14ff
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs
@@ -0,0 +1,163 @@
+// 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(
+ "The hosted agent backend storage usage was detected and the stored output enabled setting is explicitly allowing it.");
+ }
+
+ 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) 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 });
+ }
+
+ 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.
+ ///
+ /// 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 || !cancellationToken.IsCancellationRequested)
+ {
+ // 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);
+ }
+
+ 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 3a4e95b041..cf0779fa27 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 8bd86abee5..28152362cb 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 0000000000..152f0fe602
--- /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/src/Microsoft.Agents.AI.Foundry.Hosting/VolatileChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/VolatileChatHistoryProvider.cs
index 3fc912665d..be878fe631 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/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs
index 5b895d6964..c44f642830 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;
@@ -196,33 +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.
///
+ ///
+ /// 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