diff --git a/lib/crewai/src/crewai/flow/expressions.py b/lib/crewai/src/crewai/flow/expressions.py index 5fd9eb522..2250124df 100644 --- a/lib/crewai/src/crewai/flow/expressions.py +++ b/lib/crewai/src/crewai/flow/expressions.py @@ -21,6 +21,25 @@ _CEL_MACROS_WITH_LOCAL_BINDINGS = frozenset( ) +def _find_cel_eval_error(value: Any) -> Exception | None: + from celpy.evaluation import CELEvalError + + if isinstance(value, CELEvalError): + return value + if isinstance(value, dict): + for key, item in value.items(): + if (error := _find_cel_eval_error(key)) is not None: + return error + if (error := _find_cel_eval_error(item)) is not None: + return error + return None + if isinstance(value, (list, tuple)): + for item in value: + if (error := _find_cel_eval_error(item)) is not None: + return error + return None + + def _stringify_cel_value(value: Any) -> str: from celpy.adapter import CELJSONEncoder @@ -336,6 +355,8 @@ class Expression: Expression._compile_cel(expression, environment=environment) ) result = program.evaluate(cast(Context, json_to_cel(context))) + if (eval_error := _find_cel_eval_error(result)) is not None: + raise eval_error return json.loads(json.dumps(result, cls=CELJSONEncoder)) except Exception as e: raise ExpressionError( diff --git a/lib/crewai/tests/test_flow_from_definition.py b/lib/crewai/tests/test_flow_from_definition.py index c35893256..f5ac6ee15 100644 --- a/lib/crewai/tests/test_flow_from_definition.py +++ b/lib/crewai/tests/test_flow_from_definition.py @@ -2952,6 +2952,52 @@ def test_expression_template_empty_context_overrides_stored_context(): expression.render_template({}) +@pytest.mark.parametrize( + "expression", + [ + "{'a': 1/0}", + "{'a': 1, 'b': state.missing}", + "{'a': {'b': 1/0}}", + "{'a': [1/0]}", + ], +) +def test_expression_raises_for_cel_eval_error_returned_as_data(expression): + """celpy returns a map literal holding a CELEvalError instead of raising it.""" + from crewai.flow.expressions import Expression, ExpressionError + + with pytest.raises(ExpressionError, match="failed to evaluate CEL expression"): + Expression(expression, context={"state": {"score": 90}}).evaluate() + + +def test_expression_nested_cel_eval_error_reports_underlying_cause(): + from crewai.flow.expressions import Expression, ExpressionError + + expression = Expression("{'a': 1/0}", context={"state": {}}) + + with pytest.raises(ExpressionError, match="modulus or divide by zero"): + expression.evaluate() + + +def test_expression_keeps_short_circuited_cel_errors(): + """Errors that CEL logic intentionally silences must still evaluate.""" + from crewai.flow.expressions import Expression + + context = {"state": {"tags": ["a", "b"]}} + + assert Expression("{'ok': false && 1/0 == 1}", context=context).evaluate() == { + "ok": False + } + assert Expression("{'ok': true || 1/0 == 1}", context=context).evaluate() == { + "ok": True + } + assert ( + Expression( + "state.tags.exists(t, t == 'a' || 1/0 == 1)", context=context + ).evaluate() + is True + ) + + def test_expression_action_can_route_like_if_else(): yaml_str = f""" schema: crewai.flow/v1