feat: Implement AgentChat.NET Termination Conditions (#5839)

Closes #5801
This commit is contained in:
Jacob Alber 2025-03-13 12:41:13 -04:00 committed by GitHub
parent aefa66a3ce
commit cf1365763c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1078 additions and 45 deletions

View File

@ -25,6 +25,11 @@ public abstract class AgentMessage
/// </summary>
public required string Source { get; set; }
/// <summary>
/// The <see cref="IChatClient"/> usage incurred when producing this message.
/// </summary>
public RequestUsage? ModelUsage { get; set; }
// IMPORTANT NOTE: Unlike the ITypeMarshal<AgentMessage, WireProtocol.AgentMessage> implementation in ProtobufTypeMarshal,
// the .ToWire() call on this is intended to be used for directly converting a concrete message type to its leaf representation.
// In the context of Protobuf these may not be the same due to discriminated union types being real types, as opposed to
@ -495,6 +500,11 @@ public class FunctionExecutionResult
/// </summary>
public required string Id { get; set; }
/// <summary>
/// The name of the function that was called.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// The result of calling the function.
/// </summary>

View File

@ -3,6 +3,29 @@
namespace Microsoft.AutoGen.AgentChat.Abstractions;
public static class TerminationConditionExtensions
{
/// <summary>
/// Combine this termination condition with another using a logical OR.
/// </summary>
/// <param name="other">Another termination condition.</param>
/// <returns>The combined termination condition, with appropriate short-circuiting.</returns>
public static ITerminationCondition Or(this ITerminationCondition this_, ITerminationCondition other)
{
return new CombinerCondition(CombinerCondition.Or, this_, other);
}
/// <summary>
/// Combine this termination condition with another using a logical AND.
/// </summary>
/// <param name="other">Another termination condition.</param>
/// <returns>The combined termination condition, with appropriate short-circuiting.</returns>
public static ITerminationCondition And(this ITerminationCondition this_, ITerminationCondition other)
{
return new CombinerCondition(CombinerCondition.And, this_, other);
}
}
/// <summary>
/// A stateful condition that determines when a conversation should be terminated.
///
@ -12,7 +35,8 @@ namespace Microsoft.AutoGen.AgentChat.Abstractions;
///
/// Once a termination condition has been reached, it must be <see cref="Reset()"/> before it can be used again.
///
/// Termination conditions can be combined using the <see cref="Or"/> and <see cref="And"/> methods.
/// Termination conditions can be combined using the <see cref="TerminationConditionExtensions.Or"/> and
/// <see cref="TerminationConditionExtensions.And"/> methods.
/// </summary>
public interface ITerminationCondition
{
@ -38,23 +62,37 @@ public interface ITerminationCondition
public void Reset();
/// <summary>
/// Combine this termination condition with another using a logical OR.
/// Combine two termination conditions with another using an associative, short-circuiting OR.
/// </summary>
/// <param name="other">Another termination condition.</param>
/// <returns>The combined termination condition, with appropriate short-circuiting.</returns>
public ITerminationCondition Or(ITerminationCondition other)
/// <param name="left">
/// The left-hand side termination condition. If this condition is already a disjunction, the RHS condition is added to the list of clauses.
/// </param>
/// <param name="right">
/// The right-hand side termination condition. If the LHS condition is already a disjunction, this condition is added to the list of clauses.
/// </param>
/// <returns>
/// The combined termination condition, with appropriate short-circuiting.
/// </returns>
public static ITerminationCondition operator |(ITerminationCondition left, ITerminationCondition right)
{
return new CombinerCondition(CombinerCondition.Or, this, other);
return left.Or(right);
}
/// <summary>
/// Combine this termination condition with another using a logical AND.
/// Combine two termination conditions with another using an associative, short-circuiting AND.
/// </summary>
/// <param name="other">Another termination condition.</param>
/// <returns>The combined termination condition, with appropriate short-circuiting.</returns>
public ITerminationCondition And(ITerminationCondition other)
/// <param name="left">
/// The left-hand side termination condition. If this condition is already a conjunction, the RHS condition is added to the list of clauses.
/// </param>
/// <param name="right">
/// The right-hand side termination condition. If the LHS condition is already a conjunction, this condition is added to the list of clauses.
/// </param>
/// <returns>
/// The combined termination condition, with appropriate short-circuiting.
/// </returns>
public static ITerminationCondition operator &(ITerminationCondition left, ITerminationCondition right)
{
return new CombinerCondition(CombinerCondition.And, this, other);
return left.And(right);
}
}
@ -167,38 +205,4 @@ internal sealed class CombinerCondition : ITerminationCondition
return null;
}
/// <inheritdoc cref="ITerminationCondition.Or" />
/// <remarks>
/// If this condition is already a disjunction, the new condition is added to the list of clauses.
/// </remarks>
ITerminationCondition ITerminationCondition.Or(ITerminationCondition other)
{
if (this.conjunction == Or)
{
this.clauses.Add(other);
return this;
}
else
{
return new CombinerCondition(Or, this, new CombinerCondition(Or, other));
}
}
/// <inheritdoc cref="ITerminationCondition.And" />
/// <remarks>
/// If this condition is already a conjunction, the new condition is added to the list of clauses.
/// </remarks>
ITerminationCondition ITerminationCondition.And(ITerminationCondition other)
{
if (this.conjunction == And)
{
this.clauses.Add(other);
return this;
}
else
{
return new CombinerCondition(And, this, new CombinerCondition(And, other));
}
}
}

