From 82bcda7bbcad0abb24950f4255885af488c889fb Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:30:54 +0100 Subject: [PATCH 01/14] Read hosted chat history through a provider instead of the request input The handler used to fetch the platform conversation history and prepend it to the input of every turn. For a ChatClientAgent that runs in parallel with its own chat history provider, so the conversation had two sources at once. It also had a hidden cost: platform items carry no chat-history source marker, so the agent's provider stored them again as if this turn had written them, leaving a second copy of the conversation inside the persisted session that then diverges from the platform. Make the chat history provider the single source for a ChatClientAgent: - Add FoundryChatHistoryProvider, which reads the conversation through ResponseContext.GetHistoryAsync (it already resolves previous_response_id and the conversation the request belongs to) and stores nothing, because the platform persists the response items itself. An instance is created per request because it holds that request's context, and it is passed as a run-scoped override so the host does not have to mutate the agent. - Register it only when the agent was created without a chat history provider. When one was supplied at construction, that provider owns the conversation and the platform history is not used at all. - Stop adding the platform history to the input for a ChatClientAgent, since the provider now delivers it. A workflow hosted as an agent is not a ChatClientAgent and has no provider pipeline, so it keeps receiving the platform history from the handler exactly as before. --- .../AgentFrameworkResponseHandler.cs | 28 ++++- .../FoundryChatHistoryProvider.cs | 69 ++++++++++ .../AgentFrameworkResponseHandlerTests.cs | 107 ++++++++++++++++ .../FoundryChatHistoryProviderTests.cs | 119 ++++++++++++++++++ 4 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 538d424ad4f..eef6d0ea6e6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -117,6 +117,21 @@ public override async IAsyncEnumerable CreateAsync( var chatClientAgent = agent.GetService(); + // Decide who supplies the conversation history for this turn. + // + // A ChatClientAgent always runs its history through a ChatHistoryProvider. When the agent was + // created with one, that provider owns the conversation and loads it from its own store inside + // the container. When it was not, the handler registers FoundryChatHistoryProvider below so the + // platform stays the source. Either way the provider delivers the prior turns, so the handler + // must not add them to the input as well: that would send the same conversation twice, and + // platform items carry no chat-history marker, so the agent's provider would then store them + // as if they were newly written by this turn. + // + // A workflow hosted as an agent is not a ChatClientAgent and has no such pipeline, so it keeps + // receiving the platform history from the handler exactly as before. + var agentSuppliedHistoryProvider = agent.GetService()?.ChatHistoryProvider is not null; + var historyComesFromProvider = chatClientAgent is not null; + AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId) ? await sessionStore.GetSessionAsync(agent, sessionConversationId, resolvedUserId, cancellationToken).ConfigureAwait(false) : chatClientAgent is not null @@ -169,7 +184,7 @@ public override async IAsyncEnumerable CreateAsync( // 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) + if (!isResume && !historyComesFromProvider) { var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); if (history.Count > 0) @@ -194,6 +209,17 @@ public override async IAsyncEnumerable CreateAsync( var chatOptions = InputConverter.ConvertToChatOptions(request); chatOptions.Instructions = request.Instructions; + // Register the platform-backed chat history provider for agents that did not bring their own. + // It is supplied per request because it reads through this request's ResponseContext, and it is + // passed as a run-scoped override so the agent uses it for this turn without the host having to + // mutate the agent. FoundryChatHistoryProvider reads the prior turns from the platform and + // stores nothing, so the conversation is not copied into the container's session state. + if (historyComesFromProvider && !agentSuppliedHistoryProvider) + { + chatOptions.AdditionalProperties ??= []; + chatOptions.AdditionalProperties.Add(new FoundryChatHistoryProvider(context)); + } + // Inject Foundry Toolbox tools when the toolbox service is available. // // Two sources are considered: diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs new file mode 100644 index 00000000000..92abaf17253 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// A that reads the conversation history kept by the Foundry +/// platform for the current request, instead of keeping a copy inside the container. +/// +/// +/// +/// This is the provider a hosted agent gets when it was created without an explicit +/// . It makes the platform the single +/// source of the conversation: prior turns are read from the platform on every turn, and nothing +/// is written back, because the platform already persists the response items itself. +/// +/// +/// Reading goes through , which resolves the history +/// from previous_response_id and/or the conversation the request belongs to, returns +/// the items in chronological order, and caches the result for the request. An instance is created +/// per request because it holds that request's . +/// +/// +/// When an agent is created with its own chat history provider, that provider is used instead and +/// this one is never registered, so the agent's own store stays the single source. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class FoundryChatHistoryProvider : ChatHistoryProvider +{ + private readonly ResponseContext _context; + + /// + /// Initializes a new instance of the class for a single request. + /// + /// The response context of the request being handled. + public FoundryChatHistoryProvider(ResponseContext context) + { + this._context = Throw.IfNull(context); + } + + /// + protected override async ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(context); + + var history = await this._context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); + + return history.Count > 0 + ? InputConverter.ConvertOutputItemsToMessages(history, context.Session?.StateBag) + : []; + } + + /// + /// + /// Nothing is stored here. The platform persists the items of a response as part of serving the + /// request, so writing them again from inside the container would keep a second, diverging copy + /// of the same conversation. + /// + protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) => default; +} 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 65aa9ce9ade..6eb1c713a76 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -711,6 +711,113 @@ public async Task CreateAsync_DefaultAgent_IsAutoWrappedWithOpenTelemetryAsync() Assert.IsType(events[1]); } + #region Chat history source routing + + [Fact] + public async Task CreateAsync_AgentWithoutProviderPipeline_ReceivesPlatformHistoryInInputAsync() + { + // 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. + var agent = new CapturingAgent(); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + var (request, ctx) = BuildChainRequest("resp_" + new string('1', 46), callId: null); + ctx.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "earlier turn")]); + + // Act + await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); + + // Assert + Assert.NotNull(agent.CapturedMessages); + Assert.Contains(agent.CapturedMessages!, m => m.Text.Contains("earlier turn", StringComparison.Ordinal)); + } + + [Fact] + public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_SendsPlatformHistoryExactlyOnceAsync() + { + // Arrange: no chat history provider was supplied, so the platform stays the source and the + // handler registers FoundryChatHistoryProvider for the turn. + var captured = new List(); + var agent = new ChatClientAgent(CreateCapturingChatClient(captured)); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + var (request, ctx) = BuildChainRequest("resp_" + new string('2', 46), callId: null); + ctx.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "earlier turn")]); + + // Act + await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); + + // Assert: the history reaches the model, and only once. Twice would mean the handler added it + // to the input as well as the provider supplying it. + Assert.Single(captured, m => m.Text.Contains("earlier turn", StringComparison.Ordinal)); + } + + [Fact] + public async Task CreateAsync_ChatClientAgentWithHistoryProvider_UsesThatProviderInsteadOfThePlatformAsync() + { + // Arrange: the agent was created with its own chat history provider, so that provider owns the + // conversation and the platform history must not be used at all. + var captured = new List(); + var agent = new ChatClientAgent( + CreateCapturingChatClient(captured), + new ChatClientAgentOptions { ChatHistoryProvider = new FixedChatHistoryProvider("from my own store") }); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + var (request, ctx) = BuildChainRequest("resp_" + new string('3', 46), callId: null); + ctx.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "from the platform")]); + + // Act + await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); + + // Assert + 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)); + } + + private static OutputItemMessage NewHistoryMessageItem(string id, string text) => + new( + id: id, + role: MessageRole.Assistant, + content: [new MessageContentOutputTextContent(text, Array.Empty(), Array.Empty())], + status: MessageStatus.Completed); + + private static IChatClient CreateCapturingChatClient(List captured) + { + var mock = new Mock(); + mock.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable messages, ChatOptions? _, CancellationToken _) => + { + captured.AddRange(messages); + return ToAsyncEnumerableUpdatesAsync( + new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1" }); + }); + return mock.Object; + } + + private static async IAsyncEnumerable ToAsyncEnumerableUpdatesAsync(params ChatResponseUpdate[] updates) + { + foreach (var update in updates) + { + yield return update; + } + + await Task.CompletedTask; + } + + /// A chat history provider that always returns the same message, standing in for one backed by a store. + private sealed class FixedChatHistoryProvider(string text) : ChatHistoryProvider + { + protected override ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) + => new([new ChatMessage(ChatRole.User, text)]); + + protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) => default; + } + + #endregion + private static TestAgent CreateTestAgent(string responseText) { return new TestAgent(responseText); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs new file mode 100644 index 00000000000..9b1398c738c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +/// +/// Tests for , the chat history provider that reads the +/// conversation from the Foundry platform instead of keeping a copy inside the container. +/// +public class FoundryChatHistoryProviderTests +{ + [Fact] + public async Task InvokingAsync_ReturnsPlatformHistoryBeforeRequestMessagesAsync() + { + // Arrange + var context = CreateContext([NewMessageItem("msg_1", "earlier turn")]); + var provider = new FoundryChatHistoryProvider(context); + var agent = CreateAgent(); + var session = new FakeSession(); + var input = new ChatMessage(ChatRole.User, "new input"); + + // Act + var result = await provider.InvokingAsync( + new ChatHistoryProvider.InvokingContext(agent, session, [input]), + CancellationToken.None); + + // Assert: the platform history comes first, then the caller's messages. + var messages = result.ToList(); + Assert.Equal(2, messages.Count); + Assert.Contains("earlier turn", messages[0].Text); + Assert.Same(input, messages[1]); + } + + [Fact] + public async Task InvokingAsync_MarksPlatformHistoryAsChatHistoryAsync() + { + // Arrange + var context = CreateContext([NewMessageItem("msg_1", "earlier turn")]); + var provider = new FoundryChatHistoryProvider(context); + + // Act + var result = await provider.InvokingAsync( + new ChatHistoryProvider.InvokingContext(CreateAgent(), new FakeSession(), []), + CancellationToken.None); + + // Assert: marking the platform messages as chat history is what keeps another provider from + // storing them again as if this turn had produced them. + var message = Assert.Single(result); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, message.GetAgentRequestMessageSourceType()); + } + + [Fact] + public async Task InvokingAsync_WithNoPlatformHistory_ReturnsOnlyRequestMessagesAsync() + { + // Arrange + var context = CreateContext([]); + var provider = new FoundryChatHistoryProvider(context); + var input = new ChatMessage(ChatRole.User, "new input"); + + // Act + var result = await provider.InvokingAsync( + new ChatHistoryProvider.InvokingContext(CreateAgent(), new FakeSession(), [input]), + CancellationToken.None); + + // Assert + Assert.Same(input, Assert.Single(result)); + } + + [Fact] + public async Task InvokedAsync_StoresNothingInTheSessionAsync() + { + // Arrange + var context = CreateContext([]); + var provider = new FoundryChatHistoryProvider(context); + var session = new FakeSession(); + + // Act: a completed turn is reported to the provider. + await provider.InvokedAsync( + new ChatHistoryProvider.InvokedContext( + CreateAgent(), + session, + [new ChatMessage(ChatRole.User, "input")], + [new ChatMessage(ChatRole.Assistant, "answer")]), + CancellationToken.None); + + // Assert: the platform already persists the response items, so nothing is copied into the + // session state. A copy there would grow the persisted session and diverge from the platform. + Assert.Empty(session.StateBag.Serialize().EnumerateObject()); + } + + private static ResponseContext CreateContext(IReadOnlyList history) + { + var mock = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mock.Setup(x => x.GetHistoryAsync(It.IsAny())).ReturnsAsync(history); + return mock.Object; + } + + private static AIAgent CreateAgent() => new Mock().Object; + + private static OutputItemMessage NewMessageItem(string id, string text) => + new( + id: id, + role: MessageRole.Assistant, + content: [new MessageContentOutputTextContent(text, Array.Empty(), Array.Empty())], + status: MessageStatus.Completed); + + private sealed class FakeSession : AgentSession + { + } +} From eeb7026073a71fd18561d97f063cf34b042c101f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:21:56 +0100 Subject: [PATCH 02/14] Add regression tests for the duplicated hosted chat history Cover the three symptoms the previous handler produced, each verified to fail when the handler is reverted to fetching the platform history into the turn input: - the conversation the service already keeps was copied into the persisted agent session by the default in-memory history provider; - a custom history provider was asked to write that same conversation into its own database, because platform items carry no chat-history source marker and so look like content this turn produced; - an agent with its own provider received both that provider's history and the platform's in a single request. Also state precisely, in the provider's remarks, why nothing is written back: for a stored request the response orchestrator hands the finished response to its responses provider, which persists the input and output items that a later turn then reads back through GetHistoryAsync; for a non-stored request nothing is persisted and nothing is readable, so the request is self-contained either way. --- .../FoundryChatHistoryProvider.cs | 18 +++- .../AgentFrameworkResponseHandlerTests.cs | 89 ++++++++++++++++++- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs index 92abaf17253..7496fe6cec0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs @@ -61,9 +61,21 @@ protected override async ValueTask> ProvideChatHistoryA /// /// - /// Nothing is stored here. The platform persists the items of a response as part of serving the - /// request, so writing them again from inside the container would keep a second, diverging copy - /// of the same conversation. + /// + /// Nothing is stored here, because the same turn is already persisted by the server that hosts + /// this handler. When a request is made with store set to true, the response orchestrator + /// hands the finished response to its responses provider, which writes the input items and the + /// output items and links them to the conversation. Those are the very items a later turn reads + /// back through . Writing them again from inside the + /// container would keep a second copy of the same conversation in the agent session, which then + /// diverges from the one the service serves. + /// + /// + /// When store is false the service persists nothing, and there is also nothing for a later + /// turn to read: history is resolved from previous_response_id or the conversation, both of + /// which only exist for stored responses. Such a request is therefore self-contained, and storing + /// its messages here would not make them reachable by any later turn either. + /// /// protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) => default; } 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 6eb1c713a76..c3cc5ee877d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -713,6 +713,14 @@ public async Task CreateAsync_DefaultAgent_IsAutoWrappedWithOpenTelemetryAsync() #region Chat history source routing + // These tests pin down who supplies the conversation history to a hosted agent. Three of them are + // 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) + [Fact] public async Task CreateAsync_AgentWithoutProviderPipeline_ReceivesPlatformHistoryInInputAsync() { @@ -747,11 +755,56 @@ public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_SendsPlatfor // Act await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); - // Assert: the history reaches the model, and only once. Twice would mean the handler added it - // to the input as well as the provider supplying it. + // Assert: the earlier turn still reaches the model, and only one copy of it does. Assert.Single(captured, m => m.Text.Contains("earlier turn", StringComparison.Ordinal)); } + [Fact] + public async Task CreateAsync_ChatClientAgentWithHistoryProvider_DoesNotAskItToStorePlatformHistoryAsync() + { + // Arrange: an agent whose own provider records everything it is asked to store, and a platform + // that already holds an earlier turn of this conversation. + var recordingProvider = new RecordingChatHistoryProvider(); + var agent = new ChatClientAgent( + CreateCapturingChatClient([]), + new ChatClientAgentOptions { ChatHistoryProvider = recordingProvider }); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + var (request, ctx) = BuildChainRequest("resp_" + new string('5', 46), callId: null); + ctx.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "already kept by the service")]); + + // Act + await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); + + // Assert: the agent's own store must not be told to write a turn the service already holds. The + // older handler passed that turn in as ordinary input, and since platform items carry no + // chat-history source marker the provider took it for newly written content and stored it, + // duplicating into the agent's own database a conversation the service was already keeping. + Assert.DoesNotContain(recordingProvider.Stored, m => m.Text.Contains("already kept by the service", StringComparison.Ordinal)); + } + + [Fact] + public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_DoesNotCopyPlatformHistoryIntoTheSessionAsync() + { + // Arrange: the platform reports one earlier turn for this conversation. + const string ResponseId = "resp_" + "4444444444444444444444444444444444444444444444"; + var store = new InMemoryAgentSessionStore(); + var agent = new ChatClientAgent(CreateCapturingChatClient([])); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), store); + var (request, ctx) = BuildChainRequest(ResponseId, callId: null); + ctx.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "already kept by the service")]); + + // Act + await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); + + // Assert: the turn the service already keeps must not be written into the persisted session as + // well. The older handler fed that history to the agent as ordinary input, and because platform + // items carry no chat-history source marker the agent's default in-memory provider stored it as + // if this turn had produced it, leaving a second copy on disk that then drifts from the service. + Assert.DoesNotContain("already kept by the service", await SerializedSessionOfAsync(agent, store, ResponseId), StringComparison.Ordinal); + } + [Fact] public async Task CreateAsync_ChatClientAgentWithHistoryProvider_UsesThatProviderInsteadOfThePlatformAsync() { @@ -769,11 +822,21 @@ public async Task CreateAsync_ChatClientAgentWithHistoryProvider_UsesThatProvide // Act await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); - // Assert + // Assert: one source only. The older handler sent both, so the model received the agent's own + // history and the platform's interleaved as a single conversation. 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)); } + /// Reads back the session the handler persisted for a response and returns it as JSON text. + private static async Task SerializedSessionOfAsync(AIAgent agent, InMemoryAgentSessionStore store, string responseId) + { + var sessionKey = HostedConversationKey.Resolve(conversationId: null, previousResponseId: null, responseId); + var session = await store.GetSessionAsync(agent, sessionKey!, FakeHostedSessionIsolationKeyProvider.DefaultUserId, CancellationToken.None); + var serialized = await agent.SerializeSessionAsync(session, cancellationToken: CancellationToken.None); + return serialized.GetRawText(); + } + private static OutputItemMessage NewHistoryMessageItem(string id, string text) => new( id: id, @@ -816,6 +879,26 @@ protected override ValueTask> ProvideChatHistoryAsync(I protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) => default; } + /// A chat history provider that records everything it is asked to write, standing in for one backed by a database. + private sealed class RecordingChatHistoryProvider : ChatHistoryProvider + { + public List Stored { get; } = []; + + protected override ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) + => new([]); + + protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + this.Stored.AddRange(context.RequestMessages); + if (context.ResponseMessages is not null) + { + this.Stored.AddRange(context.ResponseMessages); + } + + return default; + } + } + #endregion private static TestAgent CreateTestAgent(string responseText) From 3f422703b6ab80a4d738941b084d0fba58ace410 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:43:13 +0100 Subject: [PATCH 03/14] Keep unstored turns in the session so mixed conversations stay whole A conversation can mix turns the service stores with turns it does not. History is resolved from previous_response_id or the conversation regardless of the current request's store flag, so an unstored turn still reads the stored ones back, but the service records nothing for it and a later turn would never see it again. Reading the platform history through FoundryChatHistoryProvider alone lost those turns: from the second turn onwards the handler treats the session as a resume and stops feeding history in, and the provider kept nothing of its own, so an unstored turn simply vanished from the conversation. A regression test drives three turns of one conversation, the first stored and the rest not, and without this change the model receives only [second question, ok, third question]: the stored opening turn is gone. Give the provider both halves instead of choosing one: - reading returns what the service serves, followed by the turns kept in the session, which are by definition later than anything the service recorded; - writing keeps a turn only when the service was not asked to store it, so a stored turn is never duplicated and an unstored one is never lost. The turns are held in the agent session under the provider's own state key, so they travel with the session the host already persists. --- .../AgentFrameworkResponseHandler.cs | 15 +-- .../FoundryChatHistoryProvider.cs | 110 ++++++++++++----- .../AgentFrameworkResponseHandlerTests.cs | 112 ++++++++++++++++++ .../FoundryChatHistoryProviderTests.cs | 60 ++++++++-- 4 files changed, 252 insertions(+), 45 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index eef6d0ea6e6..49281720792 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -121,14 +121,15 @@ public override async IAsyncEnumerable CreateAsync( // // A ChatClientAgent always runs its history through a ChatHistoryProvider. When the agent was // created with one, that provider owns the conversation and loads it from its own store inside - // the container. When it was not, the handler registers FoundryChatHistoryProvider below so the - // platform stays the source. Either way the provider delivers the prior turns, so the handler - // must not add them to the input as well: that would send the same conversation twice, and - // platform items carry no chat-history marker, so the agent's provider would then store them - // as if they were newly written by this turn. + // the container. When it was not, the handler registers FoundryChatHistoryProvider below, which + // reads what the service holds and keeps in the session only the turns the service was not + // asked to store. Either way the provider delivers the prior turns, so the handler must not add + // them to the input as well: that would send the same conversation twice, and service items + // carry no chat-history marker, so the agent's provider would then store them as if they were + // newly written by this turn. // // A workflow hosted as an agent is not a ChatClientAgent and has no such pipeline, so it keeps - // receiving the platform history from the handler exactly as before. + // receiving the history from the handler exactly as before. var agentSuppliedHistoryProvider = agent.GetService()?.ChatHistoryProvider is not null; var historyComesFromProvider = chatClientAgent is not null; @@ -217,7 +218,7 @@ public override async IAsyncEnumerable CreateAsync( if (historyComesFromProvider && !agentSuppliedHistoryProvider) { chatOptions.AdditionalProperties ??= []; - chatOptions.AdditionalProperties.Add(new FoundryChatHistoryProvider(context)); + chatOptions.AdditionalProperties.Add(new FoundryChatHistoryProvider(context, serviceStoresThisTurn: request.Store != false)); } // Inject Foundry Toolbox tools when the toolbox service is available. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs index 7496fe6cec0..4e2250ef132 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; @@ -12,22 +14,36 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// -/// A that reads the conversation history kept by the Foundry -/// platform for the current request, instead of keeping a copy inside the container. +/// A that reads the conversation the Foundry service keeps for the +/// current request, and keeps in the agent session only the turns the service will not keep itself. /// /// /// /// This is the provider a hosted agent gets when it was created without an explicit -/// . It makes the platform the single -/// source of the conversation: prior turns are read from the platform on every turn, and nothing -/// is written back, because the platform already persists the response items itself. +/// . Reading goes through +/// , which resolves the history from +/// previous_response_id and/or the conversation the request belongs to, returns the +/// items in chronological order, and caches the result for the request. An instance is created per +/// request because it holds that request's . /// /// -/// Reading goes through , which resolves the history -/// from previous_response_id and/or the conversation the request belongs to, returns -/// the items in chronological order, and caches the result for the request. An instance is created -/// per request because it holds that request's . +/// Writing depends on whether the request is stored, so that every turn is kept exactly once: /// +/// +/// +/// With store true the service persists this turn itself: the response orchestrator hands the +/// finished response to its responses provider, which writes the input and output items and links +/// them to the conversation. Those are the very items a later turn reads back. Writing them into the +/// agent session as well would keep a second copy that then diverges from the one the service serves, +/// so nothing is written here. +/// +/// +/// With store false the service persists nothing, and a later turn asking for this +/// conversation gets nothing back for it. The turn is therefore kept in the agent session, which is +/// the only memory it can have. Earlier stored turns of the same conversation are still read from the +/// service, so a conversation that mixes stored and unstored turns stays whole. +/// +/// /// /// When an agent is created with its own chat history provider, that provider is used instead and /// this one is never registered, so the agent's own store stays the single source. @@ -37,45 +53,77 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; internal sealed class FoundryChatHistoryProvider : ChatHistoryProvider { private readonly ResponseContext _context; + private readonly bool _serviceStoresThisTurn; + private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; /// /// Initializes a new instance of the class for a single request. /// /// The response context of the request being handled. - public FoundryChatHistoryProvider(ResponseContext context) + /// + /// when the request was made with store enabled, so the service keeps + /// this turn; when it keeps nothing and the turn must be kept in the session. + /// + public FoundryChatHistoryProvider(ResponseContext context, bool serviceStoresThisTurn) { this._context = Throw.IfNull(context); + this._serviceStoresThisTurn = serviceStoresThisTurn; + this._sessionState = new ProviderSessionState(_ => new State(), nameof(FoundryChatHistoryProvider)); } + /// + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; + /// protected override async ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) { _ = Throw.IfNull(context); - var history = await this._context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); + var served = await this._context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); - return history.Count > 0 - ? InputConverter.ConvertOutputItemsToMessages(history, context.Session?.StateBag) + // The service's turns come first: they are the earlier part of the conversation, and anything + // kept in the session is by definition a turn the service did not record, which happened after. + IEnumerable history = served.Count > 0 + ? InputConverter.ConvertOutputItemsToMessages(served, context.Session?.StateBag) : []; + + var unstored = this._sessionState.GetOrInitializeState(context.Session).Messages; + + return unstored.Count > 0 ? history.Concat(unstored) : history; } /// - /// - /// - /// Nothing is stored here, because the same turn is already persisted by the server that hosts - /// this handler. When a request is made with store set to true, the response orchestrator - /// hands the finished response to its responses provider, which writes the input items and the - /// output items and links them to the conversation. Those are the very items a later turn reads - /// back through . Writing them again from inside the - /// container would keep a second copy of the same conversation in the agent session, which then - /// diverges from the one the service serves. - /// - /// - /// When store is false the service persists nothing, and there is also nothing for a later - /// turn to read: history is resolved from previous_response_id or the conversation, both of - /// which only exist for stored responses. Such a request is therefore self-contained, and storing - /// its messages here would not make them reachable by any later turn either. - /// - /// - protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) => default; + protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(context); + + if (this._serviceStoresThisTurn) + { + return default; + } + + // Only the messages of this turn arrive here: the base class filters out everything marked as + // chat history, which is what was handed back by ProvideChatHistoryAsync above. + var state = this._sessionState.GetOrInitializeState(context.Session); + state.Messages.AddRange(context.RequestMessages); + if (context.ResponseMessages is not null) + { + state.Messages.AddRange(context.ResponseMessages); + } + + this._sessionState.SaveState(context.Session, state); + return default; + } + + /// + /// The turns of a conversation that the service was not asked to store, held in the + /// so they survive with the session. + /// + public sealed class State + { + /// Gets or sets the messages of the turns the service did not store. + [JsonPropertyName("messages")] + public List Messages { get; set; } = []; + } } 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 c3cc5ee877d..aecba3386dd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -828,6 +828,118 @@ public async Task CreateAsync_ChatClientAgentWithHistoryProvider_UsesThatProvide Assert.DoesNotContain(captured, m => m.Text.Contains("from the platform", StringComparison.Ordinal)); } + [Fact] + public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_KeepsContainerSideMemoryWhenTheRequestIsNotStoredAsync() + { + // Arrange: two turns of one conversation, and a service that holds no history for it. That is + // what a store=false request looks like: nothing was persisted, so GetHistoryItemIdsAsync finds + // no record and the service can serve nothing back. + var captured = new List(); + var store = new InMemoryAgentSessionStore(); + var agent = new ChatClientAgent(CreateCapturingChatClient(captured)); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), store); + + await DrainEventsAsync(handler.CreateAsync( + NewUnstoredRequest("conv-unstored", "first question"), + NewEmptyHistoryContext("resp_" + new string('6', 46)), + CancellationToken.None)); + captured.Clear(); + + // Act: a second turn of the same conversation. + await DrainEventsAsync(handler.CreateAsync( + NewUnstoredRequest("conv-unstored", "second question"), + NewEmptyHistoryContext("resp_" + new string('7', 46)), + CancellationToken.None)); + + // Assert: the agent still remembers the first turn. When the service stores nothing there is no + // second copy to worry about, and the container's own session is the only memory the + // conversation can have, so the default in-memory provider must keep carrying it. + Assert.Contains(captured, m => m.Text.Contains("first question", StringComparison.Ordinal)); + } + + private static CreateResponse NewUnstoredRequest(string conversationId, string text) + { + var request = new CreateResponse { Model = "test", Store = false }; + request.Conversation = BinaryData.FromString($"\"{conversationId}\""); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_" + Guid.NewGuid().ToString("N")[..8], status = "completed", role = "user", + content = new[] { new { type = "input_text", text } } } + }); + return request; + } + + private static ResponseContext NewEmptyHistoryContext(string responseId) + { + var ctx = new Mock(responseId) { CallBase = true }; + ctx.Setup(x => x.PlatformContext).Returns(new PlatformContext("alice", null)); + ctx.Setup(x => x.GetHistoryAsync(It.IsAny())).ReturnsAsync(Array.Empty()); + ctx.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Array.Empty()); + return ctx.Object; + } + + [Fact] + public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_KeepsMixedStoredAndUnstoredConversationWholeAsync() + { + // Arrange: one conversation whose first turn is stored by the service and whose later turns are + // not. From turn 2 on, the service keeps serving turn 1 and nothing else, because it was never + // asked to store the rest. + var captured = new List(); + var agent = new ChatClientAgent(CreateCapturingChatClient(captured)); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + OutputItem[] servedByTheService = + [ + NewHistoryMessageItem("msg_hist_1", "first question"), + NewHistoryMessageItem("msg_hist_2", "first answer"), + ]; + + // Turn 1 is stored, so the service records it. + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-mixed", "first question", store: true), + NewContextServing("resp_" + new string('6', 46), []), + CancellationToken.None)); + + // Turn 2 is not stored: the service still serves turn 1, and this turn is only kept in the session. + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-mixed", "second question", store: false), + NewContextServing("resp_" + new string('7', 46), servedByTheService), + CancellationToken.None)); + captured.Clear(); + + // Act: a third turn, also not stored. + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-mixed", "third question", store: false), + NewContextServing("resp_" + new string('8', 46), servedByTheService), + CancellationToken.None)); + + // Assert: the conversation is whole and each turn appears once. The stored turn comes from the + // service and the unstored one from the session, and neither is counted twice. + Assert.Single(captured, m => m.Text.Contains("first question", StringComparison.Ordinal)); + Assert.Single(captured, m => m.Text.Contains("second question", StringComparison.Ordinal)); + Assert.Single(captured, m => m.Text.Contains("third question", StringComparison.Ordinal)); + } + + private static CreateResponse NewConversationRequest(string conversationId, string text, bool store) + { + var request = new CreateResponse { Model = "test", Store = store }; + request.Conversation = BinaryData.FromString($"\"{conversationId}\""); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_" + Guid.NewGuid().ToString("N")[..8], status = "completed", role = "user", + content = new[] { new { type = "input_text", text } } } + }); + return request; + } + + private static ResponseContext NewContextServing(string responseId, IReadOnlyList history) + { + var ctx = new Mock(responseId) { CallBase = true }; + ctx.Setup(x => x.PlatformContext).Returns(new PlatformContext("alice", null)); + ctx.Setup(x => x.GetHistoryAsync(It.IsAny())).ReturnsAsync(history); + ctx.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Array.Empty()); + return ctx.Object; + } + /// Reads back the session the handler persisted for a response and returns it as JSON text. private static async Task SerializedSessionOfAsync(AIAgent agent, InMemoryAgentSessionStore store, string responseId) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs index 9b1398c738c..404d8d841b7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs @@ -23,7 +23,7 @@ public async Task InvokingAsync_ReturnsPlatformHistoryBeforeRequestMessagesAsync { // Arrange var context = CreateContext([NewMessageItem("msg_1", "earlier turn")]); - var provider = new FoundryChatHistoryProvider(context); + var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: true); var agent = CreateAgent(); var session = new FakeSession(); var input = new ChatMessage(ChatRole.User, "new input"); @@ -45,7 +45,7 @@ public async Task InvokingAsync_MarksPlatformHistoryAsChatHistoryAsync() { // Arrange var context = CreateContext([NewMessageItem("msg_1", "earlier turn")]); - var provider = new FoundryChatHistoryProvider(context); + var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: true); // Act var result = await provider.InvokingAsync( @@ -63,7 +63,7 @@ public async Task InvokingAsync_WithNoPlatformHistory_ReturnsOnlyRequestMessages { // Arrange var context = CreateContext([]); - var provider = new FoundryChatHistoryProvider(context); + var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: true); var input = new ChatMessage(ChatRole.User, "new input"); // Act @@ -76,11 +76,11 @@ public async Task InvokingAsync_WithNoPlatformHistory_ReturnsOnlyRequestMessages } [Fact] - public async Task InvokedAsync_StoresNothingInTheSessionAsync() + public async Task InvokedAsync_WhenTheServiceStoresTheTurn_KeepsNothingInTheSessionAsync() { // Arrange var context = CreateContext([]); - var provider = new FoundryChatHistoryProvider(context); + var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: true); var session = new FakeSession(); // Act: a completed turn is reported to the provider. @@ -92,11 +92,57 @@ [new ChatMessage(ChatRole.User, "input")], [new ChatMessage(ChatRole.Assistant, "answer")]), CancellationToken.None); - // Assert: the platform already persists the response items, so nothing is copied into the - // session state. A copy there would grow the persisted session and diverge from the platform. + // Assert: the service already persists the items of a stored response, so nothing is copied into + // the session. A copy there would grow the persisted session and drift from what the service serves. Assert.Empty(session.StateBag.Serialize().EnumerateObject()); } + [Fact] + public async Task InvokedAsync_WhenTheServiceDoesNotStoreTheTurn_KeepsItInTheSessionAsync() + { + // Arrange: a turn the service was not asked to store. + var context = CreateContext([]); + var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false); + var session = new FakeSession(); + + // Act + await provider.InvokedAsync( + new ChatHistoryProvider.InvokedContext( + CreateAgent(), + session, + [new ChatMessage(ChatRole.User, "unstored input")], + [new ChatMessage(ChatRole.Assistant, "unstored answer")]), + CancellationToken.None); + + // Assert: the session is the only memory this turn can have, so it is kept there. + var state = session.StateBag.Serialize().GetRawText(); + Assert.Contains("unstored input", state, StringComparison.Ordinal); + Assert.Contains("unstored answer", state, StringComparison.Ordinal); + } + + [Fact] + public async Task InvokingAsync_ReturnsServedHistoryThenTurnsTheServiceDidNotStoreAsync() + { + // Arrange: a conversation whose earlier turn the service holds, plus a later turn it was not + // asked to store, which a previous request kept in the session. + var context = CreateContext([NewMessageItem("msg_1", "stored turn")]); + var session = new FakeSession(); + await new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false).InvokedAsync( + new ChatHistoryProvider.InvokedContext( + CreateAgent(), session, [new ChatMessage(ChatRole.User, "unstored turn")], []), + CancellationToken.None); + + // Act: a later request reads the conversation back. + var result = await new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false).InvokingAsync( + new ChatHistoryProvider.InvokingContext(CreateAgent(), session, [new ChatMessage(ChatRole.User, "new input")]), + CancellationToken.None); + + // Assert: the conversation is whole and in order, with each turn appearing once. + Assert.Equal( + ["stored turn", "unstored turn", "new input"], + result.Select(m => m.Text).ToArray()); + } + private static ResponseContext CreateContext(IReadOnlyList history) { var mock = new Mock("resp_" + new string('0', 46)) { CallBase = true }; From 4f4461dc0c68eb680b9a969b2fa201464eff3057 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:18:11 +0100 Subject: [PATCH 04/14] Refuse a stored turn once a conversation holds unstored ones A conversation can move between stored and unstored turns, and the unstored ones live only in the agent session. Going back to a stored turn after that would have the service record it on top of turns the service never saw, so anyone reading the conversation back from the service would find an answer with no question. Refuse it before the model is called instead of writing that gap. Cover the whole shape with a walkthrough of nine turns over one conversation and three provider instances, each with its own session: - an instance that never took an unstored turn starts from the turn the service last saved, and does not see another instance's unstored turns; - an instance that did keeps reading the saved turns and adds its own on top; - asking such an instance for a stored turn is refused, twice, while unstored turns keep working; - a turn stored from one instance does not appear for another, because it sits on a different branch of the conversation and so is not among the turns leading to what that other instance last saved. --- .../FoundryChatHistoryProvider.cs | 24 +++- .../FoundryChatHistoryProviderTests.cs | 118 ++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs index 4e2250ef132..5a9ef539e5b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -45,6 +46,11 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// /// +/// Once a conversation holds turns the service never saw, a stored turn is refused: the service would +/// record it on top of turns it does not have, leaving a gap for anyone reading the conversation back +/// from it. The refusal happens before the model is called. +/// +/// /// When an agent is created with its own chat history provider, that provider is used instead and /// this one is never registered, so the agent's own store stays the single source. /// @@ -80,6 +86,22 @@ protected override async ValueTask> ProvideChatHistoryA { _ = Throw.IfNull(context); + var unstored = this._sessionState.GetOrInitializeState(context.Session).Messages; + + // Once a conversation has turns the service never saw, it cannot go back to being stored by the + // service: the service would record this turn on top of turns it does not have, so anyone + // reading the conversation back from it would get an answer with no question. Refuse up front + // rather than let that gap be written. + if (this._serviceStoresThisTurn && unstored.Count > 0) + { + throw new InvalidOperationException( + """ + This conversation has turns that were not stored by the service, so a stored turn cannot be added to it. + The service would record this turn without the turns that came before it, leaving a gap in the stored conversation. + Either keep using store=false for this conversation, or start a new one for stored turns. + """); + } + var served = await this._context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); // The service's turns come first: they are the earlier part of the conversation, and anything @@ -88,8 +110,6 @@ protected override async ValueTask> ProvideChatHistoryA ? InputConverter.ConvertOutputItemsToMessages(served, context.Session?.StateBag) : []; - var unstored = this._sessionState.GetOrInitializeState(context.Session).Messages; - return unstored.Count > 0 ? history.Concat(unstored) : history; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs index 404d8d841b7..622d3dd2c12 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs @@ -143,6 +143,124 @@ public async Task InvokingAsync_ReturnsServedHistoryThenTurnsTheServiceDidNotSto result.Select(m => m.Text).ToArray()); } + [Fact] + public async Task ConversationAcrossInstancesAndStoreModesAsync() + { + // Arrange: one service-side conversation and three separate provider instances, each with its + // own session. An instance only builds a local buffer once it is used for an unstored turn, and + // an instance that has never done so starts from whatever the service last saved. + var service = new FakeStoredResponses(); + var instance1 = new Instance(); + + // 1. Stored turn on instance 1: nothing precedes it, and the service records it. + Assert.Equal(["call 1"], await CallAsync(service, instance1, store: true, "call 1")); + + // The other two instances join the same conversation, so they start from the turn the service + // last saved for it. Neither holds anything of its own yet. + var instance2 = new Instance { LastStoredResponseId = instance1.LastStoredResponseId }; + var instance3 = new Instance { LastStoredResponseId = instance1.LastStoredResponseId }; + + // 2. Unstored turn on instance 1: it reads the stored turn back and keeps its own turn locally. + Assert.Equal(["call 1", "reply 1", "call 2"], await CallAsync(service, instance1, store: false, "call 2")); + + // 3. A different instance starts from the last saved turn only: instance 1's unstored turn is + // held in instance 1's own session and is invisible here. + Assert.Equal(["call 1", "reply 1", "call 3"], await CallAsync(service, instance2, store: false, "call 3")); + + // 4. Asking the service to store a turn on an instance that is already holding unstored ones is + // refused: the service would record this turn without the turns that came before it. + var refused = await Assert.ThrowsAsync( + () => CallAsync(service, instance2, store: true, "call 4")); + Assert.Contains("were not stored", refused.Message, StringComparison.Ordinal); + + // 5. Continuing unstored on the same instance is fine, and it keeps growing its local turns. + Assert.Equal( + ["call 1", "reply 1", "call 3", "reply 3", "call 5"], + await CallAsync(service, instance2, store: false, "call 5")); + + // 6. Still refused, for the same reason. + await Assert.ThrowsAsync(() => CallAsync(service, instance2, store: true, "call 6")); + + // 7. A fresh instance holds nothing locally, so it may store. It branches off the last turn the + // service saved, which is still the first one. + Assert.Equal(["call 1", "reply 1", "call 7"], await CallAsync(service, instance3, store: true, "call 7")); + + // 8. Instance 2 is unaffected by instance 3's stored turn: that turn sits on another branch of + // the conversation, so it is not among the turns leading to instance 2's last saved one. + Assert.Equal( + ["call 1", "reply 1", "call 3", "reply 3", "call 5", "reply 5", "call 8"], + await CallAsync(service, instance2, store: false, "call 8")); + + // 9. And instance 1 still sees its own thread of the conversation. + Assert.Equal( + ["call 1", "reply 1", "call 2", "reply 2", "call 9"], + await CallAsync(service, instance1, store: false, "call 9")); + } + + /// + /// Runs one turn through a new provider, the way the host does: a provider is built for the request, + /// asked for the messages to send, and then told what the turn produced. Returns the message texts + /// the model would have received. + /// + private static async Task CallAsync(FakeStoredResponses service, Instance instance, bool store, string text) + { + var context = CreateContext(service.TurnsLeadingTo(instance.LastStoredResponseId)); + var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: store); + var agent = CreateAgent(); + + var sent = (await provider.InvokingAsync( + new ChatHistoryProvider.InvokingContext(agent, instance.Session, [new ChatMessage(ChatRole.User, text)]), + CancellationToken.None)).ToList(); + + var reply = new ChatMessage(ChatRole.Assistant, text.Replace("call", "reply", StringComparison.Ordinal)); + await provider.InvokedAsync( + new ChatHistoryProvider.InvokedContext(agent, instance.Session, sent, [reply]), + CancellationToken.None); + + if (store) + { + instance.LastStoredResponseId = service.Store(instance.LastStoredResponseId, [new ChatMessage(ChatRole.User, text), reply]); + } + + return [.. sent.Select(m => m.Text)]; + } + + /// A provider instance's own session, plus the last turn the service saved for it. + private sealed class Instance + { + public FakeSession Session { get; } = new(); + + public string? LastStoredResponseId { get; set; } + } + + /// + /// Stands in for the responses the service keeps. Each stored response points at the one it followed, + /// so asking for the turns leading to a response walks back through them, and a response stored on + /// another branch is not among them. + /// + private sealed class FakeStoredResponses + { + private readonly Dictionary Messages)> _stored = new(StringComparer.Ordinal); + + public string Store(string? previousResponseId, IReadOnlyList messages) + { + var id = $"resp_{this._stored.Count + 1}"; + this._stored[id] = (previousResponseId, [.. messages]); + return id; + } + + public IReadOnlyList TurnsLeadingTo(string? responseId) + { + var chain = new List(); + for (var id = responseId; id is not null && this._stored.TryGetValue(id, out var entry); id = entry.Previous) + { + chain.InsertRange(0, entry.Messages); + } + + return [.. chain.Select((m, i) => NewMessageItem($"msg_{i}", m.Text))]; + } + } + private static ResponseContext CreateContext(IReadOnlyList history) { var mock = new Mock("resp_" + new string('0', 46)) { CallBase = true }; From dd4a042abfa2579940fda11ba7eb4dc6519eaff4 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:22:35 +0100 Subject: [PATCH 05/14] Say plainly that kept turns belong to the session The turns the service was not asked to store are written into the agent session's state bag under this provider's own state key, and a new provider is built for every request, so nothing is held on the provider object itself. The walkthrough named its three threads after provider instances, which read as if the object carried the memory. Name them after the sessions they are, and add a test that pins the behaviour down: a turn kept through one provider object is read back by a different one given the same session, and is absent for one given another session. --- .../FoundryChatHistoryProvider.cs | 6 + .../FoundryChatHistoryProviderTests.cs | 103 +++++++++++------- 2 files changed, 71 insertions(+), 38 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs index 5a9ef539e5b..83730380433 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs @@ -51,6 +51,12 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// from it. The refusal happens before the model is called. /// /// +/// What is kept belongs to the , not to this object: a new provider is built +/// for every request, and the turns it keeps are written into the session's state bag under this +/// provider's own state key. Two sessions following the same service-side conversation therefore keep +/// their unstored turns apart, and each still reads the stored ones from the service. +/// +/// /// When an agent is created with its own chat history provider, that provider is used instead and /// this one is never registered, so the agent's own store stays the single source. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs index 622d3dd2c12..9e06940f85b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs @@ -144,89 +144,116 @@ public async Task InvokingAsync_ReturnsServedHistoryThenTurnsTheServiceDidNotSto } [Fact] - public async Task ConversationAcrossInstancesAndStoreModesAsync() + public async Task InvokingAsync_WhatIsKeptBelongsToTheSessionNotToTheProviderObjectAsync() { - // Arrange: one service-side conversation and three separate provider instances, each with its - // own session. An instance only builds a local buffer once it is used for an unstored turn, and - // an instance that has never done so starts from whatever the service last saved. + // Arrange: one unstored turn kept through one provider object. + var context = CreateContext([]); + var session = new FakeSession(); + await new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false).InvokedAsync( + new ChatHistoryProvider.InvokedContext( + CreateAgent(), session, [new ChatMessage(ChatRole.User, "kept turn")], []), + CancellationToken.None); + + // Act: a brand new provider object reads that session, and another one reads a different session. + var sameSession = await new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false).InvokingAsync( + new ChatHistoryProvider.InvokingContext(CreateAgent(), session, []), + CancellationToken.None); + var otherSession = await new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false).InvokingAsync( + new ChatHistoryProvider.InvokingContext(CreateAgent(), new FakeSession(), []), + CancellationToken.None); + + // Assert: the turn follows the session it was kept in, not the object that kept it. A host builds + // a new provider for every request, so anything held on the object itself would be lost at once. + Assert.Equal(["kept turn"], sameSession.Select(m => m.Text).ToArray()); + Assert.Empty(otherSession); + } + + [Fact] + public async Task ConversationAcrossSessionsAndStoreModesAsync() + { + // Arrange: one service-side conversation and three separate sessions. A fresh provider is built + // for every call, the way the host does, so anything remembered between calls belongs to the + // session and not to the provider object. A session only starts holding turns of its own once it + // is used for an unstored one, and a session that never did starts from what the service saved. var service = new FakeStoredResponses(); - var instance1 = new Instance(); + var sessionA = new ConversationSession(); - // 1. Stored turn on instance 1: nothing precedes it, and the service records it. - Assert.Equal(["call 1"], await CallAsync(service, instance1, store: true, "call 1")); + // 1. Stored turn on session A: nothing precedes it, and the service records it. + Assert.Equal(["call 1"], await CallAsync(service, sessionA, store: true, "call 1")); - // The other two instances join the same conversation, so they start from the turn the service + // The other two sessions join the same conversation, so they start from the turn the service // last saved for it. Neither holds anything of its own yet. - var instance2 = new Instance { LastStoredResponseId = instance1.LastStoredResponseId }; - var instance3 = new Instance { LastStoredResponseId = instance1.LastStoredResponseId }; + var sessionB = new ConversationSession { LastStoredResponseId = sessionA.LastStoredResponseId }; + var sessionC = new ConversationSession { LastStoredResponseId = sessionA.LastStoredResponseId }; - // 2. Unstored turn on instance 1: it reads the stored turn back and keeps its own turn locally. - Assert.Equal(["call 1", "reply 1", "call 2"], await CallAsync(service, instance1, store: false, "call 2")); + // 2. Unstored turn on session A: it reads the stored turn back and keeps its own turn in the session. + Assert.Equal(["call 1", "reply 1", "call 2"], await CallAsync(service, sessionA, store: false, "call 2")); - // 3. A different instance starts from the last saved turn only: instance 1's unstored turn is - // held in instance 1's own session and is invisible here. - Assert.Equal(["call 1", "reply 1", "call 3"], await CallAsync(service, instance2, store: false, "call 3")); + // 3. A different session starts from the last saved turn only: session A's unstored turn is held + // in session A and is invisible here. + Assert.Equal(["call 1", "reply 1", "call 3"], await CallAsync(service, sessionB, store: false, "call 3")); - // 4. Asking the service to store a turn on an instance that is already holding unstored ones is + // 4. Asking the service to store a turn in a session that is already holding unstored ones is // refused: the service would record this turn without the turns that came before it. var refused = await Assert.ThrowsAsync( - () => CallAsync(service, instance2, store: true, "call 4")); + () => CallAsync(service, sessionB, store: true, "call 4")); Assert.Contains("were not stored", refused.Message, StringComparison.Ordinal); - // 5. Continuing unstored on the same instance is fine, and it keeps growing its local turns. + // 5. Continuing unstored in the same session is fine, and it keeps growing its own turns. Assert.Equal( ["call 1", "reply 1", "call 3", "reply 3", "call 5"], - await CallAsync(service, instance2, store: false, "call 5")); + await CallAsync(service, sessionB, store: false, "call 5")); // 6. Still refused, for the same reason. - await Assert.ThrowsAsync(() => CallAsync(service, instance2, store: true, "call 6")); + await Assert.ThrowsAsync(() => CallAsync(service, sessionB, store: true, "call 6")); - // 7. A fresh instance holds nothing locally, so it may store. It branches off the last turn the - // service saved, which is still the first one. - Assert.Equal(["call 1", "reply 1", "call 7"], await CallAsync(service, instance3, store: true, "call 7")); + // 7. A session holding nothing of its own may store. It branches off the last turn the service + // saved, which is still the first one. + Assert.Equal(["call 1", "reply 1", "call 7"], await CallAsync(service, sessionC, store: true, "call 7")); - // 8. Instance 2 is unaffected by instance 3's stored turn: that turn sits on another branch of - // the conversation, so it is not among the turns leading to instance 2's last saved one. + // 8. Session B is unaffected by the turn stored from session C: that turn sits on another branch + // of the conversation, so it is not among the turns leading to session B's last saved one. Assert.Equal( ["call 1", "reply 1", "call 3", "reply 3", "call 5", "reply 5", "call 8"], - await CallAsync(service, instance2, store: false, "call 8")); + await CallAsync(service, sessionB, store: false, "call 8")); - // 9. And instance 1 still sees its own thread of the conversation. + // 9. And session A still sees its own thread of the conversation. Assert.Equal( ["call 1", "reply 1", "call 2", "reply 2", "call 9"], - await CallAsync(service, instance1, store: false, "call 9")); + await CallAsync(service, sessionA, store: false, "call 9")); } /// - /// Runs one turn through a new provider, the way the host does: a provider is built for the request, - /// asked for the messages to send, and then told what the turn produced. Returns the message texts - /// the model would have received. + /// Runs one turn the way the host does: a new provider is built for the request, asked for the + /// messages to send, and then told what the turn produced. Because the provider is new every time, + /// whatever carries over between calls is held by the session it was given. Returns the message + /// texts the model would have received. /// - private static async Task CallAsync(FakeStoredResponses service, Instance instance, bool store, string text) + private static async Task CallAsync(FakeStoredResponses service, ConversationSession session, bool store, string text) { - var context = CreateContext(service.TurnsLeadingTo(instance.LastStoredResponseId)); + var context = CreateContext(service.TurnsLeadingTo(session.LastStoredResponseId)); var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: store); var agent = CreateAgent(); var sent = (await provider.InvokingAsync( - new ChatHistoryProvider.InvokingContext(agent, instance.Session, [new ChatMessage(ChatRole.User, text)]), + new ChatHistoryProvider.InvokingContext(agent, session.Session, [new ChatMessage(ChatRole.User, text)]), CancellationToken.None)).ToList(); var reply = new ChatMessage(ChatRole.Assistant, text.Replace("call", "reply", StringComparison.Ordinal)); await provider.InvokedAsync( - new ChatHistoryProvider.InvokedContext(agent, instance.Session, sent, [reply]), + new ChatHistoryProvider.InvokedContext(agent, session.Session, sent, [reply]), CancellationToken.None); if (store) { - instance.LastStoredResponseId = service.Store(instance.LastStoredResponseId, [new ChatMessage(ChatRole.User, text), reply]); + session.LastStoredResponseId = service.Store(session.LastStoredResponseId, [new ChatMessage(ChatRole.User, text), reply]); } return [.. sent.Select(m => m.Text)]; } - /// A provider instance's own session, plus the last turn the service saved for it. - private sealed class Instance + /// An agent session, plus the last turn the service saved for the conversation it follows. + private sealed class ConversationSession { public FakeSession Session { get; } = new(); From dc8aab44a7642a3a638466b113de8a3c5211caa5 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:33:46 +0100 Subject: [PATCH 06/14] Show which half of the conversation each provider decides The session decides what is kept, but the provider still decides two things: which service-side conversation is read, because it holds the request's response context, and whether the turn is kept at all, because it holds the request's store flag. Add two tests that separate those from the session: - two providers reading one session, each built for a request of a different conversation, return the same kept turn behind different served turns; - two providers writing to one session, one for a stored request and one for an unstored one, leave only the unstored turn behind. --- .../FoundryChatHistoryProviderTests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs index 9e06940f85b..92940ebef97 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs @@ -168,6 +168,55 @@ public async Task InvokingAsync_WhatIsKeptBelongsToTheSessionNotToTheProviderObj Assert.Empty(otherSession); } + [Fact] + public async Task InvokingAsync_ServedPartFollowsTheProviderAndKeptPartFollowsTheSessionAsync() + { + // Arrange: one session holding a turn the service was not asked to store. + var session = new FakeSession(); + await new FoundryChatHistoryProvider(CreateContext([]), serviceStoresThisTurn: false).InvokedAsync( + new ChatHistoryProvider.InvokedContext( + CreateAgent(), session, [new ChatMessage(ChatRole.User, "kept turn")], []), + CancellationToken.None); + + // Act: two providers read that same session, each built for a request of a different conversation, + // so each is served different turns by the service. + var readByFirst = await new FoundryChatHistoryProvider( + CreateContext([NewMessageItem("msg_a", "served to the first")]), serviceStoresThisTurn: false) + .InvokingAsync(new ChatHistoryProvider.InvokingContext(CreateAgent(), session, []), CancellationToken.None); + + var readBySecond = await new FoundryChatHistoryProvider( + CreateContext([NewMessageItem("msg_b", "served to the second")]), serviceStoresThisTurn: false) + .InvokingAsync(new ChatHistoryProvider.InvokingContext(CreateAgent(), session, []), CancellationToken.None); + + // Assert: the conversation each one returns is made of two halves that come from different + // places. What the service serves is decided by the request the provider was built for, so it + // differs between the two; the kept turn is decided by the session, so it is the same in both. + Assert.Equal(["served to the first", "kept turn"], readByFirst.Select(m => m.Text).ToArray()); + Assert.Equal(["served to the second", "kept turn"], readBySecond.Select(m => m.Text).ToArray()); + } + + [Fact] + public async Task InvokedAsync_WhetherATurnIsKeptFollowsTheProviderNotTheSessionAsync() + { + // Arrange: one session used for two turns, the first stored by the service and the second not. + var session = new FakeSession(); + var agent = CreateAgent(); + + // Act + await new FoundryChatHistoryProvider(CreateContext([]), serviceStoresThisTurn: true).InvokedAsync( + new ChatHistoryProvider.InvokedContext(agent, session, [new ChatMessage(ChatRole.User, "stored turn")], []), + CancellationToken.None); + await new FoundryChatHistoryProvider(CreateContext([]), serviceStoresThisTurn: false).InvokedAsync( + new ChatHistoryProvider.InvokedContext(agent, session, [new ChatMessage(ChatRole.User, "unstored turn")], []), + CancellationToken.None); + + // Assert: the same session ends up holding only the turn the service was not asked to store, so + // the decision belongs to the request the provider was built for, not to the session. + var kept = await new FoundryChatHistoryProvider(CreateContext([]), serviceStoresThisTurn: false) + .InvokingAsync(new ChatHistoryProvider.InvokingContext(agent, session, []), CancellationToken.None); + Assert.Equal(["unstored turn"], kept.Select(m => m.Text).ToArray()); + } + [Fact] public async Task ConversationAcrossSessionsAndStoreModesAsync() { From 84b8fbafe4209f4400a47af03864f7afe98613d8 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:51:13 +0100 Subject: [PATCH 07/14] Say why a hosted workflow keeps taking history from the handler The comment stated that a workflow hosted as an agent has no provider pipeline without saying what that means. It derives from AIAgent directly, so it never calls a ChatHistoryProvider and does not read the run options' additional properties: the provider could not reach it even if it were registered. --- .../AgentFrameworkResponseHandler.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 49281720792..10392131156 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -128,8 +128,9 @@ public override async IAsyncEnumerable CreateAsync( // carry no chat-history marker, so the agent's provider would then store them as if they were // newly written by this turn. // - // A workflow hosted as an agent is not a ChatClientAgent and has no such pipeline, so it keeps - // receiving the history from the handler exactly as before. + // A workflow hosted as an agent derives from AIAgent directly: it never calls a + // ChatHistoryProvider and does not read the run options' additional properties, so there is no + // pipeline to route it through and the handler stays its only source of history. var agentSuppliedHistoryProvider = agent.GetService()?.ChatHistoryProvider is not null; var historyComesFromProvider = chatClientAgent is not null; From 0a7bb6fe840d4048778a04ab47b903986969d3ba Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:02:12 +0100 Subject: [PATCH 08/14] Ask the session store whether a turn is a resume The handler decided that a turn was resuming an existing conversation by looking for state on the session. That reading broke once the handler itself started writing to the session before the check: it records the caller's identity there, so a session created moments earlier already carried state and the very first turn of a conversation looked like a resume. Its history was then never fetched, and the agent answered knowing nothing of a conversation the service was already holding. It only showed up when hosted, because running locally there is no identity to record. Let the store answer the question instead. GetSessionAsync now returns null when nothing is stored rather than quietly handing back a new session, so a non-null result means a prior turn established this session and nothing else has to be inferred. Callers that just want a usable session can use the new GetOrCreateSessionAsync, which is written in terms of GetSessionAsync so a store overriding one gets the other for free. Both store implementations and their tests follow the plain-lookup contract: a miss creates nothing, deserializes nothing, and touches no directory. --- .../AgentFrameworkResponseHandler.cs | 26 ++++-- .../AgentSessionStore.cs | 39 +++++++- .../FileSystemAgentSessionStore.cs | 6 +- .../InMemoryAgentSessionStore.cs | 13 ++- .../AgentFrameworkResponseHandlerTests.cs | 89 +++++++++++++++++++ .../FileSystemAgentSessionStoreTests.cs | 29 ++++-- 6 files changed, 172 insertions(+), 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 10392131156..4f11bf08e7c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -134,11 +134,20 @@ public override async IAsyncEnumerable CreateAsync( var agentSuppliedHistoryProvider = agent.GetService()?.ChatHistoryProvider is not null; var historyComesFromProvider = chatClientAgent is not null; - AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId) + // 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? loadedSession = !string.IsNullOrWhiteSpace(sessionConversationId) ? await sessionStore.GetSessionAsync(agent, sessionConversationId, resolvedUserId, cancellationToken).ConfigureAwait(false) - : chatClientAgent is not null + : null; + var sessionLoadedFromStore = loadedSession is not null; + + AgentSession? session = loadedSession + ?? (chatClientAgent is not null ? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false) - : await agent.CreateSessionAsync(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 @@ -180,12 +189,13 @@ public override async IAsyncEnumerable CreateAsync( // 4. Convert input: history + current input → ChatMessage[] var messages = new List(); - // 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. + // Load conversation history only for fresh sessions. When a session was already established by + // a prior turn (e.g. resuming a workflow paused at an external-input port), its state already + // contains those messages — replaying history would re-drive completed actions and break HITL + // resume semantics. The signal is whether the store actually loaded a persisted session, which + // is authoritative in both local and hosted runs. var isResume = (!string.IsNullOrWhiteSpace(conversationId) || !string.IsNullOrWhiteSpace(request.PreviousResponseId)) - && session?.StateBag?.Count > 0; + && sessionLoadedFromStore; if (!isResume && !historyComesFromProvider) { var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs index d1c93dc274c..d507db4d966 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; @@ -42,7 +43,8 @@ public abstract ValueTask SaveSessionAsync( CancellationToken cancellationToken = default); /// - /// Retrieves a serialized agent session from persistent storage. + /// Retrieves a serialized agent session from persistent storage, or when + /// no session is stored for the given identifiers. /// /// The agent that owns this session. /// The unique identifier for the conversation/session to retrieve. @@ -55,12 +57,41 @@ public abstract ValueTask SaveSessionAsync( /// /// The to monitor for cancellation requests. /// - /// 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 when nothing is stored for the given identifiers. This is a plain + /// lookup: it never creates a session. Use 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). /// - public abstract ValueTask GetSessionAsync( + public abstract ValueTask GetSessionAsync( AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default); + + /// + /// Retrieves the stored session for the given identifiers, or creates a new one via + /// when none is stored. + /// + /// The agent that owns this session. + /// The unique identifier for the conversation/session to retrieve. + /// The per-user partition key; see for its meaning. + /// The to monitor for cancellation requests. + /// A task whose result is always a usable session, never . + /// + /// 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 , so a + /// store overriding that method gets this behavior for free. + /// + public virtual async ValueTask 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); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs index 17c9a184a35..c7c3b7292d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs @@ -208,7 +208,7 @@ private string BuildNotWritableMessage(string sessionFilePath) => $"(for example {nameof(InMemoryAgentSessionStore)}) via AddFoundryResponses(agent, agentSessionStore)."; /// - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(agent); ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); @@ -216,13 +216,13 @@ public override async ValueTask GetSessionAsync(AIAgent agent, str 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. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs index 3b3b52fda57..579240432b6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs @@ -40,16 +40,15 @@ public override async ValueTask SaveSessionAsync(AIAgent agent, string conversat } /// - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask 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 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 aecba3386dd..0d6fd6316d4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -711,6 +711,90 @@ public async Task CreateAsync_DefaultAgent_IsAutoWrappedWithOpenTelemetryAsync() Assert.IsType(events[1]); } + #region Resume detection + + [Fact] + public async Task CreateAsync_FirstTurnOfAKnownConversation_StillReceivesTheServiceHistoryAsync() + { + // 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 handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + var request = new CreateResponse { Model = "test" }; + request.Conversation = BinaryData.FromString("\"conv-known\""); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "new question" } } } + }); + var ctx = new Mock("resp_" + new string('4', 46)) { CallBase = true }; + ctx.Setup(x => x.PlatformContext).Returns(new PlatformContext("alice", null)); + ctx.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync([NewHistoryMessageItem("msg_hist_1", "earlier turn")]); + ctx.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Array.Empty()); + + // Act + await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); + + // Assert: whether this is a resume is answered by the session store, not by looking for state on + // the session. The handler writes the caller's identity onto a session before this point, so a + // 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)); + } + + [Fact] + public async Task CreateAsync_SecondTurnOfAConversation_DoesNotReplayTheServiceHistoryAsync() + { + // Arrange: a first turn that persists a session for the conversation. + const string ConversationId = "conv-resumed"; + var agent = new CapturingAgent(); + var store = new InMemoryAgentSessionStore(); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), store); + await DrainEventsAsync(handler.CreateAsync( + NewConversationTurn(ConversationId, "first question"), + NewServingContext("resp_" + new string('5', 46), []), + CancellationToken.None)); + + // Act: a second turn of the same conversation, for which the service now reports history. + await DrainEventsAsync(handler.CreateAsync( + NewConversationTurn(ConversationId, "second question"), + NewServingContext("resp_" + new string('6', 46), [NewHistoryMessageItem("msg_hist_1", "first question")]), + CancellationToken.None)); + + // Assert: a session was stored by the first turn, so this one resumes it. Replaying the history + // here would re-drive work the session already carries, which is what breaks a workflow that was + // paused waiting for input. + Assert.NotNull(agent.CapturedMessages); + Assert.DoesNotContain(agent.CapturedMessages!, m => m.Text.Contains("first question", StringComparison.Ordinal)); + } + + private static CreateResponse NewConversationTurn(string conversationId, string text) + { + var request = new CreateResponse { Model = "test" }; + request.Conversation = BinaryData.FromString($"\"{conversationId}\""); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_" + Guid.NewGuid().ToString("N")[..8], status = "completed", role = "user", + content = new[] { new { type = "input_text", text } } } + }); + return request; + } + + private static ResponseContext NewServingContext(string responseId, IReadOnlyList history) + { + var ctx = new Mock(responseId) { CallBase = true }; + ctx.Setup(x => x.PlatformContext).Returns(new PlatformContext("alice", null)); + ctx.Setup(x => x.GetHistoryAsync(It.IsAny())).ReturnsAsync(history); + ctx.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Array.Empty()); + return ctx.Object; + } + + #endregion + #region Chat history source routing // These tests pin down who supplies the conversation history to a hosted agent. Three of them are @@ -945,6 +1029,11 @@ private static async Task SerializedSessionOfAsync(AIAgent agent, InMemo { var sessionKey = HostedConversationKey.Resolve(conversationId: null, previousResponseId: null, responseId); var session = await store.GetSessionAsync(agent, sessionKey!, FakeHostedSessionIsolationKeyProvider.DefaultUserId, CancellationToken.None); + + // The handler persists the session at the end of every turn, so a missing one means the turn did + // not get that far and the assertions below would otherwise pass without proving anything. + Assert.NotNull(session); + var serialized = await agent.SerializeSessionAsync(session, cancellationToken: CancellationToken.None); return serialized.GetRawText(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs index de43efbc5a3..01552edcd15 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs @@ -50,20 +50,33 @@ public void Constructor_NullOrWhitespaceRoot_Throws() } [Fact] - public async Task GetSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAgentAsync() + public async Task GetSessionAsync_NoFileOnDisk_ReturnsNullAsync() { var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent(); var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + Assert.Null(session); + Assert.Equal(0, agent.CreateCalls); + Assert.Equal(0, agent.DeserializeCalls); + } + + [Fact] + public async Task GetOrCreateSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAgentAsync() + { + var store = new FileSystemAgentSessionStore(this._root); + var agent = new TestAgent(); + + var session = await store.GetOrCreateSessionAsync(agent, "conv-1", userId: null); + Assert.NotNull(session); Assert.Equal(1, agent.CreateCalls); Assert.Equal(0, agent.DeserializeCalls); } [Fact] - public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsFreshSessionAsync() + public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsNullAsync() { var store = new FileSystemAgentSessionStore(this._root); Directory.CreateDirectory(store.RootDirectory); @@ -72,8 +85,8 @@ public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsFreshSessionAsync() var agent = new TestAgent(); var session = await store.GetSessionAsync(agent, "conv-empty", userId: null); - Assert.NotNull(session); - Assert.Equal(1, agent.CreateCalls); + Assert.Null(session); + Assert.Equal(0, agent.CreateCalls); Assert.Equal(0, agent.DeserializeCalls); } @@ -245,7 +258,7 @@ public async Task GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsyn var session = await store.GetSessionAsync(agent, "missing-id", userId: null); - Assert.NotNull(session); + Assert.Null(session); Assert.False(Directory.Exists(this._root), "Read miss must not create the root directory."); } @@ -385,11 +398,11 @@ public async Task GetSessionAsync_DifferentUser_DoesNotReadAnotherUsersSessionAs await store.SaveSessionAsync(agent, "shared-conv", NewSession(), userId: "alice"); // Bob requests the same conversationId. The per-user partition means Bob's path is distinct, - // so the store returns a fresh session (no leak), not Alice's persisted state. + // so the store returns null (no leak), not Alice's persisted state. var bobSession = await store.GetSessionAsync(agent, "shared-conv", userId: "bob"); - Assert.NotNull(bobSession); - Assert.Equal(1, agent.CreateCalls); // fresh session created for Bob + Assert.Null(bobSession); // no session for Bob under his partition + Assert.Equal(0, agent.CreateCalls); // a plain lookup never creates Assert.Equal(0, agent.DeserializeCalls); // Alice's file never deserialized for Bob } From 4bc12301cd5cfa67e3f2a79d42e70a2bbdc09864 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:22:25 +0100 Subject: [PATCH 09/14] Drop the experimental marker from an internal type FoundryChatHistoryProvider is internal, so the attribute reached no caller: the marker exists to warn people consuming the public surface. It also does not follow from the base type, which does not carry one, and most internal types in this package have none either. Removing it leaves two usings behind, so they go as well. --- .../FoundryChatHistoryProvider.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs index 83730380433..745245bc979 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs @@ -2,14 +2,12 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; using Microsoft.Extensions.AI; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -61,7 +59,6 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// this one is never registered, so the agent's own store stays the single source. /// /// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] internal sealed class FoundryChatHistoryProvider : ChatHistoryProvider { private readonly ResponseContext _context; From a9c72d04d1cb413d1b88ecf3e36a40714c5156ed Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:18:59 +0100 Subject: [PATCH 10/14] Stand down the agent's second-manager guard for the host's own provider An agent refuses a second history manager once the model reports a conversation id of its own, which happens as soon as the container lets the model keep the conversation. The guard is meant for an application that configured a provider by hand and would otherwise end up with two of them. Here the host is the one supplying the provider, deliberately and for every turn, so the guard was rejecting the arrangement it is hosting: the first turn failed while streaming, and every later one failed before reaching the model at all. Turn the three conflict settings off on the agent the host is serving, and let the provider decide what reaches the model. A test drives two turns of one conversation against a model that reports a conversation id and asserts both complete. --- .../AgentFrameworkResponseHandler.cs | 20 +++++++-- .../AgentFrameworkResponseHandlerTests.cs | 42 ++++++++++++++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 4f11bf08e7c..22d5df579d0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -131,7 +131,8 @@ public override async IAsyncEnumerable CreateAsync( // A workflow hosted as an agent derives from AIAgent directly: it never calls a // ChatHistoryProvider and does not read the run options' additional properties, so there is no // pipeline to route it through and the handler stays its only source of history. - var agentSuppliedHistoryProvider = agent.GetService()?.ChatHistoryProvider is not null; + var agentOptions = agent.GetService(); + var agentSuppliedHistoryProvider = agentOptions?.ChatHistoryProvider is not null; var historyComesFromProvider = chatClientAgent is not null; // Load an existing session when there is a conversation key. The store returns null when @@ -224,10 +225,23 @@ public override async IAsyncEnumerable CreateAsync( // Register the platform-backed chat history provider for agents that did not bring their own. // It is supplied per request because it reads through this request's ResponseContext, and it is // passed as a run-scoped override so the agent uses it for this turn without the host having to - // mutate the agent. FoundryChatHistoryProvider reads the prior turns from the platform and - // stores nothing, so the conversation is not copied into the container's session state. + // mutate the agent. FoundryChatHistoryProvider reads the prior turns from the service and keeps + // in the session only the turns the service was not asked to store. if (historyComesFromProvider && !agentSuppliedHistoryProvider) { + // An agent refuses a second history manager once the model reports a conversation id of its + // own, which happens as soon as the container lets the model keep the conversation. That + // guard is meant for an application that configured a provider by hand and would otherwise + // end up with two of them; here the host is the one supplying it, deliberately and for every + // turn, so the guard would only reject the arrangement it is hosting. Stand it down and let + // this provider decide what reaches the model. + if (agentOptions is not null) + { + agentOptions.ThrowOnChatHistoryProviderConflict = false; + agentOptions.WarnOnChatHistoryProviderConflict = false; + agentOptions.ClearOnChatHistoryProviderConflict = false; + } + chatOptions.AdditionalProperties ??= []; chatOptions.AdditionalProperties.Add(new FoundryChatHistoryProvider(context, serviceStoresThisTurn: request.Store != false)); } 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 0d6fd6316d4..cb45b00c588 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -772,6 +772,44 @@ await DrainEventsAsync(handler.CreateAsync( Assert.DoesNotContain(agent.CapturedMessages!, m => m.Text.Contains("first question", StringComparison.Ordinal)); } + [Fact] + public async Task CreateAsync_WhenTheModelReportsAConversationId_TurnsStillCompleteAsync() + { + // Arrange: a container whose model call reports a conversation id, which is what happens when + // the container's chat client lets the model keep the conversation. The agent records that id on + // the session, and from then on its own conflict policy would reject the provider the host + // registers, failing the turn. + var agent = new ChatClientAgent( + CreateCapturingChatClient([], conversationId: "conv-from-the-model"), + new ChatClientAgentOptions { Name = "hosted" }); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + + // Act + var first = await CollectEventNamesAsync(handler, "conv-model", "resp_" + new string('9', 46), "first question"); + var second = await CollectEventNamesAsync(handler, "conv-model", "resp_" + new string('a', 46), "second question"); + + // Assert: both turns run to completion. The host owns history for this agent, so the agent's + // policy of refusing a second history manager must not be left to fire on the host's own + // registration. + Assert.Contains("ResponseCompletedEvent", first); + Assert.DoesNotContain("ResponseFailedEvent", first); + Assert.Contains("ResponseCompletedEvent", second); + Assert.DoesNotContain("ResponseFailedEvent", second); + } + + private static async Task> CollectEventNamesAsync( + AgentFrameworkResponseHandler handler, string conversationId, string responseId, string text) + { + var names = new List(); + await foreach (var evt in handler.CreateAsync( + NewConversationTurn(conversationId, text), NewServingContext(responseId, []), CancellationToken.None)) + { + names.Add(evt.GetType().Name); + } + + return names; + } + private static CreateResponse NewConversationTurn(string conversationId, string text) { var request = new CreateResponse { Model = "test" }; @@ -1045,7 +1083,7 @@ private static OutputItemMessage NewHistoryMessageItem(string id, string text) = content: [new MessageContentOutputTextContent(text, Array.Empty(), Array.Empty())], status: MessageStatus.Completed); - private static IChatClient CreateCapturingChatClient(List captured) + private static IChatClient CreateCapturingChatClient(List captured, string? conversationId = null) { var mock = new Mock(); mock.Setup(c => c.GetStreamingResponseAsync( @@ -1056,7 +1094,7 @@ private static IChatClient CreateCapturingChatClient(List captured) { captured.AddRange(messages); return ToAsyncEnumerableUpdatesAsync( - new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1" }); + new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1", ConversationId = conversationId }); }); return mock.Object; } From 4bf85dc99adfecbf7c702144b99bcbf2bd12ba24 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:07:53 +0100 Subject: [PATCH 11/14] Pass a caller's request not to store on to the chat client A request asking the hosting service not to store the response was honoured there and nowhere else, so the service behind the agent's own chat client kept recording the conversation and reporting an id for it. A caller opting out of storage still ended up with a stored conversation, and the container went on continuing it. Only that direction travels. Carrying store=true across would either force storage on a container whose author turned it off on purpose or change nothing, since storing is already the default. --- .../FoundryChatHistoryProvider.cs | 152 -------- .../InputConverter.cs | 20 +- .../FoundryChatHistoryProviderTests.cs | 359 ------------------ 3 files changed, 19 insertions(+), 512 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs deleted file mode 100644 index 745245bc979..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryChatHistoryProvider.cs +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using Azure.AI.AgentServer.Responses; -using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Foundry.Hosting; - -/// -/// A that reads the conversation the Foundry service keeps for the -/// current request, and keeps in the agent session only the turns the service will not keep itself. -/// -/// -/// -/// This is the provider a hosted agent gets when it was created without an explicit -/// . Reading goes through -/// , which resolves the history from -/// previous_response_id and/or the conversation the request belongs to, returns the -/// items in chronological order, and caches the result for the request. An instance is created per -/// request because it holds that request's . -/// -/// -/// Writing depends on whether the request is stored, so that every turn is kept exactly once: -/// -/// -/// -/// With store true the service persists this turn itself: the response orchestrator hands the -/// finished response to its responses provider, which writes the input and output items and links -/// them to the conversation. Those are the very items a later turn reads back. Writing them into the -/// agent session as well would keep a second copy that then diverges from the one the service serves, -/// so nothing is written here. -/// -/// -/// With store false the service persists nothing, and a later turn asking for this -/// conversation gets nothing back for it. The turn is therefore kept in the agent session, which is -/// the only memory it can have. Earlier stored turns of the same conversation are still read from the -/// service, so a conversation that mixes stored and unstored turns stays whole. -/// -/// -/// -/// Once a conversation holds turns the service never saw, a stored turn is refused: the service would -/// record it on top of turns it does not have, leaving a gap for anyone reading the conversation back -/// from it. The refusal happens before the model is called. -/// -/// -/// What is kept belongs to the , not to this object: a new provider is built -/// for every request, and the turns it keeps are written into the session's state bag under this -/// provider's own state key. Two sessions following the same service-side conversation therefore keep -/// their unstored turns apart, and each still reads the stored ones from the service. -/// -/// -/// When an agent is created with its own chat history provider, that provider is used instead and -/// this one is never registered, so the agent's own store stays the single source. -/// -/// -internal sealed class FoundryChatHistoryProvider : ChatHistoryProvider -{ - private readonly ResponseContext _context; - private readonly bool _serviceStoresThisTurn; - private readonly ProviderSessionState _sessionState; - private IReadOnlyList? _stateKeys; - - /// - /// Initializes a new instance of the class for a single request. - /// - /// The response context of the request being handled. - /// - /// when the request was made with store enabled, so the service keeps - /// this turn; when it keeps nothing and the turn must be kept in the session. - /// - public FoundryChatHistoryProvider(ResponseContext context, bool serviceStoresThisTurn) - { - this._context = Throw.IfNull(context); - this._serviceStoresThisTurn = serviceStoresThisTurn; - this._sessionState = new ProviderSessionState(_ => new State(), nameof(FoundryChatHistoryProvider)); - } - - /// - public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; - - /// - protected override async ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(context); - - var unstored = this._sessionState.GetOrInitializeState(context.Session).Messages; - - // Once a conversation has turns the service never saw, it cannot go back to being stored by the - // service: the service would record this turn on top of turns it does not have, so anyone - // reading the conversation back from it would get an answer with no question. Refuse up front - // rather than let that gap be written. - if (this._serviceStoresThisTurn && unstored.Count > 0) - { - throw new InvalidOperationException( - """ - This conversation has turns that were not stored by the service, so a stored turn cannot be added to it. - The service would record this turn without the turns that came before it, leaving a gap in the stored conversation. - Either keep using store=false for this conversation, or start a new one for stored turns. - """); - } - - var served = await this._context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); - - // The service's turns come first: they are the earlier part of the conversation, and anything - // kept in the session is by definition a turn the service did not record, which happened after. - IEnumerable history = served.Count > 0 - ? InputConverter.ConvertOutputItemsToMessages(served, context.Session?.StateBag) - : []; - - return unstored.Count > 0 ? history.Concat(unstored) : history; - } - - /// - protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(context); - - if (this._serviceStoresThisTurn) - { - return default; - } - - // Only the messages of this turn arrive here: the base class filters out everything marked as - // chat history, which is what was handed back by ProvideChatHistoryAsync above. - var state = this._sessionState.GetOrInitializeState(context.Session); - state.Messages.AddRange(context.RequestMessages); - if (context.ResponseMessages is not null) - { - state.Messages.AddRange(context.ResponseMessages); - } - - this._sessionState.SaveState(context.Session, state); - return default; - } - - /// - /// The turns of a conversation that the service was not asked to store, held in the - /// so they survive with the session. - /// - public sealed class State - { - /// Gets or sets the messages of the turns the service did not store. - [JsonPropertyName("messages")] - public List Messages { get; set; } = []; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs index e104487df76..a367db6816e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs @@ -7,6 +7,7 @@ using System.Text.Json; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Extensions.AI; +using CreateResponseOptions = OpenAI.Responses.CreateResponseOptions; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; using SdkTextContent = Azure.AI.AgentServer.Responses.Models.TextContent; @@ -90,7 +91,7 @@ public static List ConvertOutputItemsToMessages(IReadOnlyListA configured instance. public static ChatOptions ConvertToChatOptions(CreateResponse request) { - return new ChatOptions + var options = new ChatOptions { Temperature = (float?)request.Temperature, TopP = (float?)request.TopP, @@ -100,6 +101,23 @@ public static ChatOptions ConvertToChatOptions(CreateResponse request) // the client-provided model would override it (causing failures when // clients send placeholder values like "hosted-agent"). }; + + // Only store=false travels to the service behind the chat client: a caller opting out of storage + // gets nothing recorded anywhere, while carrying store=true across would either force storage on + // a container whose author turned it off on purpose or change nothing, since storing is already + // the default. Any factory the container configured is chained, so its own choice still wins. + if (request.Store == false) + { + var containerFactory = options.RawRepresentationFactory; + options.RawRepresentationFactory = chatClient => + { + var responseOptions = containerFactory?.Invoke(chatClient) as CreateResponseOptions ?? new CreateResponseOptions(); + responseOptions.StoredOutputEnabled = false; + return responseOptions; + }; + } + + return options; } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs deleted file mode 100644 index 92940ebef97..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryChatHistoryProviderTests.cs +++ /dev/null @@ -1,359 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Azure.AI.AgentServer.Responses; -using Azure.AI.AgentServer.Responses.Models; -using Microsoft.Extensions.AI; -using Moq; - -namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; - -/// -/// Tests for , the chat history provider that reads the -/// conversation from the Foundry platform instead of keeping a copy inside the container. -/// -public class FoundryChatHistoryProviderTests -{ - [Fact] - public async Task InvokingAsync_ReturnsPlatformHistoryBeforeRequestMessagesAsync() - { - // Arrange - var context = CreateContext([NewMessageItem("msg_1", "earlier turn")]); - var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: true); - var agent = CreateAgent(); - var session = new FakeSession(); - var input = new ChatMessage(ChatRole.User, "new input"); - - // Act - var result = await provider.InvokingAsync( - new ChatHistoryProvider.InvokingContext(agent, session, [input]), - CancellationToken.None); - - // Assert: the platform history comes first, then the caller's messages. - var messages = result.ToList(); - Assert.Equal(2, messages.Count); - Assert.Contains("earlier turn", messages[0].Text); - Assert.Same(input, messages[1]); - } - - [Fact] - public async Task InvokingAsync_MarksPlatformHistoryAsChatHistoryAsync() - { - // Arrange - var context = CreateContext([NewMessageItem("msg_1", "earlier turn")]); - var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: true); - - // Act - var result = await provider.InvokingAsync( - new ChatHistoryProvider.InvokingContext(CreateAgent(), new FakeSession(), []), - CancellationToken.None); - - // Assert: marking the platform messages as chat history is what keeps another provider from - // storing them again as if this turn had produced them. - var message = Assert.Single(result); - Assert.Equal(AgentRequestMessageSourceType.ChatHistory, message.GetAgentRequestMessageSourceType()); - } - - [Fact] - public async Task InvokingAsync_WithNoPlatformHistory_ReturnsOnlyRequestMessagesAsync() - { - // Arrange - var context = CreateContext([]); - var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: true); - var input = new ChatMessage(ChatRole.User, "new input"); - - // Act - var result = await provider.InvokingAsync( - new ChatHistoryProvider.InvokingContext(CreateAgent(), new FakeSession(), [input]), - CancellationToken.None); - - // Assert - Assert.Same(input, Assert.Single(result)); - } - - [Fact] - public async Task InvokedAsync_WhenTheServiceStoresTheTurn_KeepsNothingInTheSessionAsync() - { - // Arrange - var context = CreateContext([]); - var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: true); - var session = new FakeSession(); - - // Act: a completed turn is reported to the provider. - await provider.InvokedAsync( - new ChatHistoryProvider.InvokedContext( - CreateAgent(), - session, - [new ChatMessage(ChatRole.User, "input")], - [new ChatMessage(ChatRole.Assistant, "answer")]), - CancellationToken.None); - - // Assert: the service already persists the items of a stored response, so nothing is copied into - // the session. A copy there would grow the persisted session and drift from what the service serves. - Assert.Empty(session.StateBag.Serialize().EnumerateObject()); - } - - [Fact] - public async Task InvokedAsync_WhenTheServiceDoesNotStoreTheTurn_KeepsItInTheSessionAsync() - { - // Arrange: a turn the service was not asked to store. - var context = CreateContext([]); - var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false); - var session = new FakeSession(); - - // Act - await provider.InvokedAsync( - new ChatHistoryProvider.InvokedContext( - CreateAgent(), - session, - [new ChatMessage(ChatRole.User, "unstored input")], - [new ChatMessage(ChatRole.Assistant, "unstored answer")]), - CancellationToken.None); - - // Assert: the session is the only memory this turn can have, so it is kept there. - var state = session.StateBag.Serialize().GetRawText(); - Assert.Contains("unstored input", state, StringComparison.Ordinal); - Assert.Contains("unstored answer", state, StringComparison.Ordinal); - } - - [Fact] - public async Task InvokingAsync_ReturnsServedHistoryThenTurnsTheServiceDidNotStoreAsync() - { - // Arrange: a conversation whose earlier turn the service holds, plus a later turn it was not - // asked to store, which a previous request kept in the session. - var context = CreateContext([NewMessageItem("msg_1", "stored turn")]); - var session = new FakeSession(); - await new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false).InvokedAsync( - new ChatHistoryProvider.InvokedContext( - CreateAgent(), session, [new ChatMessage(ChatRole.User, "unstored turn")], []), - CancellationToken.None); - - // Act: a later request reads the conversation back. - var result = await new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false).InvokingAsync( - new ChatHistoryProvider.InvokingContext(CreateAgent(), session, [new ChatMessage(ChatRole.User, "new input")]), - CancellationToken.None); - - // Assert: the conversation is whole and in order, with each turn appearing once. - Assert.Equal( - ["stored turn", "unstored turn", "new input"], - result.Select(m => m.Text).ToArray()); - } - - [Fact] - public async Task InvokingAsync_WhatIsKeptBelongsToTheSessionNotToTheProviderObjectAsync() - { - // Arrange: one unstored turn kept through one provider object. - var context = CreateContext([]); - var session = new FakeSession(); - await new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false).InvokedAsync( - new ChatHistoryProvider.InvokedContext( - CreateAgent(), session, [new ChatMessage(ChatRole.User, "kept turn")], []), - CancellationToken.None); - - // Act: a brand new provider object reads that session, and another one reads a different session. - var sameSession = await new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false).InvokingAsync( - new ChatHistoryProvider.InvokingContext(CreateAgent(), session, []), - CancellationToken.None); - var otherSession = await new FoundryChatHistoryProvider(context, serviceStoresThisTurn: false).InvokingAsync( - new ChatHistoryProvider.InvokingContext(CreateAgent(), new FakeSession(), []), - CancellationToken.None); - - // Assert: the turn follows the session it was kept in, not the object that kept it. A host builds - // a new provider for every request, so anything held on the object itself would be lost at once. - Assert.Equal(["kept turn"], sameSession.Select(m => m.Text).ToArray()); - Assert.Empty(otherSession); - } - - [Fact] - public async Task InvokingAsync_ServedPartFollowsTheProviderAndKeptPartFollowsTheSessionAsync() - { - // Arrange: one session holding a turn the service was not asked to store. - var session = new FakeSession(); - await new FoundryChatHistoryProvider(CreateContext([]), serviceStoresThisTurn: false).InvokedAsync( - new ChatHistoryProvider.InvokedContext( - CreateAgent(), session, [new ChatMessage(ChatRole.User, "kept turn")], []), - CancellationToken.None); - - // Act: two providers read that same session, each built for a request of a different conversation, - // so each is served different turns by the service. - var readByFirst = await new FoundryChatHistoryProvider( - CreateContext([NewMessageItem("msg_a", "served to the first")]), serviceStoresThisTurn: false) - .InvokingAsync(new ChatHistoryProvider.InvokingContext(CreateAgent(), session, []), CancellationToken.None); - - var readBySecond = await new FoundryChatHistoryProvider( - CreateContext([NewMessageItem("msg_b", "served to the second")]), serviceStoresThisTurn: false) - .InvokingAsync(new ChatHistoryProvider.InvokingContext(CreateAgent(), session, []), CancellationToken.None); - - // Assert: the conversation each one returns is made of two halves that come from different - // places. What the service serves is decided by the request the provider was built for, so it - // differs between the two; the kept turn is decided by the session, so it is the same in both. - Assert.Equal(["served to the first", "kept turn"], readByFirst.Select(m => m.Text).ToArray()); - Assert.Equal(["served to the second", "kept turn"], readBySecond.Select(m => m.Text).ToArray()); - } - - [Fact] - public async Task InvokedAsync_WhetherATurnIsKeptFollowsTheProviderNotTheSessionAsync() - { - // Arrange: one session used for two turns, the first stored by the service and the second not. - var session = new FakeSession(); - var agent = CreateAgent(); - - // Act - await new FoundryChatHistoryProvider(CreateContext([]), serviceStoresThisTurn: true).InvokedAsync( - new ChatHistoryProvider.InvokedContext(agent, session, [new ChatMessage(ChatRole.User, "stored turn")], []), - CancellationToken.None); - await new FoundryChatHistoryProvider(CreateContext([]), serviceStoresThisTurn: false).InvokedAsync( - new ChatHistoryProvider.InvokedContext(agent, session, [new ChatMessage(ChatRole.User, "unstored turn")], []), - CancellationToken.None); - - // Assert: the same session ends up holding only the turn the service was not asked to store, so - // the decision belongs to the request the provider was built for, not to the session. - var kept = await new FoundryChatHistoryProvider(CreateContext([]), serviceStoresThisTurn: false) - .InvokingAsync(new ChatHistoryProvider.InvokingContext(agent, session, []), CancellationToken.None); - Assert.Equal(["unstored turn"], kept.Select(m => m.Text).ToArray()); - } - - [Fact] - public async Task ConversationAcrossSessionsAndStoreModesAsync() - { - // Arrange: one service-side conversation and three separate sessions. A fresh provider is built - // for every call, the way the host does, so anything remembered between calls belongs to the - // session and not to the provider object. A session only starts holding turns of its own once it - // is used for an unstored one, and a session that never did starts from what the service saved. - var service = new FakeStoredResponses(); - var sessionA = new ConversationSession(); - - // 1. Stored turn on session A: nothing precedes it, and the service records it. - Assert.Equal(["call 1"], await CallAsync(service, sessionA, store: true, "call 1")); - - // The other two sessions join the same conversation, so they start from the turn the service - // last saved for it. Neither holds anything of its own yet. - var sessionB = new ConversationSession { LastStoredResponseId = sessionA.LastStoredResponseId }; - var sessionC = new ConversationSession { LastStoredResponseId = sessionA.LastStoredResponseId }; - - // 2. Unstored turn on session A: it reads the stored turn back and keeps its own turn in the session. - Assert.Equal(["call 1", "reply 1", "call 2"], await CallAsync(service, sessionA, store: false, "call 2")); - - // 3. A different session starts from the last saved turn only: session A's unstored turn is held - // in session A and is invisible here. - Assert.Equal(["call 1", "reply 1", "call 3"], await CallAsync(service, sessionB, store: false, "call 3")); - - // 4. Asking the service to store a turn in a session that is already holding unstored ones is - // refused: the service would record this turn without the turns that came before it. - var refused = await Assert.ThrowsAsync( - () => CallAsync(service, sessionB, store: true, "call 4")); - Assert.Contains("were not stored", refused.Message, StringComparison.Ordinal); - - // 5. Continuing unstored in the same session is fine, and it keeps growing its own turns. - Assert.Equal( - ["call 1", "reply 1", "call 3", "reply 3", "call 5"], - await CallAsync(service, sessionB, store: false, "call 5")); - - // 6. Still refused, for the same reason. - await Assert.ThrowsAsync(() => CallAsync(service, sessionB, store: true, "call 6")); - - // 7. A session holding nothing of its own may store. It branches off the last turn the service - // saved, which is still the first one. - Assert.Equal(["call 1", "reply 1", "call 7"], await CallAsync(service, sessionC, store: true, "call 7")); - - // 8. Session B is unaffected by the turn stored from session C: that turn sits on another branch - // of the conversation, so it is not among the turns leading to session B's last saved one. - Assert.Equal( - ["call 1", "reply 1", "call 3", "reply 3", "call 5", "reply 5", "call 8"], - await CallAsync(service, sessionB, store: false, "call 8")); - - // 9. And session A still sees its own thread of the conversation. - Assert.Equal( - ["call 1", "reply 1", "call 2", "reply 2", "call 9"], - await CallAsync(service, sessionA, store: false, "call 9")); - } - - /// - /// Runs one turn the way the host does: a new provider is built for the request, asked for the - /// messages to send, and then told what the turn produced. Because the provider is new every time, - /// whatever carries over between calls is held by the session it was given. Returns the message - /// texts the model would have received. - /// - private static async Task CallAsync(FakeStoredResponses service, ConversationSession session, bool store, string text) - { - var context = CreateContext(service.TurnsLeadingTo(session.LastStoredResponseId)); - var provider = new FoundryChatHistoryProvider(context, serviceStoresThisTurn: store); - var agent = CreateAgent(); - - var sent = (await provider.InvokingAsync( - new ChatHistoryProvider.InvokingContext(agent, session.Session, [new ChatMessage(ChatRole.User, text)]), - CancellationToken.None)).ToList(); - - var reply = new ChatMessage(ChatRole.Assistant, text.Replace("call", "reply", StringComparison.Ordinal)); - await provider.InvokedAsync( - new ChatHistoryProvider.InvokedContext(agent, session.Session, sent, [reply]), - CancellationToken.None); - - if (store) - { - session.LastStoredResponseId = service.Store(session.LastStoredResponseId, [new ChatMessage(ChatRole.User, text), reply]); - } - - return [.. sent.Select(m => m.Text)]; - } - - /// An agent session, plus the last turn the service saved for the conversation it follows. - private sealed class ConversationSession - { - public FakeSession Session { get; } = new(); - - public string? LastStoredResponseId { get; set; } - } - - /// - /// Stands in for the responses the service keeps. Each stored response points at the one it followed, - /// so asking for the turns leading to a response walks back through them, and a response stored on - /// another branch is not among them. - /// - private sealed class FakeStoredResponses - { - private readonly Dictionary Messages)> _stored = new(StringComparer.Ordinal); - - public string Store(string? previousResponseId, IReadOnlyList messages) - { - var id = $"resp_{this._stored.Count + 1}"; - this._stored[id] = (previousResponseId, [.. messages]); - return id; - } - - public IReadOnlyList TurnsLeadingTo(string? responseId) - { - var chain = new List(); - for (var id = responseId; id is not null && this._stored.TryGetValue(id, out var entry); id = entry.Previous) - { - chain.InsertRange(0, entry.Messages); - } - - return [.. chain.Select((m, i) => NewMessageItem($"msg_{i}", m.Text))]; - } - } - - private static ResponseContext CreateContext(IReadOnlyList history) - { - var mock = new Mock("resp_" + new string('0', 46)) { CallBase = true }; - mock.Setup(x => x.GetHistoryAsync(It.IsAny())).ReturnsAsync(history); - return mock.Object; - } - - private static AIAgent CreateAgent() => new Mock().Object; - - private static OutputItemMessage NewMessageItem(string id, string text) => - new( - id: id, - role: MessageRole.Assistant, - content: [new MessageContentOutputTextContent(text, Array.Empty(), Array.Empty())], - status: MessageStatus.Completed); - - private sealed class FakeSession : AgentSession - { - } -} From f2baf7dea46eb3b09c8a70ad0ea625dca79e1c4e Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:08:04 +0100 Subject: [PATCH 12/14] Hand the conversation to the agent's own provider instead of a host one The host no longer supplies a chat history provider of its own. It writes the turns the service holds into the provider the agent already created for itself, and only when that is the stock in-memory one, so an agent given a provider keeps sole control of its storage and the model receives the conversation once. A conversation the caller stops asking the service to store moves into the session state and stays there. The session's conversation id no longer names anything the service records and cannot be cleared, so the session is cloned without it on that single turn. Asking for a stored turn afterwards is refused: the service would record a turn whose predecessors it does not hold. An agent that does not read history through a provider, a hosted workflow for example, is still given its prior turns as input, now marked as chat history so no provider along the way stores them as new. --- .../AgentFrameworkResponseHandler.cs | 228 +++++++++++++----- .../HostedSessionJsonUtilities.cs | 13 + .../AgentFrameworkResponseHandlerTests.cs | 174 ++++++++++++- 3 files changed, 352 insertions(+), 63 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 22d5df579d0..7d291d60ccc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -3,9 +3,12 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Runtime.CompilerServices; using System.Security.Cryptography; +using System.Text.Json; using System.Threading; +using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Extensions.AI; @@ -33,6 +36,15 @@ public class AgentFrameworkResponseHandler : ResponseHandler /// private static readonly HostedSessionIsolationKeyProvider s_defaultIsolationKeyProvider = new PlatformHostedSessionIsolationKeyProvider(); + /// + /// Written into the session the first time the caller asks the service not to store a turn. Only its + /// presence matters: from that point on the conversation lives in the session and nowhere else. + /// + private const string InMemoryConversationActivationKey = "FoundryHostedInMemoryConversation"; + + /// Identifies the handler as the source of chat history messages it passes as input. + private const string HistorySourceId = "Microsoft.Agents.AI.Foundry.Hosting.AgentFrameworkResponseHandler"; + /// /// Initializes a new instance of the class /// that resolves agents from keyed DI services. @@ -112,43 +124,42 @@ public override async IAsyncEnumerable 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(); - // Decide who supplies the conversation history for this turn. + // Work out where this turn's conversation is kept, because it must be kept in exactly one place. // - // A ChatClientAgent always runs its history through a ChatHistoryProvider. When the agent was - // created with one, that provider owns the conversation and loads it from its own store inside - // the container. When it was not, the handler registers FoundryChatHistoryProvider below, which - // reads what the service holds and keeps in the session only the turns the service was not - // asked to store. Either way the provider delivers the prior turns, so the handler must not add - // them to the input as well: that would send the same conversation twice, and service items - // carry no chat-history marker, so the agent's provider would then store them as if they were - // newly written by this turn. + // The test is whether the agent is a ChatClientAgent, which is the only kind in the framework + // that drives a ChatHistoryProvider through ChatHistoryProvider.InvokingAsync and InvokedAsync: + // AIAgent itself has no provider, and a hosted workflow keeps its own messages by hand inside its + // session rather than through that protocol. For a ChatClientAgent the handler never puts prior + // turns into the input, or the model would receive the conversation twice; it writes into the + // provider instead, and only when that provider is the stock in-memory one the agent created for + // itself. An agent that was given a provider owns its own storage and the host does not touch it. // - // A workflow hosted as an agent derives from AIAgent directly: it never calls a - // ChatHistoryProvider and does not read the run options' additional properties, so there is no - // pipeline to route it through and the handler stays its only source of history. + // Every other agent is fed its prior turns as input, marked as chat history. That is a judgement + // rather than a certainty: the provider protocol is public, so someone could write an agent that + // drives one and would then see the conversation twice. Erring the other way is worse, because an + // agent that keeps nothing of its own would start every turn with no memory at all, and the + // marking already stops a provider from writing these messages down as if they were new. + var agentReadsHistoryFromAProvider = chatClientAgent is not null; var agentOptions = agent.GetService(); - var agentSuppliedHistoryProvider = agentOptions?.ChatHistoryProvider is not null; - var historyComesFromProvider = chatClientAgent is not null; - + var inMemoryChatHistoryProvider = agentOptions?.ChatHistoryProvider is null + ? chatClientAgent?.ChatHistoryProvider as InMemoryChatHistoryProvider + : null; // 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? loadedSession = !string.IsNullOrWhiteSpace(sessionConversationId) - ? await sessionStore.GetSessionAsync(agent, sessionConversationId, resolvedUserId, cancellationToken).ConfigureAwait(false) + AgentSession? loadedSession = !string.IsNullOrWhiteSpace(agentSessionId) + ? await sessionStore.GetSessionAsync(agent, agentSessionId, resolvedUserId, cancellationToken).ConfigureAwait(false) : null; var sessionLoadedFromStore = loadedSession is not null; - AgentSession? session = loadedSession - ?? (chatClientAgent is not null - ? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false) - : await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)); + AgentSession? session = loadedSession ?? 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 @@ -190,19 +201,28 @@ public override async IAsyncEnumerable CreateAsync( // 4. Convert input: history + current input → ChatMessage[] var messages = new List(); - // Load conversation history only for fresh sessions. When a session was already established by - // a prior turn (e.g. resuming a workflow paused at an external-input port), its state already - // contains those messages — replaying history would re-drive completed actions and break HITL - // resume semantics. The signal is whether the store actually loaded a persisted session, which - // is authoritative in both local and hosted runs. + // Hand the conversation to whoever is keeping it for this turn, before the agent runs. + if (session is not null) + { + session = await PrepareSessionConversationAsync( + agent, session, inMemoryChatHistoryProvider, context, serviceStoresThisTurn: request.Store != false, cancellationToken).ConfigureAwait(false); + } + + // An agent that does not read from a provider has nowhere to find prior turns, so the handler + // passes them as input and marks them as chat history: a provider anywhere along the way would + // otherwise take them for content this turn produced and write them down a second time. Only for + // a session no prior turn saved, since a saved one already carries them, and replaying history + // into a workflow resuming at an external-input port would re-drive actions it already completed. var isResume = (!string.IsNullOrWhiteSpace(conversationId) || !string.IsNullOrWhiteSpace(request.PreviousResponseId)) && sessionLoadedFromStore; - if (!isResume && !historyComesFromProvider) + if (!isResume && !agentReadsHistoryFromAProvider) { 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))); } } @@ -222,30 +242,6 @@ public override async IAsyncEnumerable CreateAsync( var chatOptions = InputConverter.ConvertToChatOptions(request); chatOptions.Instructions = request.Instructions; - // Register the platform-backed chat history provider for agents that did not bring their own. - // It is supplied per request because it reads through this request's ResponseContext, and it is - // passed as a run-scoped override so the agent uses it for this turn without the host having to - // mutate the agent. FoundryChatHistoryProvider reads the prior turns from the service and keeps - // in the session only the turns the service was not asked to store. - if (historyComesFromProvider && !agentSuppliedHistoryProvider) - { - // An agent refuses a second history manager once the model reports a conversation id of its - // own, which happens as soon as the container lets the model keep the conversation. That - // guard is meant for an application that configured a provider by hand and would otherwise - // end up with two of them; here the host is the one supplying it, deliberately and for every - // turn, so the guard would only reject the arrangement it is hosting. Stand it down and let - // this provider decide what reaches the model. - if (agentOptions is not null) - { - agentOptions.ThrowOnChatHistoryProviderConflict = false; - agentOptions.WarnOnChatHistoryProviderConflict = false; - agentOptions.ClearOnChatHistoryProviderConflict = false; - } - - chatOptions.AdditionalProperties ??= []; - chatOptions.AdditionalProperties.Add(new FoundryChatHistoryProvider(context, serviceStoresThisTurn: request.Store != false)); - } - // Inject Foundry Toolbox tools when the toolbox service is available. // // Two sources are considered: @@ -497,9 +493,21 @@ 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); + // A turn can end with the Responses API behind the chat client reporting that it is + // storing the conversation, which is only known once the answer comes back and lands on + // ChatClientAgentSession.ConversationId. Anything read out of the hosting service earlier + // in this turn was read on the assumption that nothing else held the conversation, so it + // is dropped rather than persisted: leaving it behind would put a copy in the session + // that stops being extended from here on, and a later turn would serve that stale copy + // back as if it were the whole conversation. + if (inMemoryChatHistoryProvider is not null && session is ChatClientAgentSession { ConversationId: not null }) + { + inMemoryChatHistoryProvider.SetMessages(session, []); + } + + await sessionStore.SaveSessionAsync(agent, agentSessionId, session, resolvedUserId, cancellationToken).ConfigureAwait(false); } } } @@ -531,6 +539,116 @@ internal static IEnumerable EmitOAuthConsentRequest( yield return builder.EmitDone(item); } + /// + /// Puts the conversation where this turn expects to find it, and returns the session to run with. + /// + /// + /// + /// Exactly one place holds the conversation. When + /// is set, the Responses API behind the agent's chat client is storing the conversation, and + /// resolves its to + /// for the turn, so there is nothing to prepare. When it is not set, the + /// is what will serve the conversation, out of + /// ; when it holds nothing, the items from + /// are written into it, which is how a restarted + /// container or a second replica picks up a conversation it has never served. + /// + /// + /// store=false on the means the hosting service records nothing + /// for this turn, so from then on holds the only copy and + /// no longer corresponds to it. A later + /// store=true is rejected, because the hosting service would record a turn whose predecessors + /// it does not hold. + /// + /// + private static async ValueTask PrepareSessionConversationAsync( + AIAgent agent, + AgentSession session, + InMemoryChatHistoryProvider? inMemoryChatHistoryProvider, + ResponseContext context, + bool serviceStoresThisTurn, + CancellationToken cancellationToken) + { + var inMemoryConversationActive = session.StateBag.TryGetValue(InMemoryConversationActivationKey, out _); + + if (serviceStoresThisTurn && inMemoryConversationActive) + { + throw new ResponsesApiException( + new Error( + "conversation_not_stored", + "This conversation has turns the service was not asked to store, so a stored turn cannot be added to it. Keep using store=false for this conversation, or start a new one."), + 400); + } + + if (inMemoryChatHistoryProvider is null) + { + // The agent has a dedicated provider, so the original session is returned as is, without the + // transformations needed to handle the in-memory one. + return session; + } + + if (!serviceStoresThisTurn && !inMemoryConversationActive) + { + // Only the presence of the key matters; the value is written so the entry reads plainly in + // a persisted session. + session.StateBag.SetValue(InMemoryConversationActivationKey, "activated"); + + if (session is ChatClientAgentSession { ConversationId: not null }) + { + session = await CloneSessionWithoutConversationIdAsync(agent, session, cancellationToken).ConfigureAwait(false); + } + } + + // Either the Responses API behind the chat client is storing the conversation, in which case the + // provider will not be consulted at all, or the provider already holds the conversation for this + // session. Both are ready to run as they are. + if (session is not ChatClientAgentSession { ConversationId: null } + || inMemoryChatHistoryProvider.GetMessages(session).Count > 0) + { + return session; + } + + var serviceConversationHistory = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); + if (serviceConversationHistory.Count > 0) + { + // The provider holds nothing for this session, so the hosting service's items become its + // starting point. They are the earlier part of the conversation, so they go in first and + // anything the provider stores from this turn on lands after them. + inMemoryChatHistoryProvider.SetMessages( + session, InputConverter.ConvertOutputItemsToMessages(serviceConversationHistory, session.StateBag)); + } + + return session; + } + + /// + /// Returns a copy of the session carrying the same and no + /// . + /// + /// + /// + /// A session whose conversation moved into no longer matches the + /// conversation state on the service, so the + /// pointing at it no longer applies. + /// + /// + /// cannot be cleared once set, so the replacement + /// is built from the state the session carries. That costs one serialization, which is why this runs + /// on the single turn where a conversation stops being stored and never on the ordinary path. + /// + /// + private static ValueTask CloneSessionWithoutConversationIdAsync( + AIAgent agent, + AgentSession session, + CancellationToken cancellationToken) + { + var serializedConversation = JsonSerializer.SerializeToElement( + new InMemorySessionStateConversation { StateBag = session.StateBag }, + HostedSessionJsonContext.Default.InMemorySessionStateConversation); + + return agent.DeserializeSessionAsync(serializedConversation, cancellationToken: cancellationToken); + } + /// /// Generates a wire-format-valid item id for an oauth_consent_request output item. /// The Responses Server SDK requires ids of the shape {prefix}_{50-char-body}; we use the diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionJsonUtilities.cs index b917f46e714..0c982d8eaa5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionJsonUtilities.cs @@ -25,6 +25,18 @@ internal static class HostedSessionJsonUtilities }; } +/// +/// The persisted shape of a whose conversation lives in +/// alone, with no conversationId because no conversation on +/// the service corresponds to it. +/// +internal sealed class InMemorySessionStateConversation +{ + /// Gets or sets the state the session carries. + [JsonPropertyName("stateBag")] + public AgentSessionStateBag? StateBag { get; set; } +} + /// /// Source-generated JSON serialization context for hosted session identity types. /// @@ -35,5 +47,6 @@ internal static class HostedSessionJsonUtilities PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = false)] [JsonSerializable(typeof(HostedSessionContext))] +[JsonSerializable(typeof(InMemorySessionStateConversation))] [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] internal partial class HostedSessionJsonContext : JsonSerializerContext; 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 cb45b00c588..9bb62842360 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -15,6 +15,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using CreateResponseOptions = OpenAI.Responses.CreateResponseOptions; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; @@ -908,10 +909,11 @@ public async Task CreateAsync_ChatClientAgentWithHistoryProvider_DoesNotAskItToS [Fact] public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_DoesNotCopyPlatformHistoryIntoTheSessionAsync() { - // Arrange: the platform reports one earlier turn for this conversation. + // Arrange: the model inside the container keeps the conversation, so it reports a conversation + // id of its own, and the platform reports one earlier turn for the same conversation. const string ResponseId = "resp_" + "4444444444444444444444444444444444444444444444"; var store = new InMemoryAgentSessionStore(); - var agent = new ChatClientAgent(CreateCapturingChatClient([])); + var agent = new ChatClientAgent(CreateCapturingChatClient([], conversationId: "conv-model")); var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), store); var (request, ctx) = BuildChainRequest(ResponseId, callId: null); ctx.Setup(x => x.GetHistoryAsync(It.IsAny())) @@ -920,10 +922,11 @@ public async Task CreateAsync_ChatClientAgentWithoutHistoryProvider_DoesNotCopyP // Act await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); - // Assert: the turn the service already keeps must not be written into the persisted session as - // well. The older handler fed that history to the agent as ordinary input, and because platform - // items carry no chat-history source marker the agent's default in-memory provider stored it as - // if this turn had produced it, leaving a second copy on disk that then drifts from the service. + // Assert: the model and the service are both keeping this conversation, so the container keeps + // none of it. The older handler fed the service's history to the agent as ordinary input, and + // because platform items carry no chat-history source marker the agent's default in-memory + // provider stored it as if this turn had produced it, leaving a third copy on disk that then + // drifts from the other two. Assert.DoesNotContain("already kept by the service", await SerializedSessionOfAsync(agent, store, ResponseId), StringComparison.Ordinal); } @@ -1041,6 +1044,149 @@ await DrainEventsAsync(handler.CreateAsync( Assert.Single(captured, m => m.Text.Contains("third question", StringComparison.Ordinal)); } + [Fact] + public async Task CreateAsync_ModelKeepsTheConversationAndTheSessionIsGone_RecoversTheHistoryFromTheServiceAsync() + { + // Arrange: the model inside the container keeps the conversation itself, so it reports a + // conversation id of its own. A later turn then lands on a container that has no session for it, + // which is what a restart or a second replica looks like. + var captured = new List(); + var agent = new ChatClientAgent(CreateCapturingChatClient(captured, conversationId: "conv-model")); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + + // Act + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-cold", "second question", store: true), + NewContextServing("resp_" + new string('9', 46), [NewHistoryMessageItem("msg_hist_1", "first question")]), + CancellationToken.None)); + + // Assert: with no session there is no container-side memory, and a conversation id the model + // minted on an earlier turn was lost with it, so the model is no longer being handed the thread + // it was keeping. The service holds the only surviving copy and it must reach the model, exactly + // once. + Assert.Single(captured, m => m.Text.Contains("first question", StringComparison.Ordinal)); + } + + [Fact] + public async Task CreateAsync_ModelKeepsTheConversation_DoesNotSendTheServiceHistoryAsWellAsync() + { + // Arrange: same agent whose model keeps the conversation, but here the container already holds + // the session from turn one, so the model is handed its own conversation id again. + var captured = new List(); + var agent = new ChatClientAgent(CreateCapturingChatClient(captured, conversationId: "conv-model")); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-warm", "first question", store: true), + NewContextServing("resp_" + new string('a', 46), []), + CancellationToken.None)); + captured.Clear(); + + // Act: a second turn, with the service now serving the first one back. + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-warm", "second question", store: true), + NewContextServing("resp_" + new string('b', 46), [NewHistoryMessageItem("msg_hist_1", "first question")]), + CancellationToken.None)); + + // Assert: the model already holds the earlier turn under its own conversation id, so sending the + // service's copy as well would deliver the same conversation twice. + Assert.DoesNotContain(captured, m => m.Text.Contains("first question", StringComparison.Ordinal)); + } + + [Fact] + public async Task CreateAsync_ConversationStopsBeingStored_TakesItOverFromTheModelAsync() + { + // Arrange: the model inside the container kept the first turn and reported a conversation id of + // its own. The caller then stops asking the service to store the conversation. + var captured = new List(); + var agent = new ChatClientAgent(CreateCapturingChatClient(captured, conversationId: "conv-model")); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-handover", "first question", store: true), + NewContextServing("resp_" + new string('c', 46), []), + CancellationToken.None)); + captured.Clear(); + + // Act: the same conversation, now unstored, with the service still serving the first turn. + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-handover", "second question", store: false), + NewContextServing("resp_" + new string('d', 46), [NewHistoryMessageItem("msg_hist_1", "first question")]), + CancellationToken.None)); + + // Assert: the conversation moves into the container whole. The session has to give up the id the + // model reported, or the agent would keep handing the model a thread the service is no longer + // extending, and the agent refuses to run at all once the model stops answering with an id for a + // session that has one. + Assert.Single(captured, m => m.Text.Contains("first question", StringComparison.Ordinal)); + Assert.Single(captured, m => m.Text.Contains("second question", StringComparison.Ordinal)); + } + + [Fact] + public async Task CreateAsync_ConversationStoppedBeingStored_RefusesToStoreItAgainAsync() + { + // Arrange: a conversation whose turns the service was not asked to store. + var agent = new ChatClientAgent(CreateCapturingChatClient([])); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-back", "first question", store: false), + NewContextServing("resp_" + new string('e', 46), []), + CancellationToken.None)); + + // Act + Assert: the service would record this turn on top of turns it does not have, so anyone + // reading the conversation back from it would get an answer with no question. Refuse up front + // rather than write that gap. + var failure = await Assert.ThrowsAsync(() => DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-back", "second question", store: true), + NewContextServing("resp_" + new string('f', 46), []), + CancellationToken.None))); + + Assert.Equal("conversation_not_stored", failure.Error.Code); + } + + [Fact] + public async Task CreateAsync_ConversationStoppedBeingStored_KeepsAnsweringFromTheSessionAsync() + { + // Arrange: turn one is stored and the Responses API behind the chat client reports a conversation + // of its own. From turn two the caller stops asking the hosting service to store anything, so the + // service keeps serving turn one and nothing else for the rest of the conversation. + const string ConversationId = "conv-continuity"; + var captured = new List(); + var store = new InMemoryAgentSessionStore(); + var agent = new ChatClientAgent(CreateCapturingChatClient(captured, conversationId: "conv-model")); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), store); + OutputItem[] servedByTheService = [NewHistoryMessageItem("msg_hist_1", "first question")]; + + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest(ConversationId, "first question", store: true), + NewContextServing("resp_" + new string('1', 45) + "0", []), + CancellationToken.None)); + + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest(ConversationId, "second question", store: false), + NewContextServing("resp_" + new string('1', 45) + "1", servedByTheService), + CancellationToken.None)); + captured.Clear(); + + // Act: a third turn, still unstored. + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest(ConversationId, "third question", store: false), + NewContextServing("resp_" + new string('1', 45) + "2", servedByTheService), + CancellationToken.None)); + + // Assert: the whole conversation reaches the model, each turn once. Turn one comes from the + // hosting service, turn two exists only in the session, and the answers must be there too or the + // model is being asked to continue a conversation it can only see one side of. + Assert.Single(captured, m => m.Text.Contains("first question", StringComparison.Ordinal)); + Assert.Single(captured, m => m.Text.Contains("second question", StringComparison.Ordinal)); + Assert.Single(captured, m => m.Text.Contains("third question", StringComparison.Ordinal)); + + // And the session is where turn two survives, since nothing else recorded it. + var persisted = await SerializedSessionOfAsync(agent, store, ConversationId); + Assert.Contains("second question", persisted, StringComparison.Ordinal); + } + private static CreateResponse NewConversationRequest(string conversationId, string text, bool store) { var request = new CreateResponse { Model = "test", Store = store }; @@ -1090,11 +1236,23 @@ private static IChatClient CreateCapturingChatClient(List captured, It.IsAny>(), It.IsAny(), It.IsAny())) - .Returns((IEnumerable messages, ChatOptions? _, CancellationToken _) => + .Returns((IEnumerable messages, ChatOptions? options, CancellationToken _) => { captured.AddRange(messages); + + // Mirror the MEAI OpenAI adapter, which reports no conversation id for a response the + // service was not asked to store: OpenAIResponsesChatClient sets ChatResponse.ConversationId + // to null whenever CreateResponseOptions.StoredOutputEnabled is false. Without that rule + // here a fake would keep handing back a stored thread the caller opted out of. + var storedOutputDisabled = + options?.RawRepresentationFactory?.Invoke(mock.Object) is CreateResponseOptions { StoredOutputEnabled: false }; + return ToAsyncEnumerableUpdatesAsync( - new ChatResponseUpdate(ChatRole.Assistant, "ok") { MessageId = "resp_msg_1", ConversationId = conversationId }); + new ChatResponseUpdate(ChatRole.Assistant, "ok") + { + MessageId = "resp_msg_1", + ConversationId = storedOutputDisabled ? null : conversationId, + }); }); return mock.Object; } From 0ae968f8dacf45898301e37c1c31379632ab6052 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:01:51 +0100 Subject: [PATCH 13/14] Run the agent's own request factory instead of replacing it ChatClientAgent chains a request's raw representation factory with the agent's by taking the agent's only when the request's returns null. The factory added for an unstored turn always answers, so anything the container configured on the agent's ChatOptions was silently dropped for that turn. The agent's factory is now invoked first and its result is what carries the setting. A result that is not a CreateResponseOptions belongs to some other chat client, which has no notion of storing a response, so it is handed back untouched. --- .../AgentFrameworkResponseHandler.cs | 2 +- .../InputConverter.cs | 24 +++++++++-- .../AgentFrameworkResponseHandlerTests.cs | 40 +++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 7d291d60ccc..a176ab1f5e5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -239,7 +239,7 @@ public override async IAsyncEnumerable CreateAsync( } // 5. Build chat options - var chatOptions = InputConverter.ConvertToChatOptions(request); + var chatOptions = InputConverter.ConvertToChatOptions(request, agentOptions?.ChatOptions?.RawRepresentationFactory); chatOptions.Instructions = request.Instructions; // Inject Foundry Toolbox tools when the toolbox service is available. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs index a367db6816e..ebcef3756c6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs @@ -88,8 +88,12 @@ public static List ConvertOutputItemsToMessages(IReadOnlyList from the SDK request properties. /// /// The create response request. + /// + /// The factory the agent carries on its own , if any, so a request that has + /// to set one of its own can run it rather than replace it. + /// /// A configured instance. - public static ChatOptions ConvertToChatOptions(CreateResponse request) + public static ChatOptions ConvertToChatOptions(CreateResponse request, Func? agentRawRepresentationFactory = null) { var options = new ChatOptions { @@ -105,13 +109,25 @@ public static ChatOptions ConvertToChatOptions(CreateResponse request) // Only store=false travels to the service behind the chat client: a caller opting out of storage // gets nothing recorded anywhere, while carrying store=true across would either force storage on // a container whose author turned it off on purpose or change nothing, since storing is already - // the default. Any factory the container configured is chained, so its own choice still wins. + // the default. + // + // 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. if (request.Store == false) { - var containerFactory = options.RawRepresentationFactory; options.RawRepresentationFactory = chatClient => { - var responseOptions = containerFactory?.Invoke(chatClient) as CreateResponseOptions ?? new CreateResponseOptions(); + var configuredByTheAgent = agentRawRepresentationFactory?.Invoke(chatClient); + if (configuredByTheAgent is not null and not CreateResponseOptions) + { + // Some other chat client's request type, which has no notion of storing a response. + // Hand it back untouched rather than discard what the container asked for. + return configuredByTheAgent; + } + + var responseOptions = configuredByTheAgent as CreateResponseOptions ?? new CreateResponseOptions(); responseOptions.StoredOutputEnabled = false; return responseOptions; }; 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 9bb62842360..a11c6453498 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -1187,6 +1187,46 @@ await DrainEventsAsync(handler.CreateAsync( Assert.Contains("second question", persisted, StringComparison.Ordinal); } + [Fact] + public async Task CreateAsync_UnstoredRequestAndAgentWithARawRepresentationFactory_KeepsBothAsync() + { + // Arrange: an agent whose own ChatOptions carry a raw representation factory, the way a container + // adds settings the chat client only understands in its own request type. The caller asks for a + // turn the service must not store. + 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 { EndUserId = "set by the container" }, + }, + }); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + + // Act + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest("conv-raw", "a question", store: false), + NewContextServing("resp_" + new string('7', 46), []), + CancellationToken.None)); + + // Assert: the agent chains a request factory with its own by taking the agent's only when the + // request's returns null, so a request factory that always answers would silently drop whatever + // the container configured. Both settings have to survive on the way to the client. + Assert.NotNull(sentToTheClient?.RawRepresentationFactory); + var raw = Assert.IsType(sentToTheClient!.RawRepresentationFactory!(client.Object)); + Assert.False(raw.StoredOutputEnabled); + Assert.Equal("set by the container", raw.EndUserId); + } + private static CreateResponse NewConversationRequest(string conversationId, string text, bool store) { var request = new CreateResponse { Model = "test", Store = store }; From a5e149eb54bb64da8601be646e86f21f3a5a1377 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:32:26 +0100 Subject: [PATCH 14/14] Cover a stored conversation that stops being stored and asks again The refusal was only tested on a conversation the service never stored. Reaching it from a stored one goes through the turn that rebuilds the session without its conversation id, so the mark saying the conversation left the service has to survive that rebuild to be found on the next turn. --- .../AgentFrameworkResponseHandlerTests.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 a11c6453498..25ce3bf008b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -1145,6 +1145,36 @@ await DrainEventsAsync(handler.CreateAsync( Assert.Equal("conversation_not_stored", failure.Error.Code); } + [Fact] + public async Task CreateAsync_StoredThenUnstoredConversation_StillRefusesToStoreItAgainAsync() + { + // Arrange: turn one is stored and the Responses API behind the chat client reports a conversation + // of its own, so turn two both rebuilds the session without that conversation id and marks it. + const string ConversationId = "conv-there-and-back"; + var agent = new ChatClientAgent(CreateCapturingChatClient([], conversationId: "conv-model")); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest(ConversationId, "first question", store: true), + NewContextServing("resp_" + new string('2', 45) + "0", []), + CancellationToken.None)); + + await DrainEventsAsync(handler.CreateAsync( + NewConversationRequest(ConversationId, "second question", store: false), + NewContextServing("resp_" + new string('2', 45) + "1", [NewHistoryMessageItem("msg_hist_1", "first question")]), + CancellationToken.None)); + + // Act + Assert: the service holds turn one and nothing after it, so recording this turn would + // leave the stored conversation jumping straight from turn one to turn three. The mark that says + // so is written into the session state, which has to survive turn two rebuilding the session. + var failure = await Assert.ThrowsAsync(() => DrainEventsAsync(handler.CreateAsync( + NewConversationRequest(ConversationId, "third question", store: true), + NewContextServing("resp_" + new string('2', 45) + "2", [NewHistoryMessageItem("msg_hist_1", "first question")]), + CancellationToken.None))); + + Assert.Equal("conversation_not_stored", failure.Error.Code); + } + [Fact] public async Task CreateAsync_ConversationStoppedBeingStored_KeepsAnsweringFromTheSessionAsync() {