Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -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;
Expand All @@ -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;

/// <summary>
Expand All @@ -34,16 +41,6 @@ public class AgentFrameworkResponseHandler : ResponseHandler
/// </summary>
private static readonly HostedSessionIsolationKeyProvider s_defaultIsolationKeyProvider = new PlatformHostedSessionIsolationKeyProvider();

/// <summary>Identifies the handler as the source of chat history messages it passes as input.</summary>
private const string HistorySourceId = "Microsoft.Agents.AI.Foundry.Hosting.AgentFrameworkResponseHandler";

/// <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.
/// </summary>
private const string WorkflowSessionTypeName = "WorkflowSession";

/// <summary>
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
/// that resolves agents from keyed DI services.
Expand Down Expand Up @@ -127,17 +124,15 @@ public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
conversationId, request.PreviousResponseId, context.ResponseId);

var agentOptions = agent.GetService<ChatClientAgentOptions>();
var hostingOptions = this._serviceProvider.GetService<IOptions<FoundryResponsesOptions>>()?.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
Expand Down Expand Up @@ -169,43 +164,17 @@ 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);

// 3. Emit lifecycle events
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<ChatMessage>();

// 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)
Expand All @@ -219,16 +188,12 @@ public override async IAsyncEnumerable<ResponseStreamEvent> 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<ChatHistoryProvider>(new VolatileChatHistoryProvider());

// Inject Foundry Toolbox tools when the toolbox service is available.
//
// Two sources are considered:
Expand Down Expand Up @@ -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<ChatClientAgent>() 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<ChatHistoryProvider>(
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
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
}
}

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,47 @@
// Copyright (c) Microsoft. All rights reserved.

using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;

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);
}
Loading
Loading