Remove create/delete from AgentThread to avoid needing a thread implementation for each service. (#64)

* Remove create/delete from thread to avoid needing a thread implementation for each service.

* Address pr feedback.
This commit is contained in:
westey
2025-06-10 15:51:37 +01:00
committed by GitHub
Unverified
parent 6882e003f5
commit 46a117d581
4 changed files with 58 additions and 315 deletions
@@ -39,14 +39,19 @@ public abstract class Agent
public virtual string? Instructions { get; }
/// <summary>
/// Create a new <see cref="AgentThread"/> that is compatible with the agent.
/// Get a new <see cref="AgentThread"/> instance that is compatible with the agent.
/// </summary>
/// <returns>A new <see cref="AgentThread"/> instance that is in the created state.</returns>
/// <returns>A new <see cref="AgentThread"/> instance.</returns>
/// <remarks>
/// <para>
/// If an agent supports multiple thread types, this method should return the default thread
/// type for the agent or whatever the agent was configured to use.
/// </para>
/// <para>
/// If the thread needs to be created via a service call it would be created on first use.
/// </para>
/// </remarks>
public abstract Task<AgentThread> CreateThreadAsync();
public abstract AgentThread GetNewThread();
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread.
@@ -189,23 +194,17 @@ public abstract class Agent
CancellationToken cancellationToken = default);
/// <summary>
/// Ensures that the thread exists, is of the expected type, and is active, plus adds the provided message to the thread.
/// Checks that the thread is of the expected type, or if null, creates the default thread type.
/// </summary>
/// <typeparam name="TThreadType">The expected type of the thead.</typeparam>
/// <param name="messages">The messages to add to the thread once it is setup.</param>
/// <param name="thread">The thread to create if it's null, validate it's type if not null, and start if it is not active.</param>
/// <param name="thread">The thread to create if it's null and validate its type if not null.</param>
/// <param name="constructThread">A callback to use to construct the thread if it's null.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async task that completes once all update are complete.</returns>
protected virtual async Task<TThreadType> EnsureThreadExistsWithMessagesAsync<TThreadType>(
IReadOnlyCollection<ChatMessage> messages,
protected virtual TThreadType ValidateOrCreateThreadType<TThreadType>(
AgentThread? thread,
Func<TThreadType> constructThread,
CancellationToken cancellationToken)
Func<TThreadType> constructThread)
where TThreadType : AgentThread
{
Throw.IfNull(messages);
thread ??= constructThread is not null ? constructThread() : throw new ArgumentNullException(nameof(constructThread));
if (thread is not TThreadType concreteThreadType)
@@ -213,23 +212,11 @@ public abstract class Agent
throw new NotSupportedException($"{this.GetType().Name} currently only supports agent threads of type {nameof(TThreadType)}.");
}
// We have to explicitly call create here to ensure that the thread is created
// before we run using the thread. While threads will be created when
// notified of new messages, some agents support invoking without a message,
// and in that case no messages will be sent in the next step.
await thread.CreateAsync(cancellationToken).ConfigureAwait(false);
// Notify the thread that new messages are available.
foreach (var message in messages)
{
await this.NotifyThreadOfNewMessage(thread, message, cancellationToken).ConfigureAwait(false);
}
return concreteThreadType;
}
/// <summary>
/// Notfiy the given thread that a new message is available.
/// Notfiy the given thread that new messages are available.
/// </summary>
/// <remarks>
/// <para>
@@ -248,12 +235,15 @@ public abstract class Agent
/// require the message.
/// </para>
/// </remarks>
/// <param name="thread">The thread to notify of the new message.</param>
/// <param name="message">The message to pass to the thread.</param>
/// <param name="thread">The thread to notify of the new messages.</param>
/// <param name="messages">The messages to pass to the thread.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async task that completes once the notification is complete.</returns>
protected Task NotifyThreadOfNewMessage(AgentThread thread, ChatMessage message, CancellationToken cancellationToken)
protected async Task NotifyThreadOfNewMessagesAsync(AgentThread thread, IReadOnlyCollection<ChatMessage> messages, CancellationToken cancellationToken)
{
return thread.OnNewMessageAsync(message, cancellationToken);
if (messages.Count > 0)
{
await thread.OnNewMessagesAsync(messages, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -11,113 +12,25 @@ namespace Microsoft.Agents;
/// Base abstraction for all agent threads.
/// A thread represents a specific conversation with an agent.
/// </summary>
/// <remarks>
/// This class is used to manage the lifecycle of an agent thread.
/// The thread can be not-start, started or ended.
/// </remarks>
public abstract class AgentThread
public class AgentThread
{
/// <summary>
/// Gets the id of the current thread.
/// Gets or sets the id of the current thread.
/// </summary>
public string? Id { get; protected set; }
public string? Id { get; set; }
/// <summary>
/// Gets a value indicating whether the thread has been deleted.
/// </summary>
public bool IsDeleted { get; protected set; } = false;
/// <summary>
/// Creates the thread and returns the thread id.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the thread has been created.</returns>
/// <exception cref="InvalidOperationException">The thread has been deleted.</exception>
internal async Task CreateAsync(CancellationToken cancellationToken = default)
{
if (this.IsDeleted)
{
throw new InvalidOperationException("This thread has been deleted and cannot be recreated.");
}
if (this.Id is not null)
{
return;
}
this.Id = await this.CreateCoreAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Deletes the current thread.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the thread has been deleted.</returns>
/// <exception cref="InvalidOperationException">The thread was never created.</exception>
public async Task DeleteAsync(CancellationToken cancellationToken = default)
{
if (this.IsDeleted)
{
return;
}
if (this.Id is null)
{
throw new InvalidOperationException("This thread cannot be deleted, since it has not been created.");
}
await this.DeleteCoreAsync(cancellationToken).ConfigureAwait(false);
this.IsDeleted = true;
}
/// <summary>
/// This method is called when a new message has been contributed to the chat by any participant.
/// This method is called when new messages have been contributed to the chat by any participant.
/// </summary>
/// <remarks>
/// Inheritors can use this method to update their context based on the new message.
/// </remarks>
/// <param name="newMessage">The new message.</param>
/// <param name="newMessages">The new messages.</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 completes when the context has been updated.</returns>
/// <exception cref="InvalidOperationException">The thread has been deleted.</exception>
internal async Task OnNewMessageAsync(ChatMessage newMessage, CancellationToken cancellationToken = default)
protected internal virtual Task OnNewMessagesAsync(IReadOnlyCollection<ChatMessage> newMessages, CancellationToken cancellationToken = default)
{
if (this.IsDeleted)
{
throw new InvalidOperationException("This thread has been deleted and cannot be used anymore.");
}
if (this.Id is null)
{
await this.CreateAsync(cancellationToken).ConfigureAwait(false);
}
await this.OnNewMessageCoreAsync(newMessage, cancellationToken).ConfigureAwait(false);
return Task.CompletedTask;
}
/// <summary>
/// Creates the thread and returns the thread id.
/// Checks have already been completed in the <see cref="CreateAsync"/> method to ensure that the thread can be created.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The id of the thread that was created if one is available.</returns>
protected abstract Task<string?> CreateCoreAsync(CancellationToken cancellationToken);
/// <summary>
/// Deletes the current thread.
/// Checks have already been completed in the <see cref="DeleteAsync"/> method to ensure that the thread can be deleted.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the thread has been deleted.</returns>
protected abstract Task DeleteCoreAsync(CancellationToken cancellationToken);
/// <summary>
/// This method is called when a new message has been contributed to the chat by any participant.
/// Checks have already been completed in the <see cref="OnNewMessageAsync"/> method to ensure that the thread can be updated.
/// </summary>
/// <param name="newMessage">The new message.</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 completes when the context has been updated.</returns>
protected abstract Task OnNewMessageCoreAsync(ChatMessage newMessage, CancellationToken cancellationToken = default);
}
@@ -212,25 +212,35 @@ public class AgentTests
}
[Fact]
public async Task EnsureThreadExistsWithMessagesVerifiesAndCreatesThreadAndNotifiesThreadAsync()
public void ValidateOrCreateThreadTypeVerifiesAndCreatesThread()
{
var messages = new[] { new ChatMessage(ChatRole.User, "msg1"), new ChatMessage(ChatRole.User, "msg2") };
var cancellationToken = new CancellationToken();
// Custom thread type for type checking
var threadMock = new Mock<TestAgentThread>() { CallBase = true };
var agent = new MockAgent();
// Should create and notify
var result = await agent.EnsureThreadExistsWithMessagesAsync<TestAgentThread>(messages, null, () => threadMock.Object, cancellationToken);
// Should create
var result = agent.ValidateOrCreateThreadType<TestAgentThread>(null, () => threadMock.Object);
Assert.Same(threadMock.Object, result);
threadMock.Protected().Verify("OnNewMessageCoreAsync", Times.Once(), messages[0], CancellationToken.None);
threadMock.Protected().Verify("OnNewMessageCoreAsync", Times.Once(), messages[1], CancellationToken.None);
// Should throw if wrong type
var wrongThread = new Mock<AgentThread>().Object;
await Assert.ThrowsAsync<NotSupportedException>(() => agent.EnsureThreadExistsWithMessagesAsync<TestAgentThread>(messages, wrongThread, () => threadMock.Object, cancellationToken));
Assert.Throws<NotSupportedException>(() => agent.ValidateOrCreateThreadType<TestAgentThread>(wrongThread, () => threadMock.Object));
}
[Fact]
public async Task NotifyThreadOfNewMessagesNotifiesThreadAsync()
{
var cancellationToken = new CancellationToken();
var messages = new[] { new ChatMessage(ChatRole.User, "msg1"), new ChatMessage(ChatRole.User, "msg2") };
var threadMock = new Mock<TestAgentThread>() { CallBase = true };
var agent = new MockAgent();
await agent.NotifyThreadOfNewMessagesAsync(threadMock.Object, messages, cancellationToken);
threadMock.Protected().Verify("OnNewMessagesAsync", Times.Once(), messages, cancellationToken);
}
/// <summary>
@@ -241,25 +251,26 @@ public class AgentTests
}
/// <summary>
/// Mock class to test the <see cref="Agent.EnsureThreadExistsWithMessagesAsync{TThreadType}"/> method.
/// Mock class to test the <see cref="Agent.ValidateOrCreateThreadType{TThreadType}"/> method.
/// </summary>
private sealed class MockAgent : Agent
{
public new Task<TThreadType> EnsureThreadExistsWithMessagesAsync<TThreadType>(
IReadOnlyCollection<ChatMessage> messages,
public new TThreadType ValidateOrCreateThreadType<TThreadType>(
AgentThread? thread,
Func<TThreadType> constructThread,
CancellationToken cancellationToken)
Func<TThreadType> constructThread)
where TThreadType : AgentThread
{
return base.EnsureThreadExistsWithMessagesAsync<TThreadType>(
messages,
return base.ValidateOrCreateThreadType<TThreadType>(
thread,
constructThread,
cancellationToken);
constructThread);
}
public override Task<AgentThread> CreateThreadAsync()
public new Task NotifyThreadOfNewMessagesAsync(AgentThread thread, IReadOnlyCollection<ChatMessage> messages, CancellationToken cancellationToken)
{
return base.NotifyThreadOfNewMessagesAsync(thread, messages, cancellationToken);
}
public override AgentThread GetNewThread()
{
throw new System.NotImplementedException();
}
@@ -1,171 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.Abstractions.UnitTests;
/// <summary>
/// Contains tests for the <see cref="AgentThread"/> class.
/// </summary>
public class AgentThreadTests
{
/// <summary>
/// Tests that the CreateAsync method sets the Id and invokes CreateInternalAsync once.
/// </summary>
[Fact]
public async Task CreateShouldSetIdAndInvokeCreateInternalOnceAsync()
{
// Arrange
var thread = new TestAgentThread();
// Act
await thread.CreateAsync();
await thread.CreateAsync();
// Assert
Assert.Equal("test-thread-id", thread.Id);
Assert.Equal(1, thread.CreateInternalAsyncCount);
}
/// <summary>
/// Tests that the CreateAsync method throws an InvalidOperationException if the thread is deleted.
/// </summary>
[Fact]
public async Task CreateShouldThrowIfThreadDeletedAsync()
{
// Arrange
var thread = new TestAgentThread();
await thread.CreateAsync();
await thread.DeleteAsync();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => thread.CreateAsync());
Assert.Equal(1, thread.CreateInternalAsyncCount);
Assert.Equal(1, thread.DeleteInternalAsyncCount);
}
/// <summary>
/// Tests that the DeleteAsync method sets IsDeleted and invokes DeleteInternalAsync.
/// </summary>
[Fact]
public async Task DeleteShouldSetIsDeletedAndInvokeDeleteInternalAsync()
{
// Arrange
var thread = new TestAgentThread();
await thread.CreateAsync();
// Act
await thread.DeleteAsync();
// Assert
Assert.True(thread.IsDeleted);
Assert.Equal(1, thread.CreateInternalAsyncCount);
Assert.Equal(1, thread.DeleteInternalAsyncCount);
}
/// <summary>
/// Tests that the DeleteAsync method does not invoke DeleteInternalAsync if the thread is already deleted.
/// </summary>
[Fact]
public async Task DeleteShouldNotInvokeDeleteInternalIfAlreadyDeletedAsync()
{
// Arrange
var thread = new TestAgentThread();
await thread.CreateAsync();
await thread.DeleteAsync();
// Act
await thread.DeleteAsync();
// Assert
Assert.True(thread.IsDeleted);
Assert.Equal(1, thread.CreateInternalAsyncCount);
Assert.Equal(1, thread.DeleteInternalAsyncCount);
}
/// <summary>
/// Tests that the DeleteAsync method throws an InvalidOperationException if the thread was never created.
/// </summary>
[Fact]
public async Task DeleteShouldThrowIfNeverCreatedAsync()
{
// Arrange
var thread = new TestAgentThread();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => thread.DeleteAsync());
Assert.Equal(0, thread.CreateInternalAsyncCount);
Assert.Equal(0, thread.DeleteInternalAsyncCount);
}
/// <summary>
/// Tests that the OnNewMessageAsync method creates the thread if it is not already created.
/// </summary>
[Fact]
public async Task OnNewMessageShouldCreateThreadIfNotCreatedAsync()
{
// Arrange
var thread = new TestAgentThread();
var message = new ChatMessage();
// Act
await thread.OnNewMessageAsync(message);
// Assert
Assert.Equal("test-thread-id", thread.Id);
Assert.Equal(1, thread.CreateInternalAsyncCount);
Assert.Equal(1, thread.OnNewMessageInternalAsyncCount);
}
/// <summary>
/// Tests that the OnNewMessageAsync method throws an InvalidOperationException if the thread is deleted.
/// </summary>
[Fact]
public async Task OnNewMessageShouldThrowIfThreadDeletedAsync()
{
// Arrange
var thread = new TestAgentThread();
await thread.CreateAsync();
await thread.DeleteAsync();
var message = new ChatMessage();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => thread.OnNewMessageAsync(message));
Assert.Equal(1, thread.CreateInternalAsyncCount);
Assert.Equal(1, thread.DeleteInternalAsyncCount);
Assert.Equal(0, thread.OnNewMessageInternalAsyncCount);
}
private sealed class TestAgentThread : AgentThread
{
public int CreateInternalAsyncCount { get; private set; }
public int DeleteInternalAsyncCount { get; private set; }
public int OnNewMessageInternalAsyncCount { get; private set; }
public new Task CreateAsync(CancellationToken cancellationToken = default)
{
return base.CreateAsync(cancellationToken);
}
protected override Task<string?> CreateCoreAsync(CancellationToken cancellationToken)
{
this.CreateInternalAsyncCount++;
return Task.FromResult<string?>("test-thread-id");
}
protected override Task DeleteCoreAsync(CancellationToken cancellationToken)
{
this.DeleteInternalAsyncCount++;
return Task.CompletedTask;
}
protected override Task OnNewMessageCoreAsync(ChatMessage newMessage, CancellationToken cancellationToken = default)
{
this.OnNewMessageInternalAsyncCount++;
return Task.CompletedTask;
}
}
}