Repository navigation
Labels
pythonUsage: [Issues, PRs], Target: PythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflowUsage: [Issues], Target: all issues that can be reproduced by the triage workflowworkflowsUsage: [Issues, PRs], Target: WorkflowsUsage: [Issues, PRs], Target: Workflows
Description
Description
FanOutEdgeRunner.send_message checks that every ID a selection function returns is a valid target, but not that each ID appears once. It then delivers the message once per entry, so a target named twice runs twice on the same message.
A selection function that maps tags or categories to executors hits this as soon as two tags share an executor. With an AgentExecutor target that is a second model call and a duplicated response. The add_multi_selection_edge_group docstring describes the return value as the IDs of the target executors that "should receive the message".
Code Sample
import asyncio
from dataclasses import dataclass
from agent_framework import WorkflowBuilder, WorkflowContext, executor
calls = {"billing_team": 0, "tech_team": 0}
@dataclass
class Ticket:
text: str
tags: list[str]
@executor(id="triage")
async def triage(ticket: Ticket, ctx: WorkflowContext[Ticket]) -> None:
await ctx.send_message(ticket)
@executor(id="billing_team")
async def billing_team(ticket: Ticket, ctx: WorkflowContext[None, str]) -> None:
calls["billing_team"] += 1
await ctx.yield_output(f"billing handled: {ticket.text}")
@executor(id="tech_team")
async def tech_team(ticket: Ticket, ctx: WorkflowContext[None, str]) -> None:
calls["tech_team"] += 1
await ctx.yield_output(f"tech handled: {ticket.text}")
ROUTES = {"billing": "billing_team", "refund": "billing_team", "bug": "tech_team"}
def route_by_tags(ticket: Ticket, target_ids: list[str]) -> list[str]:
return [ROUTES[tag] for tag in ticket.tags]
async def main() -> None:
workflow = (
WorkflowBuilder(start_executor=triage)
.add_multi_selection_edge_group(triage, [billing_team, tech_team], selection_func=route_by_tags)
.build()
)
result = await workflow.run(Ticket("charged twice, want my money back", ["billing", "refund"]))
print("calls:", calls)
print("outputs:", result.get_outputs())
asyncio.run(main())Output on main (2c46deb):
calls: {'billing_team': 2, 'tech_team': 0}
outputs: ['billing handled: charged twice, want my money back', 'billing handled: charged twice, want my money back']
Expected: billing_team runs once and yields one output.
Package Versions
agent-framework-core 1.19.0 (main at 2c46deb)
Python Version
3.13.13
Metadata
Metadata
Labels
pythonUsage: [Issues, PRs], Target: PythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflowUsage: [Issues], Target: all issues that can be reproduced by the triage workflowworkflowsUsage: [Issues, PRs], Target: WorkflowsUsage: [Issues, PRs], Target: Workflows
Type
Projects
- StatusShow more project fieldsDone
Activity
github-actions commentedon Sep 24, 2026
🤖 Automated triage reproduction notes (agent-authored — trust but verify)
Agent analysis
Repro:
python/packages/core/agent_framework/_workflows/_edge_runner.py::FanOutEdgeRunner.send_messagepreserves duplicate IDs returned by a selection function and executes the corresponding edge twice at lines 328-371. Trigger it with an untargeted message and a selection function returning[target.id, target.id]; the regression test constructs a two-targetFanOutEdgeGroupand observescall_count == 2.python/packages/core/tests/workflow/test_runner.py::test_fanout_edge_runner_delivers_once_when_selection_contains_duplicate_target_ids1.19.01.19.0