Skip to content

Commit 192abdc

Browse files
authored
Revert "feat: pass invocation_state to edge condition calls (#2305)" (#2389)
Co-authored-by: Mackenzie Zastrow <zastrowm@users.noreply.github.com>
1 parent 95cebc9 commit 192abdc

4 files changed

Lines changed: 21 additions & 895 deletions

File tree

strands-py/src/strands/multiagent/__init__.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,10 @@
99
"""
1010

1111
from .base import MultiAgentBase, MultiAgentResult, Status
12-
from .graph import EdgeCondition, EdgeConditionWithContext, GraphBuilder, GraphResult
12+
from .graph import GraphBuilder, GraphResult
1313
from .swarm import Swarm, SwarmResult
1414

1515
__all__ = [
16-
"EdgeCondition",
17-
"EdgeConditionWithContext",
1816
"GraphBuilder",
1917
"GraphResult",
2018
"MultiAgentBase",

strands-py/src/strands/multiagent/graph.py

Lines changed: 11 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,11 @@
1616

1717
import asyncio
1818
import copy
19-
import inspect
20-
import json
2119
import logging
2220
import time
2321
from collections.abc import AsyncIterator, Callable, Mapping
2422
from dataclasses import dataclass, field
25-
from typing import Any, Protocol, TypeGuard, cast
23+
from typing import Any, cast
2624

2725
from opentelemetry import trace as trace_api
2826

@@ -64,42 +62,6 @@
6462
_DEFAULT_GRAPH_ID = "default_graph"
6563

6664

67-
class EdgeConditionWithContext(Protocol):
68-
"""Protocol for edge conditions that receive invocation_state.
69-
70-
This allows conditions to make routing decisions based on runtime context
71-
passed during graph invocation, such as feature flags, user roles, or
72-
environment-specific configuration.
73-
74-
Designed with **kwargs for future extensibility without breaking changes.
75-
76-
Not @runtime_checkable because the expected use case is a function or lambda,
77-
and isinstance() checks cannot structurally distinguish callable signatures.
78-
Dispatch uses _is_context_condition() with inspect.signature() instead.
79-
"""
80-
81-
def __call__(self, state: "GraphState", *, invocation_state: dict[str, Any], **kwargs: Any) -> bool:
82-
"""Evaluate whether the edge should be traversed."""
83-
...
84-
85-
86-
LegacyEdgeCondition = Callable[["GraphState"], bool]
87-
EdgeCondition = LegacyEdgeCondition | EdgeConditionWithContext
88-
89-
90-
def _is_context_condition(condition: EdgeCondition) -> TypeGuard[EdgeConditionWithContext]:
91-
"""Check if a condition function accepts invocation_state parameter.
92-
93-
Uses inspect.signature() for reliable detection, returning a TypeGuard
94-
so mypy can narrow the type at call sites.
95-
"""
96-
try:
97-
sig = inspect.signature(condition)
98-
return "invocation_state" in sig.parameters
99-
except (ValueError, TypeError):
100-
return False
101-
102-
10365
@dataclass
10466
class GraphState:
10567
"""Graph execution state.
@@ -185,35 +147,17 @@ class GraphEdge:
185147

186148
from_node: "GraphNode"
187149
to_node: "GraphNode"
188-
condition: EdgeCondition | None = None
189-
_is_context_condition_cached: bool | None = field(default=None, init=False, repr=False, compare=False)
150+
condition: Callable[[GraphState], bool] | None = None
190151

191152
def __hash__(self) -> int:
192153
"""Return hash for GraphEdge based on from_node and to_node."""
193154
return hash((self.from_node.node_id, self.to_node.node_id))
194155

195-
def should_traverse(self, state: GraphState, *, invocation_state: dict[str, Any] | None = None) -> bool:
196-
"""Check if this edge should be traversed based on condition.
197-
198-
Args:
199-
state: The current graph execution state.
200-
invocation_state: Runtime context passed during graph invocation.
201-
New-style conditions (EdgeConditionWithContext) receive this parameter.
202-
Legacy conditions (Callable[[GraphState], bool]) are called with state only.
203-
"""
204-
condition = self.condition
205-
if condition is None:
156+
def should_traverse(self, state: GraphState) -> bool:
157+
"""Check if this edge should be traversed based on condition."""
158+
if self.condition is None:
206159
return True
207-
if self._check_is_context_condition(condition):
208-
return condition(state, invocation_state=invocation_state or {})
209-
legacy_condition = cast(LegacyEdgeCondition, condition)
210-
return legacy_condition(state)
211-
212-
def _check_is_context_condition(self, condition: EdgeCondition) -> TypeGuard[EdgeConditionWithContext]:
213-
"""Check and cache whether this edge's condition accepts invocation_state."""
214-
if self._is_context_condition_cached is None:
215-
self._is_context_condition_cached = _is_context_condition(condition)
216-
return self._is_context_condition_cached
160+
return self.condition(state)
217161

218162

219163
@dataclass
@@ -332,14 +276,9 @@ def add_edge(
332276
self,
333277
from_node: str | GraphNode,
334278
to_node: str | GraphNode,
335-
condition: EdgeCondition | None = None,
279+
condition: Callable[[GraphState], bool] | None = None,
336280
) -> GraphEdge:
337-
"""Add an edge between two nodes with optional condition function.
338-
339-
The condition can be either:
340-
- A legacy callable: Callable[[GraphState], bool] - receives only graph state
341-
- A new-style callable: EdgeConditionWithContext - receives graph state and invocation_state
342-
"""
281+
"""Add an edge between two nodes with optional condition function that receives full GraphState."""
343282

344283
def resolve_node(node: str | GraphNode, node_type: str) -> GraphNode:
345284
if isinstance(node, str):
@@ -552,7 +491,6 @@ def __init__(
552491

553492
self._resume_next_nodes: list[GraphNode] = []
554493
self._resume_from_session = False
555-
self._current_invocation_state: dict[str, Any] = {}
556494
self.id = id
557495

558496
run_async(lambda: self.hooks.invoke_callbacks_async(MultiAgentInitializedEvent(self)))
@@ -631,10 +569,6 @@ async def stream_async(
631569
if invocation_state is None:
632570
invocation_state = {}
633571

634-
if self.session_manager is not None:
635-
self._validate_invocation_state(invocation_state)
636-
self._current_invocation_state = invocation_state
637-
638572
await self.hooks.invoke_callbacks_async(BeforeMultiAgentInvocationEvent(self, invocation_state))
639573

640574
logger.debug("task=<%s> | starting graph execution", task)
@@ -955,7 +889,7 @@ def _is_node_ready_with_conditions(self, node: GraphNode, completed_batch: list[
955889
# Check if at least one incoming edge condition is satisfied
956890
for edge in incoming_edges:
957891
if edge.from_node in completed_batch:
958-
if edge.should_traverse(self.state, invocation_state=self._current_invocation_state):
892+
if edge.should_traverse(self.state):
959893
logger.debug(
960894
"from=<%s>, to=<%s> | edge ready via satisfied condition", edge.from_node.node_id, node.node_id
961895
)
@@ -1191,7 +1125,7 @@ def _build_node_input(self, node: GraphNode) -> list[ContentBlock]:
11911125
and edge.from_node in self.state.completed_nodes
11921126
and edge.from_node.node_id in self.state.results
11931127
):
1194-
if edge.should_traverse(self.state, invocation_state=self._current_invocation_state):
1128+
if edge.should_traverse(self.state):
11951129
dependency_results[edge.from_node.node_id] = self.state.results[edge.from_node.node_id]
11961130

11971131
if not dependency_results:
@@ -1252,20 +1186,6 @@ def _build_result(self, interrupts: list[Interrupt]) -> GraphResult:
12521186
interrupts=interrupts,
12531187
)
12541188

1255-
@staticmethod
1256-
def _validate_invocation_state(invocation_state: dict[str, Any]) -> None:
1257-
"""Validate that invocation_state is JSON-serializable.
1258-
1259-
Raises:
1260-
TypeError: If invocation_state contains non-JSON-serializable values.
1261-
"""
1262-
try:
1263-
json.dumps(invocation_state)
1264-
except (TypeError, ValueError) as e:
1265-
raise TypeError(
1266-
f"invocation_state must be JSON-serializable for session persistence: {e}"
1267-
) from e
1268-
12691189
def serialize_state(self) -> dict[str, Any]:
12701190
"""Serialize the current graph state to a dictionary."""
12711191
compute_nodes = self._compute_ready_nodes_for_resume()
@@ -1281,7 +1201,6 @@ def serialize_state(self) -> dict[str, Any]:
12811201
"next_nodes_to_execute": next_nodes,
12821202
"current_task": encode_bytes_values(self.state.task),
12831203
"execution_order": [n.node_id for n in self.state.execution_order],
1284-
"invocation_state": self._current_invocation_state,
12851204
"_internal_state": {
12861205
"interrupt_state": self._interrupt_state.to_dict(),
12871206
},
@@ -1304,10 +1223,6 @@ def deserialize_state(self, payload: dict[str, Any]) -> None:
13041223
internal_state = payload["_internal_state"]
13051224
self._interrupt_state = _InterruptState.from_dict(internal_state["interrupt_state"])
13061225

1307-
invocation_state = payload.get("invocation_state", {})
1308-
self._validate_invocation_state(invocation_state)
1309-
self._current_invocation_state = invocation_state
1310-
13111226
if not payload.get("next_nodes_to_execute"):
13121227
# Reset all nodes
13131228
for node in self.nodes.values():
@@ -1331,40 +1246,11 @@ def _compute_ready_nodes_for_resume(self) -> list[GraphNode]:
13311246
incoming = [e for e in self.edges if e.to_node is node]
13321247
if not incoming:
13331248
ready_nodes.append(node)
1334-
elif self._is_node_ready_for_resume(node, incoming, completed_nodes):
1249+
elif all(e.from_node in completed_nodes and e.should_traverse(self.state) for e in incoming):
13351250
ready_nodes.append(node)
13361251

13371252
return ready_nodes
13381253

1339-
def _is_node_ready_for_resume(
1340-
self,
1341-
node: GraphNode,
1342-
incoming: list[GraphEdge],
1343-
completed_nodes: set[GraphNode],
1344-
) -> bool:
1345-
"""Check if a node is ready for resume, accounting for conditional edges.
1346-
1347-
A node is ready if all TRAVERSABLE incoming edges have their source completed.
1348-
Edges whose condition evaluates to False are excluded from the check — they
1349-
represent paths that were intentionally skipped.
1350-
1351-
Re-evaluates conditions (rather than caching traversal results) intentionally:
1352-
invocation_state may change between invocations, so conditions must reflect
1353-
current runtime context. This means condition logic changes between serialize
1354-
and resume will also take effect — consistent with the graph being defined in code.
1355-
"""
1356-
traversable_edges = [
1357-
e
1358-
for e in incoming
1359-
# Short-circuit: skip signature inspection + cache lookup for unconditional edges.
1360-
if e.condition is None or e.should_traverse(self.state, invocation_state=self._current_invocation_state)
1361-
]
1362-
1363-
if not traversable_edges:
1364-
return False
1365-
1366-
return all(e.from_node in completed_nodes for e in traversable_edges)
1367-
13681254
def _from_dict(self, payload: dict[str, Any]) -> None:
13691255
self.state.status = Status(payload["status"])
13701256
# Hydrate completed nodes & results

0 commit comments

Comments
 (0)