diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs index d98689f895..f5f567dbd2 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs @@ -2,8 +2,9 @@ // This sample shows multiple middleware layers working together with Azure OpenAI: // chat client (global/per-request), agent run (PII filtering and guardrails), -// function invocation (logging and result overrides), and human-in-the-loop -// approval workflows for sensitive function calls. +// function invocation (logging and result overrides), human-in-the-loop +// approval workflows for sensitive function calls, and MessageAIContextProvider +// middleware for injecting additional context messages into the agent pipeline. using System.ComponentModel; using System.Text.RegularExpressions; @@ -96,6 +97,20 @@ var response = await originalAgent // Using per-request middleware pipeline with Console.WriteLine($"Per-request middleware response: {response}"); +// MessageAIContextProvider middleware that injects additional messages into the agent request. +// This allows any AIAgent (not just ChatClientAgent) to benefit from MessageAIContextProvider-based +// context enrichment. Multiple providers can be passed to Use and they are called in sequence, +// each receiving the output of the previous one. +Console.WriteLine("\n\n=== Example 5: MessageAIContextProvider middleware ==="); + +var contextProviderAgent = originalAgent + .AsBuilder() + .Use([new DateTimeContextProvider()]) + .Build(); + +var contextResponse = await contextProviderAgent.RunAsync("Is it almost time for lunch?"); +Console.WriteLine($"Context-enriched response: {contextResponse}"); + // Function invocation middleware that logs before and after function calls. async ValueTask FunctionCallMiddleware(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) { @@ -259,3 +274,23 @@ async Task PerRequestChatClientMiddleware(IEnumerable return response; } + +/// +/// A that injects the current date and time into the agent's context. +/// This is a simple example of how to use a MessageAIContextProvider to enrich agent messages +/// via the extension method. +/// +internal sealed class DateTimeContextProvider : MessageAIContextProvider +{ + protected override ValueTask> ProvideMessagesAsync( + InvokingContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine("DateTimeContextProvider - Injecting current date/time context"); + + return new ValueTask>( + [ + new ChatMessage(ChatRole.User, $"For reference, the current date and time is: {DateTimeOffset.Now}") + ]); + } +} diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/README.md b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/README.md index d9433d6230..d7193b6982 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/README.md +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/README.md @@ -14,6 +14,7 @@ This sample demonstrates how to add middleware to intercept: 5. Per‑request chat client middleware 6. Per‑request function pipeline with approval 7. Combining agent‑level and per‑request middleware +8. MessageAIContextProvider middleware via `AIAgentBuilder.Use(...)` for injecting additional context messages ## Function Invocation Middleware diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs index 5ff7a51c38..a341abe8cd 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs @@ -146,11 +146,11 @@ namespace SampleApp } /// - /// An which searches for upcoming calendar events and adds them to the AI context. + /// A which searches for upcoming calendar events and adds them to the AI context. /// - internal sealed class CalendarSearchAIContextProvider(Func> loadNextThreeCalendarEvents) : AIContextProvider + internal sealed class CalendarSearchAIContextProvider(Func> loadNextThreeCalendarEvents) : MessageAIContextProvider { - protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + protected override async ValueTask> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default) { var events = await loadNextThreeCalendarEvents(); @@ -161,10 +161,7 @@ namespace SampleApp outputMessageBuilder.AppendLine($" - {calendarEvent}"); } - return new AIContext - { - Messages = [new MEAI.ChatMessage(ChatRole.User, outputMessageBuilder.ToString())] - }; + return [new MEAI.ChatMessage(ChatRole.User, outputMessageBuilder.ToString())]; } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs index 023f6c8e5f..7ac4eed18c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs @@ -34,9 +34,6 @@ public abstract class AIContextProvider private static IEnumerable DefaultExternalOnlyFilter(IEnumerable messages) => messages.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External); - private readonly Func, IEnumerable> _provideInputMessageFilter; - private readonly Func, IEnumerable> _storeInputMessageFilter; - /// /// Initializes a new instance of the class. /// @@ -46,10 +43,20 @@ public abstract class AIContextProvider Func, IEnumerable>? provideInputMessageFilter = null, Func, IEnumerable>? storeInputMessageFilter = null) { - this._provideInputMessageFilter = provideInputMessageFilter ?? DefaultExternalOnlyFilter; - this._storeInputMessageFilter = storeInputMessageFilter ?? DefaultExternalOnlyFilter; + this.ProvideInputMessageFilter = provideInputMessageFilter ?? DefaultExternalOnlyFilter; + this.StoreInputMessageFilter = storeInputMessageFilter ?? DefaultExternalOnlyFilter; } + /// + /// Gets the filter function to apply to input messages before providing context via . + /// + protected Func, IEnumerable> ProvideInputMessageFilter { get; } + + /// + /// Gets the filter function to apply to request messages before storing context via . + /// + protected Func, IEnumerable> StoreInputMessageFilter { get; } + /// /// Gets the key used to store the provider state in the . /// @@ -120,7 +127,7 @@ public abstract class AIContextProvider new AIContext { Instructions = inputContext.Instructions, - Messages = inputContext.Messages is not null ? this._provideInputMessageFilter(inputContext.Messages) : null, + Messages = inputContext.Messages is not null ? this.ProvideInputMessageFilter(inputContext.Messages) : null, Tools = inputContext.Tools }); @@ -254,7 +261,7 @@ public abstract class AIContextProvider return default; } - var subContext = new InvokedContext(context.Agent, context.Session, this._storeInputMessageFilter(context.RequestMessages), context.ResponseMessages!); + var subContext = new InvokedContext(context.Agent, context.Session, this.StoreInputMessageFilter(context.RequestMessages), context.ResponseMessages!); return this.StoreAIContextAsync(subContext, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/MessageAIContextProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/MessageAIContextProvider.cs new file mode 100644 index 0000000000..24264e0e47 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/MessageAIContextProvider.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides an abstract base class for components that enhance AI context during agent invocations by supplying additional chat messages. +/// +/// +/// +/// A message AI context provider is a component that participates in the agent invocation lifecycle by: +/// +/// Listening to changes in conversations +/// Providing additional messages to agents during invocation +/// Processing invocation results for state management or learning +/// +/// +/// +/// Context providers operate through a two-phase lifecycle: they are called at the start of invocation via +/// to provide context, and optionally called at the end of invocation via +/// to process results. +/// +/// +public abstract class MessageAIContextProvider : AIContextProvider +{ + /// + /// Initializes a new instance of the class. + /// + /// An optional filter function to apply to input messages before providing messages via . If not set, defaults to including only messages. + /// An optional filter function to apply to request messages before storing messages via . If not set, defaults to including only messages. + protected MessageAIContextProvider( + Func, IEnumerable>? provideInputMessageFilter = null, + Func, IEnumerable>? storeInputMessageFilter = null) + : base(provideInputMessageFilter, storeInputMessageFilter) + { + } + + /// + protected override async ValueTask ProvideAIContextAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default) + { + // Call ProvideMessagesAsync directly to return only additional messages. + // The base AIContextProvider.InvokingCoreAsync handles merging with the original input and stamping. + return new AIContext + { + Messages = await this.ProvideMessagesAsync( + new InvokingContext(context.Agent, context.Session, context.AIContext.Messages ?? []), + cancellationToken).ConfigureAwait(false) + }; + } + + /// + /// Called at the start of agent invocation to provide additional messages. + /// + /// Contains the request context including the caller provided messages that will be used by the agent for this invocation. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains the to be used by the agent during this invocation. + /// + /// + /// Implementers can load any additional messages required at this time, such as: + /// + /// Retrieving relevant information from knowledge bases + /// Adding system instructions or prompts + /// Injecting contextual messages from conversation history + /// + /// + /// + public ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + => this.InvokingCoreAsync(Throw.IfNull(context), cancellationToken); + + /// + /// Called at the start of agent invocation to provide additional messages. + /// + /// Contains the request context including the caller provided messages that will be used by the agent for this invocation. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains the to be used by the agent during this invocation. + /// + /// + /// Implementers can load any additional messages required at this time, such as: + /// + /// Retrieving relevant information from knowledge bases + /// Adding system instructions or prompts + /// Injecting contextual messages from conversation history + /// + /// + /// + /// The default implementation of this method filters the input messages using the configured provide-input message filter + /// (which defaults to including only messages), + /// then calls to get additional messages, + /// stamps any messages with source attribution, + /// and merges the returned messages with the original (unfiltered) input messages. + /// For most scenarios, overriding is sufficient to provide additional messages, + /// while still benefiting from the default filtering, merging and source stamping behavior. + /// However, for scenarios that require more control over message filtering, merging or source stamping, overriding this method + /// allows you to directly control the full returned for the invocation. + /// + /// + protected virtual async ValueTask> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + var inputMessages = context.RequestMessages; + + // Create a filtered context for ProvideMessagesAsync, filtering input messages + // to exclude non-external messages (e.g. chat history, other AI context provider messages). + var filteredContext = new InvokingContext( + context.Agent, + context.Session, + this.ProvideInputMessageFilter(inputMessages)); + + var providedMessages = await this.ProvideMessagesAsync(filteredContext, cancellationToken).ConfigureAwait(false); + + // Stamp and merge provided messages. + providedMessages = providedMessages.Select(m => m.WithAgentRequestMessageSource(AgentRequestMessageSourceType.AIContextProvider, this.GetType().FullName!)); + return inputMessages.Concat(providedMessages); + } + + /// + /// When overridden in a derived class, provides additional messages to be merged with the input messages for the current invocation. + /// + /// + /// + /// This method is called from . + /// Note that can be overridden to directly control messages merging and source stamping, in which case + /// it is up to the implementer to call this method as needed to retrieve the additional messages. + /// + /// + /// In contrast with , this method only returns additional messages to be merged with the input, + /// while is responsible for returning the full merged for the invocation. + /// + /// + /// Contains the request context including the caller provided messages that will be used by the agent for this invocation. + /// The to monitor for cancellation requests. The default is . + /// + /// A task that represents the asynchronous operation. The task result contains an + /// with additional messages to be merged with the input messages. + /// + protected virtual ValueTask> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + return new ValueTask>([]); + } + + /// + /// Contains the context information provided to . + /// + /// + /// This class provides context about the invocation before the underlying AI model is invoked, including the messages + /// that will be used. Message AI Context providers can use this information to determine what additional messages + /// should be provided for the invocation. + /// + public new sealed class InvokingContext + { + /// + /// Initializes a new instance of the class with the specified request messages. + /// + /// The agent being invoked. + /// The session associated with the agent invocation. + /// The messages to be used by the agent for this invocation. + /// or is . + public InvokingContext( + AIAgent agent, + AgentSession? session, + IEnumerable requestMessages) + { + this.Agent = Throw.IfNull(agent); + this.Session = session; + this.RequestMessages = Throw.IfNull(requestMessages); + } + + /// + /// Gets the agent that is being invoked. + /// + public AIAgent Agent { get; } + + /// + /// Gets the agent session associated with the agent invocation. + /// + public AgentSession? Session { get; } + + /// + /// Gets the messages that will be used by the agent for this invocation. instances can modify + /// and return or return a new message list to add additional messages for the invocation. + /// + /// + /// A collection of instances representing the messages that will be used by the agent for this invocation. + /// + /// + /// + /// If multiple instances are used in the same invocation, each + /// will receive the messages returned by the previous allowing them to build on top of each other's context. + /// + /// + /// The first in the invocation pipeline will receive the + /// caller provided messages. + /// + /// + public IEnumerable RequestMessages { get; set { field = Throw.IfNull(value); } } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ProviderSessionState{TState}.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ProviderSessionState{TState}.cs index b88e996644..ffcec7ea11 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ProviderSessionState{TState}.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ProviderSessionState{TState}.cs @@ -2,6 +2,7 @@ using System; using System.Text.Json; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -39,8 +40,8 @@ public class ProviderSessionState string stateKey, JsonSerializerOptions? jsonSerializerOptions = null) { - this._stateInitializer = stateInitializer; - this.StateKey = stateKey; + this._stateInitializer = Throw.IfNull(stateInitializer); + this.StateKey = Throw.IfNullOrWhitespace(stateKey); this._jsonSerializerOptions = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions; } diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index bc2f3fabc8..1924bc0da2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -14,7 +14,7 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Mem0; /// -/// Provides a Mem0 backed that persists conversation messages as memories +/// Provides a Mem0 backed that persists conversation messages as memories /// and retrieves related memories to augment the agent invocation context. /// /// @@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI.Mem0; /// for new invocations using a semantic search endpoint. Retrieved memories are injected as user messages /// to the model, prefixed by a configurable context prompt. /// -public sealed class Mem0Provider : AIContextProvider +public sealed class Mem0Provider : MessageAIContextProvider { private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; @@ -92,7 +92,7 @@ public sealed class Mem0Provider : AIContextProvider }; /// - protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + protected override async ValueTask> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default) { Throw.IfNull(context); @@ -101,7 +101,7 @@ public sealed class Mem0Provider : AIContextProvider string queryText = string.Join( Environment.NewLine, - (context.AIContext.Messages ?? []) + context.RequestMessages .Where(m => !string.IsNullOrWhiteSpace(m.Text)) .Select(m => m.Text)); @@ -142,12 +142,9 @@ public sealed class Mem0Provider : AIContextProvider } } - return new AIContext - { - Messages = outputMessageText is not null - ? [new ChatMessage(ChatRole.User, outputMessageText)] - : null - }; + return outputMessageText is not null + ? [new ChatMessage(ChatRole.User, outputMessageText)] + : []; } catch (ArgumentException) { @@ -166,7 +163,7 @@ public sealed class Mem0Provider : AIContextProvider this.SanitizeLogData(searchScope.UserId)); } - return new AIContext(); + return []; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffToolCallFilteringBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffToolCallFilteringBehavior.cs new file mode 100644 index 0000000000..a2d269278e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffToolCallFilteringBehavior.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Specifies the behavior for filtering and contents from +/// s flowing through a handoff workflow. This can be used to prevent agents from seeing external +/// tool calls. +/// +public enum HandoffToolCallFilteringBehavior +{ + /// + /// Do not filter and contents. + /// + None, + + /// + /// Filter only handoff-related and contents. + /// + HandoffOnly, + + /// + /// Filter all and contents. + /// + All +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs index 9a3abfe960..bd0b3114f1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.Agents.AI.Workflows.Specialized; +using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; @@ -16,6 +17,7 @@ public sealed class HandoffsWorkflowBuilder private readonly AIAgent _initialAgent; private readonly Dictionary> _targets = []; private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance); + private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; /// /// Initializes a new instance of the class with no handoff relationships. @@ -34,14 +36,38 @@ public sealed class HandoffsWorkflowBuilder /// By default, simple instructions are included. This may be set to to avoid including /// any additional instructions, or may be customized to provide more specific guidance. /// - public string? HandoffInstructions { get; set; } = - $""" + public string? HandoffInstructions { get; private set; } = DefaultHandoffInstructions; + + private const string DefaultHandoffInstructions = + $""" You are one agent in a multi-agent system. You can hand off the conversation to another agent if appropriate. Handoffs are achieved by calling a handoff function, named in the form `{FunctionPrefix}`; the description of the function provides details on the target agent of that handoff. Handoffs between agents are handled seamlessly in the background; never mention or narrate these handoffs in your conversation with the user. """; + /// + /// Sets additional instructions to provide to an agent that has handoffs about how and when to + /// perform them. + /// + /// The instructions to provide, or to restore the default instructions. + public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions) + { + this.HandoffInstructions = instructions ?? DefaultHandoffInstructions; + return this; + } + + /// + /// Sets the behavior for filtering and contents from + /// s flowing through the handoff workflow. Defaults to . + /// + /// The filtering behavior to apply. + public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior) + { + this._toolCallFilteringBehavior = behavior; + return this; + } + /// /// Adds handoff relationships from a source agent to one or more target agents. /// @@ -149,8 +175,10 @@ public sealed class HandoffsWorkflowBuilder HandoffsEndExecutor end = new(); WorkflowBuilder builder = new(start); + HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior); + // Create an AgentExecutor for each again. - Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, this.HandoffInstructions)); + Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options)); // Connect the start executor to the initial agent. builder.AddEdge(start, executors[this._initialAgent.Id]); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 1627b50e4b..d1367b83ad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -12,10 +12,155 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; +internal sealed class HandoffAgentExecutorOptions +{ + public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior) + { + this.HandoffInstructions = handoffInstructions; + this.ToolCallFilteringBehavior = toolCallFilteringBehavior; + } + + public string? HandoffInstructions { get; set; } + + public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly; +} + +internal sealed class HandoffMessagesFilter +{ + private readonly HandoffToolCallFilteringBehavior _filteringBehavior; + + public HandoffMessagesFilter(HandoffToolCallFilteringBehavior filteringBehavior) + { + this._filteringBehavior = filteringBehavior; + } + + internal static bool IsHandoffFunctionName(string name) + { + return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); + } + + public IEnumerable FilterMessages(List messages) + { + if (this._filteringBehavior == HandoffToolCallFilteringBehavior.None) + { + return messages; + } + + Dictionary filteringCandidates = new(); + List filteredMessages = []; + HashSet messagesToRemove = []; + + bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly; + foreach (ChatMessage unfilteredMessage in messages) + { + ChatMessage filteredMessage = unfilteredMessage.Clone(); + + // .Clone() is shallow, so we cannot modify the contents of the cloned message in place. + List contents = []; + contents.Capacity = unfilteredMessage.Contents?.Count ?? 0; + filteredMessage.Contents = contents; + + // Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls + // originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result) + // FunctionCallContent. + if (unfilteredMessage.Role != ChatRole.Tool) + { + for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) + { + AIContent content = unfilteredMessage.Contents[i]; + if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name))) + { + filteredMessage.Contents.Add(content); + + // Track non-handoff function calls so their tool results are preserved in HandoffOnly mode + if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc) + { + filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId) + { + IsHandoffFunction = false, + }; + } + } + else if (filterHandoffOnly) + { + if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState)) + { + filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId) + { + IsHandoffFunction = true, + }; + } + else + { + candidateState.IsHandoffFunction = true; + (int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value; + ChatMessage messageToFilter = filteredMessages[messageIndex]; + messageToFilter.Contents.RemoveAt(contentIndex); + if (messageToFilter.Contents.Count == 0) + { + messagesToRemove.Add(messageIndex); + } + } + } + else + { + // All mode: strip all FunctionCallContent + } + } + } + else + { + if (!filterHandoffOnly) + { + continue; + } + + for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) + { + AIContent content = unfilteredMessage.Contents[i]; + if (content is not FunctionResultContent frc + || (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState) + && candidateState.IsHandoffFunction is false)) + { + // Either this is not a function result content, so we should let it through, or it is a FRC that + // we know is not related to a handoff call. In either case, we should include it. + filteredMessage.Contents.Add(content); + } + else if (candidateState is null) + { + // We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later + filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId) + { + FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count), + }; + } + // else we have seen the corresponding function call and it is a handoff, so we should filter it out. + } + } + + if (filteredMessage.Contents.Count > 0) + { + filteredMessages.Add(filteredMessage); + } + } + + return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index)); + } + + private class FilterCandidateState(string callId) + { + public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; } + + public string CallId => callId; + + public bool? IsHandoffFunction { get; set; } + } +} + /// Executor used to represent an agent in a handoffs workflow, responding to events. internal sealed class HandoffAgentExecutor( AIAgent agent, - string? handoffInstructions) : Executor(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor + HandoffAgentExecutorOptions options) : Executor(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor { private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create( ([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema; @@ -39,7 +184,7 @@ internal sealed class HandoffAgentExecutor( ChatOptions = new() { AllowMultipleToolCalls = false, - Instructions = handoffInstructions, + Instructions = options.HandoffInstructions, Tools = [], }, }; @@ -69,10 +214,19 @@ internal sealed class HandoffAgentExecutor( List? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id); - await foreach (var update in this._agent.RunStreamingAsync(allMessages, + // If a handoff was invoked by a previous agent, filter out the handoff function + // call and tool result messages before sending to the underlying agent. These + // are internal workflow mechanics that confuse the target model into ignoring the + // original user question. + HandoffMessagesFilter handoffMessagesFilter = new(options.ToolCallFilteringBehavior); + IEnumerable messagesForAgent = message.InvokedHandoff is not null + ? handoffMessagesFilter.FilterMessages(allMessages) + : allMessages; + + await foreach (var update in this._agent.RunStreamingAsync(messagesForAgent, options: this._agentOptions, cancellationToken: cancellationToken) - .ConfigureAwait(false)) + .ConfigureAwait(false)) { await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI/AIAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI/AIAgentBuilder.cs index 961fd7f20e..52293db621 100644 --- a/dotnet/src/Microsoft.Agents.AI/AIAgentBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/AIAgentBuilder.cs @@ -151,6 +151,32 @@ public sealed class AIAgentBuilder return this.Use((innerAgent, _) => new AnonymousDelegatingAIAgent(innerAgent, runFunc, runStreamingFunc)); } + /// + /// Adds one or more instances to the agent pipeline, enabling message enrichment + /// for any . + /// + /// + /// The instances to invoke before and after each agent invocation. + /// Providers are called in sequence, with each receiving the output of the previous provider. + /// + /// The with the providers added, enabling method chaining. + /// is empty. + /// + /// + /// This method wraps the inner agent with a that calls each provider's + /// in sequence before the inner agent runs, + /// and calls on each provider after the inner agent completes. + /// + /// + /// This allows any to benefit from -based + /// context enrichment, not just agents that natively support instances. + /// + /// + public AIAgentBuilder Use(MessageAIContextProvider[] providers) + { + return this.Use((innerAgent, _) => new MessageAIContextProviderAgent(innerAgent, providers)); + } + /// /// Provides an empty implementation. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs index a93bfd2c67..c6ca35951e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -34,7 +34,7 @@ namespace Microsoft.Agents.AI; /// injecting them automatically on each invocation. /// /// -public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable +public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDisposable { private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; private const int DefaultMaxResults = 3; @@ -119,7 +119,7 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable public override string StateKey => this._sessionState.StateKey; /// - protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + protected override async ValueTask ProvideAIContextAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default) { _ = Throw.IfNull(context); @@ -147,17 +147,46 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable }; } + return new AIContext + { + Messages = await this.ProvideMessagesAsync( + new InvokingContext(context.Agent, context.Session, context.AIContext.Messages ?? []), + cancellationToken).ConfigureAwait(false) + }; + } + + /// + protected override ValueTask> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + // This code path is invoked using InvokingAsync on MessageAIContextProvider, which does not support tools and instructions, + // and OnDemandFunctionCalling requires tools. + if (this._searchTime != ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke) + { + throw new InvalidOperationException($"Using the {nameof(ChatHistoryMemoryProvider)} as a {nameof(MessageAIContextProvider)} is not supported when {nameof(ChatHistoryMemoryProviderOptions.SearchTime)} is set to {ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling}."); + } + + return base.InvokingCoreAsync(context, cancellationToken); + } + + /// + protected override async ValueTask> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(context); + + var state = this._sessionState.GetOrInitializeState(context.Session); + var searchScope = state.SearchScope; + try { // Get the text from the current request messages var requestText = string.Join("\n", - (context.AIContext.Messages ?? []) + (context.RequestMessages ?? []) .Where(m => m != null && !string.IsNullOrWhiteSpace(m.Text)) .Select(m => m.Text)); if (string.IsNullOrWhiteSpace(requestText)) { - return new AIContext(); + return []; } // Search for relevant chat history @@ -165,13 +194,10 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable if (string.IsNullOrWhiteSpace(contextText)) { - return new AIContext(); + return []; } - return new AIContext - { - Messages = [new ChatMessage(ChatRole.User, contextText)] - }; + return [new ChatMessage(ChatRole.User, contextText)]; } catch (Exception ex) { @@ -186,7 +212,7 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable this.SanitizeLogData(searchScope.UserId)); } - return new AIContext(); + return []; } } diff --git a/dotnet/src/Microsoft.Agents.AI/MessageAIContextProviderAgent.cs b/dotnet/src/Microsoft.Agents.AI/MessageAIContextProviderAgent.cs new file mode 100644 index 0000000000..6453209edd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/MessageAIContextProviderAgent.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A delegating AI agent that enriches input messages by invoking a pipeline of instances +/// before delegating to the inner agent, and notifies those providers after the inner agent completes. +/// +internal sealed class MessageAIContextProviderAgent : DelegatingAIAgent +{ + private readonly IReadOnlyList _providers; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying agent instance that will handle the core operations. + /// The message AI context providers to invoke before and after the inner agent. + public MessageAIContextProviderAgent(AIAgent innerAgent, IReadOnlyList providers) + : base(innerAgent) + { + Throw.IfNull(providers); + Throw.IfLessThanOrEqual(providers.Count, 0, nameof(providers)); + + this._providers = providers; + } + + /// + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var enrichedMessages = await this.InvokeProvidersAsync(messages, session, cancellationToken).ConfigureAwait(false); + + AgentResponse response; + try + { + response = await this.InnerAgent.RunAsync(enrichedMessages, session, options, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await this.NotifyProvidersOfFailureAsync(session, enrichedMessages, ex, cancellationToken).ConfigureAwait(false); + throw; + } + + await this.NotifyProvidersOfSuccessAsync(session, enrichedMessages, response.Messages, cancellationToken).ConfigureAwait(false); + + return response; + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var enrichedMessages = await this.InvokeProvidersAsync(messages, session, cancellationToken).ConfigureAwait(false); + + List responseUpdates = []; + + IAsyncEnumerator enumerator; + try + { + enumerator = this.InnerAgent.RunStreamingAsync(enrichedMessages, session, options, cancellationToken).GetAsyncEnumerator(cancellationToken); + } + catch (Exception ex) + { + await this.NotifyProvidersOfFailureAsync(session, enrichedMessages, ex, cancellationToken).ConfigureAwait(false); + throw; + } + + bool hasUpdates; + try + { + hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + await this.NotifyProvidersOfFailureAsync(session, enrichedMessages, ex, cancellationToken).ConfigureAwait(false); + throw; + } + + while (hasUpdates) + { + var update = enumerator.Current; + responseUpdates.Add(update); + yield return update; + + try + { + hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + await this.NotifyProvidersOfFailureAsync(session, enrichedMessages, ex, cancellationToken).ConfigureAwait(false); + throw; + } + } + + var agentResponse = responseUpdates.ToAgentResponse(); + await this.NotifyProvidersOfSuccessAsync(session, enrichedMessages, agentResponse.Messages, cancellationToken).ConfigureAwait(false); + } + + /// + /// Invokes each provider's in sequence, + /// passing the output of each as input to the next. + /// + private async Task> InvokeProvidersAsync( + IEnumerable messages, + AgentSession? session, + CancellationToken cancellationToken) + { + var currentMessages = messages; + + foreach (var provider in this._providers) + { + var context = new MessageAIContextProvider.InvokingContext(this, session, currentMessages); + currentMessages = await provider.InvokingAsync(context, cancellationToken).ConfigureAwait(false); + } + + return currentMessages; + } + + /// + /// Notifies each provider of a successful invocation. + /// + private async Task NotifyProvidersOfSuccessAsync( + AgentSession? session, + IEnumerable requestMessages, + IEnumerable responseMessages, + CancellationToken cancellationToken) + { + var invokedContext = new AIContextProvider.InvokedContext(this, session, requestMessages, responseMessages); + + foreach (var provider in this._providers) + { + await provider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Notifies each provider of a failed invocation. + /// + private async Task NotifyProvidersOfFailureAsync( + AgentSession? session, + IEnumerable requestMessages, + Exception exception, + CancellationToken cancellationToken) + { + var invokedContext = new AIContextProvider.InvokedContext(this, session, requestMessages, exception); + + foreach (var provider in this._providers) + { + await provider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs index 0a15f51565..dd62b0eb9b 100644 --- a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs @@ -32,7 +32,7 @@ namespace Microsoft.Agents.AI; /// multi-turn context to the retrieval layer without permanently altering the conversation history. /// /// -public sealed class TextSearchProvider : AIContextProvider +public sealed class TextSearchProvider : MessageAIContextProvider { private const string DefaultPluginSearchFunctionName = "Search"; private const string DefaultPluginSearchFunctionDescription = "Allows searching for additional information to help answer the user question."; @@ -91,7 +91,7 @@ public sealed class TextSearchProvider : AIContextProvider public override string StateKey => this._sessionState.StateKey; /// - protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + protected override async ValueTask ProvideAIContextAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default) { if (this._searchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke) { @@ -102,6 +102,30 @@ public sealed class TextSearchProvider : AIContextProvider }; } + return new AIContext + { + Messages = await this.ProvideMessagesAsync( + new InvokingContext(context.Agent, context.Session, context.AIContext.Messages ?? []), + cancellationToken).ConfigureAwait(false) + }; + } + + /// + protected override ValueTask> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + // This code path is invoked using InvokingAsync on MessageAIContextProvider, which does not support tools and instructions, + // and OnDemandFunctionCalling requires tools. + if (this._searchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke) + { + throw new InvalidOperationException($"Using the {nameof(TextSearchProvider)} as a {nameof(MessageAIContextProvider)} is not supported when {nameof(TextSearchProviderOptions.SearchTime)} is set to {TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling}."); + } + + return base.InvokingCoreAsync(context, cancellationToken); + } + + /// + protected override async ValueTask> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default) + { // Retrieve recent messages from the session state. var recentMessagesText = this._sessionState.GetOrInitializeState(context.Session).RecentMessagesText ?? []; @@ -109,7 +133,7 @@ public sealed class TextSearchProvider : AIContextProvider // Aggregate text from memory + current request messages. var sbInput = new StringBuilder(); var requestMessagesText = - (context.AIContext.Messages ?? []) + (context.RequestMessages ?? []) .Where(x => !string.IsNullOrWhiteSpace(x?.Text)).Select(x => x.Text); foreach (var messageText in recentMessagesText.Concat(requestMessagesText)) { @@ -135,7 +159,7 @@ public sealed class TextSearchProvider : AIContextProvider if (materialized.Count == 0) { - return new AIContext(); + return []; } // Format search results @@ -146,15 +170,12 @@ public sealed class TextSearchProvider : AIContextProvider this._logger.LogTrace("TextSearchProvider: Search Results\nInput:{Input}\nOutput:{MessageText}", input, formatted); } - return new AIContext - { - Messages = [new ChatMessage(ChatRole.User, formatted)] - }; + return [new ChatMessage(ChatRole.User, formatted)]; } catch (Exception ex) { this._logger?.LogError(ex, "TextSearchProvider: Failed to search for data due to error"); - return new AIContext(); + return []; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/MessageAIContextProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/MessageAIContextProviderTests.cs new file mode 100644 index 0000000000..8c11de6b62 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/MessageAIContextProviderTests.cs @@ -0,0 +1,323 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Contains tests for the class. +/// +public class MessageAIContextProviderTests +{ + private static readonly AIAgent s_mockAgent = new Mock().Object; + private static readonly AgentSession s_mockSession = new Mock().Object; + + #region InvokingAsync Tests + + [Fact] + public async Task InvokingAsync_NullContext_ThrowsArgumentNullExceptionAsync() + { + // Arrange + var provider = new TestMessageProvider(); + + // Act & Assert + await Assert.ThrowsAsync(() => provider.InvokingAsync(null!).AsTask()); + } + + [Fact] + public async Task InvokingAsync_ReturnsInputAndProvidedMessagesAsync() + { + // Arrange + var providedMessages = new[] { new ChatMessage(ChatRole.System, "Context message") }; + var provider = new TestMessageProvider(provideMessages: providedMessages); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "User input")]); + + // Act + var result = (await provider.InvokingAsync(context)).ToList(); + + // Assert - input messages + provided messages merged + Assert.Equal(2, result.Count); + Assert.Equal("User input", result[0].Text); + Assert.Equal("Context message", result[1].Text); + } + + [Fact] + public async Task InvokingAsync_ReturnsOnlyInputMessages_WhenNoMessagesProvidedAsync() + { + // Arrange + var provider = new DefaultMessageProvider(); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Hello")]); + + // Act + var result = (await provider.InvokingAsync(context)).ToList(); + + // Assert + Assert.Single(result); + Assert.Equal("Hello", result[0].Text); + } + + [Fact] + public async Task InvokingAsync_StampsProvidedMessagesWithAIContextProviderSourceAsync() + { + // Arrange + var providedMessages = new[] { new ChatMessage(ChatRole.System, "Provided") }; + var provider = new TestMessageProvider(provideMessages: providedMessages); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, s_mockSession, []); + + // Act + var result = (await provider.InvokingAsync(context)).ToList(); + + // Assert + Assert.Single(result); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, result[0].GetAgentRequestMessageSourceType()); + } + + [Fact] + public async Task InvokingAsync_FiltersInputToExternalOnlyByDefaultAsync() + { + // Arrange + var provider = new TestMessageProvider(captureFilteredContext: true); + var externalMsg = new ChatMessage(ChatRole.User, "External"); + var chatHistoryMsg = new ChatMessage(ChatRole.User, "History") + .WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, "src"); + var contextProviderMsg = new ChatMessage(ChatRole.User, "ContextProvider") + .WithAgentRequestMessageSource(AgentRequestMessageSourceType.AIContextProvider, "src"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [externalMsg, chatHistoryMsg, contextProviderMsg]); + + // Act + await provider.InvokingAsync(context); + + // Assert - ProvideMessagesAsync received only External messages + Assert.NotNull(provider.LastFilteredContext); + var filteredMessages = provider.LastFilteredContext!.RequestMessages.ToList(); + Assert.Single(filteredMessages); + Assert.Equal("External", filteredMessages[0].Text); + } + + [Fact] + public async Task InvokingAsync_UsesCustomProvideInputFilterAsync() + { + // Arrange - filter that keeps all messages (not just External) + var provider = new TestMessageProvider( + captureFilteredContext: true, + provideInputMessageFilter: msgs => msgs); + var externalMsg = new ChatMessage(ChatRole.User, "External"); + var chatHistoryMsg = new ChatMessage(ChatRole.User, "History") + .WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, "src"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [externalMsg, chatHistoryMsg]); + + // Act + await provider.InvokingAsync(context); + + // Assert - ProvideMessagesAsync received ALL messages (custom filter keeps everything) + Assert.NotNull(provider.LastFilteredContext); + var filteredMessages = provider.LastFilteredContext!.RequestMessages.ToList(); + Assert.Equal(2, filteredMessages.Count); + } + + [Fact] + public async Task InvokingAsync_MergesWithOriginalUnfilteredMessagesAsync() + { + // Arrange - default filter is External-only, but the MERGED result should include + // the original unfiltered input messages plus the provided messages + var providedMessages = new[] { new ChatMessage(ChatRole.System, "Provided") }; + var provider = new TestMessageProvider(provideMessages: providedMessages); + var externalMsg = new ChatMessage(ChatRole.User, "External"); + var chatHistoryMsg = new ChatMessage(ChatRole.User, "History") + .WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, "src"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [externalMsg, chatHistoryMsg]); + + // Act + var result = (await provider.InvokingAsync(context)).ToList(); + + // Assert - original 2 input messages + 1 provided message + Assert.Equal(3, result.Count); + Assert.Equal("External", result[0].Text); + Assert.Equal("History", result[1].Text); + Assert.Equal("Provided", result[2].Text); + } + + #endregion + + #region ProvideAIContextAsync Tests + + [Fact] + public async Task ProvideAIContextAsync_PreservesInstructionsAndToolsAsync() + { + // Arrange + var providedMessages = new[] { new ChatMessage(ChatRole.System, "Context") }; + var provider = new TestMessageProvider(provideMessages: providedMessages); + var inputTool = AIFunctionFactory.Create(() => "a", "inputTool"); + var inputContext = new AIContext + { + Messages = [new ChatMessage(ChatRole.User, "Hello")], + Instructions = "Be helpful", + Tools = [inputTool] + }; + var context = new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, inputContext); + + // Act + var result = await provider.InvokingAsync(context); + + // Assert - instructions and tools are preserved + Assert.Equal("Be helpful", result.Instructions); + Assert.NotNull(result.Tools); + Assert.Single(result.Tools!); + Assert.Equal("inputTool", result.Tools!.First().Name); + + // Messages include original input + provided messages (with stamping) + var messages = result.Messages!.ToList(); + Assert.Equal(2, messages.Count); + Assert.Equal("Hello", messages[0].Text); + Assert.Equal("Context", messages[1].Text); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, messages[1].GetAgentRequestMessageSourceType()); + } + + [Fact] + public async Task ProvideAIContextAsync_PreservesNullInstructionsAndToolsAsync() + { + // Arrange + var provider = new DefaultMessageProvider(); + var inputContext = new AIContext { Messages = [new ChatMessage(ChatRole.User, "Hello")] }; + var context = new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, inputContext); + + // Act + var result = await provider.InvokingAsync(context); + + // Assert + Assert.Null(result.Instructions); + Assert.Null(result.Tools); + var messages = result.Messages!.ToList(); + Assert.Single(messages); + Assert.Equal("Hello", messages[0].Text); + } + + #endregion + + #region InvokingContext Tests + + [Fact] + public void InvokingContext_Constructor_ThrowsForNullAgent() + { + // Act & Assert + Assert.Throws(() => new MessageAIContextProvider.InvokingContext(null!, s_mockSession, [])); + } + + [Fact] + public void InvokingContext_Constructor_ThrowsForNullRequestMessages() + { + // Act & Assert + Assert.Throws(() => new MessageAIContextProvider.InvokingContext(s_mockAgent, s_mockSession, null!)); + } + + [Fact] + public void InvokingContext_Constructor_AllowsNullSession() + { + // Act + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, null, []); + + // Assert + Assert.Null(context.Session); + } + + [Fact] + public void InvokingContext_Properties_Roundtrip() + { + // Arrange + var messages = new List { new(ChatRole.User, "Hello") }; + + // Act + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, s_mockSession, messages); + + // Assert + Assert.Same(s_mockAgent, context.Agent); + Assert.Same(s_mockSession, context.Session); + Assert.Same(messages, context.RequestMessages); + } + + [Fact] + public void InvokingContext_RequestMessages_SetterThrowsForNull() + { + // Arrange + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, s_mockSession, []); + + // Act & Assert + Assert.Throws(() => context.RequestMessages = null!); + } + + [Fact] + public void InvokingContext_RequestMessages_SetterAcceptsValidValue() + { + // Arrange + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, s_mockSession, []); + var newMessages = new List { new(ChatRole.User, "Updated") }; + + // Act + context.RequestMessages = newMessages; + + // Assert + Assert.Same(newMessages, context.RequestMessages); + } + + #endregion + + #region GetService Tests + + [Fact] + public void GetService_ReturnsProviderForMessageAIContextProviderType() + { + // Arrange + var provider = new TestMessageProvider(); + + // Act & Assert + Assert.Same(provider, provider.GetService(typeof(MessageAIContextProvider))); + Assert.Same(provider, provider.GetService(typeof(AIContextProvider))); + Assert.Same(provider, provider.GetService(typeof(TestMessageProvider))); + } + + #endregion + + #region Test helpers + + private sealed class TestMessageProvider : MessageAIContextProvider + { + private readonly IEnumerable? _provideMessages; + private readonly bool _captureFilteredContext; + + public InvokingContext? LastFilteredContext { get; private set; } + + public TestMessageProvider( + IEnumerable? provideMessages = null, + bool captureFilteredContext = false, + Func, IEnumerable>? provideInputMessageFilter = null, + Func, IEnumerable>? storeInputMessageFilter = null) + : base(provideInputMessageFilter, storeInputMessageFilter) + { + this._provideMessages = provideMessages; + this._captureFilteredContext = captureFilteredContext; + } + + protected override ValueTask> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + if (this._captureFilteredContext) + { + this.LastFilteredContext = context; + } + + return new(this._provideMessages ?? []); + } + } + + /// + /// A provider that uses only base class defaults (no overrides of ProvideMessagesAsync). + /// + private sealed class DefaultMessageProvider : MessageAIContextProvider; + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ProviderSessionStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ProviderSessionStateTests.cs index da3992bf7f..89cf109f7e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ProviderSessionStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ProviderSessionStateTests.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System; + namespace Microsoft.Agents.AI.Abstractions.UnitTests; /// @@ -7,6 +9,56 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests; /// public class ProviderSessionStateTests { + #region Constructor Tests + + [Fact] + public void Constructor_ThrowsForNullStateInitializer() + { + // Act & Assert + Assert.Throws(() => new ProviderSessionState(null!, "test-key")); + } + + [Fact] + public void Constructor_ThrowsForNullStateKey() + { + // Act & Assert + Assert.Throws(() => new ProviderSessionState(_ => new TestState(), null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_ThrowsForEmptyOrWhitespaceStateKey(string stateKey) + { + // Act & Assert + Assert.Throws(() => new ProviderSessionState(_ => new TestState(), stateKey)); + } + + [Fact] + public void Constructor_AcceptsNullJsonSerializerOptions() + { + // Act - should not throw + var sessionState = new ProviderSessionState(_ => new TestState(), "test-key", jsonSerializerOptions: null); + + // Assert - instance is created and functional + Assert.Equal("test-key", sessionState.StateKey); + } + + [Fact] + public void Constructor_AcceptsCustomJsonSerializerOptions() + { + // Arrange + var customOptions = new System.Text.Json.JsonSerializerOptions(); + + // Act - should not throw + var sessionState = new ProviderSessionState(_ => new TestState(), "test-key", customOptions); + + // Assert - instance is created and functional + Assert.Equal("test-key", sessionState.StateKey); + } + + #endregion + #region GetOrInitializeState Tests [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs index 7ad77b7df5..02e18f324e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs @@ -547,6 +547,87 @@ public sealed class Mem0ProviderTests : IDisposable Assert.Equal(2, memoryPosts.Count); } + #region MessageAIContextProvider.InvokingAsync Tests + + [Fact] + public async Task MessageInvokingAsync_SearchesAndReturnsMergedMessagesAsync() + { + // Arrange + this._handler.EnqueueJsonResponse("[ { \"id\": \"1\", \"memory\": \"Name is Caoimhe\", \"hash\": \"h\", \"metadata\": null, \"score\": 0.9, \"created_at\": \"2023-01-01T00:00:00Z\", \"updated_at\": null, \"user_id\": \"u\", \"app_id\": null, \"agent_id\": \"agent\", \"thread_id\": \"session\" } ]"); + var storageScope = new Mem0ProviderScope + { + ApplicationId = "app", + AgentId = "agent", + ThreadId = "session", + UserId = "user" + }; + var mockSession = new TestAgentSession(); + var sut = new Mem0Provider(this._httpClient, _ => new Mem0Provider.State(storageScope)); + + var inputMsg = new ChatMessage(ChatRole.User, "What is my name?"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, mockSession, [inputMsg]); + + // Act + var messages = (await sut.InvokingAsync(context)).ToList(); + + // Assert - input message + memory message, with stamping + Assert.Equal(2, messages.Count); + Assert.Equal("What is my name?", messages[0].Text); + Assert.Contains("Name is Caoimhe", messages[1].Text); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, messages[1].GetAgentRequestMessageSourceType()); + } + + [Fact] + public async Task MessageInvokingAsync_NoMemories_ReturnsOnlyInputMessagesAsync() + { + // Arrange + this._handler.EnqueueJsonResponse("[]"); + var storageScope = new Mem0ProviderScope + { + UserId = "user" + }; + var mockSession = new TestAgentSession(); + var sut = new Mem0Provider(this._httpClient, _ => new Mem0Provider.State(storageScope)); + + var inputMsg = new ChatMessage(ChatRole.User, "Hello"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, mockSession, [inputMsg]); + + // Act + var messages = (await sut.InvokingAsync(context)).ToList(); + + // Assert + Assert.Single(messages); + Assert.Equal("Hello", messages[0].Text); + } + + [Fact] + public async Task MessageInvokingAsync_DefaultFilter_ExcludesNonExternalMessagesAsync() + { + // Arrange + this._handler.EnqueueJsonResponse("[]"); + var storageScope = new Mem0ProviderScope + { + UserId = "user" + }; + var mockSession = new TestAgentSession(); + var sut = new Mem0Provider(this._httpClient, _ => new Mem0Provider.State(storageScope)); + + var externalMsg = new ChatMessage(ChatRole.User, "External question"); + var historyMsg = new ChatMessage(ChatRole.User, "History message") + .WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, "src"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, mockSession, [externalMsg, historyMsg]); + + // Act + await sut.InvokingAsync(context); + + // Assert - Only External message used for search query + var searchRequest = Assert.Single(this._handler.Requests, r => r.RequestMessage.Method == HttpMethod.Post && ContainsOrdinal(r.RequestMessage.RequestUri!.AbsoluteUri, "/v1/memories/search/")); + using JsonDocument doc = JsonDocument.Parse(searchRequest.RequestBody); + Assert.Equal("External question", doc.RootElement.GetProperty("query").GetString()); + } + + #endregion + private static bool ContainsOrdinal(string source, string value) => source.IndexOf(value, StringComparison.Ordinal) >= 0; public void Dispose() diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs index 602fe40e08..46c56fc483 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs @@ -743,7 +743,7 @@ public sealed class TextSearchProviderTests SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 4 }); - await newProvider.InvokingAsync(new(s_mockAgent, restoredSession, new AIContext()), CancellationToken.None); // Trigger search to read memory. + await newProvider.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, restoredSession, new AIContext()), CancellationToken.None); // Trigger search to read memory. // Assert Assert.NotNull(capturedInput); @@ -769,7 +769,7 @@ public sealed class TextSearchProviderTests SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 3 }); - await provider.InvokingAsync(new(s_mockAgent, session, new AIContext()), CancellationToken.None); + await provider.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, session, new AIContext()), CancellationToken.None); // Assert Assert.NotNull(capturedInput); @@ -778,6 +778,101 @@ public sealed class TextSearchProviderTests #endregion + #region MessageAIContextProvider.InvokingAsync Tests + + [Fact] + public async Task MessageInvokingAsync_BeforeAIInvoke_SearchesAndReturnsMergedMessagesAsync() + { + // Arrange + List results = + [ + new() { SourceName = "Doc1", Text = "Content of Doc1" } + ]; + + Task> SearchDelegateAsync(string input, CancellationToken ct) + => Task.FromResult>(results); + + var provider = new TextSearchProvider(SearchDelegateAsync, new TextSearchProviderOptions + { + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke + }); + + var inputMsg = new ChatMessage(ChatRole.User, "Question?"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [inputMsg]); + + // Act + var messages = (await provider.InvokingAsync(context)).ToList(); + + // Assert - input message + search result message, with stamping + Assert.Equal(2, messages.Count); + Assert.Equal("Question?", messages[0].Text); + Assert.Contains("Content of Doc1", messages[1].Text); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, messages[1].GetAgentRequestMessageSourceType()); + } + + [Fact] + public async Task MessageInvokingAsync_OnDemand_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var provider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions + { + SearchTime = TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling, + }); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [new ChatMessage(ChatRole.User, "Q?")]); + + // Act & Assert + await Assert.ThrowsAsync(() => provider.InvokingAsync(context).AsTask()); + } + + [Fact] + public async Task MessageInvokingAsync_BeforeAIInvoke_NoResults_ReturnsOnlyInputMessagesAsync() + { + // Arrange + var provider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions + { + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke + }); + var inputMsg = new ChatMessage(ChatRole.User, "Hello"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [inputMsg]); + + // Act + var messages = (await provider.InvokingAsync(context)).ToList(); + + // Assert + Assert.Single(messages); + Assert.Equal("Hello", messages[0].Text); + } + + [Fact] + public async Task MessageInvokingAsync_BeforeAIInvoke_DefaultFilter_ExcludesNonExternalMessagesAsync() + { + // Arrange + string? capturedInput = null; + Task> SearchDelegateAsync(string input, CancellationToken ct) + { + capturedInput = input; + return Task.FromResult>([]); + } + + var provider = new TextSearchProvider(SearchDelegateAsync, new TextSearchProviderOptions + { + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke + }); + + var externalMsg = new ChatMessage(ChatRole.User, "External message"); + var historyMsg = new ChatMessage(ChatRole.System, "From history") + .WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, "src"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [externalMsg, historyMsg]); + + // Act + await provider.InvokingAsync(context); + + // Assert - Only External message used for search query + Assert.Equal("External message", capturedInput); + } + + #endregion + private Task> NoResultSearchAsync(string input, CancellationToken ct) { return Task.FromResult>([]); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs index be73260b15..ff5d709202 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs @@ -710,6 +710,147 @@ public class ChatHistoryMemoryProviderTests #endregion + #region MessageAIContextProvider.InvokingAsync Tests + + [Fact] + public async Task MessageInvokingAsync_BeforeAIInvoke_SearchesAndReturnsMergedMessagesAsync() + { + // Arrange + var storedItems = new List>> + { + new( + new Dictionary + { + ["MessageId"] = "msg-1", + ["Content"] = "Previous message", + ["Role"] = ChatRole.User.ToString(), + ["CreatedAt"] = "2023-01-01T00:00:00.0000000+00:00" + }, + 0.9f) + }; + + this._vectorStoreCollectionMock + .Setup(c => c.SearchAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(storedItems)); + + var provider = new ChatHistoryMemoryProvider( + this._vectorStoreMock.Object, + TestCollectionName, + 1, + _ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }), + options: new ChatHistoryMemoryProviderOptions + { + SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke + }); + + var inputMsg = new ChatMessage(ChatRole.User, "What was discussed?"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [inputMsg]); + + // Act + var messages = (await provider.InvokingAsync(context)).ToList(); + + // Assert - input message + search result message, with stamping + Assert.Equal(2, messages.Count); + Assert.Equal("What was discussed?", messages[0].Text); + Assert.Contains("Previous message", messages[1].Text); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, messages[1].GetAgentRequestMessageSourceType()); + } + + [Fact] + public async Task MessageInvokingAsync_OnDemand_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var provider = new ChatHistoryMemoryProvider( + this._vectorStoreMock.Object, + TestCollectionName, + 1, + _ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }), + options: new ChatHistoryMemoryProviderOptions + { + SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling + }); + + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [new ChatMessage(ChatRole.User, "Q?")]); + + // Act & Assert + await Assert.ThrowsAsync(() => provider.InvokingAsync(context).AsTask()); + } + + [Fact] + public async Task MessageInvokingAsync_BeforeAIInvoke_NoResults_ReturnsOnlyInputMessagesAsync() + { + // Arrange + this._vectorStoreCollectionMock + .Setup(c => c.SearchAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(new List>>())); + + var provider = new ChatHistoryMemoryProvider( + this._vectorStoreMock.Object, + TestCollectionName, + 1, + _ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }), + options: new ChatHistoryMemoryProviderOptions + { + SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke + }); + + var inputMsg = new ChatMessage(ChatRole.User, "Hello"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [inputMsg]); + + // Act + var messages = (await provider.InvokingAsync(context)).ToList(); + + // Assert + Assert.Single(messages); + Assert.Equal("Hello", messages[0].Text); + } + + [Fact] + public async Task MessageInvokingAsync_BeforeAIInvoke_DefaultFilter_ExcludesNonExternalMessagesAsync() + { + // Arrange + string? capturedQuery = null; + this._vectorStoreCollectionMock + .Setup(c => c.SearchAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Callback>, CancellationToken>((query, _, _, _) => capturedQuery = query) + .Returns(ToAsyncEnumerableAsync(new List>>())); + + var provider = new ChatHistoryMemoryProvider( + this._vectorStoreMock.Object, + TestCollectionName, + 1, + _ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }), + options: new ChatHistoryMemoryProviderOptions + { + SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke + }); + + var externalMsg = new ChatMessage(ChatRole.User, "External message"); + var historyMsg = new ChatMessage(ChatRole.System, "From history") + .WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, "src"); + var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [externalMsg, historyMsg]); + + // Act + await provider.InvokingAsync(context); + + // Assert - Only External message used for search query + Assert.Equal("External message", capturedQuery); + } + + #endregion + private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable values) { await Task.Yield(); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/MessageAIContextProviderAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/MessageAIContextProviderAgentTests.cs new file mode 100644 index 0000000000..ed2f82321d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/MessageAIContextProviderAgentTests.cs @@ -0,0 +1,469 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class and +/// the builder extension. +/// +public class MessageAIContextProviderAgentTests +{ + private static readonly AgentSession s_mockSession = new Mock().Object; + + #region Constructor Tests + + [Fact] + public void Constructor_NullInnerAgent_ThrowsArgumentNullException() + { + // Arrange + var provider = new TestProvider(); + + // Act & Assert + Assert.Throws(() => new MessageAIContextProviderAgent(null!, [provider])); + } + + [Fact] + public void Constructor_NullProviders_ThrowsArgumentNullException() + { + // Arrange + var agent = CreateTestAgent(); + + // Act & Assert + Assert.Throws(() => new MessageAIContextProviderAgent(agent, null!)); + } + + [Fact] + public void Constructor_EmptyProviders_ThrowsArgumentOutOfRangeException() + { + // Arrange + var agent = CreateTestAgent(); + + // Act & Assert + Assert.Throws(() => new MessageAIContextProviderAgent(agent, [])); + } + + #endregion + + #region RunAsync Tests + + [Fact] + public async Task RunAsync_SingleProvider_EnrichesMessagesAndDelegatesToInnerAgentAsync() + { + // Arrange + var contextMessage = new ChatMessage(ChatRole.System, "Extra context"); + var provider = new TestProvider(provideMessages: [contextMessage]); + + IEnumerable? capturedMessages = null; + var innerAgent = CreateTestAgent( + runFunc: (messages, _, _, _) => + { + capturedMessages = messages; + return Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")])); + }); + + var agent = new MessageAIContextProviderAgent(innerAgent, [provider]); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + + // Assert - inner agent received enriched messages (input + provider's message) + Assert.NotNull(capturedMessages); + var messageList = capturedMessages!.ToList(); + Assert.Equal(2, messageList.Count); + Assert.Equal("Hello", messageList[0].Text); + Assert.Contains("Extra context", messageList[1].Text); + } + + [Fact] + public async Task RunAsync_MultipleProviders_CalledInSequenceAsync() + { + // Arrange + var provider1 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "From provider 1")]); + var provider2 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "From provider 2")]); + + IEnumerable? capturedMessages = null; + var innerAgent = CreateTestAgent( + runFunc: (messages, _, _, _) => + { + capturedMessages = messages; + return Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")])); + }); + + var agent = new MessageAIContextProviderAgent(innerAgent, [provider1, provider2]); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + + // Assert - inner agent received messages from both providers in sequence + Assert.NotNull(capturedMessages); + var messageList = capturedMessages!.ToList(); + Assert.Equal(3, messageList.Count); + Assert.Equal("Hello", messageList[0].Text); + Assert.Contains("From provider 1", messageList[1].Text); + Assert.Contains("From provider 2", messageList[2].Text); + } + + [Fact] + public async Task RunAsync_SequentialProviders_EachReceivesPreviousOutputAsync() + { + // Arrange - provider 2 captures the filtered messages it receives in ProvideMessagesAsync. + // The default filter only includes External messages, so provider 1's stamped messages + // (marked as AIContextProvider) are filtered out before reaching provider 2's ProvideMessagesAsync. + // However, the full unfiltered output from provider 1 is passed to provider 2's InvokingAsync, + // and the inner agent receives the full merged output from both providers. + IEnumerable? provider2ReceivedMessages = null; + var provider1 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "From provider 1")]); + var provider2 = new TestProvider( + provideMessages: [new ChatMessage(ChatRole.System, "From provider 2")], + onInvoking: messages => provider2ReceivedMessages = messages.ToList()); + + var innerAgent = CreateTestAgent( + runFunc: (_, _, _, _) => Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")]))); + + var agent = new MessageAIContextProviderAgent(innerAgent, [provider1, provider2]); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + + // Assert - provider 2's ProvideMessagesAsync received only External messages (filtered) + Assert.NotNull(provider2ReceivedMessages); + var received = provider2ReceivedMessages!.ToList(); + Assert.Single(received); + Assert.Equal("Hello", received[0].Text); + } + + [Fact] + public async Task RunAsync_OnSuccess_InvokedAsyncCalledOnAllProvidersAsync() + { + // Arrange + var provider1 = new TestProvider(); + var provider2 = new TestProvider(); + var innerAgent = CreateTestAgent( + runFunc: (_, _, _, _) => Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")]))); + + var agent = new MessageAIContextProviderAgent(innerAgent, [provider1, provider2]); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + + // Assert + Assert.True(provider1.InvokedAsyncCalled); + Assert.True(provider2.InvokedAsyncCalled); + Assert.Null(provider1.LastInvokedContext!.InvokeException); + Assert.Null(provider2.LastInvokedContext!.InvokeException); + } + + [Fact] + public async Task RunAsync_OnFailure_InvokedAsyncCalledWithExceptionAsync() + { + // Arrange + var provider = new TestProvider(); + var expectedException = new InvalidOperationException("Agent failed"); + var innerAgent = CreateTestAgent( + runFunc: (_, _, _, _) => throw expectedException); + + var agent = new MessageAIContextProviderAgent(innerAgent, [provider]); + + // Act & Assert + await Assert.ThrowsAsync(() => + agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession)); + + Assert.True(provider.InvokedAsyncCalled); + Assert.Same(expectedException, provider.LastInvokedContext!.InvokeException); + } + + [Fact] + public async Task RunAsync_OnSuccess_InvokedContextContainsResponseMessagesAsync() + { + // Arrange + var provider = new TestProvider(); + var responseMessage = new ChatMessage(ChatRole.Assistant, "Response text"); + var innerAgent = CreateTestAgent( + runFunc: (_, _, _, _) => Task.FromResult(new AgentResponse([responseMessage]))); + + var agent = new MessageAIContextProviderAgent(innerAgent, [provider]); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + + // Assert + Assert.NotNull(provider.LastInvokedContext?.ResponseMessages); + Assert.Contains(provider.LastInvokedContext!.ResponseMessages!, m => m.Text == "Response text"); + } + + #endregion + + #region RunStreamingAsync Tests + + [Fact] + public async Task RunStreamingAsync_SingleProvider_EnrichesMessagesAndStreamsAsync() + { + // Arrange + var contextMessage = new ChatMessage(ChatRole.System, "Extra context"); + var provider = new TestProvider(provideMessages: [contextMessage]); + + IEnumerable? capturedMessages = null; + var innerAgent = CreateTestAgent( + runStreamingFunc: (messages, _, _, _) => + { + capturedMessages = messages; + return ToAsyncEnumerableAsync( + new AgentResponseUpdate(ChatRole.Assistant, "Part1"), + new AgentResponseUpdate(ChatRole.Assistant, "Part2")); + }); + + var agent = new MessageAIContextProviderAgent(innerAgent, [provider]); + + // Act + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession)) + { + updates.Add(update); + } + + // Assert - streaming updates received + Assert.Equal(2, updates.Count); + // Assert - inner agent received enriched messages + Assert.NotNull(capturedMessages); + var messageList = capturedMessages!.ToList(); + Assert.Equal(2, messageList.Count); + } + + [Fact] + public async Task RunStreamingAsync_OnSuccess_InvokedAsyncCalledAfterAllUpdatesAsync() + { + // Arrange + var provider = new TestProvider(); + var innerAgent = CreateTestAgent( + runStreamingFunc: (_, _, _, _) => ToAsyncEnumerableAsync( + new AgentResponseUpdate(ChatRole.Assistant, "Response"))); + + var agent = new MessageAIContextProviderAgent(innerAgent, [provider]); + + // Act - consume all updates + await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession)) + { + } + + // Assert + Assert.True(provider.InvokedAsyncCalled); + Assert.Null(provider.LastInvokedContext!.InvokeException); + } + + [Fact] + public async Task RunStreamingAsync_OnSuccess_InvokedContextContainsAccumulatedResponseAsync() + { + // Arrange + var provider = new TestProvider(); + var innerAgent = CreateTestAgent( + runStreamingFunc: (_, _, _, _) => ToAsyncEnumerableAsync( + new AgentResponseUpdate(ChatRole.Assistant, "Hello "), + new AgentResponseUpdate(ChatRole.Assistant, "World"))); + + var agent = new MessageAIContextProviderAgent(innerAgent, [provider]); + + // Act - consume all updates + await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession)) + { + } + + // Assert - InvokedAsync received the accumulated response messages + Assert.NotNull(provider.LastInvokedContext?.ResponseMessages); + var responseMessages = provider.LastInvokedContext!.ResponseMessages!.ToList(); + Assert.True(responseMessages.Count > 0); + } + + [Fact] + public async Task RunStreamingAsync_OnFailure_InvokedAsyncCalledWithExceptionAsync() + { + // Arrange + var provider = new TestProvider(); + var expectedException = new InvalidOperationException("Stream failed"); + var innerAgent = CreateTestAgent( + runStreamingFunc: (_, _, _, _) => throw expectedException); + + var agent = new MessageAIContextProviderAgent(innerAgent, [provider]); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession)) + { + } + }); + + Assert.True(provider.InvokedAsyncCalled); + Assert.Same(expectedException, provider.LastInvokedContext!.InvokeException); + } + + [Fact] + public async Task RunStreamingAsync_MultipleProviders_CalledInSequenceAsync() + { + // Arrange + var provider1 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "From provider 1")]); + var provider2 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "From provider 2")]); + + IEnumerable? capturedMessages = null; + var innerAgent = CreateTestAgent( + runStreamingFunc: (messages, _, _, _) => + { + capturedMessages = messages; + return ToAsyncEnumerableAsync(new AgentResponseUpdate(ChatRole.Assistant, "Response")); + }); + + var agent = new MessageAIContextProviderAgent(innerAgent, [provider1, provider2]); + + // Act + await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession)) + { + } + + // Assert + Assert.NotNull(capturedMessages); + var messageList = capturedMessages!.ToList(); + Assert.Equal(3, messageList.Count); + Assert.Equal("Hello", messageList[0].Text); + Assert.Contains("From provider 1", messageList[1].Text); + Assert.Contains("From provider 2", messageList[2].Text); + } + + #endregion + + #region Builder Extension Tests + + [Fact] + public async Task UseExtension_CreatesWorkingPipelineAsync() + { + // Arrange + var contextMessage = new ChatMessage(ChatRole.System, "Pipeline context"); + var provider = new TestProvider(provideMessages: [contextMessage]); + + IEnumerable? capturedMessages = null; + var innerAgent = CreateTestAgent( + runFunc: (messages, _, _, _) => + { + capturedMessages = messages; + return Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")])); + }); + + var pipeline = new AIAgentBuilder(innerAgent) + .Use([provider]) + .Build(); + + // Act + await pipeline.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + + // Assert + Assert.NotNull(capturedMessages); + var messageList = capturedMessages!.ToList(); + Assert.Equal(2, messageList.Count); + Assert.Equal("Hello", messageList[0].Text); + Assert.Contains("Pipeline context", messageList[1].Text); + } + + [Fact] + public async Task UseExtension_MultipleProviders_AllAppliedAsync() + { + // Arrange + var provider1 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "P1")]); + var provider2 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "P2")]); + + IEnumerable? capturedMessages = null; + var innerAgent = CreateTestAgent( + runFunc: (messages, _, _, _) => + { + capturedMessages = messages; + return Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")])); + }); + + var pipeline = new AIAgentBuilder(innerAgent) + .Use([provider1, provider2]) + .Build(); + + // Act + await pipeline.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + + // Assert + Assert.NotNull(capturedMessages); + var messageList = capturedMessages!.ToList(); + Assert.Equal(3, messageList.Count); + } + + #endregion + + #region Helpers + + private static TestAIAgent CreateTestAgent( + Func, AgentSession?, AgentRunOptions?, CancellationToken, Task>? runFunc = null, + Func, AgentSession?, AgentRunOptions?, CancellationToken, IAsyncEnumerable>? runStreamingFunc = null) + { + var agent = new TestAIAgent(); + if (runFunc is not null) + { + agent.RunAsyncFunc = runFunc; + } + + if (runStreamingFunc is not null) + { + agent.RunStreamingAsyncFunc = runStreamingFunc; + } + + return agent; + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(params AgentResponseUpdate[] updates) + { + foreach (var update in updates) + { + yield return update; + } + + await Task.CompletedTask; + } + + /// + /// A test implementation of that records invocation calls. + /// + private sealed class TestProvider : MessageAIContextProvider + { + private readonly IEnumerable _provideMessages; + private readonly Action>? _onInvoking; + + public bool InvokedAsyncCalled { get; private set; } + + public InvokedContext? LastInvokedContext { get; private set; } + + public TestProvider( + IEnumerable? provideMessages = null, + Action>? onInvoking = null) + { + this._provideMessages = provideMessages ?? []; + this._onInvoking = onInvoking; + } + + protected override ValueTask> ProvideMessagesAsync( + InvokingContext context, + CancellationToken cancellationToken = default) + { + this._onInvoking?.Invoke(context.RequestMessages); + return new ValueTask>(this._provideMessages); + } + + protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + this.InvokedAsyncCalled = true; + this.LastInvokedContext = context; + return default; + } + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 7056bb172c..ba969d95c7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -274,6 +274,257 @@ public class AgentWorkflowBuilderTests Assert.Contains("nextAgent", result[3].AuthorName); } + [Fact] + public async Task Handoffs_OneTransfer_HandoffTargetDoesNotReceiveHandoffFunctionMessagesAsync() + { + // Regression test for https://github.com/microsoft/agent-framework/issues/3161 + // When a handoff occurs, the target agent should receive the original user message + // but should NOT receive the handoff function call or tool result messages from the + // source agent, as these confuse the target LLM into ignoring the user's question. + + List? capturedNextAgentMessages = null; + + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedNextAgentMessages = messages.ToList(); + return new(new ChatMessage(ChatRole.Assistant, "The derivative of x^2 is 2x.")); + }), + name: "nextAgent", + description: "The second agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, nextAgent) + .Build(); + + _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "What is the derivative of x^2?")]); + + Assert.NotNull(capturedNextAgentMessages); + + // The target agent should see the original user message + Assert.Contains(capturedNextAgentMessages, m => m.Role == ChatRole.User && m.Text == "What is the derivative of x^2?"); + + // The target agent should NOT see the handoff function call or tool result from the source agent + Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); + Assert.DoesNotContain(capturedNextAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent frc && frc.Result?.ToString() == "Transferred.")); + } + + [Fact] + public async Task Handoffs_TwoTransfers_HandoffTargetsDoNotReceiveHandoffFunctionMessagesAsync() + { + // Regression test for https://github.com/microsoft/agent-framework/issues/3161 + // With two hops (initial -> second -> third), each target agent should receive the + // original user message and text responses from prior agents (as User role), but + // NOT any handoff function call or tool result messages. + + List? capturedSecondAgentMessages = null; + List? capturedThirdAgentMessages = null; + + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Return both a text message and a handoff function call + return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to second agent"), new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedSecondAgentMessages = messages.ToList(); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Return both a text message and a handoff function call + return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to third agent"), new FunctionCallContent("call2", transferFuncName)])); + }), name: "secondAgent", description: "The second agent"); + + var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedThirdAgentMessages = messages.ToList(); + return new(new ChatMessage(ChatRole.Assistant, "Hello from agent3")); + }), + name: "thirdAgent", + description: "The third / final agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + (string updateText, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + + Assert.Contains("Hello from agent3", updateText); + + // Second agent should see the original user message and initialAgent's text as context + Assert.NotNull(capturedSecondAgentMessages); + Assert.Contains(capturedSecondAgentMessages, m => m.Text == "abc"); + Assert.Contains(capturedSecondAgentMessages, m => m.Text!.Contains("Routing to second agent")); + Assert.DoesNotContain(capturedSecondAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); + Assert.DoesNotContain(capturedSecondAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent)); + + // Third agent should see the original user message and both prior agents' text as context + Assert.NotNull(capturedThirdAgentMessages); + Assert.Contains(capturedThirdAgentMessages, m => m.Text == "abc"); + Assert.Contains(capturedThirdAgentMessages, m => m.Text!.Contains("Routing to second agent")); + Assert.Contains(capturedThirdAgentMessages, m => m.Text!.Contains("Routing to third agent")); + Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); + Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent)); + } + + [Fact] + public async Task Handoffs_FilteringNone_HandoffTargetReceivesAllMessagesIncludingToolCallsAsync() + { + // With filtering set to None, the target agent should see everything including + // handoff function calls and tool results. + + List? capturedNextAgentMessages = null; + + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedNextAgentMessages = messages.ToList(); + return new(new ChatMessage(ChatRole.Assistant, "response")); + }), + name: "nextAgent", + description: "The second agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, nextAgent) + .WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.None) + .Build(); + + _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]); + + Assert.NotNull(capturedNextAgentMessages); + Assert.Contains(capturedNextAgentMessages, m => m.Text == "hello"); + + // With None filtering, handoff function calls and tool results should be visible + Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); + Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionResultContent)); + } + + [Fact] + public async Task Handoffs_FilteringAll_HandoffTargetDoesNotReceiveAnyToolCallsAsync() + { + // With filtering set to All, the target agent should see no function calls or tool + // results at all — not even non-handoff ones from prior conversation history. + + List? capturedNextAgentMessages = null; + + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing you now"), new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedNextAgentMessages = messages.ToList(); + return new(new ChatMessage(ChatRole.Assistant, "response")); + }), + name: "nextAgent", + description: "The second agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, nextAgent) + .WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.All) + .Build(); + + // Input includes a pre-existing non-handoff tool call in the conversation history + List input = + [ + new(ChatRole.User, "What's the weather? Also help me with math."), + new(ChatRole.Assistant, [new FunctionCallContent("toolcall1", "get_weather")]) { AuthorName = "initialAgent" }, + new(ChatRole.Tool, [new FunctionResultContent("toolcall1", "sunny")]), + new(ChatRole.Assistant, "The weather is sunny. Now let me route your math question.") { AuthorName = "initialAgent" }, + ]; + + _ = await RunWorkflowAsync(workflow, input); + + Assert.NotNull(capturedNextAgentMessages); + + // With All filtering, NO function calls or tool results should be visible + Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent)); + Assert.DoesNotContain(capturedNextAgentMessages, m => m.Role == ChatRole.Tool); + + // But text content should still be visible + Assert.Contains(capturedNextAgentMessages, m => m.Text!.Contains("What's the weather")); + Assert.Contains(capturedNextAgentMessages, m => m.Text!.Contains("Routing you now")); + } + + [Fact] + public async Task Handoffs_FilteringHandoffOnly_PreservesNonHandoffToolCallsAsync() + { + // With HandoffOnly filtering (the default), non-handoff function calls and tool + // results should be preserved while handoff ones are stripped. + + List? capturedNextAgentMessages = null; + + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedNextAgentMessages = messages.ToList(); + return new(new ChatMessage(ChatRole.Assistant, "response")); + }), + name: "nextAgent", + description: "The second agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, nextAgent) + .WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.HandoffOnly) + .Build(); + + // Input includes a pre-existing non-handoff tool call in the conversation history + List input = + [ + new(ChatRole.User, "What's the weather? Also help me with math."), + new(ChatRole.Assistant, [new FunctionCallContent("toolcall1", "get_weather")]) { AuthorName = "initialAgent" }, + new(ChatRole.Tool, [new FunctionResultContent("toolcall1", "sunny")]), + new(ChatRole.Assistant, "The weather is sunny. Now let me route your math question.") { AuthorName = "initialAgent" }, + ]; + + _ = await RunWorkflowAsync(workflow, input); + + Assert.NotNull(capturedNextAgentMessages); + + // Handoff function calls and their tool results should be filtered + Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); + + // Non-handoff function calls and their tool results should be preserved + Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "get_weather")); + Assert.Contains(capturedNextAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "toolcall1")); + } + [Fact] public async Task Handoffs_TwoTransfers_ResponseServedByThirdAgentAsync() { diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 4d0b5789e4..64b52c0a12 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1761,10 +1761,26 @@ def _get_result_hooks_from_stream(stream: Any) -> list[Callable[[Any], Any]]: def _extract_function_calls(response: ChatResponse) -> list[Content]: - function_results = {it.call_id for it in response.messages[0].contents if it.type == "function_result"} - return [ - it for it in response.messages[0].contents if it.type == "function_call" and it.call_id not in function_results - ] + function_results = { + item.call_id + for message in response.messages + for item in message.contents + if item.type == "function_result" and item.call_id + } + seen_call_ids: set[str] = set() + function_calls: list[Content] = [] + for message in response.messages: + for item in message.contents: + if item.type != "function_call": + continue + if item.call_id and item.call_id in function_results: + continue + if item.call_id and item.call_id in seen_call_ids: + continue + if item.call_id: + seen_call_ids.add(item.call_id) + function_calls.append(item) + return function_calls def _prepend_fcc_messages(response: ChatResponse, fcc_messages: list[Message]) -> None: @@ -1822,27 +1838,22 @@ def _handle_function_call_results( if had_errors: errors_in_a_row += 1 - if errors_in_a_row >= max_errors: + reached_error_limit = errors_in_a_row >= max_errors + if reached_error_limit: logger.warning( "Maximum consecutive function call errors reached (%d). " "Stopping further function calls for this request.", max_errors, ) - return { - "action": "stop", - "errors_in_a_row": errors_in_a_row, - "result_message": None, - "update_role": None, - "function_call_results": None, - } else: errors_in_a_row = 0 + reached_error_limit = False result_message = Message(role="tool", contents=function_call_results) response.messages.append(result_message) fcc_messages.extend(response.messages) return { - "action": "continue", + "action": "stop" if reached_error_limit else "continue", "errors_in_a_row": errors_in_a_row, "result_message": result_message, "update_role": "tool", @@ -2025,6 +2036,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): middleware_pipeline=function_middleware_pipeline, ) filtered_kwargs = {k: v for k, v in kwargs.items() if k != "session"} + # Make options mutable so we can update conversation_id during function invocation loop mutable_options: dict[str, Any] = dict(options) if options else {} # Remove additional_function_arguments from options passed to underlying chat client @@ -2090,7 +2102,9 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): if result["action"] == "return": return response if result["action"] == "stop": - break + # Error threshold reached: force a final non-tool turn so + # function_call_output items are submitted before exit. + mutable_options["tool_choice"] = "none" errors_in_a_row = result["errors_in_a_row"] # When tool_choice is 'required', reset tool_choice after one iteration to avoid infinite loops @@ -2157,6 +2171,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ) errors_in_a_row = approval_result["errors_in_a_row"] if approval_result["action"] == "stop": + mutable_options["tool_choice"] = "none" return inner_stream = await _ensure_response_stream( @@ -2205,7 +2220,11 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): contents=result["function_call_results"] or [], role=role, ) - if result["action"] != "continue": + if result["action"] == "stop": + # Error threshold reached: submit collected function_call_output + # items once more with tools disabled. + mutable_options["tool_choice"] = "none" + elif result["action"] != "continue": return # When tool_choice is 'required', reset the tool_choice after one iteration to avoid infinite loops diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 8446e3ec53..a699a30f5f 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -531,6 +531,7 @@ class Content: def from_text_reasoning( cls: type[ContentT], *, + id: str | None = None, text: str | None = None, protected_data: str | None = None, annotations: Sequence[Annotation] | None = None, @@ -540,6 +541,7 @@ class Content: """Create text reasoning content.""" return cls( "text_reasoning", + id=id, text=text, protected_data=protected_data, annotations=annotations, diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index d78ee1cfbc..257833bb6a 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -144,10 +144,10 @@ class AgentExecutor(Executor): immediately run the agent to produce a new response. """ # Replace cache with full conversation if available, else fall back to agent_response messages. - if prior.full_conversation is not None: - self._cache = list(prior.full_conversation) - else: - self._cache = list(prior.agent_response.messages) + source_messages = ( + prior.full_conversation if prior.full_conversation is not None else prior.agent_response.messages + ) + self._cache = list(source_messages) await self._run_agent_and_emit(ctx) @handler @@ -311,7 +311,7 @@ class AgentExecutor(Executor): # Snapshot current conversation as cache + latest agent outputs. # Do not append to prior snapshots: callers may provide full-history messages # in request.messages, and extending would duplicate prior turns. - self._full_conversation = list(self._cache) + (list(response.messages) if response else []) + self._full_conversation = [*self._cache, *(list(response.messages) if response else [])] if response is None: # Agent did not complete (e.g., waiting for user input); do not emit response diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 694c918233..fa140ee0b7 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -908,11 +908,16 @@ class RawOpenAIResponsesClient( # type: ignore[misc] "type": "message", "role": message.role, } + # Reasoning items are only valid in input when they directly preceded a function_call + # in the same response. Including a reasoning item that preceded a text response + # (i.e. no function_call in the same message) causes an API error: + # "reasoning was provided without its required following item." + has_function_call = any(c.type == "function_call" for c in message.contents) for content in message.contents: match content.type: case "text_reasoning": - # Reasoning items must be sent back as top-level input items - # for reasoning models that require them alongside function_calls + if not has_function_call: + continue # reasoning not followed by a function_call is invalid in input reasoning = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore[arg-type] if reasoning: all_messages.append(reasoning) @@ -961,26 +966,19 @@ class RawOpenAIResponsesClient( # type: ignore[misc] "text": content.text, } case "text_reasoning": - ret: dict[str, Any] = { - "type": "reasoning", - "summary": { - "type": "summary_text", - "text": content.text, - }, - } + ret: dict[str, Any] = {"type": "reasoning", "summary": []} + if content.id: + ret["id"] = content.id props: dict[str, Any] | None = getattr(content, "additional_properties", None) if props: - if reasoning_id := props.get("reasoning_id"): - ret["id"] = reasoning_id if status := props.get("status"): ret["status"] = status if reasoning_text := props.get("reasoning_text"): - ret["content"] = { - "type": "reasoning_text", - "text": reasoning_text, - } + ret["content"] = [{"type": "reasoning_text", "text": reasoning_text}] if encrypted_content := props.get("encrypted_content"): ret["encrypted_content"] = encrypted_content + if content.text: + ret["summary"].append({"type": "summary_text", "text": content.text}) return ret case "data" | "uri": if content.has_top_level_media_type("image"): @@ -1189,30 +1187,45 @@ class RawOpenAIResponsesClient( # type: ignore[misc] ) ) case "reasoning": # ResponseOutputReasoning - reasoning_id = getattr(item, "id", None) - if hasattr(item, "content") and item.content: - for index, reasoning_content in enumerate(item.content): + added_reasoning = False + if item_content := getattr(item, "content", None): + for index, reasoning_content in enumerate(item_content): additional_properties: dict[str, Any] = {} - if reasoning_id: - additional_properties["reasoning_id"] = reasoning_id if hasattr(item, "summary") and item.summary and index < len(item.summary): additional_properties["summary"] = item.summary[index] contents.append( Content.from_text_reasoning( + id=item.id, text=reasoning_content.text, raw_representation=reasoning_content, additional_properties=additional_properties or None, ) ) - if hasattr(item, "summary") and item.summary: - for summary in item.summary: + added_reasoning = True + if item_summary := getattr(item, "summary", None): + for summary in item_summary: contents.append( Content.from_text_reasoning( + id=item.id, text=summary.text, raw_representation=summary, # type: ignore[arg-type] - additional_properties={"reasoning_id": reasoning_id} if reasoning_id else None, ) ) + added_reasoning = True + if not added_reasoning: + # Reasoning item with no visible text (e.g. encrypted reasoning). + # Always emit an empty marker so co-occurrence detection can be done + additional_properties_empty: dict[str, Any] = {} + if encrypted := getattr(item, "encrypted_content", None): + additional_properties_empty["encrypted_content"] = encrypted + contents.append( + Content.from_text_reasoning( + id=item.id, + text="", + raw_representation=item, + additional_properties=additional_properties_empty or None, + ) + ) case "code_interpreter_call": # ResponseOutputCodeInterpreterCall call_id = getattr(item, "call_id", None) or getattr(item, "id", None) outputs: list[Content] = [] @@ -1427,36 +1440,36 @@ class RawOpenAIResponsesClient( # type: ignore[misc] case "response.reasoning_text.delta": contents.append( Content.from_text_reasoning( + id=event.item_id, text=event.delta, raw_representation=event, - additional_properties={"reasoning_id": event.item_id}, ) ) metadata.update(self._get_metadata_from_response(event)) case "response.reasoning_text.done": contents.append( Content.from_text_reasoning( + id=event.item_id, text=event.text, raw_representation=event, - additional_properties={"reasoning_id": event.item_id}, ) ) metadata.update(self._get_metadata_from_response(event)) case "response.reasoning_summary_text.delta": contents.append( Content.from_text_reasoning( + id=event.item_id, text=event.delta, raw_representation=event, - additional_properties={"reasoning_id": event.item_id}, ) ) metadata.update(self._get_metadata_from_response(event)) case "response.reasoning_summary_text.done": contents.append( Content.from_text_reasoning( + id=event.item_id, text=event.text, raw_representation=event, - additional_properties={"reasoning_id": event.item_id}, ) ) metadata.update(self._get_metadata_from_response(event)) @@ -1630,11 +1643,10 @@ class RawOpenAIResponsesClient( # type: ignore[misc] ) case "reasoning": # ResponseOutputReasoning reasoning_id = getattr(event_item, "id", None) + added_reasoning = False if hasattr(event_item, "content") and event_item.content: for index, reasoning_content in enumerate(event_item.content): additional_properties: dict[str, Any] = {} - if reasoning_id: - additional_properties["reasoning_id"] = reasoning_id if ( hasattr(event_item, "summary") and event_item.summary @@ -1643,11 +1655,27 @@ class RawOpenAIResponsesClient( # type: ignore[misc] additional_properties["summary"] = event_item.summary[index] contents.append( Content.from_text_reasoning( + id=reasoning_id or None, text=reasoning_content.text, raw_representation=reasoning_content, additional_properties=additional_properties or None, ) ) + added_reasoning = True + if not added_reasoning: + # Reasoning item with no visible text (e.g. encrypted reasoning). + # Always emit an empty marker so co-occurrence detection can occur. + additional_properties_empty: dict[str, Any] = {} + if encrypted := getattr(event_item, "encrypted_content", None): + additional_properties_empty["encrypted_content"] = encrypted + contents.append( + Content.from_text_reasoning( + id=reasoning_id or None, + text="", + raw_representation=event_item, + additional_properties=additional_properties_empty or None, + ) + ) case _: logger.debug("Unparsed event of type: %s: %s", event.type, event) case "response.function_call_arguments.delta": diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index d87c2ab0d1..1dfd257942 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -171,6 +171,62 @@ async def test_base_client_with_streaming_function_calling(chat_client_base: Sup assert exec_counter == 1 +async def test_base_client_executes_function_calls_across_multiple_response_messages( + chat_client_base: SupportsChatGetResponse, +): + exec_counter = 0 + + @tool(name="test_function", approval_mode="never_require") + def ai_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="1", + name="test_function", + arguments='{"arg1": "v1"}', + ) + ], + ), + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="2", + name="test_function", + arguments='{"arg1": "v2"}', + ) + ], + ), + ], + conversation_id="conv_after_first_call", + ), + ChatResponse( + messages=Message(role="assistant", text="done"), + conversation_id="conv_after_second_call", + ), + ] + + response = await chat_client_base.get_response( + [Message(role="user", text="hello")], + options={"tool_choice": "auto", "tools": [ai_func], "conversation_id": "conv_initial"}, + ) + + assert exec_counter == 2 + function_results = [ + content for msg in response.messages for content in msg.contents if content.type == "function_result" + ] + assert len(function_results) == 2 + assert {result.call_id for result in function_results} == {"1", "2"} + + async def test_function_invocation_inside_aiohttp_server(chat_client_base: SupportsChatGetResponse): import aiohttp from aiohttp import web @@ -921,6 +977,36 @@ async def test_function_invocation_config_max_consecutive_errors(chat_client_bas assert len(function_calls) <= 2 +async def test_function_invocation_stop_clears_conversation_id_non_stream(chat_client_base: SupportsChatGetResponse): + """Stop-path responses should not carry a continuation conversation_id.""" + + @tool(name="error_function", approval_mode="never_require") + def error_func(arg1: str) -> str: + raise ValueError("Function error") + + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="1", name="error_function", arguments='{"arg1": "value1"}') + ], + ), + conversation_id="resp_1", + ) + ] + chat_client_base.function_invocation_configuration["max_consecutive_errors_per_request"] = 1 + session_stub = type("SessionStub", (), {"service_session_id": "resp_seed"})() + + response = await chat_client_base.get_response( + [Message(role="user", text="hello")], + options={"tool_choice": "auto", "tools": [error_func]}, + session=session_stub, + ) + + assert response.conversation_id is None + + async def test_function_invocation_config_terminate_on_unknown_calls_false(chat_client_base: SupportsChatGetResponse): """Test that terminate_on_unknown_calls=False returns error message for unknown functions.""" exec_counter = 0 @@ -2140,6 +2226,43 @@ async def test_streaming_function_invocation_config_max_consecutive_errors(chat_ assert len(function_calls) <= 2 +async def test_streaming_function_invocation_stop_clears_conversation_id(chat_client_base: SupportsChatGetResponse): + """Streaming stop-path responses should not carry a continuation conversation_id.""" + + @tool(name="error_function", approval_mode="never_require") + def error_func(arg1: str) -> str: + raise ValueError("Function error") + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[ + Content.from_function_call(call_id="1", name="error_function", arguments='{"arg1": "value1"}') + ], + role="assistant", + conversation_id="resp_1", + ) + ] + ] + chat_client_base.function_invocation_configuration["max_consecutive_errors_per_request"] = 1 + session_stub = type("SessionStub", (), {"service_session_id": "resp_seed"})() + + stream = chat_client_base.get_response( + "hello", + options={"tool_choice": "auto", "tools": [error_func]}, + stream=True, + session=session_stub, + ) + async for _ in stream: + pass + response = await stream.get_final_response() + + # After the stop-path cleanup call, the accumulated stream response keeps the + # conversation_id from the first inner call; the cleanup call's own response id + # is what matters for server-side resolution but is not reflected in the mock here. + assert response is not None + + async def test_streaming_function_invocation_config_terminate_on_unknown_calls_false( chat_client_base: SupportsChatGetResponse, ): @@ -2869,8 +2992,9 @@ async def test_streaming_function_calling_response_includes_reasoning_and_tool_r ChatResponseUpdate( contents=[ Content.from_text_reasoning( + id="rs_test123", text="Let me search for that", - additional_properties={"reasoning_id": "rs_test123", "status": "completed"}, + additional_properties={"status": "completed"}, ) ], role="assistant", @@ -2912,8 +3036,7 @@ async def test_streaming_function_calling_response_includes_reasoning_and_tool_r assert "function_result" in all_content_types, "Function result must be in response messages for chaining" assert "text" in all_content_types, "Final text must be in response messages" - # Verify reasoning has the reasoning_id preserved + # Verify reasoning has the id preserved reasoning_contents = [c for msg in response.messages for c in msg.contents if c.type == "text_reasoning"] assert len(reasoning_contents) >= 1 - assert reasoning_contents[0].additional_properties is not None - assert reasoning_contents[0].additional_properties.get("reasoning_id") == "rs_test123" + assert reasoning_contents[0].id == "rs_test123" diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 691e5cc6b6..6c98f3bdfa 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -821,8 +821,9 @@ def test_prepare_message_for_openai_includes_reasoning_with_function_call() -> N client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") reasoning = Content.from_text_reasoning( + id="rs_abc123", text="Let me analyze the request", - additional_properties={"status": "completed", "reasoning_id": "rs_abc123"}, + additional_properties={"status": "completed"}, ) function_call = Content.from_function_call( call_id="call_123", @@ -841,7 +842,7 @@ def test_prepare_message_for_openai_includes_reasoning_with_function_call() -> N assert "function_call" in types reasoning_item = next(item for item in result if item["type"] == "reasoning") - assert reasoning_item["summary"]["text"] == "Let me analyze the request" + assert reasoning_item["summary"][0]["text"] == "Let me analyze the request" assert reasoning_item["id"] == "rs_abc123", "Reasoning id must be preserved for the API" @@ -860,8 +861,9 @@ def test_prepare_messages_for_openai_full_conversation_with_reasoning() -> None: role="assistant", contents=[ Content.from_text_reasoning( + id="rs_test123", text="I need to search for hotels", - additional_properties={"reasoning_id": "rs_test123", "status": "completed"}, + additional_properties={"status": "completed"}, ), Content.from_function_call( call_id="call_1", @@ -1895,6 +1897,7 @@ def test_prepare_content_for_openai_text_reasoning_comprehensive() -> None: # Test TextReasoningContent with all additional properties comprehensive_reasoning = Content.from_text_reasoning( + id="rs_comprehensive", text="Comprehensive reasoning summary", additional_properties={ "status": "in_progress", @@ -1904,10 +1907,11 @@ def test_prepare_content_for_openai_text_reasoning_comprehensive() -> None: ) result = client._prepare_content_for_openai("assistant", comprehensive_reasoning, {}) # type: ignore assert result["type"] == "reasoning" - assert result["summary"]["text"] == "Comprehensive reasoning summary" + assert result["id"] == "rs_comprehensive" + assert result["summary"][0]["text"] == "Comprehensive reasoning summary" assert result["status"] == "in_progress" - assert result["content"]["type"] == "reasoning_text" - assert result["content"]["text"] == "Step-by-step analysis" + assert result["content"][0]["type"] == "reasoning_text" + assert result["content"][0]["text"] == "Step-by-step analysis" assert result["encrypted_content"] == "secure_data_456" @@ -1931,6 +1935,7 @@ def test_streaming_reasoning_text_delta_event() -> None: assert len(response.contents) == 1 assert response.contents[0].type == "text_reasoning" + assert response.contents[0].id == "reasoning_123" assert response.contents[0].text == "reasoning delta" assert response.contents[0].raw_representation == event mock_metadata.assert_called_once_with(event) diff --git a/python/packages/core/tests/workflow/test_full_conversation.py b/python/packages/core/tests/workflow/test_full_conversation.py index 74fe419e71..23861ecc69 100644 --- a/python/packages/core/tests/workflow/test_full_conversation.py +++ b/python/packages/core/tests/workflow/test_full_conversation.py @@ -3,6 +3,7 @@ from collections.abc import AsyncIterable, Awaitable, Sequence from typing import Any +import pytest from pydantic import PrivateAttr from typing_extensions import Never @@ -54,6 +55,67 @@ class _SimpleAgent(BaseAgent): return _run() +class _ToolHistoryAgent(BaseAgent): + """Agent that emits tool-call internals plus a final assistant summary.""" + + def __init__(self, *, summary_text: str, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._summary_text = summary_text + + def _messages(self) -> list[Message]: + return [ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_weather_1", + name="get_weather", + arguments='{"location":"Seattle"}', + ) + ], + ), + Message( + role="tool", + contents=[Content.from_function_result(call_id="call_weather_1", result="Sunny, 72F")], + ), + Message(role="assistant", contents=[Content.from_text(text=self._summary_text)]), + ] + + def run( + self, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: + if stream: + + async def _stream() -> AsyncIterable[AgentResponseUpdate]: + yield AgentResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_weather_1", + name="get_weather", + arguments='{"location":"Seattle"}', + ) + ], + role="assistant", + ) + yield AgentResponseUpdate( + contents=[Content.from_function_result(call_id="call_weather_1", result="Sunny, 72F")], + role="tool", + ) + yield AgentResponseUpdate(contents=[Content.from_text(text=self._summary_text)], role="assistant") + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + + async def _run() -> AgentResponse: + return AgentResponse(messages=self._messages()) + + return _run() + + class _CaptureFullConversation(Executor): """Captures AgentExecutorResponse.full_conversation and completes the workflow.""" @@ -153,6 +215,39 @@ async def test_sequential_adapter_uses_full_conversation() -> None: assert seen[1].role == "assistant" and "A1 reply" in (seen[1].text or "") +async def test_sequential_handoff_preserves_function_call_for_non_reasoning_model() -> None: + # Arrange: non-reasoning agent emits function_call + function_result + summary + first = _ToolHistoryAgent( + id="tool_history_agent", + name="ToolHistory", + summary_text="The weather in Seattle is sunny and 72F.", + ) + second = _CaptureAgent(id="capture_agent", name="Capture", reply_text="Captured") + wf = SequentialBuilder(participants=[first, second]).build() + + # Act + result = await wf.run("Check weather and continue") + + # Assert workflow completed + outputs = result.get_outputs() + assert outputs + + # For non-reasoning models (no text_reasoning), function_call and function_result are + # both kept so the receiving agent has the full call/result pair as context. + seen = second._last_messages # pyright: ignore[reportPrivateUsage] + assert len(seen) == 4 # user, assistant(function_call), tool(function_result), assistant(summary) + assert seen[0].role == "user" + assert "Check weather and continue" in (seen[0].text or "") + assert seen[1].role == "assistant" + assert any(content.type == "function_call" for content in seen[1].contents) + assert seen[2].role == "tool" + assert any(content.type == "function_result" for content in seen[2].contents) + assert seen[3].role == "assistant" + assert "Seattle is sunny" in (seen[3].text or "") + # No text_reasoning should appear (non-reasoning model) + assert all(content.type != "text_reasoning" for msg in seen for content in msg.contents) + + class _RoundTripCoordinator(Executor): """Loops once back to the same agent with full conversation + feedback.""" @@ -212,3 +307,109 @@ async def test_agent_executor_full_conversation_round_trip_does_not_duplicate_hi assert payload["texts"][1] == "draft reply" assert payload["texts"][2] == "apply feedback" assert payload["texts"][3] == "draft reply" + + +class _SessionIdCapturingAgent(BaseAgent): + """Records service_session_id of the session at run() time.""" + + _captured_service_session_id: str | None = PrivateAttr(default="NOT_CAPTURED") + + def run( + self, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: + self._captured_service_session_id = session.service_session_id if session else None + + async def _run() -> AgentResponse: + return AgentResponse(messages=[Message("assistant", ["done"])]) + + return _run() + + +class _FullHistoryReplayCoordinator(Executor): + """Coordinator that pre-sets service_session_id on a target executor then replays the full + conversation (including function calls) back to it via AgentExecutorRequest.""" + + def __init__(self, *, target_exec: AgentExecutor, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._target_exec = target_exec + + @handler + async def handle( + self, + response: AgentExecutorResponse, + ctx: WorkflowContext[Never, Any], + ) -> None: + full_conv = list(response.full_conversation or response.agent_response.messages) + full_conv.append(Message(role="user", text="follow-up")) + # Simulate a prior run: the target executor has a stored previous_response_id. + self._target_exec._session.service_session_id = "resp_PREVIOUS_RUN" # pyright: ignore[reportPrivateUsage] + await ctx.send_message( + AgentExecutorRequest(messages=full_conv, should_respond=True), + target_id=self._target_exec.id, + ) + + +@pytest.mark.xfail( + reason="reset_service_session support not yet implemented — see #4047", + strict=True, +) +async def test_run_request_with_full_history_clears_service_session_id() -> None: + """Replaying a full conversation (including function calls) via AgentExecutorRequest must + clear service_session_id so the API does not receive both previous_response_id and the + same function-call items in input — which would cause a 'Duplicate item' API error.""" + tool_agent = _ToolHistoryAgent( + id="tool_agent", name="ToolAgent", summary_text="Done." + ) + tool_exec = AgentExecutor(tool_agent, id="tool_agent") + + spy_agent = _SessionIdCapturingAgent(id="spy_agent", name="SpyAgent") + spy_exec = AgentExecutor(spy_agent, id="spy_agent") + + coordinator = _FullHistoryReplayCoordinator(id="coord", target_exec=spy_exec) + + wf = ( + WorkflowBuilder(start_executor=tool_exec, output_executors=[coordinator]) + .add_edge(tool_exec, coordinator) + .add_edge(coordinator, spy_exec) + .build() + ) + + result = await wf.run("initial prompt") + assert result.get_outputs() is not None + + # The spy agent must have seen service_session_id=None (cleared before run). + # Without the fix, it would see "resp_PREVIOUS_RUN" and the API would raise + # "Duplicate item found" because the same function-call IDs appear in both + # previous_response_id (server-stored) and the explicit input messages. + assert spy_agent._captured_service_session_id is None # pyright: ignore[reportPrivateUsage] + + +async def test_from_response_preserves_service_session_id() -> None: + """from_response hands off a prior agent's full conversation to the next executor. + The receiving executor's service_session_id is preserved so the API can continue + the conversation using previous_response_id.""" + tool_agent = _ToolHistoryAgent( + id="tool_agent2", name="ToolAgent", summary_text="Done." + ) + tool_exec = AgentExecutor(tool_agent, id="tool_agent2") + + spy_agent = _SessionIdCapturingAgent(id="spy_agent2", name="SpyAgent") + spy_exec = AgentExecutor(spy_agent, id="spy_agent2") + # Simulate a prior run on the spy executor. + spy_exec._session.service_session_id = "resp_PREVIOUS_RUN" # pyright: ignore[reportPrivateUsage] + + wf = ( + WorkflowBuilder(start_executor=tool_exec, output_executors=[spy_exec]) + .add_edge(tool_exec, spy_exec) + .build() + ) + + result = await wf.run("start") + assert result.get_outputs() is not None + + assert spy_agent._captured_service_session_id == "resp_PREVIOUS_RUN" # pyright: ignore[reportPrivateUsage] diff --git a/python/samples/02-agents/conversations/redis_history_provider.py b/python/samples/02-agents/conversations/redis_history_provider.py index 10b5265c41..17e1094775 100644 --- a/python/samples/02-agents/conversations/redis_history_provider.py +++ b/python/samples/02-agents/conversations/redis_history_provider.py @@ -20,7 +20,6 @@ management, enabling persistent conversation history storage across sessions with Redis as the backend data store. """ - # Default Redis URL for local Redis Stack. # Override via the REDIS_URL environment variable for remote or authenticated instances. REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") diff --git a/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py b/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py index fdcc626099..68c9eb5ae0 100644 --- a/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py +++ b/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py @@ -153,7 +153,7 @@ class Coordinator(Executor): # Human approved the draft as-is; forward it unchanged. await ctx.send_message( AgentExecutorRequest( - messages=original_request.conversation + [Message("user", text="The draft is approved as-is.")], + messages=[*original_request.conversation, *[Message("user", text="The draft is approved as-is.")]], should_respond=True, ), target_id=self.final_editor_id, @@ -161,16 +161,15 @@ class Coordinator(Executor): return # Human provided feedback; prompt the writer to revise. - conversation: list[Message] = list(original_request.conversation) instruction = ( "A human reviewer shared the following guidance:\n" f"{note or 'No specific guidance provided.'}\n\n" "Rewrite the draft from the previous assistant message into a polished final version. " "Keep the response under 120 words and reflect any requested tone adjustments." ) - conversation.append(Message("user", text=instruction)) await ctx.send_message( - AgentExecutorRequest(messages=conversation, should_respond=True), target_id=self.writer_id + AgentExecutorRequest(messages=[Message("user", text=instruction)], should_respond=True), + target_id=self.writer_id, ) diff --git a/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py b/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py index ed5fd77ce2..b7e6046d40 100644 --- a/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py +++ b/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py @@ -123,7 +123,8 @@ class Coordinator(Executor): ) conversation.append(Message("user", text=instruction)) await ctx.send_message( - AgentExecutorRequest(messages=conversation, should_respond=True), target_id=self.writer_name + AgentExecutorRequest(messages=conversation, should_respond=True), + target_id=self.writer_name, ) diff --git a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py index 8c74a3b153..e5c6bd09f8 100644 --- a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py +++ b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py @@ -144,7 +144,9 @@ async def run_agent_framework_with_cycle() -> None: if last_message and "APPROVED" in last_message.text: await context.yield_output("Content approved.") else: - await context.send_message(AgentExecutorRequest(messages=response.full_conversation, should_respond=True)) + await context.send_message( + AgentExecutorRequest(messages=response.full_conversation, should_respond=True) + ) workflow = ( WorkflowBuilder(start_executor=researcher)