feat: pass invocation_state to edge condition calls#2305
Conversation
Add support for edge conditions that receive invocation_state, enabling conditional routing based on runtime context (feature flags, user roles, environment config) passed during graph invocation. Also fixes a deadlock in _compute_ready_nodes_for_resume() where conditional edges evaluating to False would block downstream nodes from ever becoming ready on interrupt/resume workflows. Resolves strands-agents#1346
| """ | ||
| try: | ||
| sig = inspect.signature(condition) | ||
| return "invocation_state" in sig.parameters |
There was a problem hiding this comment.
Issue: inspect.signature() is called on every should_traverse() invocation with no caching. In graphs with many edges or cyclic execution, this could become a performance bottleneck since signature introspection is relatively expensive.
Suggestion: Cache the result per condition function using functools.lru_cache or a simple dict keyed by id(condition):
_condition_type_cache: dict[int, bool] = {}
def _is_context_condition(condition: EdgeCondition) -> TypeGuard[EdgeConditionWithContext]:
cond_id = id(condition)
if cond_id not in _condition_type_cache:
try:
sig = inspect.signature(condition)
_condition_type_cache[cond_id] = "invocation_state" in sig.parameters
except (ValueError, TypeError):
_condition_type_cache[cond_id] = False
return _condition_type_cache[cond_id]The issue spec also mentions this: "Consider caching signature inspection results for performance if needed."
| _DEFAULT_GRAPH_ID = "default_graph" | ||
|
|
||
|
|
||
| @runtime_checkable |
There was a problem hiding this comment.
Issue: The @runtime_checkable decorator is present but not used for dispatch — _is_context_condition uses inspect.signature() instead of isinstance(). This is the correct choice (since isinstance can't distinguish the two signatures structurally), but the @runtime_checkable decorator may mislead users into thinking they can use isinstance(cond, EdgeConditionWithContext) for the same purpose.
Suggestion: Consider adding a brief comment noting that @runtime_checkable is for type-checking ergonomics / documentation purposes only, not for runtime dispatch, since isinstance will match both legacy and new-style conditions.
|
Issue: The PR introduces a public API change ( Suggestion: Consider adding the
|
| "next_nodes_to_execute": next_nodes, | ||
| "current_task": encode_bytes_values(self.state.task), | ||
| "execution_order": [n.node_id for n in self.state.execution_order], | ||
| "invocation_state": self._current_invocation_state, |
There was a problem hiding this comment.
Issue: invocation_state is serialized directly into the session payload without validation. If a user passes non-JSON-serializable objects (e.g., class instances, functions) in invocation_state, this will fail silently or raise an unclear error during serialization.
Suggestion: Consider either:
- Documenting that
invocation_statevalues must be JSON-serializable, or - Adding a validation/guard in
serialize_state()that provides a clear error message if serialization fails due to non-serializable invocation_state values.
There was a problem hiding this comment.
Addressed in 7cc0d04 — added _validate_invocation_state() that calls json.dumps() and raises a clear TypeError if the value isn't serializable. It's gated on session_manager is not None (serialization only matters when sessions persist), and also validated symmetrically on the deserialization path in deserialize_state.
| Edges whose condition evaluates to False are excluded from the check — they | ||
| represent paths that were intentionally skipped. | ||
| """ | ||
| traversable_edges = [ |
There was a problem hiding this comment.
Issue: In the _is_node_ready_for_resume method, the logic evaluates all edge conditions to determine traversability. However, since invocation_state is restored from the serialized payload, but the edge conditions themselves are defined in code — if the graph code changes between serialize and deserialize (e.g., condition logic updated), the behavior could be subtly different on resume. This is inherent to the design but worth noting.
Also, is calling e.should_traverse() here redundant with the e.condition is None check? should_traverse already returns True when condition is None, so the filter could be simplified to just e.should_traverse(...):
traversable_edges = [
e for e in incoming
if e.should_traverse(self.state, invocation_state=self._current_invocation_state)
]Suggestion: The explicit e.condition is None check is likely a micro-optimization to avoid the inspect.signature() call for unconditional edges. If caching is added to _is_context_condition, this optimization becomes unnecessary and the code can be simplified.
There was a problem hiding this comment.
Addressed in 7cc0d04. You're right that with the WeakKeyDictionary cache now in place, the e.condition is None short-circuit is no longer necessary for performance. I kept it because it still avoids the method call + cache lookup entirely for unconditional edges, and makes the "unconditional edges always traverse" semantics explicit at the call site. Added an inline comment explaining the intent. Happy to simplify if you'd prefer the terser form.
On the serialize/deserialize behavior divergence — added a docstring to _compute_ready_nodes_for_resume acknowledging this explicitly: re-evaluation is intentional since invocation_state may differ between invocations, and condition logic changes taking effect on resume is consistent with the graph being defined in code rather than serialized.
|
|
||
| graph = Graph.__new__(Graph) | ||
| graph.nodes = {"A": node_a, "B": node_b} | ||
| graph.edges = [edge] |
There was a problem hiding this comment.
Issue: Tests using Graph.__new__(Graph) bypass __init__ and assign graph.edges as a list (e.g., graph.edges = [edge]) while the actual Graph class types it as set[GraphEdge]. This works at runtime since both are iterable, but it creates a type inconsistency and could mask issues where set semantics (deduplication, ordering) matter.
Suggestion: Consider using graph.edges = {edge} (set literal) in tests for consistency with the actual class, or extract a helper function that creates a minimally-valid Graph instance for testing without bypassing init.
|
Assessment: Comment Well-designed feature that cleanly extends the existing edge condition system with invocation_state context while maintaining full backwards compatibility. The protocol-based approach with Review Categories
The deadlock fix in |
- Cache inspect.signature() results via WeakKeyDictionary - Remove unused @runtime_checkable decorator - Gate serialization validation on session_manager presence - Add validation on deserialization path for symmetry - Move json import to module level - Add inline comments for short-circuit and cache behavior - Extract _make_graph() test helper, fix list->set type consistency
I do not think i have permissions to add labels to this PR |
Description
Add support for edge conditions that receive
invocation_state, enabling conditional routing based on runtime context (feature flags, user roles, environment config) passed duringgraph invocation.
Also fixes a deadlock in
_compute_ready_nodes_for_resume()where conditional edges evaluating toFalsewould block downstream nodes from ever becoming ready on interrupt/resumeworkflows.
Public API Changes
GraphBuilder.add_edge()now accepts conditions with an extended signature via theEdgeConditionWithContextProtocol:Both signatures are supported indefinitely — no deprecation, no breaking changes.
Related Issues
Resolves #1346
Documentation PR
strands-agents/docs#847
Type of Change
New feature
Testing
How have you tested the change?
hatch run prepareChecklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.