Skip to content

.NET: [Bug]: Fan-out edge runs a target twice when the target selector returns its index twice #9431

Closed

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

  1. 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.
  2. Run the workflow with a message that carries both tags.
  3. 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.

Activity

  1. added
    .NETUsage: [Issues, PRs], Target: .Net
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Oct 15, 2026
  2. added
    reproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow
    on Oct 15, 2026
  3. github-actions commented on Oct 15, 2026

    @github-actions
    Contributor

    🤖 Automated triage reproduction notes (agent-authored — trust but verify)

    Agent analysis

    Repro: dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanOutEdgeRunner.cs::ChaseEdgeAsync preserves duplicate sink IDs when a selector returns the same index repeatedly, causing DeliveryMapping.MapInto to enqueue the envelope twice. Minimal repro: construct a fan-out edge with one sink and selector (_, _) => [0, 0], chase one envelope, and assert that mapping.Deliveries contains one target; current behavior returns two.

    • Failing test: dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs
    • Files examined: dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanOutEdgeRunner.cs, dotnet/src/Microsoft.Agents.AI.Workflows/Execution/DeliveryMapping.cs, dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs
    • Tests run: Test_FanOutEdgeRunner_DeduplicatesTargetsAsync (failed), Test_FanOutEdgeRunnerAsync (passed)
    • Reported version: 1.25.0
  4. added
    workflowsUsage: [Issues, PRs], Target: Workflows
    and removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

.NETUsage: [Issues, PRs], Target: .NetreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflowworkflowsUsage: [Issues, PRs], Target: Workflows

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions