.NET: [BREAKING] Propagate CancellationToken into Workflow Executors and message handlers (#1280)

* feat: Propagate CancellationToken to Executors

* Also adds cancellation propagation to `Executor`-accessible APIs
* Adds registrators for cancellable handlers to `RouteBuilder`
* [BREAKING]: Adds `CancellationToken` to `IMessageHandler.HandleAsync`

* test: Re-enable Concurrent Orchestration test

* refactor: Delete unused IInputCoordinator

* refactor: Remove superfluous argument qualifications
This commit is contained in:
Jacob Alber
2025-10-08 00:28:44 +00:00
committed by GitHub
parent eb049c43a6
commit 5902bcb10a
74 changed files with 910 additions and 557 deletions
@@ -160,7 +160,7 @@ internal sealed class SpamDetectionExecutor : ReflectingExecutor<SpamDetectionEx
this._spamDetectionAgent = spamDetectionAgent;
}
public async ValueTask<DetectionResult> HandleAsync(ChatMessage message, IWorkflowContext context)
public async ValueTask<DetectionResult> HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Generate a random email ID and store the email content to the shared state
var newEmail = new Email
@@ -168,10 +168,10 @@ internal sealed class SpamDetectionExecutor : ReflectingExecutor<SpamDetectionEx
EmailId = Guid.NewGuid().ToString("N"),
EmailContent = message.Text
};
await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope);
await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope, cancellationToken);
// Invoke the agent
var response = await this._spamDetectionAgent.RunAsync(message);
var response = await this._spamDetectionAgent.RunAsync(message, cancellationToken: cancellationToken);
var detectionResult = JsonSerializer.Deserialize<DetectionResult>(response.Text);
detectionResult!.EmailId = newEmail.EmailId;
@@ -205,7 +205,7 @@ internal sealed class EmailAssistantExecutor : ReflectingExecutor<EmailAssistant
this._emailAssistantAgent = emailAssistantAgent;
}
public async ValueTask<EmailResponse> HandleAsync(DetectionResult message, IWorkflowContext context)
public async ValueTask<EmailResponse> HandleAsync(DetectionResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (message.IsSpam)
{
@@ -213,11 +213,11 @@ internal sealed class EmailAssistantExecutor : ReflectingExecutor<EmailAssistant
}
// Retrieve the email content from the shared state
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope)
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken)
?? throw new InvalidOperationException("Email not found.");
// Invoke the agent
var response = await this._emailAssistantAgent.RunAsync(email.EmailContent);
var response = await this._emailAssistantAgent.RunAsync(email.EmailContent, cancellationToken: cancellationToken);
var emailResponse = JsonSerializer.Deserialize<EmailResponse>(response.Text);
return emailResponse!;
@@ -232,8 +232,8 @@ internal sealed class SendEmailExecutor() : ReflectingExecutor<SendEmailExecutor
/// <summary>
/// Simulate the sending of an email.
/// </summary>
public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) =>
await context.YieldOutputAsync($"Email sent: {message.Response}");
public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
await context.YieldOutputAsync($"Email sent: {message.Response}", cancellationToken);
}
/// <summary>
@@ -244,11 +244,11 @@ internal sealed class HandleSpamExecutor() : ReflectingExecutor<HandleSpamExecut
/// <summary>
/// Simulate the handling of a spam message.
/// </summary>
public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context)
public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (message.IsSpam)
{
await context.YieldOutputAsync($"Email marked as spam: {message.Reason}");
await context.YieldOutputAsync($"Email marked as spam: {message.Reason}", cancellationToken);
}
else
{
@@ -185,7 +185,7 @@ internal sealed class SpamDetectionExecutor : ReflectingExecutor<SpamDetectionEx
this._spamDetectionAgent = spamDetectionAgent;
}
public async ValueTask<DetectionResult> HandleAsync(ChatMessage message, IWorkflowContext context)
public async ValueTask<DetectionResult> HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Generate a random email ID and store the email content
var newEmail = new Email
@@ -193,10 +193,10 @@ internal sealed class SpamDetectionExecutor : ReflectingExecutor<SpamDetectionEx
EmailId = Guid.NewGuid().ToString("N"),
EmailContent = message.Text
};
await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope);
await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope, cancellationToken);
// Invoke the agent
var response = await this._spamDetectionAgent.RunAsync(message);
var response = await this._spamDetectionAgent.RunAsync(message, cancellationToken: cancellationToken);
var detectionResult = JsonSerializer.Deserialize<DetectionResult>(response.Text);
detectionResult!.EmailId = newEmail.EmailId;
@@ -230,7 +230,7 @@ internal sealed class EmailAssistantExecutor : ReflectingExecutor<EmailAssistant
this._emailAssistantAgent = emailAssistantAgent;
}
public async ValueTask<EmailResponse> HandleAsync(DetectionResult message, IWorkflowContext context)
public async ValueTask<EmailResponse> HandleAsync(DetectionResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (message.spamDecision == SpamDecision.Spam)
{
@@ -238,10 +238,10 @@ internal sealed class EmailAssistantExecutor : ReflectingExecutor<EmailAssistant
}
// Retrieve the email content from the context
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope);
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken);
// Invoke the agent
var response = await this._emailAssistantAgent.RunAsync(email!.EmailContent);
var response = await this._emailAssistantAgent.RunAsync(email!.EmailContent, cancellationToken: cancellationToken);
var emailResponse = JsonSerializer.Deserialize<EmailResponse>(response.Text);
return emailResponse!;
@@ -256,8 +256,8 @@ internal sealed class SendEmailExecutor() : ReflectingExecutor<SendEmailExecutor
/// <summary>
/// Simulate the sending of an email.
/// </summary>
public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) =>
await context.YieldOutputAsync($"Email sent: {message.Response}").ConfigureAwait(false);
public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
await context.YieldOutputAsync($"Email sent: {message.Response}", cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -268,11 +268,11 @@ internal sealed class HandleSpamExecutor() : ReflectingExecutor<HandleSpamExecut
/// <summary>
/// Simulate the handling of a spam message.
/// </summary>
public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context)
public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (message.spamDecision == SpamDecision.Spam)
{
await context.YieldOutputAsync($"Email marked as spam: {message.Reason}").ConfigureAwait(false);
await context.YieldOutputAsync($"Email marked as spam: {message.Reason}", cancellationToken).ConfigureAwait(false);
}
else
{
@@ -289,12 +289,12 @@ internal sealed class HandleUncertainExecutor() : ReflectingExecutor<HandleUncer
/// <summary>
/// Simulate the handling of an uncertain spam decision.
/// </summary>
public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context)
public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (message.spamDecision == SpamDecision.Uncertain)
{
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope);
await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}");
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken);
await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}", cancellationToken);
}
else
{
@@ -241,7 +241,7 @@ internal sealed class EmailAnalysisExecutor : ReflectingExecutor<EmailAnalysisEx
this._emailAnalysisAgent = emailAnalysisAgent;
}
public async ValueTask<AnalysisResult> HandleAsync(ChatMessage message, IWorkflowContext context)
public async ValueTask<AnalysisResult> HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Generate a random email ID and store the email content
var newEmail = new Email
@@ -249,10 +249,10 @@ internal sealed class EmailAnalysisExecutor : ReflectingExecutor<EmailAnalysisEx
EmailId = Guid.NewGuid().ToString("N"),
EmailContent = message.Text
};
await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope);
await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope, cancellationToken);
// Invoke the agent
var response = await this._emailAnalysisAgent.RunAsync(message);
var response = await this._emailAnalysisAgent.RunAsync(message, cancellationToken: cancellationToken);
var AnalysisResult = JsonSerializer.Deserialize<AnalysisResult>(response.Text);
AnalysisResult!.EmailId = newEmail.EmailId;
@@ -287,7 +287,7 @@ internal sealed class EmailAssistantExecutor : ReflectingExecutor<EmailAssistant
this._emailAssistantAgent = emailAssistantAgent;
}
public async ValueTask<EmailResponse> HandleAsync(AnalysisResult message, IWorkflowContext context)
public async ValueTask<EmailResponse> HandleAsync(AnalysisResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (message.spamDecision == SpamDecision.Spam)
{
@@ -295,10 +295,10 @@ internal sealed class EmailAssistantExecutor : ReflectingExecutor<EmailAssistant
}
// Retrieve the email content from the context
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope);
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken);
// Invoke the agent
var response = await this._emailAssistantAgent.RunAsync(email!.EmailContent);
var response = await this._emailAssistantAgent.RunAsync(email!.EmailContent, cancellationToken: cancellationToken);
var emailResponse = JsonSerializer.Deserialize<EmailResponse>(response.Text);
return emailResponse!;
@@ -313,8 +313,8 @@ internal sealed class SendEmailExecutor() : ReflectingExecutor<SendEmailExecutor
/// <summary>
/// Simulate the sending of an email.
/// </summary>
public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) =>
await context.YieldOutputAsync($"Email sent: {message.Response}");
public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
await context.YieldOutputAsync($"Email sent: {message.Response}", cancellationToken);
}
/// <summary>
@@ -325,11 +325,11 @@ internal sealed class HandleSpamExecutor() : ReflectingExecutor<HandleSpamExecut
/// <summary>
/// Simulate the handling of a spam message.
/// </summary>
public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context)
public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (message.spamDecision == SpamDecision.Spam)
{
await context.YieldOutputAsync($"Email marked as spam: {message.Reason}");
await context.YieldOutputAsync($"Email marked as spam: {message.Reason}", cancellationToken);
}
else
{
@@ -346,12 +346,12 @@ internal sealed class HandleUncertainExecutor() : ReflectingExecutor<HandleUncer
/// <summary>
/// Simulate the handling of an uncertain spam decision.
/// </summary>
public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context)
public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (message.spamDecision == SpamDecision.Uncertain)
{
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope);
await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}");
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken);
await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}", cancellationToken);
}
else
{
@@ -385,13 +385,13 @@ internal sealed class EmailSummaryExecutor : ReflectingExecutor<EmailSummaryExec
this._emailSummaryAgent = emailSummaryAgent;
}
public async ValueTask<AnalysisResult> HandleAsync(AnalysisResult message, IWorkflowContext context)
public async ValueTask<AnalysisResult> HandleAsync(AnalysisResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Read the email content from the shared states
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope);
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken);
// Invoke the agent
var response = await this._emailSummaryAgent.RunAsync(email!.EmailContent);
var response = await this._emailSummaryAgent.RunAsync(email!.EmailContent, cancellationToken: cancellationToken);
var emailSummary = JsonSerializer.Deserialize<EmailSummary>(response.Text);
message.EmailSummary = emailSummary!.Summary;
@@ -410,17 +410,17 @@ internal sealed class DatabaseEvent(string message) : WorkflowEvent(message) { }
/// </summary>
internal sealed class DatabaseAccessExecutor() : ReflectingExecutor<DatabaseAccessExecutor>("DatabaseAccessExecutor"), IMessageHandler<AnalysisResult>
{
public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context)
public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// 1. Save the email content
await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope);
await Task.Delay(100); // Simulate database access delay
await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken);
await Task.Delay(100, cancellationToken); // Simulate database access delay
// 2. Save the analysis result
await Task.Delay(100); // Simulate database access delay
await Task.Delay(100, cancellationToken); // Simulate database access delay
// Not using the `WorkflowCompletedEvent` because this is not the end of the workflow.
// The end of the workflow is signaled by the `SendEmailExecutor` or the `HandleUnknownExecutor`.
await context.AddEventAsync(new DatabaseEvent($"Email {message.EmailId} saved to database."));
await context.AddEventAsync(new DatabaseEvent($"Email {message.EmailId} saved to database."), cancellationToken);
}
}