You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Currently, Strands graphs are effectively DAGs — while the code doesn't explicitly reject cycles, _find_newly_ready_nodes only considers destination nodes of outbound edges from the just-completed batch, meaning back-edges pointing to already-completed nodes will never re-schedule them. There is no mechanism for a completed node to become "ready" again based on downstream state or edge conditions.
This makes it impossible to build common agentic patterns that require iteration:
ReAct loops: LLM calls tools, evaluates results, and decides whether to call more tools or finish
Iterative refinement: A generator produces output, an evaluator grades it, and routes back to the generator if quality is insufficient
Multi-turn data gathering: An agent collects information across multiple passes, looping back to a data-collection node until a completeness threshold is met
Retry with adaptation: A node fails or produces partial results, and the graph routes back to retry with modified context
The existing reset_on_revisit flag and max_node_executions limit suggest looping was anticipated in the design, but the scheduling logic doesn't actually support it.
Proposed Solution
Introduce first-class support for graph cycles through edge-condition-driven back-edges:
Back-edge scheduling in _find_newly_ready_nodes
When a node completes and its outbound edge points to an already-completed node, evaluate the edge condition. If it returns True, re-schedule that node for execution:
def _find_newly_ready_nodes(self, completed_batch: list[GraphNode]) -> list[GraphNode]:
candidates = {edge.to_node for edge in self.edges if edge.from_node in completed_batch}
newly_ready = []
for node in candidates:
if node in self.state.completed_nodes:
# Back-edge: node was already completed, check if it should loop
if self._should_revisit(node, completed_batch):
self._prepare_for_revisit(node)
newly_ready.append(node)
elif self._is_node_ready_with_conditions(node, completed_batch):
newly_ready.append(node)
return newly_ready
Node state reset on revisit
When a node is re-scheduled, clear its previous execution state (leveraging the existing reset_on_revisit mechanism and reset_executor_state()). Remove it from completed_nodes so dependency resolution works
correctly for its downstream nodes.
Loop termination via edge conditions with invocation_state
Edge conditions (enhanced by PR #2305 to receive invocation_state) serve as the loop exit mechanism:
def should_loop_back(state: GraphState, *, invocation_state: dict[str, Any]) -> bool:
"""Edge condition on back-edge: return True to loop, False to exit."""
results = state.results.get("evaluator")
return results and "needs_improvement" in str(results.result)
builder.add_edge("evaluator", "generator", condition=should_loop_back)
builder.add_edge("evaluator", "output_formatter", condition=lambda s, **_: not should_loop_back(s))
Safety: max_node_executions as recursion limit
The existing max_node_executions serves as the infinite-loop guard (analogous to LangGraph's recursion_limit). This counts total node executions across all iterations. Additionally, consider a per-node execution cap for finer control.
Status.SKIPPED for conditional bypass within loops
Introduce Status.SKIPPED (see #2240 / PR #2258) to allow hooks to bypass a node while still satisfying its downstream dependencies. Within a loop, a skipped node on iteration N is still eligible for execution on iteration N+1 after reset:
Separate from SKIPPED — aligns with the TypeScript SDK's cancel behavior. Cancelled nodes terminate their branch; downstream nodes do NOT execute. Useful for aborting optional side-branches within a loop without killing the entire cycle.
builder.add_node(collector_agent, "collector")
builder.add_node(validator_agent, "validator")
builder.set_entry_point("collector")
builder.add_edge("collector", "validator")
builder.add_edge("validator", "collector", condition=incomplete) # Loop back for more data
# collector uses interrupt() to ask user for input on each iteration
# Graph pauses, resumes with user response, collector completes, validator checks
Loses accumulated state between iterations, no per-iteration checkpointing, interrupt/resume breaks
Additional Context
Existing infra that supports this: reset_on_revisit, max_node_executions, execution_timeout, reset_executor_state() — these were clearly designed with revisiting in mind
TypeScript SDK: Has Status.CANCELLED for branch termination; looping support would benefit from cross-SDK design alignment
LangGraph reference: Uses Pregel-style super-steps with recursion_limit (default 10007). Key insight: they don't validate against cycles at compile time, and checkpoints are saved per super-step, enabling interrupt/resume mid-loop. Their RemainingSteps managed value lets nodes detect they're approaching the limit and wrap up gracefully — worth considering as a future enhancement.
Problem Statement
Currently, Strands graphs are effectively DAGs — while the code doesn't explicitly reject cycles,
_find_newly_ready_nodesonly considers destination nodes of outbound edges from the just-completed batch, meaning back-edges pointing to already-completed nodes will never re-schedule them. There is no mechanism for a completed node to become "ready" again based on downstream state or edge conditions.This makes it impossible to build common agentic patterns that require iteration:
The existing
reset_on_revisitflag andmax_node_executionslimit suggest looping was anticipated in the design, but the scheduling logic doesn't actually support it.Proposed Solution
Introduce first-class support for graph cycles through edge-condition-driven back-edges:
_find_newly_ready_nodesWhen a node completes and its outbound edge points to an already-completed node, evaluate the edge condition. If it returns True, re-schedule that node for execution:
When a node is re-scheduled, clear its previous execution state (leveraging the existing
reset_on_revisitmechanism andreset_executor_state()). Remove it fromcompleted_nodesso dependency resolution workscorrectly for its downstream nodes.
invocation_stateEdge conditions (enhanced by PR #2305 to receive
invocation_state) serve as the loop exit mechanism:max_node_executionsas recursion limitThe existing
max_node_executionsserves as the infinite-loop guard (analogous to LangGraph'srecursion_limit). This counts total node executions across all iterations. Additionally, consider a per-node execution cap for finer control.Status.SKIPPEDfor conditional bypass within loopsIntroduce
Status.SKIPPED(see #2240 / PR #2258) to allow hooks to bypass a node while still satisfying its downstream dependencies. Within a loop, a skipped node on iteration N is still eligible for execution on iteration N+1 after reset:Status.CANCELLEDfor branch terminationSeparate from SKIPPED — aligns with the TypeScript SDK's cancel behavior. Cancelled nodes terminate their branch; downstream nodes do NOT execute. Useful for aborting optional side-branches within a loop without killing the entire cycle.
Use Case
Alternatives Solutions
invocation_stateinfra, familiar graph semanticsCommand-style routing (LangGraph approach)Additional Context
reset_on_revisit,max_node_executions,execution_timeout,reset_executor_state()— these were clearly designed with revisiting in mindinvocation_stateto edge conditions — enables runtime-dynamic loop exit decisionscancel_nodebug — theSKIPPED/CANCELLEDstatus distinction proposed there is a prerequisite for clean conditional bypass within loopsStatus.CANCELLEDfor branch termination; looping support would benefit from cross-SDK design alignmentrecursion_limit(default 10007). Key insight: they don't validate against cycles at compile time, and checkpoints are saved per super-step, enabling interrupt/resume mid-loop. TheirRemainingStepsmanaged value lets nodes detect they're approaching the limit and wrap up gracefully — worth considering as a future enhancement.