Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Shared.DiagnosticIds;

namespace Microsoft.Agents.AI.Foundry.Hosting;
Expand Down Expand Up @@ -40,9 +41,10 @@ public class AgentFrameworkResponseHandler : ResponseHandler
/// <summary>
/// The session type a hosted workflow runs with. It is internal to <c>Microsoft.Agents.AI.Workflows</c>,
/// so it is recognised by name: taking a reference to it would mean opening that package's internals,
/// which cannot be done here because both packages compile the same shared source files.
/// which cannot be done here because both packages compile the same shared source files. The full name
/// is matched so a session of the same short name from another namespace is not mistaken for it.
/// </summary>
private const string WorkflowSessionTypeName = "WorkflowSession";
private const string WorkflowSessionTypeName = "Microsoft.Agents.AI.Workflows.WorkflowSession";

/// <summary>
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
Expand Down Expand Up @@ -169,19 +171,6 @@ public override async IAsyncEnumerable<ResponseStreamEvent> 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);

Expand All @@ -195,7 +184,7 @@ public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
// Add the chat history to the request. Workflow sessions accumulate previous turns and must not
// get the full history again; their types are internal, hence the check on the type name.
if (sessionLoadedFromStore is null
|| !string.Equals(sessionLoadedFromStore.GetType().Name, WorkflowSessionTypeName, StringComparison.Ordinal))
|| !string.Equals(sessionLoadedFromStore.GetType().FullName, WorkflowSessionTypeName, StringComparison.Ordinal))
{
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
Expand All @@ -219,16 +208,14 @@ public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
}

// 5. Build chat options
var chatOptions = InputConverter.ConvertToChatOptions(request, agentOptions?.ChatOptions?.RawRepresentationFactory);
var hostingOptions = this._serviceProvider.GetService<IOptions<FoundryResponsesOptions>>()?.Value;
var allowStoredOutputEnabled = hostingOptions?.AllowStoredOutputEnabled ?? false;
var chatOptions = InputConverter.ConvertToChatOptions(
request,
agentOptions?.ChatOptions?.RawRepresentationFactory,
hostingOptions);
chatOptions.Instructions = request.Instructions;

// Everything the agent needs for this turn is already in the input, so the provider it would
// otherwise run is replaced for the duration by one that keeps its messages in memory and is
// dropped when the run ends. Serving from a longer-lived one would deliver the conversation
// twice, and storing into it would leave a copy the hosting service never sees.
chatOptions.AdditionalProperties ??= [];
chatOptions.AdditionalProperties.Add<ChatHistoryProvider>(new VolatileChatHistoryProvider());

// Inject Foundry Toolbox tools when the toolbox service is available.
//
// Two sources are considered:
Expand Down Expand Up @@ -371,7 +358,20 @@ await this._toolboxService
}
}

// Everything the agent needs for this turn is already in the input, so the provider it would
// otherwise run is replaced for the duration by one that keeps its messages in memory and is
// dropped when the run ends. Serving from a longer-lived one would deliver the conversation
// twice, and storing into it would leave a copy the hosting service never sees.
//
// A container that allows its own service to keep the conversation has taken history over, so
// nothing is put in its way: the agent already stands its own provider down when that service
// hands back a conversation id, and an override here would only collide with it.
var options = new ChatClientAgentRunOptions(chatOptions);
if (!allowStoredOutputEnabled)
{
options.AdditionalProperties ??= [];
options.AdditionalProperties.Add<ChatHistoryProvider>(new VolatileChatHistoryProvider());
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
}

// 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
Expand All @@ -385,6 +385,13 @@ await this._toolboxService
// NOTE: C# forbids 'yield return' inside a try block that has a catch clause,
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
bool emittedTerminal = false;
bool allowAgentSessionStoreInTheService = true;

// The session picks up the id of any conversation the agent's own service kept, at the end of
// the run and before the agent reports anything else about it.
bool NotAllowedAgentSessionStoredInTheService() =>
!allowStoredOutputEnabled && session is ChatClientAgentSession { ConversationId: not null };

var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
stream,
Expand Down Expand Up @@ -453,6 +460,16 @@ await this._toolboxService