View File

@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Usage.cs
namespace Microsoft.AutoGen.AgentChat.Abstractions;
public struct RequestUsage
{
public int PromptTokens { get; set; }
public int CompletionTokens { get; set; }
}

View File

@ -0,0 +1,53 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// ExternalTermination.cs
using Microsoft.AutoGen.AgentChat.Abstractions;
namespace Microsoft.AutoGen.AgentChat.Terminations;
/// <summary>
/// A <see cref="ITerminationCondition"/> that is externally controlled by calling the <see cref="Set"/> method.
/// </summary>
public sealed class ExternalTermination : ITerminationCondition
{
public ExternalTermination()
{
this.TerminationQueued = false;
this.IsTerminated = false;
}
public bool TerminationQueued { get; private set; }
public bool IsTerminated { get; private set; }
/// <summary>
/// Set the termination condition to terminated.
/// </summary>
public void Set()
{
this.TerminationQueued = true;
}
public ValueTask<StopMessage?> CheckAndUpdateAsync(IList<AgentMessage> messages)
{
if (this.IsTerminated)
{
throw new TerminatedException();
}
if (this.TerminationQueued)
{
this.IsTerminated = true;
string message = "External termination requested.";
StopMessage result = new() { Content = message, Source = nameof(ExternalTermination) };
return ValueTask.FromResult<StopMessage?>(result);
}
return ValueTask.FromResult<StopMessage?>(null);
}
public void Reset()
{
this.TerminationQueued = false;
this.IsTerminated = false;
}
}

View File

@ -0,0 +1,51 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// FunctionCallTermination.cs
using Microsoft.AutoGen.AgentChat.Abstractions;
namespace Microsoft.AutoGen.AgentChat.Terminations;
/// <summary>
/// Terminate the conversation if a <see cref="ToolCallExecutionEvent"/> with a specific name is received.
/// </summary>
public sealed class FunctionCallTermination : ITerminationCondition
{
/// <summary>
/// Initializes a new instance of the <see cref="FunctionCallTermination"/> class.
/// </summary>
/// <param name="functionName">The name of the function to look for in the messages.</param>
public FunctionCallTermination(string functionName)
{
this.FunctionName = functionName;
this.IsTerminated = false;
}
public string FunctionName { get; }
public bool IsTerminated { get; private set; }
public ValueTask<StopMessage?> CheckAndUpdateAsync(IList<AgentMessage> messages)
{
if (this.IsTerminated)
{
throw new TerminatedException();
}
foreach (AgentMessage item in messages)
{
if (item is ToolCallExecutionEvent toolCallEvent && toolCallEvent.Content.Any(execution => execution.Name == this.FunctionName))
{
this.IsTerminated = true;
string message = $"Function '{this.FunctionName}' was executed.";
StopMessage result = new() { Content = message, Source = nameof(FunctionCallTermination) };
return ValueTask.FromResult<StopMessage?>(result);
}
}
return ValueTask.FromResult<StopMessage?>(null);
}
public void Reset()
{
this.IsTerminated = false;
}
}

View File

@ -0,0 +1,53 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// HandoffTermination.cs
using Microsoft.AutoGen.AgentChat.Abstractions;
namespace Microsoft.AutoGen.AgentChat.Terminations;
/// <summary>
/// Terminate the conversation if a <see cref="HandoffMessage"/> with the given <see cref="HandoffMessage.Target"/>
/// is received.
/// </summary>
public sealed class HandoffTermination : ITerminationCondition
{
/// <summary>
/// Initializes a new instance of the <see cref="HandoffTermination"/> class.
/// </summary>
/// <param name="target">The target of the handoff message.</param>
public HandoffTermination(string target)
{
this.Target = target;
this.IsTerminated = false;
}
public string Target { get; }
public bool IsTerminated { get; private set; }
public ValueTask<StopMessage?> CheckAndUpdateAsync(IList<AgentMessage> messages)
{
if (this.IsTerminated)
{
throw new TerminatedException();
}
foreach (AgentMessage item in messages)
{
if (item is HandoffMessage handoffMessage && handoffMessage.Target == this.Target)
{
this.IsTerminated = true;
string message = $"Handoff to {handoffMessage.Target} from {handoffMessage.Source} detected.";
StopMessage result = new() { Content = message, Source = nameof(HandoffTermination) };
return ValueTask.FromResult<StopMessage?>(result);
}
}
return ValueTask.FromResult<StopMessage?>(null);
}
public void Reset()
{
this.IsTerminated = false;
}
}

