.NET Workflow - Declarative Workflow Event Fix (#765)

* Checkpoint

* Update workflows/DeepResearch.yaml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Comment

* Fix comment

* Update package version

* Fix nuget haxx

* Checkpoint

* Code complete

* Testing

* Message content workaround

* Add sequential flow

* Checkpoint

* Integration test project

* Checkpoint

* Checkpoint cleanup

* Complete

* Checkpoint

* Fix tests

* Comment cleanup

* Namespace

* Formatting

* Analyzer updates

* Workflow update

* Fixed!

* Update dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs

Co-authored-by: Tao Chen <taochen@microsoft.com>

* Fix build error

* Purge "immutable" set and dictionary

* Fix as task

* Collection expression

* Another

* Frozen => Readonly (perf)

* Fix

* Namespace

* Checkpoint

* Add test

* Fix

* Rollback workflow edit

* nitty

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Tao Chen <taochen@microsoft.com>
This commit is contained in:
Chris
2025-09-16 11:57:07 -07:00
committed by GitHub
Unverified
parent 6b91d41a73
commit b2d2019874
12 changed files with 100 additions and 34 deletions
@@ -143,11 +143,11 @@ internal sealed class Program
Debug.WriteLine($"EXECUTOR EXIT #{executorCompleted.ExecutorId}");
break;
case DeclarativeActionInvokeEvent actionInvoked:
case DeclarativeActionInvokedEvent actionInvoked:
Debug.WriteLine($"ACTION ENTER #{actionInvoked.ActionId} [{actionInvoked.ActionType}]");
break;
case DeclarativeActionCompleteEvent actionComplete:
case DeclarativeActionCompletedEvent actionComplete:
Debug.WriteLine($"ACTION EXIT #{actionComplete.ActionId} [{actionComplete.ActionType}]");
break;
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Declarative;
/// <summary>
/// Event that indicates a declarative action has been invoked.
/// </summary>
public sealed class DeclarativeActionInvokeEvent : WorkflowEvent
public sealed class DeclarativeActionInvokedEvent : WorkflowEvent
{
/// <summary>
/// The declarative action id.
@@ -30,7 +30,7 @@ public sealed class DeclarativeActionInvokeEvent : WorkflowEvent
/// </summary>
public string? PriorActionId { get; }
internal DeclarativeActionInvokeEvent(DialogAction action, string? priorActionId) : base(action)
internal DeclarativeActionInvokedEvent(DialogAction action, string? priorActionId) : base(action)
{
this.ActionId = action.GetId();
this.ActionType = action.GetType().Name;
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Declarative;
/// <summary>
/// Event that indicates a declarative action has completed.
/// </summary>
public sealed class DeclarativeActionCompleteEvent : WorkflowEvent
public sealed class DeclarativeActionCompletedEvent : WorkflowEvent
{
/// <summary>
/// The declarative action identifier.
@@ -25,7 +25,7 @@ public sealed class DeclarativeActionCompleteEvent : WorkflowEvent
/// </summary>
public string? ParentActionId { get; }
internal DeclarativeActionCompleteEvent(DialogAction action) : base(action)
internal DeclarativeActionCompletedEvent(DialogAction action) : base(action)
{
this.ActionId = action.GetId();
this.ActionType = action.GetType().Name;
@@ -12,10 +12,10 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions;
internal static class IWorkflowContextExtensions
{
public static ValueTask RaiseInvocationEventAsync(this IWorkflowContext context, DialogAction action, string? priorEventId = null) =>
context.AddEventAsync(new DeclarativeActionInvokeEvent(action, priorEventId));
context.AddEventAsync(new DeclarativeActionInvokedEvent(action, priorEventId));
public static ValueTask RaiseCompletionEventAsync(this IWorkflowContext context, DialogAction action) =>
context.AddEventAsync(new DeclarativeActionCompleteEvent(action));
context.AddEventAsync(new DeclarativeActionCompletedEvent(action));
public static ValueTask SendResultMessageAsync(this IWorkflowContext context, string id, object? result = null, CancellationToken cancellationToken = default) =>
context.SendMessageAsync(new ExecutorResultMessage(id, result));
@@ -68,7 +68,13 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
{
if (this._workflowModel.GetDepth(item.Id.Value) > 1)
{
string completionId = this.ContinuationFor(item.Id.Value); // End scope
DelegateAction<ExecutorResultMessage>? action = null;
ConditionGroupExecutor? conditionGroup = this._workflowModel.LocateParent<ConditionGroupExecutor>(parentId);
if (conditionGroup is not null)
{
action = conditionGroup.DoneAsync;
}
string completionId = this.ContinuationFor(item.Id.Value, action); // End scope
this._workflowModel.AddLinkFromPeer(item.Id.Value, completionId); // Connect with final action
this._workflowModel.AddLink(completionId, Steps.Post(parentId)); // Merge with parent scope
}
@@ -175,10 +181,10 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
ForeachExecutor? loopExecutor = this._workflowModel.LocateParent<ForeachExecutor>(item.GetParentId());
if (loopExecutor is not null)
{
string parentId = GetParentId(item);
this.ContinueWith(new DelegateActionExecutor(item.Id.Value, this._workflowState), parentId);
this._workflowModel.AddLink(item.Id.Value, Steps.Post(loopExecutor.Id));
this.RestartAfter(item.Id.Value, parentId);
DefaultActionExecutor breakLoopExecutor = new(item, this._workflowState);
this.ContinueWith(breakLoopExecutor);
this._workflowModel.AddLink(breakLoopExecutor.Id, Steps.Post(loopExecutor.Id));
this.RestartAfter(breakLoopExecutor.Id, breakLoopExecutor.ParentId);
}
}
@@ -189,10 +195,10 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
ForeachExecutor? loopExecutor = this._workflowModel.LocateParent<ForeachExecutor>(item.GetParentId());
if (loopExecutor is not null)
{
string parentId = GetParentId(item);
this.ContinueWith(new DelegateActionExecutor(item.Id.Value, this._workflowState), parentId);
this._workflowModel.AddLink(item.Id.Value, ForeachExecutor.Steps.Next(loopExecutor.Id));
this.RestartAfter(item.Id.Value, parentId);
DefaultActionExecutor continueLoopExecutor = new(item, this._workflowState);
this.ContinueWith(continueLoopExecutor);
this._workflowModel.AddLink(continueLoopExecutor.Id, Steps.Post(loopExecutor.Id));
this.RestartAfter(continueLoopExecutor.Id, continueLoopExecutor.ParentId);
}
}
@@ -200,9 +206,9 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
{
this.Trace(item);
string parentId = GetParentId(item);
this.ContinueWith(new DelegateActionExecutor(item.Id.Value, this._workflowState), parentId);
this.RestartAfter(item.Id.Value, parentId);
DefaultActionExecutor endExecutor = new(item, this._workflowState);
this.ContinueWith(endExecutor);
this.RestartAfter(item.Id.Value, endExecutor.ParentId);
}
protected override void Visit(Question item)
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.Workflows.Declarative.Interpreter;
using Microsoft.Agents.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
namespace Microsoft.Agents.Workflows.Declarative.ObjectModel;
internal sealed class DefaultActionExecutor(DialogAction model, WorkflowFormulaState state) :
DeclarativeActionExecutor(model, state)
{
protected override ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
// No action needed - the edge will be followed automatically
return default;
}
}
@@ -74,7 +74,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output, AgentFixtu
Workflow<TInput> workflow = DeclarativeWorkflowBuilder.Build<TInput>(workflowPath, workflowOptions);
WorkflowEvents workflowEvents = await WorkflowHarness.RunAsync(workflow, (TInput)GetInput<TInput>(testcase));
foreach (DeclarativeActionInvokeEvent actionInvokeEvent in workflowEvents.ActionInvokeEvents)
foreach (DeclarativeActionInvokedEvent actionInvokeEvent in workflowEvents.ActionInvokeEvents)
{
this.Output.WriteLine($"ACTION: {actionInvokeEvent.ActionId} [{actionInvokeEvent.ActionType}]");
}
@@ -12,12 +12,12 @@ internal sealed class WorkflowEvents
{
this.Events = workflowEvents;
this.EventCounts = workflowEvents.GroupBy(e => e.GetType()).ToImmutableDictionary(e => e.Key, e => e.Count());
this.ActionInvokeEvents = workflowEvents.OfType<DeclarativeActionInvokeEvent>().ToImmutableList();
this.ActionCompleteEvents = workflowEvents.OfType<DeclarativeActionCompleteEvent>().ToImmutableList();
this.ActionInvokeEvents = workflowEvents.OfType<DeclarativeActionInvokedEvent>().ToImmutableList();
this.ActionCompleteEvents = workflowEvents.OfType<DeclarativeActionCompletedEvent>().ToImmutableList();
}
public ImmutableList<WorkflowEvent> Events { get; }
public IImmutableDictionary<Type, int> EventCounts { get; }
public ImmutableList<DeclarativeActionInvokeEvent> ActionInvokeEvents { get; }
public ImmutableList<DeclarativeActionCompleteEvent> ActionCompleteEvents { get; private set; }
public ImmutableList<DeclarativeActionInvokedEvent> ActionInvokeEvents { get; }
public ImmutableList<DeclarativeActionCompletedEvent> ActionCompleteEvents { get; private set; }
}
@@ -58,7 +58,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
public async Task LoopContinueAction()
{
await this.RunWorkflow("LoopContinue.yaml");
this.AssertExecutionCount(expectedCount: 23);
this.AssertExecutionCount(expectedCount: 7);
this.AssertExecuted("foreach_loop");
this.AssertExecuted("continueLoop_now");
this.AssertExecuted("end_all");
@@ -66,6 +66,15 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
this.AssertNotExecuted("sendActivity_loop");
}
[Fact]
public async Task EndConversationAction()
{
await this.RunWorkflow("EndConversation.yaml");
this.AssertExecutionCount(expectedCount: 1);
this.AssertExecuted("end_all");
this.AssertNotExecuted("sendActivity_1");
}
[Fact]
public async Task GotoAction()
{
@@ -89,7 +98,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
this.AssertExecuted("conditionGroup_test");
if (input % 2 == 0)
{
this.AssertExecuted("conditionItem_even");
this.AssertExecuted("conditionItem_even", isScope: true);
this.AssertExecuted("sendActivity_even");
this.AssertNotExecuted("conditionItem_odd");
this.AssertNotExecuted("sendActivity_odd");
@@ -97,7 +106,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
}
else
{
this.AssertExecuted("conditionItem_odd");
this.AssertExecuted("conditionItem_odd", isScope: true);
this.AssertExecuted("sendActivity_odd");
this.AssertNotExecuted("conditionItem_even");
this.AssertNotExecuted("sendActivity_even");
@@ -117,13 +126,13 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
this.AssertExecuted("conditionGroup_test");
if (input % 2 == 0)
{
this.AssertExecuted("sendActivity_else");
this.AssertExecuted("sendActivity_else", isScope: true);
this.AssertNotExecuted("conditionItem_odd");
this.AssertNotExecuted("sendActivity_odd");
}
else
{
this.AssertExecuted("conditionItem_odd");
this.AssertExecuted("conditionItem_odd", isScope: true);
this.AssertExecuted("sendActivity_odd");
this.AssertNotExecuted("sendActivity_else");
}
@@ -220,10 +229,15 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
Assert.DoesNotContain(this.WorkflowEvents.OfType<ExecutorCompletedEvent>(), e => e.ExecutorId == executorId);
}
private void AssertExecuted(string executorId)
private void AssertExecuted(string executorId, bool isScope = false)
{
Assert.Contains(this.WorkflowEvents.OfType<ExecutorInvokedEvent>(), e => e.ExecutorId == executorId);
Assert.Contains(this.WorkflowEvents.OfType<ExecutorCompletedEvent>(), e => e.ExecutorId == executorId);
if (!isScope)
{
Assert.Contains(this.WorkflowEvents.OfType<DeclarativeActionInvokedEvent>(), e => e.ActionId == executorId);
Assert.Contains(this.WorkflowEvents.OfType<DeclarativeActionCompletedEvent>(), e => e.ActionId == executorId);
}
}
private void AssertMessage(string message)
@@ -251,6 +265,14 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
ExecutorResultMessage? message = invokeEvent.Data as ExecutorResultMessage;
this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]");
}
else if (workflowEvent is DeclarativeActionInvokedEvent actionInvokeEvent)
{
this.Output.WriteLine($"ACTION ENTER: {actionInvokeEvent.ActionId}");
}
else if (workflowEvent is DeclarativeActionCompletedEvent actionCompleteEvent)
{
this.Output.WriteLine($"ACTION EXIT: {actionCompleteEvent.ActionId}");
}
else if (workflowEvent is AgentRunResponseEvent messageEvent)
{
this.Output.WriteLine($"MESSAGE: {messageEvent.Response.Messages[0].Text.Trim()}");
@@ -31,8 +31,8 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor
workflowBuilder.AddEdge(workflowExecutor, executor);
StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build<WorkflowFormulaState>(), this.State);
WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync();
Assert.Contains(events, e => e is DeclarativeActionInvokeEvent);
Assert.Contains(events, e => e is DeclarativeActionCompleteEvent);
Assert.Contains(events, e => e is DeclarativeActionInvokedEvent);
Assert.Contains(events, e => e is DeclarativeActionCompletedEvent);
return events;
}
@@ -0,0 +1,13 @@
kind: AdaptiveDialog
beginDialog:
kind: OnActivity
id: my_workflow
type: Message
actions:
- kind: EndConversation
id: end_all
- kind: SendActivity
id: send_activity_1
activity: NEVER 1!
+7 -1
View File
@@ -29,10 +29,12 @@ beginDialog:
entity:
kind: StringPrebuiltEntity
# Confirm input
- kind: ConditionGroup
id: check_completion
conditions:
# Didn't match
- condition: =Topic.OriginalInput <> Topic.ConfirmedInput
id: check_confirm
actions:
@@ -46,7 +48,8 @@ beginDialog:
id: goto_again
actionId: question_confirm
elseActions: # // %%% REMOVE / BUG
# Confirmed
elseActions:
- kind: SendActivity
id: sendActivity_confirmed
activity: |-
@@ -55,3 +58,6 @@ beginDialog:
Confirmed input:
{Topic.ConfirmedInput}