if (failedEvent is not null)
{
// The run may have failed precisely because the agent's own service kept the
// conversation: the session picks up that id before the agent goes on to complain
// about having two history managers. Report the deployment problem that caused it
// rather than the confusing symptom.
if (NotAllowedAgentSessionStoredInTheService())
{
allowAgentSessionStoreInTheService = false;
throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError();
}

yield return failedEvent;
yield break;
}
Expand All @@ -478,13 +495,29 @@ await this._toolboxService
{
await enumerator.DisposeAsync().ConfigureAwait(false);

// The run is over, so the session now carries whatever the agent's own service handed back.
// A conversation id there means that service kept this turn, which is a second recording of
// a conversation the hosting service already recorded.
allowAgentSessionStoreInTheService &= !NotAllowedAgentSessionStoredInTheService();

// Persist session after streaming completes (successful or not). The user id partitions the
// persisted session per end user, mirroring the load above so multi-turn continuity is preserved.
if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId))
// A session pointing at a conversation the agent's own service kept is never persisted: every
// later turn would resume onto that conversation and keep the double recording going.
if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId) && allowAgentSessionStoreInTheService)
{
await sessionStore.SaveSessionAsync(agent, agentSessionId, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
}
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
}

if (!allowAgentSessionStoreInTheService)
{
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();
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
}
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;

namespace Microsoft.Agents.AI.Foundry.Hosting;

/// <summary>
/// Options for hosting agents behind the Foundry Responses API.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FoundryResponsesOptions
{
/// <summary>
/// Gets or sets a value indicating whether the agent's own chat client is allowed to store the
/// responses it produces.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// While this is <see langword="false"/>, 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 <see langword="true"/> to leave the agent's own
/// setting exactly as the container configured it, in which case hosting neither changes it nor
/// checks it.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
public bool AllowStoredOutputEnabled { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to include an encrypted version of reasoning tokens in
/// reasoning item outputs.
/// </summary>
/// <remarks>
/// 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
/// <see cref="AllowStoredOutputEnabled"/> is <see langword="false"/>, because that is when hosting
/// turns storage off and the reasoning items would otherwise be lost between turns.
/// </remarks>
/// <value>
/// Default is <see langword="true"/>.
/// </value>
public bool IncludeReasoningEncryptedContent { get; set; } = true;
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal static class HostedStoredOutputCompatibility
{
/// <summary>
/// HTTP status returned when the agent's own service stored the turn. <c>501 Not Implemented</c>
/// is a server-side classification, because the deployment, not the caller, is misconfigured; it is
/// also non-retryable and distinct from the generic <c>500</c> so it stands out in telemetry.
/// </summary>
internal const int MisconfiguredAgentStatusCode = 501;

/// <summary>
/// Stable error code emitted in the response body so callers and tooling can match the condition.
/// </summary>
internal const string MisconfiguredAgentErrorCode = "agent_stored_output_not_disabled";

/// <summary>
/// Returns the error to throw when the agent's own service kept the turn.
/// </summary>
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);

/// <summary>
/// Installs a factory on <paramref name="options"/> that turns storage off on the request the agent's
/// chat client is about to build.
/// </summary>
/// <param name="options">The chat options for this run.</param>
/// <param name="agentRawRepresentationFactory">
/// The factory the agent carries on its own <see cref="ChatOptions"/>, if any. It is invoked here and
/// its result is what gets the setting, because <c>ChatClientAgent</c> 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.
/// </param>
/// <param name="includeReasoningEncryptedContent">
/// Whether to ask for the encrypted form of the reasoning tokens, which is what keeps reasoning
/// usable across turns while storage is off.
/// </param>
/// <remarks>
/// 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.
/// </remarks>
internal static void DisableStoredOutput(
ChatOptions options,
Func<IChatClient, object?>? 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);
}
};
}

/// <summary>
/// Reads whether a request the agent's chat client would send asks for the response to be stored.
/// Returns <see langword="null"/> when the request shape carries no such setting, which is a request
/// type this package has nothing to say about.
/// </summary>
internal static bool? ReadsAsStoringResponses(object? rawRepresentation) => rawRepresentation switch
{
CreateResponseOptions responseOptions => responseOptions.StoredOutputEnabled,
ChatCompletionOptions completionOptions => completionOptions.StoredOutputEnabled,
_ => null,
};

/// <summary>
/// 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
/// <c>AsIChatClientWithStoredOutputDisabled</c> does.
/// </summary>
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;
}
}
Loading
Loading