Verified on 2026-11-13 against
strands-agents1.63.0, Python 3.11 and Bedrock inus-east-1. Every number below came out of a run. The scripts and raw output live in https://github.com/pinxiong/strands-in-action/tree/master/usecases/07-graph. The model isamazon.nova-pro-v1:0.
The last post in this series ended with a swarm that had no scheduler. Three agents passed a task around by calling one tool, and the runtime read the absence of that call as completion. The task is small: given a topic, produce research notes, add up the per-node token counts and write a short brief. This post runs the same task on a graph instead, using the same fixed input file and the same three roles, so the traces stay comparable. I assume you have read the UC06 trace, so I will not restate it.
Here is the judgment that changes. In a swarm the model decides who runs next, because
routing is a tool call it can choose to make. In a graph that decision is gone from the
model entirely: an edge fires when a plain Python function returns True. You write
that function, you draw the edges, and you set the limits. The routing judgment is
yours now. The other judgment is not. Each node is still a full agent loop, and the
model inside it still decides which tools to call and what words to produce. Since
your predicate reads those words, the model still supplies the evidence your routing
runs on. Structure is deterministic. Input is not.
That split is the whole design, and most of the surprises come from it. This post walks the mechanism through source and through runs. An edge that fires has its predicate evaluated twice. A join runs twice because readiness is an OR. A graph with no limits warns you and then runs forever. And one documented failure behaviour does not match what the runtime actually does.
One scope note before the trace. This is the open-source Strands Agents SDK, a Python library you install and run inside your own process. Bedrock supplies the model; it is not a hosted orchestrator. Every run below happens in a single process with a handful of nodes (two to four), one model and short tasks that finish in about half a minute. That is a bench for inspecting a mechanism, not a sample of production multi-agent traffic. The numbers describe the runtime’s behaviour rather than the quality of any answer.
A predicate, not a judge#
GraphEdge holds an optional condition. The method below is the entire routing
decision, and it is plain Python. There is no model call in it.
# strands/multiagent/graph.py:209-224 -- verbatim, strands-agents 1.63.0
def should_traverse(self, state: GraphState, *, invocation_state: dict[str, Any]) -> bool:
"""Check if this edge should be traversed based on condition.
Args:
state: The current graph execution state.
invocation_state: Runtime context passed during graph invocation.
New-style conditions (EdgeConditionWithContext) receive this parameter.
Legacy conditions (Callable[[GraphState], bool]) are called with state only.
"""
condition = self.condition
if condition is None:
return True
if self._check_is_context_condition(condition):
return condition(state, invocation_state=invocation_state)
legacy_condition = cast(LegacyEdgeCondition, condition)
return legacy_condition(state)An edge with no condition returns True. An edge with one calls it and takes the
boolean. That is the deterministic half of the title.
To test what the predicate can see, I built a three-node chain. The calculator has
two outgoing edges. One leads to a writer and is guarded by "3640" in text, where
3640 is the token total the calculator produced from the fixed input. The other leads
to a fallback and is guarded by the opposite test. Here text is the concatenation of
everything in state.results. Because upstream results are the final text the model
wrote, the predicate is a Python string test over model output. The run finished COMPLETED with 5145
tokens in 31813 ms and this order: researcher, calculator, writer. The fallback never
ran.
The predicate was called three times, and that is the first surprise. One call rejected
the fallback edge. Two calls approved the writer edge, once when the runtime looked for
ready nodes and once when it built the writer’s input. The source explains why:
_is_node_ready_with_conditions and _build_node_input each call should_traverse
for the same approved edge. If your condition has a side effect, it happens twice.
At every one of those three calls I snapshotted the per-node model call counters. All
three snapshots were identical: {researcher: 2, calculator: 2, writer: 0, fallback: 0}.
Each finished node made two model calls, one to choose a tool and one to write its final
answer. No model was consulted while routing. The decision was a string test on text
that those earlier model calls had produced.
So “the judgment isn’t yours” is precise. You own the structure of the decision, but the evidence it evaluates is written by a model you cannot fully direct. If the calculator had phrased its answer without the number 3640, the other branch would have run. I did not test that counterfactual, and I am not claiming it as a result.
First parent wins#
The next surprise is in how a node with several parents becomes ready. Most graph
runtimes treat a join as a barrier: wait until every parent has finished. Strands does
not. The loop returns True on the first satisfied incoming edge.
# strands/multiagent/graph.py:1021-1038 -- verbatim, strands-agents 1.63.0
def _is_node_ready_with_conditions(self, node: GraphNode, completed_batch: list["GraphNode"]) -> bool:
"""Check if a node is ready considering conditional edges."""
# Get incoming edges to this node
incoming_edges = [edge for edge in self.edges if edge.to_node == node]
# Check if at least one incoming edge condition is satisfied
for edge in incoming_edges:
if edge.from_node in completed_batch:
if edge.should_traverse(self.state, invocation_state=self._current_invocation_state):
logger.debug(
"from=<%s>, to=<%s> | edge ready via satisfied condition", edge.from_node.node_id, node.node_id
)
return True
else:
logger.debug(
"from=<%s>, to=<%s> | edge condition not satisfied", edge.from_node.node_id, node.node_id
)
return FalseThe comment says “at least one”, and the early return True is the OR. To make the
test decisive I put one parent in a later batch:
A (fast, batch 1) ---> B (slow, batch 2)
\ /
+----------------> joinA -> B, A -> join and B -> join. After batch 1 finishes, join has one incoming
edge from A that is already satisfied. If join were a barrier it would wait for B.
The run reported COMPLETED with 1365 tokens in 9473 ms and an execution order of
A, join, B, join.
Read that order carefully. The join ran twice. It started at the same moment as B,
overlapped B’s wall-clock interval (overlap_pairs = [('join', 'B')]) and finished
before B did. Its input contained exactly one dependency block, From A:. When B
completed, the second incoming edge became satisfied and the join was nominated again.
Readiness is an OR over edges in the completed batch, and nothing checks whether the
node has already run.
This is not a bug to report as a defect; it is the semantics you must design around. A
fan-in node can observe a partial set of parents, and it can be executed once per
arriving parent. If you need a barrier, you have to build it yourself with a condition
that inspects state.completed_nodes.
State is a string, not a shared object#
What a downstream node receives is not a structured state object. The runtime builds a list of text blocks by concatenating the original task and the string form of each completed dependency’s result.
# strands/multiagent/graph.py:1285-1325 -- verbatim, strands-agents 1.63.0
dependency_results = {}
for edge in self.edges:
if (
edge.to_node == node
and edge.from_node in self.state.completed_nodes
and edge.from_node.node_id in self.state.results
):
if edge.should_traverse(self.state, invocation_state=self._current_invocation_state):
dependency_results[edge.from_node.node_id] = self.state.results[edge.from_node.node_id]
if not dependency_results:
# No dependencies - return task as ContentBlocks
if isinstance(self.state.task, str):
return [ContentBlock(text=self.state.task)]
else:
return cast(list[ContentBlock], self.state.task)
# Combine task with dependency outputs
node_input = []
# Add original task
if isinstance(self.state.task, str):
node_input.append(ContentBlock(text=f"Original Task: {self.state.task}"))
else:
# Add task content blocks with a prefix
node_input.append(ContentBlock(text="Original Task:"))
node_input.extend(cast(list[ContentBlock], self.state.task))
# Add dependency outputs
node_input.append(ContentBlock(text="\nInputs from previous nodes:"))
for dep_id, node_result in dependency_results.items():
node_input.append(ContentBlock(text=f"\nFrom {dep_id}:"))
# Get all agent results from this node (flattened if nested)
agent_results = node_result.get_agent_results()
for result in agent_results:
agent_name = getattr(result, "agent_name", "Agent")
result_text = str(result)
node_input.append(ContentBlock(text=f" - {agent_name}: {result_text}"))
return node_inputNote that should_traverse appears here again. That is the second predicate call from
the fork experiment. A dependency whose edge condition now returns False is omitted
from the input, even if the node ran and produced a result.
The linear chain shows the output of this builder verbatim. It finished COMPLETED
with 5109 tokens in 9863 ms, and each of the three nodes made two model calls. The
writer’s first message began:
Original Task: Produce the brief described in data/brief-input.json. Research first, then compute the totals, then write the brief.
Inputs from previous nodes:
From calculator:
- Agent: The total token count is 3640, and the cost is $0.002912.The researcher saw only the raw task. The calculator saw Original Task: plus a
From researcher: block. The writer saw Original Task: plus a From calculator:
block, and not the researcher’s text. Each node gets one hop of history, rendered as
prose. UC06’s swarm had an explicit SharedContext object that was re-serialized into
every downstream prompt. A graph has no equivalent channel in this path: what flows is
the string of a result, one edge at a time.
Unbounded by default#
A graph with a cycle needs a stopping rule, and the runtime does not supply one for
you. GraphState.should_continue only checks limits that you configured.
# strands/multiagent/graph.py:161-180 -- verbatim, strands-agents 1.63.0
def should_continue(
self,
max_node_executions: int | None,
execution_timeout: float | None,
) -> tuple[bool, str]:
"""Check if the graph should continue execution.
Returns: (should_continue, reason)
"""
# Check node execution limit (only if set)
if max_node_executions is not None and len(self.execution_order) >= max_node_executions:
return False, f"Max node executions reached: {max_node_executions}"
# Check timeout (only if set)
if execution_timeout is not None:
elapsed = self.execution_time / 1000 + time.time() - self.start_time
if elapsed > execution_timeout:
return False, f"Execution timed out: {execution_timeout}s"
return True, "Continuing"Both checks are guarded by “only if set”, and the fallback returns True. GraphBuilder.build() calls a validator that
warns when neither is set.
# strands/multiagent/graph.py:491-504 -- verbatim, strands-agents 1.63.0
def _validate_graph(self) -> None:
"""Validate graph structure."""
# Validate entry points exist
entry_point_ids = {node.node_id for node in self.entry_points}
invalid_entries = entry_point_ids - set(self.nodes.keys())
if invalid_entries:
raise ValueError(f"Entry points not found in nodes: {invalid_entries}")
if self._max_node_executions is None and self._execution_timeout is None:
warnings.warn(
"graph has no max_node_executions or execution_timeout set; execution is unbounded",
stacklevel=3,
)I built a two-node cycle (ping -> pong -> ping) with a condition that always returns
True. Building it with no limits produced exactly one warning: graph has no max_node_executions or execution_timeout set; execution is unbounded. I did not run
that graph, because with no cap it would not terminate.
To observe the cycle I built a second copy with only a six second timeout. It reported
FAILED after three node executions. With set_max_node_executions(6) instead, it
reported FAILED after exactly six executions, 644 tokens and 18586 ms. Hitting the
cap is a failure, not a completed run. UC06 found that a swarm’s two limits are really
one counter: the node cap and the handoff cap both read the same node_history length.
Here the two limits are genuinely independent, yet neither is set by default. reset_on_revisit also defaults to False, so a revisited node keeps its
message history unless you ask for a reset.
Convergence is your responsibility. The structure is yours, and so is the obligation to prove that it stops.
Parallel for real, and a documented behaviour that did not happen#
A batch of nodes really does run concurrently. _execute_nodes_parallel starts one
asyncio task per node and merges their event streams through a shared queue. I ran
two independent entry points that each sleep for three seconds. The result was
COMPLETED with 2046 tokens in 5493 ms, and the wall-clock intervals intersected:
overlap_pairs = [('right', 'left')]. UC06 ran the same two-sleeping-branch bench on a
swarm and found no overlapping intervals, because a swarm hands off serially. That
difference is real, and it is a consequence of the two runtimes rather than of the
task.
The failure path is where the documentation and the runtime part company.
_find_newly_ready_nodes describes pruning:
# strands/multiagent/graph.py:999-1010 -- verbatim, strands-agents 1.63.0
def _find_newly_ready_nodes(self, completed_batch: list["GraphNode"]) -> list["GraphNode"]:
"""Find nodes that became ready after the last execution.
Only evaluates destination nodes of outbound edges from the completed batch,
instead of iterating over all nodes in the graph. Failed nodes (e.g. a
cancelled agent) are excluded so they cannot nominate successors — their
downstream branch is pruned while sibling branches continue.
"""
# Strip failed/cancelled nodes so their outgoing edges are never walked.
completed_batch = [node for node in completed_batch if node.execution_status != Status.FAILED]
if not completed_batch:
return []I injected two different failures into a graph with two branches, bad and good,
each with its own downstream node. Neither injection produced the behaviour above.
First, a tool that raises. The run finished COMPLETED with 1659 tokens in 4794 ms,
and all four nodes ran: good, bad, good_downstream, bad_downstream. The bad node’s
status was COMPLETED, because a tool exception is returned to the agent as a tool
result. The agent finishes its turn normally, so the node is not failed at all.
There is no failed node to prune.
Second, BeforeNodeCallEvent.cancel_node, which is the human intervention point. The
hook sets cancel_node, and _execute_node responds by raising.
# strands/multiagent/graph.py:1066-1072 -- verbatim, strands-agents 1.63.0
if before_event.cancel_node:
cancel_message = (
before_event.cancel_node if isinstance(before_event.cancel_node, str) else "node cancelled by user"
)
logger.debug("reason=<%s> | cancelling execution", cancel_message)
yield MultiAgentNodeCancelEvent(node.node_id, cancel_message)
raise RuntimeError(cancel_message)That exception is caught by the node’s except block, which marks the node failed and
then re-raises:
# strands/multiagent/graph.py:1225-1226 -- verbatim, strands-agents 1.63.0
# Re-raise to stop graph execution (fail-fast behavior)
raiseThe whole graph aborted with RuntimeError: cancelled by a human before the node ran.
No GraphResult was produced. The graph state held state_failed_nodes = ['bad'] and
an empty execution_order, but there was no result to inspect. The docstring’s promise
that sibling branches continue did not hold for this path, because the exception stops
execution before _find_newly_ready_nodes is reached. The comment in the except block
is explicit about it. It says that all failures stop graph execution, and it says
“Re-raise to stop graph execution (fail-fast behavior)”.
I could not produce a run in this version where a node is marked FAILED and the
graph still continues to a sibling branch. That is a limit of what I measured, not a
claim that the pruning code is unreachable. If you design around pruning, verify it on
your installed version first.
One more construction-time boundary: a node executor cannot be an Agent with a
session manager. The Agent constructor accepts one, but GraphBuilder.add_node
raises ValueError: Session persistence is not supported for Graph agents yet. UC06
found the same restriction in swarms.
What surprised me#
Three things. The predicate ran twice per approved edge, which means a condition with side effects is a bug waiting to happen. The join ran twice and overlapped a parent it was supposed to wait for, which means fan-in is an OR over arrivals rather than a barrier. And the pruning described in a docstring did not survive contact with either failure I injected. All three came out of the traces rather than out of reading the source, and the third one contradicts a comment in that source.
When not to use this#
Do not use a graph when you want the model to decide the route. Routing here is a
Python predicate over state and over text that earlier nodes produced; if the decision
needs open-ended judgment, put it inside a node and let the agent choose a tool, as in
UC06. Do not assume a fan-in node sees all its parents, and do not assume it runs once.
Do not ship a cyclic graph without set_max_node_executions or
set_execution_timeout, and do not treat a cap hit as success. Do not rely on the
pruning behaviour described in _find_newly_ready_nodes without testing it yourself.
This experiment does not cover interrupt resumption, serialize_state, session-backed
graphs, cross-process A2A, or any workload larger than three short agents in one
process. It does not measure answer quality or cost per useful result. It does not
cover behaviour under model latency spikes. The two failure injections are the only ones I ran; a node that
fails through a different stop reason may behave differently.
How I measured#
Every number above is from one of eight graph runs on 2026-11-13, plus one
construction-time check, with strands-agents 1.63.0, amazon.nova-pro-v1:0 in
us-east-1, Python 3.11, one process and no concurrency outside the graph’s own
batch. Token counts are the graph’s accumulated
usage reported by GraphResult, not my own estimate. Timings are the reported
execution_time in milliseconds. Wall-clock overlaps come from BeforeNodeCallEvent
and AfterNodeCallEvent timestamps recorded in the same process. Each run’s raw
stdout is in evidence/*.txt and its raw fields in evidence/*.json; the fixture
input is fixed in data/brief-input.json and is byte-for-byte the UC06 file. Model
output varies between runs, so token counts and timings are observations of these
runs, not benchmarks. Each experiment has a control or a complement: the linear chain
against the conditional fork, the capped cycle against the timed one and parallel
entry points against the UC06 swarm trace.
Try it yourself#
git clone https://github.com/pinxiong/strands-in-action.git
cd strands-in-action/usecases/07-graph
python3 -m venv .venv && . .venv/bin/activate
pip install strands-agents==1.63.0 boto3
export AWS_DEFAULT_REGION=us-east-1The full source, the fixed fixture and all raw evidence are in
https://github.com/pinxiong/strands-in-action/tree/master/usecases/07-graph.
The scripts hard-code us-east-1 and amazon.nova-pro-v1:0, so set the region as
above and provide credentials through aws configure, AWS_PROFILE or environment
variables. Enable model access for amazon.nova-pro-v1:0 in that region first, or
Bedrock will reject the call. Then run the nine commands below. Eight of them execute
a graph; the session one only checks a construction-time error. The unbounded
command builds one limit-free graph it never runs (to capture the warning), then runs
a second copy with a six-second timeout:
.venv/bin/python src/graph_trace.py --mode linear
.venv/bin/python src/graph_trace.py --mode condition
.venv/bin/python src/graph_fanin.py --mode or
.venv/bin/python src/graph_parallel.py --mode overlap
.venv/bin/python src/graph_limits.py --mode unbounded
.venv/bin/python src/graph_limits.py --mode capped
.venv/bin/python src/graph_failure.py --mode raise
.venv/bin/python src/graph_failure.py --mode cancel
.venv/bin/python src/graph_failure.py --mode sessionEach script writes its own evidence/*.txt and evidence/*.json. The fork condition
looks for the literal 3640 in upstream text; if your model phrases the total
differently, the other branch will run. That difference is the point of the experiment,
so check predicate_log before comparing your numbers with mine.
Takeaways#
- Routing is a Python predicate, not a model decision. The model still writes the text that the predicate reads.
- A predicate can be evaluated twice for the same edge, so keep conditions pure.
- Readiness is an OR over incoming edges in the completed batch. A join can run before all its parents finish, and it can run more than once.
- Downstream nodes receive a rendered string of their completed dependencies, not a shared state object.
- A cycle is unbounded by default. Set a limit and treat a cap hit as
FAILED. - Nodes in one batch really run in parallel, unlike a swarm’s serial handoffs.
- In 1.63.0, both failures I injected either completed the node or aborted the whole graph. Verify pruning on your version before depending on it.
The graph is yours. The judgment inside each node, and the words your predicates read, are not. Design the structure for that boundary. Prove convergence separately.