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 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 - { - // Not readable through this endpoint; try the next one. - } + return null; } - - return null; } public async ValueTask InitializeAsync() 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 ae711b6902..6ebc65e32b 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; @@ -381,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(); @@ -423,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] @@ -721,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\""); @@ -744,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] @@ -780,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( @@ -789,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] @@ -868,15 +872,15 @@ 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() + 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); @@ -886,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] @@ -959,7 +965,7 @@ public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_DoesNotCopyP } [Fact] - public async Task CreateAsync_ChatClientAgentWithHistoryProvider_UsesThatProviderInsteadOfThePlatformAsync() + public async Task CreateAsync_ChatClientAgentWithHistoryProvider_LeavesThatProviderToSupplyTheHistoryAsync() { // Arrange: the agent was created with its own chat history provider. var captured = new List(); @@ -974,12 +980,11 @@ public async Task CreateAsync_ChatClientAgentWithHistoryProvider_UsesThatProvide // 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] @@ -1044,7 +1049,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 +1062,125 @@ 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))); + + Assert.Equal("agent_stored_output_not_disabled", failure.Error.Code); + 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() + { + // 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] + 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 + 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))); + // 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.Equal("service_managed_chat_history_not_supported", failure.Error.Code); - Assert.Equal(400, failure.StatusCode); + // 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] @@ -1130,6 +1238,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] @@ -1441,7 +1626,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, @@ -1453,12 +1638,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 @@ -1767,12 +1947,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 0000000000..b44b5f96cc --- /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 f49c669f66..472f7f2b4e 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] 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 0000000000..aebca80e88 --- /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 +{ +}