mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into crickman/dotnet-sample-improvements
This commit is contained in:
@@ -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<object?> FunctionCallMiddleware(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -259,3 +274,23 @@ async Task<ChatResponse> PerRequestChatClientMiddleware(IEnumerable<ChatMessage>
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="MessageAIContextProvider"/> 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 <see cref="AIAgentBuilder.Use(MessageAIContextProvider[])"/> extension method.
|
||||
/// </summary>
|
||||
internal sealed class DateTimeContextProvider : MessageAIContextProvider
|
||||
{
|
||||
protected override ValueTask<IEnumerable<ChatMessage>> ProvideMessagesAsync(
|
||||
InvokingContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine("DateTimeContextProvider - Injecting current date/time context");
|
||||
|
||||
return new ValueTask<IEnumerable<ChatMessage>>(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, $"For reference, the current date and time is: {DateTimeOffset.Now}")
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -146,11 +146,11 @@ namespace SampleApp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> which searches for upcoming calendar events and adds them to the AI context.
|
||||
/// A <see cref="MessageAIContextProvider"/> which searches for upcoming calendar events and adds them to the AI context.
|
||||
/// </summary>
|
||||
internal sealed class CalendarSearchAIContextProvider(Func<Task<string[]>> loadNextThreeCalendarEvents) : AIContextProvider
|
||||
internal sealed class CalendarSearchAIContextProvider(Func<Task<string[]>> loadNextThreeCalendarEvents) : MessageAIContextProvider
|
||||
{
|
||||
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
protected override async ValueTask<IEnumerable<MEAI.ChatMessage>> 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())];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,6 @@ public abstract class AIContextProvider
|
||||
private static IEnumerable<ChatMessage> DefaultExternalOnlyFilter(IEnumerable<ChatMessage> messages)
|
||||
=> messages.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External);
|
||||
|
||||
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> _provideInputMessageFilter;
|
||||
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> _storeInputMessageFilter;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AIContextProvider"/> class.
|
||||
/// </summary>
|
||||
@@ -46,10 +43,20 @@ public abstract class AIContextProvider
|
||||
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideInputMessageFilter = null,
|
||||
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
|
||||
{
|
||||
this._provideInputMessageFilter = provideInputMessageFilter ?? DefaultExternalOnlyFilter;
|
||||
this._storeInputMessageFilter = storeInputMessageFilter ?? DefaultExternalOnlyFilter;
|
||||
this.ProvideInputMessageFilter = provideInputMessageFilter ?? DefaultExternalOnlyFilter;
|
||||
this.StoreInputMessageFilter = storeInputMessageFilter ?? DefaultExternalOnlyFilter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the filter function to apply to input messages before providing context via <see cref="ProvideAIContextAsync"/>.
|
||||
/// </summary>
|
||||
protected Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> ProvideInputMessageFilter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the filter function to apply to request messages before storing context via <see cref="StoreAIContextAsync"/>.
|
||||
/// </summary>
|
||||
protected Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> StoreInputMessageFilter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key used to store the provider state in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for components that enhance AI context during agent invocations by supplying additional chat messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A message AI context provider is a component that participates in the agent invocation lifecycle by:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Listening to changes in conversations</description></item>
|
||||
/// <item><description>Providing additional messages to agents during invocation</description></item>
|
||||
/// <item><description>Processing invocation results for state management or learning</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Context providers operate through a two-phase lifecycle: they are called at the start of invocation via
|
||||
/// <see cref="AIContextProvider.InvokingAsync"/> to provide context, and optionally called at the end of invocation via
|
||||
/// <see cref="AIContextProvider.InvokedAsync"/> to process results.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class MessageAIContextProvider : AIContextProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessageAIContextProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="provideInputMessageFilter">An optional filter function to apply to input messages before providing messages via <see cref="ProvideMessagesAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
|
||||
/// <param name="storeInputMessageFilter">An optional filter function to apply to request messages before storing messages via <see cref="AIContextProvider.StoreAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
|
||||
protected MessageAIContextProvider(
|
||||
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideInputMessageFilter = null,
|
||||
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
|
||||
: base(provideInputMessageFilter, storeInputMessageFilter)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<AIContext> 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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called at the start of agent invocation to provide additional messages.
|
||||
/// </summary>
|
||||
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="IEnumerable{ChatMessage}"/> to be used by the agent during this invocation.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementers can load any additional messages required at this time, such as:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Retrieving relevant information from knowledge bases</description></item>
|
||||
/// <item><description>Adding system instructions or prompts</description></item>
|
||||
/// <item><description>Injecting contextual messages from conversation history</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
=> this.InvokingCoreAsync(Throw.IfNull(context), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Called at the start of agent invocation to provide additional messages.
|
||||
/// </summary>
|
||||
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="IEnumerable{ChatMessage}"/> to be used by the agent during this invocation.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementers can load any additional messages required at this time, such as:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Retrieving relevant information from knowledge bases</description></item>
|
||||
/// <item><description>Adding system instructions or prompts</description></item>
|
||||
/// <item><description>Injecting contextual messages from conversation history</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default implementation of this method filters the input messages using the configured provide-input message filter
|
||||
/// (which defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages),
|
||||
/// then calls <see cref="ProvideMessagesAsync"/> to get additional messages,
|
||||
/// stamps any messages with <see cref="AgentRequestMessageSourceType.AIContextProvider"/> source attribution,
|
||||
/// and merges the returned messages with the original (unfiltered) input messages.
|
||||
/// For most scenarios, overriding <see cref="ProvideMessagesAsync"/> 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 <see cref="IEnumerable{ChatMessage}"/> returned for the invocation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected virtual async ValueTask<IEnumerable<ChatMessage>> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When overridden in a derived class, provides additional messages to be merged with the input messages for the current invocation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method is called from <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/>.
|
||||
/// Note that <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// In contrast with <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/>, this method only returns additional messages to be merged with the input,
|
||||
/// while <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/> is responsible for returning the full merged <see cref="IEnumerable{ChatMessage}"/> for the invocation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains an <see cref="IEnumerable{ChatMessage}"/>
|
||||
/// with additional messages to be merged with the input messages.
|
||||
/// </returns>
|
||||
protected virtual ValueTask<IEnumerable<ChatMessage>> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<IEnumerable<ChatMessage>>([]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains the context information provided to <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public new sealed class InvokingContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvokingContext"/> class with the specified request messages.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent being invoked.</param>
|
||||
/// <param name="session">The session associated with the agent invocation.</param>
|
||||
/// <param name="requestMessages">The messages to be used by the agent for this invocation.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> or <paramref name="requestMessages"/> is <see langword="null"/>.</exception>
|
||||
public InvokingContext(
|
||||
AIAgent agent,
|
||||
AgentSession? session,
|
||||
IEnumerable<ChatMessage> requestMessages)
|
||||
{
|
||||
this.Agent = Throw.IfNull(agent);
|
||||
this.Session = session;
|
||||
this.RequestMessages = Throw.IfNull(requestMessages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent that is being invoked.
|
||||
/// </summary>
|
||||
public AIAgent Agent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent session associated with the agent invocation.
|
||||
/// </summary>
|
||||
public AgentSession? Session { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the messages that will be used by the agent for this invocation. <see cref="MessageAIContextProvider"/> instances can modify
|
||||
/// and return or return a new message list to add additional messages for the invocation.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A collection of <see cref="ChatMessage"/> instances representing the messages that will be used by the agent for this invocation.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If multiple <see cref="MessageAIContextProvider"/> instances are used in the same invocation, each <see cref="MessageAIContextProvider"/>
|
||||
/// will receive the messages returned by the previous <see cref="MessageAIContextProvider"/> allowing them to build on top of each other's context.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The first <see cref="MessageAIContextProvider"/> in the invocation pipeline will receive the
|
||||
/// caller provided messages.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IEnumerable<ChatMessage> RequestMessages { get; set { field = Throw.IfNull(value); } }
|
||||
}
|
||||
}
|
||||
@@ -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<TState>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ using Microsoft.Shared.Diagnostics;
|
||||
namespace Microsoft.Agents.AI.Mem0;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a Mem0 backed <see cref="AIContextProvider"/> that persists conversation messages as memories
|
||||
/// Provides a Mem0 backed <see cref="MessageAIContextProvider"/> that persists conversation messages as memories
|
||||
/// and retrieves related memories to augment the agent invocation context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -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.
|
||||
/// </remarks>
|
||||
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
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
protected override async ValueTask<IEnumerable<ChatMessage>> 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the behavior for filtering <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents from
|
||||
/// <see cref="ChatMessage"/>s flowing through a handoff workflow. This can be used to prevent agents from seeing external
|
||||
/// tool calls.
|
||||
/// </summary>
|
||||
public enum HandoffToolCallFilteringBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Do not filter <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Filter only handoff-related <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
|
||||
/// </summary>
|
||||
HandoffOnly,
|
||||
|
||||
/// <summary>
|
||||
/// Filter all <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
|
||||
/// </summary>
|
||||
All
|
||||
}
|
||||
@@ -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<AIAgent, HashSet<HandoffTarget>> _targets = [];
|
||||
private readonly HashSet<AIAgent> _allAgents = new(AIAgentIDEqualityComparer.Instance);
|
||||
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
|
||||
@@ -34,14 +36,38 @@ public sealed class HandoffsWorkflowBuilder
|
||||
/// By default, simple instructions are included. This may be set to <see langword="null"/> to avoid including
|
||||
/// any additional instructions, or may be customized to provide more specific guidance.
|
||||
/// </remarks>
|
||||
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}<agent_id>`; 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.
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Sets additional instructions to provide to an agent that has handoffs about how and when to
|
||||
/// perform them.
|
||||
/// </summary>
|
||||
/// <param name="instructions">The instructions to provide, or <see langword="null"/> to restore the default instructions.</param>
|
||||
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
|
||||
{
|
||||
this.HandoffInstructions = instructions ?? DefaultHandoffInstructions;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the behavior for filtering <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents from
|
||||
/// <see cref="ChatMessage"/>s flowing through the handoff workflow. Defaults to <see cref="HandoffToolCallFilteringBehavior.HandoffOnly"/>.
|
||||
/// </summary>
|
||||
/// <param name="behavior">The filtering behavior to apply.</param>
|
||||
public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
|
||||
{
|
||||
this._toolCallFilteringBehavior = behavior;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds handoff relationships from a source agent to one or more target agents.
|
||||
/// </summary>
|
||||
@@ -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<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, this.HandoffInstructions));
|
||||
Dictionary<string, HandoffAgentExecutor> 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]);
|
||||
|
||||
@@ -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<ChatMessage> FilterMessages(List<ChatMessage> messages)
|
||||
{
|
||||
if (this._filteringBehavior == HandoffToolCallFilteringBehavior.None)
|
||||
{
|
||||
return messages;
|
||||
}
|
||||
|
||||
Dictionary<string, FilterCandidateState> filteringCandidates = new();
|
||||
List<ChatMessage> filteredMessages = [];
|
||||
HashSet<int> 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<AIContent> 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; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
|
||||
internal sealed class HandoffAgentExecutor(
|
||||
AIAgent agent,
|
||||
string? handoffInstructions) : Executor<HandoffState, HandoffState>(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor
|
||||
HandoffAgentExecutorOptions options) : Executor<HandoffState, HandoffState>(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<ChatMessage>? 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<ChatMessage> 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);
|
||||
|
||||
|
||||
@@ -151,6 +151,32 @@ public sealed class AIAgentBuilder
|
||||
return this.Use((innerAgent, _) => new AnonymousDelegatingAIAgent(innerAgent, runFunc, runStreamingFunc));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds one or more <see cref="MessageAIContextProvider"/> instances to the agent pipeline, enabling message enrichment
|
||||
/// for any <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
/// <param name="providers">
|
||||
/// The <see cref="MessageAIContextProvider"/> instances to invoke before and after each agent invocation.
|
||||
/// Providers are called in sequence, with each receiving the output of the previous provider.
|
||||
/// </param>
|
||||
/// <returns>The <see cref="AIAgentBuilder"/> with the providers added, enabling method chaining.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="providers"/> is empty.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method wraps the inner agent with a <see cref="DelegatingAIAgent"/> that calls each provider's
|
||||
/// <see cref="MessageAIContextProvider.InvokingAsync"/> in sequence before the inner agent runs,
|
||||
/// and calls <see cref="AIContextProvider.InvokedAsync"/> on each provider after the inner agent completes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This allows any <see cref="AIAgent"/> to benefit from <see cref="MessageAIContextProvider"/>-based
|
||||
/// context enrichment, not just agents that natively support <see cref="AIContextProvider"/> instances.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public AIAgentBuilder Use(MessageAIContextProvider[] providers)
|
||||
{
|
||||
return this.Use((innerAgent, _) => new MessageAIContextProviderAgent(innerAgent, providers));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides an empty <see cref="IServiceProvider"/> implementation.
|
||||
/// </summary>
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// injecting them automatically on each invocation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
protected override async ValueTask<AIContext> 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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask<IEnumerable<ChatMessage>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<IEnumerable<ChatMessage>> 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating AI agent that enriches input messages by invoking a pipeline of <see cref="MessageAIContextProvider"/> instances
|
||||
/// before delegating to the inner agent, and notifies those providers after the inner agent completes.
|
||||
/// </summary>
|
||||
internal sealed class MessageAIContextProviderAgent : DelegatingAIAgent
|
||||
{
|
||||
private readonly IReadOnlyList<MessageAIContextProvider> _providers;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessageAIContextProviderAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerAgent">The underlying agent instance that will handle the core operations.</param>
|
||||
/// <param name="providers">The message AI context providers to invoke before and after the inner agent.</param>
|
||||
public MessageAIContextProviderAgent(AIAgent innerAgent, IReadOnlyList<MessageAIContextProvider> providers)
|
||||
: base(innerAgent)
|
||||
{
|
||||
Throw.IfNull(providers);
|
||||
Throw.IfLessThanOrEqual(providers.Count, 0, nameof(providers));
|
||||
|
||||
this._providers = providers;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var enrichedMessages = await this.InvokeProvidersAsync(messages, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
List<AgentResponseUpdate> responseUpdates = [];
|
||||
|
||||
IAsyncEnumerator<AgentResponseUpdate> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes each provider's <see cref="MessageAIContextProvider.InvokingAsync"/> in sequence,
|
||||
/// passing the output of each as input to the next.
|
||||
/// </summary>
|
||||
private async Task<IEnumerable<ChatMessage>> InvokeProvidersAsync(
|
||||
IEnumerable<ChatMessage> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies each provider of a successful invocation.
|
||||
/// </summary>
|
||||
private async Task NotifyProvidersOfSuccessAsync(
|
||||
AgentSession? session,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
IEnumerable<ChatMessage> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies each provider of a failed invocation.
|
||||
/// </summary>
|
||||
private async Task NotifyProvidersOfFailureAsync(
|
||||
AgentSession? session,
|
||||
IEnumerable<ChatMessage> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// multi-turn context to the retrieval layer without permanently altering the conversation history.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
protected override async ValueTask<AIContext> 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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask<IEnumerable<ChatMessage>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<IEnumerable<ChatMessage>> 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+323
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="MessageAIContextProvider"/> class.
|
||||
/// </summary>
|
||||
public class MessageAIContextProviderTests
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
|
||||
private static readonly AgentSession s_mockSession = new Mock<AgentSession>().Object;
|
||||
|
||||
#region InvokingAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_NullContext_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestMessageProvider();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => 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<ArgumentNullException>(() => new MessageAIContextProvider.InvokingContext(null!, s_mockSession, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_Constructor_ThrowsForNullRequestMessages()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => 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<ChatMessage> { 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<ArgumentNullException>(() => context.RequestMessages = null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_RequestMessages_SetterAcceptsValidValue()
|
||||
{
|
||||
// Arrange
|
||||
var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var newMessages = new List<ChatMessage> { 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<ChatMessage>? _provideMessages;
|
||||
private readonly bool _captureFilteredContext;
|
||||
|
||||
public InvokingContext? LastFilteredContext { get; private set; }
|
||||
|
||||
public TestMessageProvider(
|
||||
IEnumerable<ChatMessage>? provideMessages = null,
|
||||
bool captureFilteredContext = false,
|
||||
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideInputMessageFilter = null,
|
||||
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
|
||||
: base(provideInputMessageFilter, storeInputMessageFilter)
|
||||
{
|
||||
this._provideMessages = provideMessages;
|
||||
this._captureFilteredContext = captureFilteredContext;
|
||||
}
|
||||
|
||||
protected override ValueTask<IEnumerable<ChatMessage>> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._captureFilteredContext)
|
||||
{
|
||||
this.LastFilteredContext = context;
|
||||
}
|
||||
|
||||
return new(this._provideMessages ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A provider that uses only base class defaults (no overrides of ProvideMessagesAsync).
|
||||
/// </summary>
|
||||
private sealed class DefaultMessageProvider : MessageAIContextProvider;
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,6 +9,56 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
/// </summary>
|
||||
public class ProviderSessionStateTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsForNullStateInitializer()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ProviderSessionState<TestState>(null!, "test-key"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsForNullStateKey()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ProviderSessionState<TestState>(_ => new TestState(), null!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Constructor_ThrowsForEmptyOrWhitespaceStateKey(string stateKey)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new ProviderSessionState<TestState>(_ => new TestState(), stateKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_AcceptsNullJsonSerializerOptions()
|
||||
{
|
||||
// Act - should not throw
|
||||
var sessionState = new ProviderSessionState<TestState>(_ => 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<TestState>(_ => new TestState(), "test-key", customOptions);
|
||||
|
||||
// Assert - instance is created and functional
|
||||
Assert.Equal("test-key", sessionState.StateKey);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetOrInitializeState Tests
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<TextSearchProvider.TextSearchResult> results =
|
||||
[
|
||||
new() { SourceName = "Doc1", Text = "Content of Doc1" }
|
||||
];
|
||||
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
=> Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(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<InvalidOperationException>(() => 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<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
|
||||
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<IEnumerable<TextSearchProvider.TextSearchResult>> NoResultSearchAsync(string input, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
|
||||
@@ -710,6 +710,147 @@ public class ChatHistoryMemoryProviderTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region MessageAIContextProvider.InvokingAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task MessageInvokingAsync_BeforeAIInvoke_SearchesAndReturnsMergedMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var storedItems = new List<VectorSearchResult<Dictionary<string, object?>>>
|
||||
{
|
||||
new(
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["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<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<InvalidOperationException>(() => provider.InvokingAsync(context).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MessageInvokingAsync_BeforeAIInvoke_NoResults_ReturnsOnlyInputMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._vectorStoreCollectionMock
|
||||
.Setup(c => c.SearchAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
|
||||
|
||||
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<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, int, VectorSearchOptions<Dictionary<string, object?>>, CancellationToken>((query, _, _, _) => capturedQuery = query)
|
||||
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
|
||||
|
||||
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<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="MessageAIContextProviderAgent"/> class and
|
||||
/// the <see cref="AIAgentBuilder.Use(MessageAIContextProvider[])"/> builder extension.
|
||||
/// </summary>
|
||||
public class MessageAIContextProviderAgentTests
|
||||
{
|
||||
private static readonly AgentSession s_mockSession = new Mock<AgentSession>().Object;
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullInnerAgent_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestProvider();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new MessageAIContextProviderAgent(null!, [provider]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullProviders_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var agent = CreateTestAgent();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new MessageAIContextProviderAgent(agent, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_EmptyProviders_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
// Arrange
|
||||
var agent = CreateTestAgent();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => 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<ChatMessage>? 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<ChatMessage>? 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<ChatMessage>? 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<InvalidOperationException>(() =>
|
||||
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<ChatMessage>? 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<AgentResponseUpdate>();
|
||||
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<InvalidOperationException>(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<ChatMessage>? 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<ChatMessage>? 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<ChatMessage>? 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<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken, Task<AgentResponse>>? runFunc = null,
|
||||
Func<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken, IAsyncEnumerable<AgentResponseUpdate>>? 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<AgentResponseUpdate> ToAsyncEnumerableAsync(params AgentResponseUpdate[] updates)
|
||||
{
|
||||
foreach (var update in updates)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test implementation of <see cref="MessageAIContextProvider"/> that records invocation calls.
|
||||
/// </summary>
|
||||
private sealed class TestProvider : MessageAIContextProvider
|
||||
{
|
||||
private readonly IEnumerable<ChatMessage> _provideMessages;
|
||||
private readonly Action<IEnumerable<ChatMessage>>? _onInvoking;
|
||||
|
||||
public bool InvokedAsyncCalled { get; private set; }
|
||||
|
||||
public InvokedContext? LastInvokedContext { get; private set; }
|
||||
|
||||
public TestProvider(
|
||||
IEnumerable<ChatMessage>? provideMessages = null,
|
||||
Action<IEnumerable<ChatMessage>>? onInvoking = null)
|
||||
{
|
||||
this._provideMessages = provideMessages ?? [];
|
||||
this._onInvoking = onInvoking;
|
||||
}
|
||||
|
||||
protected override ValueTask<IEnumerable<ChatMessage>> ProvideMessagesAsync(
|
||||
InvokingContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._onInvoking?.Invoke(context.RequestMessages);
|
||||
return new ValueTask<IEnumerable<ChatMessage>>(this._provideMessages);
|
||||
}
|
||||
|
||||
protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.InvokedAsyncCalled = true;
|
||||
this.LastInvokedContext = context;
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -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<ChatMessage>? 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<ChatMessage>? capturedSecondAgentMessages = null;
|
||||
List<ChatMessage>? 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<ChatMessage>? 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<ChatMessage>? 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<ChatMessage> 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<ChatMessage>? 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<ChatMessage> 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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user