Skip to content

Commit b2e7706

Browse files
authored
Fix Foreach body exit wiring in declarative workflows (microsoft#6050)
1 parent 08541ee commit b2e7706

3 files changed

Lines changed: 137 additions & 13 deletions

File tree

python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -817,10 +817,14 @@ def _create_foreach_structure(
817817
condition=lambda msg: isinstance(msg, LoopIterationResult) and msg.has_next,
818818
)
819819

820-
# Body exit -> Next (get all exits from body and wire to next_executor)
821-
body_exits = self._get_source_exits(body_entry)
822-
for body_exit in body_exits:
823-
builder.add_edge(source=body_exit, target=next_executor)
820+
# Wire from the LAST body action so the loop only advances after the
821+
# whole body completes. _get_branch_exit walks the chain, skips
822+
# terminators (Break/Continue), and returns nested If/Switch
823+
# structures so _get_source_exits can flatten their branch exits.
824+
body_exit = self._get_branch_exit(body_entry)
825+
if body_exit is not None:
826+
for source_exit in self._get_source_exits(body_exit):
827+
builder.add_edge(source=source_exit, target=next_executor)
824828

825829
# Next -> body (when has_next=True, loop back)
826830
builder.add_edge(
@@ -1008,16 +1012,12 @@ def _get_structure_entry(self, entry: Any) -> Any:
10081012
return entry.evaluator if is_structure else entry
10091013

10101014
def _get_branch_exit(self, branch_entry: Any) -> Any | None:
1011-
"""Get the exit executor of a branch.
1015+
"""Get the exit point of a branch for downstream wiring.
10121016
1013-
For a linear sequence of actions, returns the last executor.
1014-
For nested structures, returns None (they have their own branch_exits).
1015-
1016-
Args:
1017-
branch_entry: The first executor of the branch
1018-
1019-
Returns:
1020-
The exit executor, or None if branch is empty or ends with a structure
1017+
Returns the last executor (or its ``_exit_executor``) for a linear chain,
1018+
the nested If/Switch structure itself when the chain ends in one (so
1019+
callers can flatten ``branch_exits`` via :meth:`_get_source_exits`), or
1020+
``None`` when the branch is empty or ends in a terminator action.
10211021
"""
10221022
if branch_entry is None:
10231023
return None

python/packages/declarative/tests/test_graph_coverage.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2224,6 +2224,101 @@ def test_create_continue_executor_no_loop_context(self):
22242224
class TestBuilderEdgeWiring:
22252225
"""Tests for builder edge wiring methods."""
22262226

2227+
def test_foreach_advance_edge_wired_from_last_body_action(self):
2228+
"""Advance edge must come from the last body action."""
2229+
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
2230+
2231+
yaml_def = {
2232+
"name": "foreach_seq",
2233+
"actions": [
2234+
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
2235+
{
2236+
"kind": "Foreach",
2237+
"id": "loop",
2238+
"itemsSource": "=Local.items",
2239+
"iteratorVariable": "Local.item",
2240+
"actions": [
2241+
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
2242+
{"kind": "SendActivity", "id": "step_2", "activity": {"text": "two"}},
2243+
{"kind": "SendActivity", "id": "step_3", "activity": {"text": "three"}},
2244+
],
2245+
},
2246+
],
2247+
}
2248+
2249+
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
2250+
edges = {(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges}
2251+
2252+
assert ("step_3", "loop_next") in edges
2253+
assert ("step_1", "loop_next") not in edges
2254+
assert ("step_2", "loop_next") not in edges
2255+
assert ("step_1", "step_2") in edges
2256+
assert ("step_2", "step_3") in edges
2257+
2258+
def test_foreach_advance_edge_skipped_for_terminator_body(self):
2259+
"""BreakLoop at end of body wires itself to loop_next; no duplicate edge."""
2260+
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
2261+
2262+
yaml_def = {
2263+
"name": "foreach_terminator",
2264+
"actions": [
2265+
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A"]},
2266+
{
2267+
"kind": "Foreach",
2268+
"id": "loop",
2269+
"itemsSource": "=Local.items",
2270+
"iteratorVariable": "Local.item",
2271+
"actions": [
2272+
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
2273+
{"kind": "BreakLoop", "id": "stop"},
2274+
],
2275+
},
2276+
],
2277+
}
2278+
2279+
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
2280+
all_edges = [(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges]
2281+
assert all_edges.count(("stop", "loop_next")) == 1
2282+
assert ("step_1", "loop_next") not in all_edges
2283+
2284+
def test_foreach_advance_edge_with_if_as_last_body_action(self):
2285+
"""Trailing If in a Foreach body wires every branch exit to loop_next."""
2286+
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
2287+
2288+
yaml_def = {
2289+
"name": "foreach_if_last",
2290+
"actions": [
2291+
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
2292+
{
2293+
"kind": "Foreach",
2294+
"id": "loop",
2295+
"itemsSource": "=Local.items",
2296+
"iteratorVariable": "Local.item",
2297+
"actions": [
2298+
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
2299+
{
2300+
"kind": "If",
2301+
"id": "check",
2302+
"condition": '=Local.item = "A"',
2303+
"then": [
2304+
{"kind": "SendActivity", "id": "then_action", "activity": {"text": "then"}},
2305+
],
2306+
"else": [
2307+
{"kind": "SendActivity", "id": "else_action", "activity": {"text": "else"}},
2308+
],
2309+
},
2310+
],
2311+
},
2312+
],
2313+
}
2314+
2315+
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
2316+
edges = {(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges}
2317+
2318+
assert ("then_action", "loop_next") in edges
2319+
assert ("else_action", "loop_next") in edges
2320+
assert ("step_1", "loop_next") not in edges
2321+
22272322
def test_wire_to_target_with_if_structure(self):
22282323
"""Test wiring to an If structure routes to evaluator."""
22292324
from agent_framework import WorkflowBuilder

python/packages/declarative/tests/test_graph_workflow_integration.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,35 @@ async def test_workflow_with_foreach_loop(self):
121121
assert "b" in outputs
122122
assert "c" in outputs
123123

124+
@pytest.mark.asyncio
125+
async def test_foreach_multi_action_body_runs_sequentially(self):
126+
"""Body actions must complete per item before advancing."""
127+
yaml_def = {
128+
"name": "loop_sequential_body",
129+
"actions": [
130+
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
131+
{
132+
"kind": "Foreach",
133+
"id": "loop",
134+
"itemsSource": "=Local.items",
135+
"iteratorVariable": "Local.item",
136+
"actions": [
137+
{"kind": "SendActivity", "id": "step_1", "activity": {"text": '="1-" & Local.item'}},
138+
{"kind": "SendActivity", "id": "step_2", "activity": {"text": '="2-" & Local.item'}},
139+
{"kind": "SendActivity", "id": "step_3", "activity": {"text": '="3-" & Local.item'}},
140+
],
141+
},
142+
],
143+
}
144+
145+
builder = DeclarativeWorkflowBuilder(yaml_def)
146+
workflow = builder.build()
147+
148+
events = await workflow.run(ActionTrigger())
149+
outputs = events.get_outputs()
150+
151+
assert outputs == ["1-A", "2-A", "3-A", "1-B", "2-B", "3-B"]
152+
124153
@pytest.mark.asyncio
125154
async def test_workflow_with_switch(self):
126155
"""Test workflow with Switch/ConditionGroup."""

0 commit comments

Comments
 (0)