View File

@ -0,0 +1,52 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// MaxMessageTermination.cs
using Microsoft.AutoGen.AgentChat.Abstractions;
namespace Microsoft.AutoGen.AgentChat.Terminations;
/// <summary>
/// Terminate the conversation after a maximum number of messages have been exchanged.
/// </summary>
public sealed class MaxMessageTermination : ITerminationCondition
{
/// <summary>
/// Initializes a new instance of the <see cref="MaxMessageTermination"/> class.
/// </summary>
/// <param name="maxMessages">The maximum number of messages allowed in the conversation.</param>
public MaxMessageTermination(int maxMessages, bool includeAgentEvent = false)
{
this.MaxMessages = maxMessages;
this.MessageCount = 0;
this.IncludeAgentEvent = includeAgentEvent;
}
public int MaxMessages { get; }
public int MessageCount { get; private set; }
public bool IncludeAgentEvent { get; }
public bool IsTerminated => this.MessageCount >= this.MaxMessages;
public ValueTask<StopMessage?> CheckAndUpdateAsync(IList<AgentMessage> messages)
{
if (this.IsTerminated)
{
throw new TerminatedException();
}
this.MessageCount += messages.Where(m => this.IncludeAgentEvent || m is not AgentEvent).Count();
if (this.IsTerminated)
{
StopMessage result = new() { Content = "Max message count reached", Source = nameof(MaxMessageTermination) };
return ValueTask.FromResult<StopMessage?>(result);
}
return ValueTask.FromResult<StopMessage?>(null);
}
public void Reset()
{
this.MessageCount = 0;
}
}

View File

@ -0,0 +1,50 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// SourceMatchTermination.cs
using Microsoft.AutoGen.AgentChat.Abstractions;
namespace Microsoft.AutoGen.AgentChat.Terminations;
/// <summary>
/// Terminate the conversation after a specific source responds.
/// </summary>
public sealed class SourceMatchTermination : ITerminationCondition
{
/// <summary>
/// Initializes a new instance of the <see cref="SourceMatchTermination"/> class.
/// </summary>
/// <param name="sources">List of source names to terminate the conversation.</param>
public SourceMatchTermination(params IEnumerable<string> sources)
{
this.Sources = [.. sources];
}
public HashSet<string> Sources { get; }
public bool IsTerminated { get; private set; }
public ValueTask<StopMessage?> CheckAndUpdateAsync(IList<AgentMessage> messages)
{
if (this.IsTerminated)
{
throw new TerminatedException();
}
foreach (AgentMessage item in messages)
{
if (this.Sources.Contains(item.Source))
{
this.IsTerminated = true;
string message = $"'{item.Source}' answered.";
StopMessage result = new() { Content = message, Source = nameof(SourceMatchTermination) };
return ValueTask.FromResult<StopMessage?>(result);
}
}
return ValueTask.FromResult<StopMessage?>(null);
}
public void Reset()
{
this.IsTerminated = false;
}
}

View File

@ -0,0 +1,76 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// TextMentionTermination.cs
using Microsoft.AutoGen.AgentChat.Abstractions;
using Microsoft.Extensions.AI;
namespace Microsoft.AutoGen.AgentChat.Terminations;
/// <summary>
/// Terminate the conversation if a specific text is mentioned.
/// </summary>
public sealed class TextMentionTermination : ITerminationCondition
{
/// <summary>
/// Initializes a new instance of the <see cref="TextMentionTermination"/> class.
/// </summary>
/// <param name="targetText">The text to look for in the messages.</param>
/// <param name="sources">Check only the messages of the specified agents for the text to look for.</param>
public TextMentionTermination(string targetText, IEnumerable<string>? sources = null)
{
this.TargetText = targetText;
this.Sources = sources != null ? [.. sources] : null;
this.IsTerminated = false;
}
public string TargetText { get; }
public HashSet<string>? Sources { get; }
public bool IsTerminated { get; private set; }
private bool CheckMultiModalData(MultiModalData data)
{
return data.ContentType == MultiModalData.Type.String &&
((TextContent)data.AIContent).Text.Contains(this.TargetText);
}
public ValueTask<StopMessage?> CheckAndUpdateAsync(IList<AgentMessage> messages)
{
if (this.IsTerminated)
{
throw new TerminatedException();
}
foreach (AgentMessage item in messages)
{
if (this.Sources != null && !this.Sources.Contains(item.Source))
{
continue;
}
bool hasMentions = item switch
{
TextMessage textMessage => textMessage.Content.Contains(this.TargetText),
MultiModalMessage multiModalMessage => multiModalMessage.Content.Any(CheckMultiModalData),
StopMessage stopMessage => stopMessage.Content.Contains(this.TargetText),
ToolCallSummaryMessage toolCallSummaryMessage => toolCallSummaryMessage.Content.Contains(this.TargetText),
_ => false
};
if (hasMentions)
{
this.IsTerminated = true;
StopMessage result = new() { Content = "Text mention received", Source = nameof(TextMentionTermination) };
return ValueTask.FromResult<StopMessage?>(result);
}
}
return ValueTask.FromResult<StopMessage?>(null);
}
public void Reset()
{
this.IsTerminated = false;
}
}

