Skip to content

Commit 74f38f9

Browse files
committed
test: enhance agent-loop tests with goto and iteration patterns
1 parent df0ebe8 commit 74f38f9

1 file changed

Lines changed: 126 additions & 4 deletions

File tree

tests/core/test_agent_loop_pattern.py

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
"""Test the agent-loop pattern: `agent >> tool >> agent` cycles through tasks
2-
until `terminate_workflow()` is called.
1+
"""Test the agent-loop patterns demonstrated in examples/02_workflows/agent_loop.py.
32
4-
This pattern is documented in the README as the Graflow equivalent of a
5-
LangGraph agent loop with conditional edges.
3+
Three loop styles:
4+
1. agent_tool_loop — static cycle with `>>` + terminate_workflow()
5+
2. loop_with_goto — dynamic jumps with next_task(goto=True) + max_cycles
6+
3. loop_with_iteration — single-task self-loop with next_iteration() + max_cycles
67
"""
78

89
from graflow import task, workflow
@@ -59,3 +60,124 @@ def tool():
5960

6061
assert call_counts["agent"] == 1
6162
assert call_counts["tool"] == 0
63+
64+
65+
def test_loop_with_goto_early_exit():
66+
"""loop_with_goto: exits early when completion condition is met."""
67+
call_counts = {"agent": 0, "tool": 0}
68+
quality_threshold = 0.75
69+
70+
with workflow("ralph_loop") as wf:
71+
state = {"score": 0.0}
72+
73+
@task(inject_context=True, max_cycles=5)
74+
def agent(context):
75+
call_counts["agent"] += 1
76+
state["score"] += 0.2
77+
if state["score"] >= quality_threshold:
78+
context.get_channel().set("final_score", state["score"])
79+
return "done"
80+
context.next_task(tool, goto=True)
81+
82+
@task(inject_context=True)
83+
def tool(context):
84+
call_counts["tool"] += 1
85+
context.next_task(agent, goto=True)
86+
87+
_, exec_ctx = wf.execute("agent", ret_context=True)
88+
89+
assert call_counts["agent"] == 4 # score: 0.2, 0.4, 0.6, 0.8 (>= 0.75)
90+
assert call_counts["tool"] == 3
91+
assert exec_ctx.channel.get("final_score") == 0.8
92+
assert exec_ctx.cycle_controller.get_cycle_count("agent") == 4
93+
94+
95+
def test_loop_with_goto_exhausts_budget():
96+
"""loop_with_goto: stops when max_cycles budget is exhausted."""
97+
call_counts = {"agent": 0, "tool": 0}
98+
99+
with workflow("ralph_budget") as wf:
100+
101+
@task(inject_context=True, max_cycles=3)
102+
def agent(context):
103+
call_counts["agent"] += 1
104+
# Never meets exit condition — relies on budget
105+
if context.can_iterate():
106+
context.next_task(tool, goto=True)
107+
else:
108+
context.get_channel().set("status", "budget_exhausted")
109+
110+
@task(inject_context=True)
111+
def tool(context):
112+
call_counts["tool"] += 1
113+
context.next_task(agent, goto=True)
114+
115+
_, exec_ctx = wf.execute("agent", ret_context=True)
116+
117+
assert call_counts["agent"] == 3
118+
assert call_counts["tool"] == 2
119+
assert exec_ctx.channel.get("status") == "budget_exhausted"
120+
121+
122+
def test_loop_with_iteration_early_exit():
123+
"""loop_with_iteration: exits early when completion condition is met."""
124+
125+
with workflow("self_refine") as wf:
126+
127+
@task(inject_context=True, max_cycles=5)
128+
def refine(context, data=None):
129+
score = (data or {}).get("score", 0.1) + 0.2
130+
if score >= 0.85:
131+
context.get_channel().set("result", f"draft_v{context.cycle_count}")
132+
return "done"
133+
if context.can_iterate():
134+
context.next_iteration({"score": score})
135+
136+
_, exec_ctx = wf.execute("refine", ret_context=True)
137+
138+
assert exec_ctx.channel.get("result") == "draft_v4"
139+
assert exec_ctx.cycle_controller.get_cycle_count("refine") == 4
140+
141+
142+
def test_loop_with_iteration_exhausts_budget():
143+
"""loop_with_iteration: stops when max_cycles is reached."""
144+
145+
with workflow("self_refine_budget") as wf:
146+
147+
@task(inject_context=True, max_cycles=3)
148+
def refine(context, data=None):
149+
total = (data or {}).get("total", 0) + 1
150+
if context.can_iterate():
151+
context.next_iteration({"total": total})
152+
else:
153+
context.get_channel().set("total", total)
154+
155+
_, exec_ctx = wf.execute("refine", ret_context=True)
156+
157+
assert exec_ctx.channel.get("total") == 3
158+
assert exec_ctx.cycle_controller.get_cycle_count("refine") == 3
159+
160+
161+
def test_loop_with_iteration_then_next_task():
162+
"""loop_with_iteration: next_task() hands off to a downstream task after loop exits."""
163+
164+
with workflow("iter_then_publish") as wf:
165+
166+
@task(inject_context=True, max_cycles=3)
167+
def refine(context, data=None):
168+
draft = f"draft_v{context.cycle_count}"
169+
context.get_channel().set("result", draft)
170+
if context.can_iterate():
171+
context.next_iteration({"draft": draft})
172+
else:
173+
context.next_task(publish)
174+
175+
@task(inject_context=True)
176+
def publish(context):
177+
result = context.get_channel().get("result")
178+
context.get_channel().set("published", f"published_{result}")
179+
180+
_, exec_ctx = wf.execute("refine", ret_context=True)
181+
182+
assert exec_ctx.cycle_controller.get_cycle_count("refine") == 3
183+
assert exec_ctx.channel.get("published") == "published_draft_v3"

0 commit comments

Comments
 (0)