Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
82bcda7
Read hosted chat history through a provider instead of the request input
rogerbarreto Jul 30, 2026
eeb7026
Add regression tests for the duplicated hosted chat history
rogerbarreto Jul 30, 2026
3f42270
Keep unstored turns in the session so mixed conversations stay whole
rogerbarreto Jul 30, 2026
4f4461d
Refuse a stored turn once a conversation holds unstored ones
rogerbarreto Jul 30, 2026
dd4a042
Say plainly that kept turns belong to the session
rogerbarreto Jul 30, 2026
dc8aab4
Show which half of the conversation each provider decides
rogerbarreto Jul 30, 2026
84b8fba
Say why a hosted workflow keeps taking history from the handler
rogerbarreto Jul 30, 2026
0a7bb6f
Ask the session store whether a turn is a resume
rogerbarreto Aug 4, 2026
4bc1230
Drop the experimental marker from an internal type
rogerbarreto Aug 4, 2026
a9c72d0
Stand down the agent's second-manager guard for the host's own provider
rogerbarreto Aug 4, 2026
4bf85dc
Pass a caller's request not to store on to the chat client
rogerbarreto Aug 5, 2026
f2baf7d
Hand the conversation to the agent's own provider instead of a host one
rogerbarreto Aug 5, 2026
0ae968f
Run the agent's own request factory instead of replacing it
rogerbarreto Aug 5, 2026
a5e149e
Cover a stored conversation that stops being stored and asks again
rogerbarreto Aug 5, 2026
1477c28
Leave the conversation to the AgentServer storage provider alone
rogerbarreto Aug 6, 2026
a3ea304
Narrow the history skip to a resumed workflow
rogerbarreto Aug 6, 2026
aa78220
Add a live test that a hosted turn is not stored twice
rogerbarreto Aug 6, 2026
6b1981e
Let the session carry the conversation in the downstream store test
rogerbarreto Aug 6, 2026
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,6 +3,7 @@
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 Down Expand Up @@ -33,6 +34,16 @@ 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 @@ -112,16 +123,21 @@ public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
// (resolvedUserId is null) there is no user to partition on, so the session is unscoped/shared
// by design — per-user isolation applies only when a user identity was resolved (hosted).
var conversationId = request.GetConversationId();
var sessionConversationId = HostedConversationKey.Resolve(
var agentSessionId = HostedConversationKey.Resolve(
conversationId, request.PreviousResponseId, context.ResponseId);

var chatClientAgent = agent.GetService<ChatClientAgent>();
var agentOptions = agent.GetService<ChatClientAgentOptions>();

// 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 = !string.IsNullOrWhiteSpace(sessionConversationId)
? await sessionStore.GetSessionAsync(agent, sessionConversationId, resolvedUserId, cancellationToken).ConfigureAwait(false)
: chatClientAgent is not null
? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
AgentSession? session = sessionLoadedFromStore ?? 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 @@ -153,6 +169,19 @@ 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);
}
Comment thread
rogerbarreto marked this conversation as resolved.

// 3. Create the SDK event stream builder
var stream = new ResponseEventStream(context, request);

Expand All @@ -163,18 +192,17 @@ public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
// 4. Convert input: history + current input → ChatMessage[]
var messages = new List<ChatMessage>();

// Load conversation history only for fresh sessions. When a session already exists
// (e.g. resuming a workflow paused at an external-input port), the workflow's
// checkpointed state already contains the prior turns' messages — replaying history
// would re-drive completed actions and break HITL resume semantics.
var isResume = (!string.IsNullOrWhiteSpace(conversationId) || !string.IsNullOrWhiteSpace(request.PreviousResponseId))
&& session?.StateBag?.Count > 0;
if (!isResume)
// 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))
Comment thread
rogerbarreto marked this conversation as resolved.
Comment thread
rogerbarreto marked this conversation as resolved.
{
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
{
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag));
messages.AddRange(InputConverter
.ConvertOutputItemsToMessages(history, session?.StateBag)
.Select(m => m.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, HistorySourceId)));
}
}

Expand All @@ -191,9 +219,16 @@ public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
}

// 5. Build chat options
var chatOptions = InputConverter.ConvertToChatOptions(request);
var chatOptions = InputConverter.ConvertToChatOptions(request, agentOptions?.ChatOptions?.RawRepresentationFactory);
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());
Comment thread
rogerbarreto marked this conversation as resolved.

// Inject Foundry Toolbox tools when the toolbox service is available.
//
// Two sources are considered:
Expand Down Expand Up @@ -445,9 +480,9 @@ await this._toolboxService