View File

@ -0,0 +1,63 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// TextMessageTermination.cs
using Microsoft.AutoGen.AgentChat.Abstractions;
namespace Microsoft.AutoGen.AgentChat.Terminations;
/// <summary>
/// Terminate the conversation if a <see cref="TextMessage"/> is received.
///
/// This termination condition checks for TextMessage instances in the message sequence. When a TextMessage is found,
/// it terminates the conversation if either:
///
/// <list type="bullet">
/// <item>No source was specified (terminates on any <see cref="TextMessage"/>)</item>
/// <item>The message source matches the specified source</item>
/// </list>
///
/// </summary>
public sealed class TextMessageTermination : ITerminationCondition
{
/// <summary>
/// Initializes a new instance of the <see cref="TextMessageTermination"/> class.
/// </summary>
/// <param name="source">
/// The source name to match against incoming messages. If <c>null</c>, matches any source.
/// Defaults to <c>null</c>.
/// </param>
public TextMessageTermination(string? source = null)
{
this.Source = source;
this.IsTerminated = false;
}
public string? Source { get; }
public bool IsTerminated { get; private set; }
public ValueTask<StopMessage?> CheckAndUpdateAsync(IList<AgentMessage> messages)
{
if (this.IsTerminated)
{
throw new TerminatedException();
}
foreach (AgentMessage item in messages)
{
if (item is TextMessage textMessage && (this.Source == null || textMessage.Source == this.Source))
{
this.IsTerminated = true;
string message = $"Text message received from '{textMessage.Source}'.";
StopMessage result = new() { Content = message, Source = nameof(TextMessageTermination) };
return ValueTask.FromResult<StopMessage?>(result);
}
}
return ValueTask.FromResult<StopMessage?>(null);
}
public void Reset()
{
this.IsTerminated = false;
}
}

View File

@ -0,0 +1,59 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// TimeoutTermination.cs
using Microsoft.AutoGen.AgentChat.Abstractions;
namespace Microsoft.AutoGen.AgentChat.Terminations;
/// <summary>
/// Terminate the conversation after the specified duration has passed.
/// </summary>
public sealed class TimeoutTermination : ITerminationCondition
{
/// <summary>
/// Initializes a new instance of the <see cref="TimeoutTermination"/> class.
/// </summary>
/// <param name="timeout">The maximum duration before terminating the conversation.</param>
public TimeoutTermination(TimeSpan timeout)
{
this.Timeout = timeout;
this.StartTime = DateTime.UtcNow;
}
/// <summary>
/// Initializes a new instance of the <see cref="TimeoutTermination"/> class.
/// </summary>
/// <param name="seconds">The maximum duration in seconds before terminating the conversation.</param>
public TimeoutTermination(float seconds) : this(TimeSpan.FromSeconds(seconds))
{
}
public TimeSpan Timeout { get; }
public DateTime StartTime { get; private set; }
public bool IsTerminated { get; private set; }
public ValueTask<StopMessage?> CheckAndUpdateAsync(IList<AgentMessage> messages)
{
if (this.IsTerminated)
{
throw new TerminatedException();
}
if (DateTime.UtcNow - this.StartTime >= this.Timeout)
{
this.IsTerminated = true;
string message = $"Timeout of {this.Timeout.TotalSeconds} seconds reached.";
StopMessage result = new() { Content = message, Source = nameof(TimeoutTermination) };
return ValueTask.FromResult<StopMessage?>(result);
}
return ValueTask.FromResult<StopMessage?>(null);
}
public void Reset()
{
this.IsTerminated = false;
this.StartTime = DateTime.UtcNow;
}
}

View File

