1616
1717import asyncio
1818import copy
19- import inspect
20- import json
2119import logging
2220import time
2321from collections .abc import AsyncIterator , Callable , Mapping
2422from dataclasses import dataclass , field
25- from typing import Any , Protocol , TypeGuard , cast
23+ from typing import Any , cast
2624
2725from opentelemetry import trace as trace_api
2826
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
10466class 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