Repository navigation
.NET: [Bug]: Fan-out edge runs a target twice when the target selector returns its index twice #9431
Description
Observed Behavior
FanOutEdgeRunner.ChaseEdgeAsync maps each index returned by the fan-out target selector to a sink ID and keeps every entry (dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanOutEdgeRunner.cs:26-35). When the selector returns the same index twice, the target ends up in the DeliveryMapping twice, MapInto enqueues the envelope twice into that executor's queue, and the executor handles the same message twice in the next superstep.
A selector that maps tags or categories to targets hits this as soon as two tags share a target. With an agent executor as the target, the message appears twice in the agent's prompt, and the agent runs twice if the TurnToken is routed the same way.
Expected Behavior
Each selected target receives the message once. FanOutEdgeData describes the selector as mapping a message "to a subset of the target executor nodes", and SwitchBuilder already collects its case indices in a HashSet<int>, so AddSwitch does not hit this. A selector passed to AddFanOutEdge does, and so does an AddFanOutEdge target list that names one executor twice.
Steps to Reproduce
- Add a fan-out edge with
AddFanOutEdge<T>(source, targets, targetSelector), where the selector maps tags on the message to target indices and two tags map to the same target. - Run the workflow with a message that carries both tags.
- The shared target runs twice and yields two outputs.
Minimal Reproduction
using Microsoft.Agents.AI.Workflows;
Dictionary<string, int> calls = new() { ["billing_team"] = 0, ["tech_team"] = 0 };
TriageExecutor triage = new();
TeamExecutor billingTeam = new("billing_team", calls);
TeamExecutor techTeam = new("tech_team", calls);
// Targets: 0 = billing_team, 1 = tech_team
Dictionary<string, int> routes = new() { ["billing"] = 0, ["refund"] = 0, ["bug"] = 1 };
Workflow workflow = new WorkflowBuilder(triage)
.AddFanOutEdge<Ticket>(triage, [billingTeam, techTeam], (ticket, targetCount) => ticket!.Tags.Select(tag => routes[tag]))
.WithOutputFrom(billingTeam, techTeam)
.Build();
await using Run run = await InProcessExecution.RunAsync(workflow, new Ticket("charged twice, want my money back", ["billing", "refund"]));
List<object?> outputs = run.NewEvents.OfType<WorkflowOutputEvent>().Select(e => e.Data).ToList();
Console.WriteLine($"calls: billing_team={calls["billing_team"]}, tech_team={calls["tech_team"]}");
Console.WriteLine($"outputs: [{string.Join(", ", outputs.Select(o => $"\"{o}\""))}]");
internal sealed record Ticket(string Text, string[] Tags);
[SendsMessage(typeof(Ticket))]
internal sealed class TriageExecutor() : Executor<Ticket>("triage")
{
public override ValueTask HandleAsync(Ticket message, IWorkflowContext context, CancellationToken cancellationToken = default)
=> context.SendMessageAsync(message, cancellationToken);
}
[YieldsOutput(typeof(string))]
internal sealed class TeamExecutor(string id, Dictionary<string, int> calls) : Executor<Ticket>(id)
{
public override async ValueTask HandleAsync(Ticket message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
calls[this.Id]++;
await context.YieldOutputAsync($"{this.Id} handled: {message.Text}", cancellationToken);
}
}Output with Microsoft.Agents.AI.Workflows 1.25.0, and the same with a project reference to main at deecbcb:
calls: billing_team=2, tech_team=0
outputs: ["billing_team handled: charged twice, want my money back", "billing_team handled: charged twice, want my money back"]
Expected: billing_team=1 and one output.
Error Messages and Stack Traces
No response
Package Versions
Microsoft.Agents.AI.Workflows: 1.25.0 (also reproduced on 1.0.0 and on main at deecbcb)
.NET Version
.NET 10.0 (SDK 10.0.402)
Operating System
macOS
Regression
No
Additional Context
The repeat is never removed after the index-to-ID mapping: the IDs are checked against the envelope's target, mapped to executors, filtered by CanHandle, and handed to DeliveryMapping as is. Dropping repeated IDs right after the mapping, keeping the order in which the selector first names them, fixes it. #8739 made the same change in the other SDK's edge runner. I am opening a PR with this fix and tests.
Acknowledgements
- I searched existing issues and did not find a duplicate.
- I personally verified this behavior and the reproduction details are authentic.
- I will wait for explicit maintainer agreement before starting implementation of a non-trivial change.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
github-actions commentedon Oct 15, 2026
🤖 Automated triage reproduction notes (agent-authored — trust but verify)
Agent analysis
Repro:
dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanOutEdgeRunner.cs::ChaseEdgeAsyncpreserves duplicate sink IDs when a selector returns the same index repeatedly, causingDeliveryMapping.MapIntoto enqueue the envelope twice. Minimal repro: construct a fan-out edge with one sink and selector(_, _) => [0, 0], chase one envelope, and assert thatmapping.Deliveriescontains one target; current behavior returns two.dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs1.25.0