@ -0,0 +1,73 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// TokenUsageTermination.cs
using Microsoft.AutoGen.AgentChat.Abstractions;
namespace Microsoft.AutoGen.AgentChat.Terminations;
/// <summary>
/// Terminate the conversation if the token usage limit is reached.
/// </summary>
public sealed class TokenUsageTermination : ITerminationCondition
{
/// <summary>
/// Initializes a new instance of the <see cref="TokenUsageTermination"/> class.
/// </summary>
/// <param name="maxTotalTokens">The maximum total number of tokens allowed in the conversation.</param>
/// <param name="maxPromptTokens">The maximum number of prompt tokens allowed in the conversation.</param>
/// <param name="maxCompletionTokens">The maximum number of completion tokens allowed in the conversation.</param>
public TokenUsageTermination(int? maxTotalTokens = null, int? maxPromptTokens = null, int? maxCompletionTokens = null)
{
this.MaxTotalTokens = maxTotalTokens;
this.MaxPromptTokens = maxPromptTokens;
this.MaxCompletionTokens = maxCompletionTokens;
this.PromptTokenCount = 0;
this.CompletionTokenCount = 0;
}
public int? MaxTotalTokens { get; }
public int? MaxPromptTokens { get; }
public int? MaxCompletionTokens { get; }
public int TotalTokenCount => this.PromptTokenCount + this.CompletionTokenCount;
public int PromptTokenCount { get; private set; }
public int CompletionTokenCount { get; private set; }
public bool IsTerminated =>
(this.MaxTotalTokens != null && this.TotalTokenCount >= this.MaxTotalTokens) ||
(this.MaxPromptTokens != null && this.PromptTokenCount >= this.MaxPromptTokens) ||
(this.MaxCompletionTokens != null && this.CompletionTokenCount >= this.MaxCompletionTokens);
public ValueTask<StopMessage?> CheckAndUpdateAsync(IList<AgentMessage> messages)
{
if (this.IsTerminated)
{
throw new TerminatedException();
}
foreach (AgentMessage item in messages)
{
if (item.ModelUsage is RequestUsage usage)
{
this.PromptTokenCount += usage.PromptTokens;
this.CompletionTokenCount += usage.CompletionTokens;
}
}
if (this.IsTerminated)
{
string message = $"Token usage limit reached, total token count: {this.TotalTokenCount}, prompt token count: {this.PromptTokenCount}, completion token count: {this.CompletionTokenCount}.";
StopMessage result = new() { Content = message, Source = nameof(TokenUsageTermination) };
return ValueTask.FromResult<StopMessage?>(result);
}
return ValueTask.FromResult<StopMessage?>(null);
}
public void Reset()
{
this.PromptTokenCount = 0;
this.CompletionTokenCount = 0;
}
}

View File