// 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(sessionConversationId))
if (session is not null && !string.IsNullOrWhiteSpace(agentSessionId))
{
await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
await sessionStore.SaveSessionAsync(agent, agentSessionId, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -42,7 +43,8 @@ public abstract ValueTask SaveSessionAsync(
CancellationToken cancellationToken = default);

/// <summary>
/// Retrieves a serialized agent session from persistent storage.
/// Retrieves a serialized agent session from persistent storage, or <see langword="null"/> when
/// no session is stored for the given identifiers.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
Expand All @@ -55,12 +57,41 @@ public abstract ValueTask SaveSessionAsync(
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous retrieval operation.
/// The task result contains the session, or a new session if not found.
/// A task that represents the asynchronous retrieval operation. The task result contains the restored
/// session, or <see langword="null"/> when nothing is stored for the given identifiers. This is a plain
/// lookup: it never creates a session. Use <see cref="GetOrCreateSessionAsync"/> to get a ready-to-use
/// session (loading an existing one or creating a new one), and use this method when the caller needs to
/// distinguish a resumed session from a fresh one (a non-null result means a prior turn established it).
/// </returns>
public abstract ValueTask<AgentSession> GetSessionAsync(
public abstract ValueTask<AgentSession?> GetSessionAsync(
Comment thread
rogerbarreto marked this conversation as resolved.
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default);

/// <summary>
/// Retrieves the stored session for the given identifiers, or creates a new one via
/// <see cref="AIAgent.CreateSessionAsync"/> when none is stored.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
/// <param name="userId">The per-user partition key; see <see cref="GetSessionAsync"/> for its meaning.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task whose result is always a usable session, never <see langword="null"/>.</returns>
/// <remarks>
/// This is the convenience path for callers that only need a session to work with and do not care whether
/// it was loaded or freshly created. It is implemented in terms of <see cref="GetSessionAsync"/>, so a
/// store overriding that method gets this behavior for free.
/// </remarks>
public virtual async ValueTask<AgentSession> GetOrCreateSessionAsync(
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);

return await this.GetSessionAsync(agent, conversationId, userId, cancellationToken).ConfigureAwait(false)
?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -208,21 +208,21 @@ private string BuildNotWritableMessage(string sessionFilePath) =>
$"(for example {nameof(InMemoryAgentSessionStore)}) via AddFoundryResponses(agent, agentSessionStore).";

/// <inheritdoc/>
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
public override async ValueTask<AgentSession?> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);

string path = this.GetSessionPath(agent, conversationId, userId);
if (!File.Exists(path))
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
return null;
}

byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
if (bytes.Length == 0)
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
return null;
}

// Parse and clone so the document buffer can be released.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,15 @@ public override async ValueTask SaveSessionAsync(AIAgent agent, string conversat
}

/// <inheritdoc/>
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
public override async ValueTask<AgentSession?> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
{
var key = GetKey(agent, conversationId, userId);
JsonElement? sessionContent = this._sessions.TryGetValue(key, out var existingSession) ? existingSession : null;

return sessionContent switch
if (!this._sessions.TryGetValue(key, out var existingSession))
{
null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false),
_ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false),
};
return null;
}

return await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false);
}

// Keyed with the same a-/u-/c- prefix scheme as FileSystemAgentSessionStore so the in-memory store
Expand Down
47 changes: 45 additions & 2 deletions dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
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;

Expand Down Expand Up @@ -87,10 +89,14 @@ public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<Outpu
/// Creates <see cref="ChatOptions"/> from the SDK request properties.
/// </summary>
/// <param name="request">The create response request.</param>
/// <param name="agentRawRepresentationFactory">
/// The factory the agent carries on its own <see cref="ChatOptions"/>, if any, so a request that has
/// to set one of its own can run it rather than replace it.
/// </param>
/// <returns>A configured <see cref="ChatOptions"/> instance.</returns>
public static ChatOptions ConvertToChatOptions(CreateResponse request)
public static ChatOptions ConvertToChatOptions(CreateResponse request, Func<IChatClient, object?>? agentRawRepresentationFactory = null)
{
return new ChatOptions
var options = new ChatOptions
{
Temperature = (float?)request.Temperature,
TopP = (float?)request.TopP,
Expand All @@ -100,6 +106,43 @@ public static ChatOptions ConvertToChatOptions(CreateResponse request)
// the client-provided model would override it (causing failures when
// clients send placeholder values like "hosted-agent").
};

// The service behind the agent's chat client is never asked to store a response. Recording a
// hosted turn is the AgentServer SDK's job, done by its storage provider around this handler,
// and a second recording downstream is a conversation nothing here reads and no one reconciles.
// The caller's own store flag is not carried across: it says what the hosting service should
// record, which is a separate question and one this handler has no say in.
//
// 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; such a client keeping a conversation of its own is caught later by the
// conversation id check in the handler.
//
// The agent's own factory 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
// (ChatClientAgent.PrepareChatOptions). A request factory that always answers would otherwise
// drop whatever the container configured.
options.RawRepresentationFactory = chatClient =>
{
switch (agentRawRepresentationFactory?.Invoke(chatClient))
{
case CreateResponseOptions responseOptions:
responseOptions.StoredOutputEnabled = false;
Comment thread
rogerbarreto marked this conversation as resolved.
return responseOptions;

case ChatCompletionOptions completionOptions:
completionOptions.StoredOutputEnabled = false;
return completionOptions;

case { } configuredByTheAgent:
return configuredByTheAgent;

default:
return new CreateResponseOptions { StoredOutputEnabled = false };
}
};

return options;
}

/// <summary>
Expand Down
Loading
Loading