@ -12,6 +12,7 @@ using Xunit;
namespace Microsoft.AutoGen.AgentChat.Tests;
[Trait("Category", "UnitV2")]
public class AgentChatSmokeTest
{
public class SpeakMessageAgent : ChatAgentBase

View File

@ -72,6 +72,7 @@ internal sealed class LifecycleObjectFixture : LifecycleObject
}
}
[Trait("Category", "UnitV2")]
public class LifecycleObjectTests
{
/*

View File

@ -8,6 +8,7 @@ using Xunit;
namespace Microsoft.AutoGen.AgentChat.Tests;
[Trait("Category", "UnitV2")]
public class RunContextStackTests
{
public static IRunContextLayer CreateLayer(Action<Mock<IRunContextLayer>>? setupAction = null)

View File

@ -0,0 +1,476 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// TerminationConditionTests.cs
using FluentAssertions;
using Microsoft.AutoGen.AgentChat.Abstractions;
using Microsoft.AutoGen.AgentChat.Terminations;
using Microsoft.Extensions.AI;
using Xunit;
namespace Microsoft.AutoGen.AgentChat.Tests;
[Trait("Category", "UnitV2")]
public static class TerminationExtensions
{
public static async Task InvokeExpectingNullAsync<TTermination>(this TTermination termination, IList<AgentMessage> messages, bool reset = true)
where TTermination : ITerminationCondition
{
(await termination.CheckAndUpdateAsync(messages)).Should().BeNull();
termination.IsTerminated.Should().BeFalse();
if (reset)
{
termination.Reset();
}
}
private static readonly HashSet<string> AnonymousTerminationConditions = ["CombinerCondition", nameof(ITerminationCondition)];
public static async Task InvokeExpectingStopAsync<TTermination>(this TTermination termination, IList<AgentMessage> messages, bool reset = true)
where TTermination : ITerminationCondition
{
StopMessage? stopMessage = await termination.CheckAndUpdateAsync(messages);
stopMessage.Should().NotBeNull();
string name = typeof(TTermination).Name;
if (!AnonymousTerminationConditions.Contains(name))
{
stopMessage!.Source.Should().Be(typeof(TTermination).Name);
}
termination.IsTerminated.Should().BeTrue();
if (reset)
{
termination.Reset();
}
}
public static async Task InvokeExpectingFailureAsync<TTermination>(this TTermination termination, IList<AgentMessage> messages, bool reset = true)
where TTermination : ITerminationCondition
{
Func<Task> failureAction = () => termination.CheckAndUpdateAsync(messages).AsTask();
await failureAction.Should().ThrowAsync<TerminatedException>();
termination.IsTerminated.Should().BeTrue();
if (reset)
{
termination.Reset();
}
}
}
public class TerminationConditionTests
{
[Fact]
public async Task Test_HandoffTermination()
{
HandoffTermination termination = new("target");
termination.IsTerminated.Should().BeFalse();
TextMessage textMessage = new() { Content = "Hello", Source = "user" };
HandoffMessage targetHandoffMessage = new() { Target = "target", Source = "user", Context = "Hello" };
HandoffMessage otherHandoffMessage = new() { Target = "another", Source = "user", Context = "Hello" };
await termination.InvokeExpectingNullAsync([]);
await termination.InvokeExpectingNullAsync([textMessage]);
await termination.InvokeExpectingStopAsync([targetHandoffMessage]);
await termination.InvokeExpectingNullAsync([otherHandoffMessage]);
await termination.InvokeExpectingStopAsync([textMessage, targetHandoffMessage], reset: false);
await termination.InvokeExpectingFailureAsync([], reset: false);
termination.Reset();
termination.IsTerminated.Should().BeFalse();
}
[Fact]
public async Task StopMessageTermination()
{
StopMessageTermination termination = new();
termination.IsTerminated.Should().BeFalse();
TextMessage textMessage = new() { Content = "Hello", Source = "user" };
TextMessage otherMessage = new() { Content = "World", Source = "aser" };
StopMessage stopMessage = new() { Content = "Stop", Source = "user" };
await termination.InvokeExpectingNullAsync([]);
await termination.InvokeExpectingNullAsync([textMessage]);
await termination.InvokeExpectingStopAsync([stopMessage]);
await termination.InvokeExpectingNullAsync([textMessage, otherMessage]);
await termination.InvokeExpectingStopAsync([textMessage, stopMessage], reset: false);
await termination.InvokeExpectingFailureAsync([], reset: false);
termination.Reset();
termination.IsTerminated.Should().BeFalse();
}
[Fact]
public async Task Test_TextMesssageTermination()
{
TextMessageTermination termination = new();
termination.IsTerminated.Should().BeFalse();
TextMessage userMessage = new() { Content = "Hello", Source = "user" };
TextMessage agentMessage = new() { Content = "World", Source = "agent" };
StopMessage stopMessage = new() { Content = "Stop", Source = "user" };
await termination.InvokeExpectingNullAsync([]);
await termination.InvokeExpectingStopAsync([userMessage]);
await termination.InvokeExpectingStopAsync([agentMessage]);
await termination.InvokeExpectingNullAsync([stopMessage]);
termination = new("user");
await termination.InvokeExpectingNullAsync([agentMessage]);
await termination.InvokeExpectingNullAsync([stopMessage]);
await termination.InvokeExpectingStopAsync([userMessage], reset: false);
await termination.InvokeExpectingFailureAsync([], reset: false);
termination.Reset();
termination.IsTerminated.Should().BeFalse();
}
[Fact]
public async Task MaxMessageTermination()
{
MaxMessageTermination termination = new(2);
termination.IsTerminated.Should().BeFalse();
TextMessage textMessage = new() { Content = "Hello", Source = "user" };
TextMessage otherMessage = new() { Content = "World", Source = "agent" };
UserInputRequestedEvent uiRequest = new() { Source = "agent", RequestId = "1" };
await termination.InvokeExpectingNullAsync([]);
await termination.InvokeExpectingNullAsync([textMessage]);
await termination.InvokeExpectingStopAsync([textMessage, otherMessage]);
await termination.InvokeExpectingNullAsync([textMessage, uiRequest]);
termination = new(2, includeAgentEvent: true);
await termination.InvokeExpectingStopAsync([textMessage, uiRequest], reset: false);
await termination.InvokeExpectingFailureAsync([], reset: false);
termination.Reset();
termination.IsTerminated.Should().BeFalse();
}
[Fact]
public async Task Test_TextMentionTermination()
{
TextMentionTermination termination = new("stop");
termination.IsTerminated.Should().BeFalse();
TextMessage textMessage = new() { Content = "Hello", Source = "user" };
TextMessage userStopMessage = new() { Content = "stop", Source = "user" };
TextMessage agentStopMessage = new() { Content = "stop", Source = "agent" };
await termination.InvokeExpectingNullAsync([]);
await termination.InvokeExpectingNullAsync([textMessage]);
await termination.InvokeExpectingStopAsync([userStopMessage]);
termination = new("stop", sources: ["agent"]);
await termination.InvokeExpectingNullAsync([textMessage]);
await termination.InvokeExpectingNullAsync([userStopMessage]);
await termination.InvokeExpectingStopAsync([agentStopMessage], reset: false);
await termination.InvokeExpectingFailureAsync([], reset: false);
termination.Reset();
termination.IsTerminated.Should().BeFalse();
}
[Fact]
public async Task Text_TokenUsageTermination()
{
TokenUsageTermination termination = new(10);
termination.IsTerminated.Should().BeFalse();
RequestUsage usage_10_10 = new() { CompletionTokens = 10, PromptTokens = 10 };
RequestUsage usage_01_01 = new() { CompletionTokens = 1, PromptTokens = 1 };
RequestUsage usage_05_00 = new() { CompletionTokens = 5, PromptTokens = 0 };
RequestUsage usage_00_05 = new() { CompletionTokens = 0, PromptTokens = 5 };
await termination.InvokeExpectingNullAsync([]);
await termination.InvokeExpectingStopAsync([
new TextMessage { Content = "Hello", Source = "user", ModelUsage = usage_10_10 },
]);
await termination.InvokeExpectingNullAsync([
new TextMessage { Content = "Hello", Source = "user", ModelUsage = usage_01_01 },
new TextMessage { Content = "World", Source = "agent", ModelUsage = usage_01_01 },
]);
await termination.InvokeExpectingStopAsync([
new TextMessage { Content = "Hello", Source = "user", ModelUsage = usage_05_00 },
new TextMessage { Content = "World", Source = "agent", ModelUsage = usage_00_05 },
], reset: false);
await termination.InvokeExpectingFailureAsync([], reset: false);
termination.Reset();
termination.IsTerminated.Should().BeFalse();
}
public class AgentTextEvent : AgentEvent
{
public required string Content { get; set; }
public override Extensions.AI.ChatMessage ToCompletionClientMessage(ChatRole role)
{
return new Extensions.AI.ChatMessage(ChatRole.Assistant, this.Content);
}
}
[Fact]
public async Task Text_Termination_AndCombinator()
{
ITerminationCondition lhsClause = new MaxMessageTermination(2);
ITerminationCondition rhsClause = new TextMentionTermination("stop");
ITerminationCondition termination = lhsClause & rhsClause;
termination.IsTerminated.Should().BeFalse();
TextMessage userMessage = new() { Content = "Hello", Source = "user" };
AgentTextEvent agentMessage = new() { Content = "World", Source = "agent" };
TextMessage userStopMessage = new() { Content = "stop", Source = "user" };
await termination.InvokeExpectingNullAsync([]);
await termination.InvokeExpectingNullAsync([userMessage]);
await termination.InvokeExpectingNullAsync([userMessage, agentMessage], reset: false);
lhsClause.IsTerminated.Should().BeFalse();
rhsClause.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingStopAsync([userStopMessage], reset: false);
lhsClause.IsTerminated.Should().BeTrue();
rhsClause.IsTerminated.Should().BeTrue();
termination.IsTerminated.Should().BeTrue();
await termination.InvokeExpectingFailureAsync([], reset: false);
lhsClause.IsTerminated.Should().BeTrue();
rhsClause.IsTerminated.Should().BeTrue();
termination.IsTerminated.Should().BeTrue();
termination.Reset();
termination.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingNullAsync([userMessage, agentMessage], reset: false);
lhsClause.IsTerminated.Should().BeFalse();
rhsClause.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingNullAsync([userMessage], reset: false);
lhsClause.IsTerminated.Should().BeTrue();
rhsClause.IsTerminated.Should().BeFalse();
termination.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingNullAsync([userMessage], reset: false);
lhsClause.IsTerminated.Should().BeTrue();
rhsClause.IsTerminated.Should().BeFalse();
termination.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingStopAsync([userStopMessage], reset: false);
lhsClause.IsTerminated.Should().BeTrue();
rhsClause.IsTerminated.Should().BeTrue();
termination.IsTerminated.Should().BeTrue();
await termination.InvokeExpectingFailureAsync([], reset: false);
lhsClause.IsTerminated.Should().BeTrue();
rhsClause.IsTerminated.Should().BeTrue();
termination.IsTerminated.Should().BeTrue();
termination.Reset();
termination.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingNullAsync([agentMessage, userStopMessage], reset: false);
lhsClause.IsTerminated.Should().BeFalse();
rhsClause.IsTerminated.Should().BeTrue();
termination.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingStopAsync([userMessage], reset: false);
lhsClause.IsTerminated.Should().BeTrue();
rhsClause.IsTerminated.Should().BeTrue();
termination.IsTerminated.Should().BeTrue();
await termination.InvokeExpectingFailureAsync([], reset: false);
lhsClause.IsTerminated.Should().BeTrue();
rhsClause.IsTerminated.Should().BeTrue();
termination.IsTerminated.Should().BeTrue();
termination.Reset();
termination.IsTerminated.Should().BeFalse();
}
[Fact]
public async Task Test_Termination_OrCombiner()
{
ITerminationCondition lhsClause = new MaxMessageTermination(3);
ITerminationCondition rhsClause = new TextMentionTermination("stop");
ITerminationCondition termination = lhsClause | rhsClause;
termination.IsTerminated.Should().BeFalse();
TextMessage userMessage = new() { Content = "Hello", Source = "user" };
AgentTextEvent agentMessage = new() { Content = "World", Source = "agent" };
TextMessage userStopMessage = new() { Content = "stop", Source = "user" };
await termination.InvokeExpectingNullAsync([]);
await termination.InvokeExpectingNullAsync([userMessage]);
await termination.InvokeExpectingNullAsync([userMessage, agentMessage]);
await termination.InvokeExpectingNullAsync([userMessage, agentMessage, userMessage], reset: false);
lhsClause.IsTerminated.Should().BeFalse();
rhsClause.IsTerminated.Should().BeFalse();
termination.IsTerminated.Should().BeFalse();
termination.Reset();
termination.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingStopAsync([userMessage, agentMessage, userStopMessage], reset: false);
lhsClause.IsTerminated.Should().BeFalse();
rhsClause.IsTerminated.Should().BeTrue();
termination.IsTerminated.Should().BeTrue();
await termination.InvokeExpectingFailureAsync([], reset: false);
lhsClause.IsTerminated.Should().BeFalse();
rhsClause.IsTerminated.Should().BeTrue();
termination.IsTerminated.Should().BeTrue();
termination.Reset();
termination.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingStopAsync([userMessage, userMessage, userMessage], reset: false);
lhsClause.IsTerminated.Should().BeTrue();
rhsClause.IsTerminated.Should().BeFalse();
termination.IsTerminated.Should().BeTrue();
await termination.InvokeExpectingFailureAsync([], reset: false);
lhsClause.IsTerminated.Should().BeTrue();
rhsClause.IsTerminated.Should().BeFalse();
termination.IsTerminated.Should().BeTrue();
termination.Reset();
termination.IsTerminated.Should().BeFalse();
}
[Fact]
public async Task Test_TimeoutTermination()
{
TextMessage userMessage = new() { Content = "Hello", Source = "user" };
TimeoutTermination termination = new(0.15f);
termination.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingNullAsync([]);
await Task.Delay(TimeSpan.FromSeconds(0.20f));
await termination.InvokeExpectingStopAsync([], reset: false);
await termination.InvokeExpectingFailureAsync([], reset: false);
termination.Reset();
termination.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingNullAsync([userMessage]);
await Task.Delay(TimeSpan.FromSeconds(0.20f));
await termination.InvokeExpectingStopAsync([], reset: false);
}
[Fact]
public async Task Test_ExternalTermination()
{
ExternalTermination termination = new();
termination.IsTerminated.Should().BeFalse();
TextMessage userMessage = new() { Content = "Hello", Source = "user" };
await termination.InvokeExpectingNullAsync([]);
await termination.InvokeExpectingNullAsync([userMessage]);
termination.Set();
termination.IsTerminated.Should().BeFalse(); // We only terminate on the next check
await termination.InvokeExpectingStopAsync([], reset: false);
await termination.InvokeExpectingFailureAsync([], reset: false);
termination.Reset();
termination.IsTerminated.Should().BeFalse();
await termination.InvokeExpectingNullAsync([userMessage]);
}
private ToolCallRequestEvent CreateFunctionRequest(string functionName, string id = "1", string arguments = "")
{
ToolCallRequestEvent result = new ToolCallRequestEvent
{
Source = "agent"
};
result.Content.Add(
new FunctionCall
{
Id = id,
Name = functionName,
Arguments = arguments,
});
return result;
}
private ToolCallExecutionEvent CreateFunctionResponse(string functionName, string id = "1", string content = "")
{
ToolCallExecutionEvent result = new ToolCallExecutionEvent
{
Source = "agent"
};
result.Content.Add(
new FunctionExecutionResult
{
Id = id,
Name = functionName,
Content = content,
});
return result;
}
[Fact]
public async Task Test_FunctionCallTermination()
{
FunctionCallTermination termination = new("test_function");
termination.IsTerminated.Should().BeFalse();
TextMessage userMessage = new() { Content = "Hello", Source = "user" };
ToolCallRequestEvent toolCallRequest = CreateFunctionRequest("test_function");
ToolCallExecutionEvent testExecution = CreateFunctionResponse("test_function");
ToolCallExecutionEvent otherExecution = CreateFunctionResponse("other_function");
await termination.InvokeExpectingNullAsync([]);
await termination.InvokeExpectingNullAsync([userMessage]);
await termination.InvokeExpectingNullAsync([toolCallRequest]);
await termination.InvokeExpectingNullAsync([otherExecution]);
await termination.InvokeExpectingStopAsync([testExecution], reset: false);
await termination.InvokeExpectingFailureAsync([], reset: false);
termination.Reset();
termination.IsTerminated.Should().BeFalse();
}
}