Skip to content

Commit 6ad821b

Browse files
authored
Add expressions to FlowDefinition actions (#6145)
* Add expressions to FlowDefinition actions Let definitions compute values without Python. A new `call: expression` action evaluates a Common Expression Language (CEL) expression, and tool `with:` blocks now render `${...}` CEL templates. Example 1: ```yaml decide: do: call: expression expr: "state.score >= 80 ? 'qualified' : 'nurture'" router: true emit: [qualified, nurture] ``` Example 2: ```yaml search: do: call: tool ref: my.pkg:SearchTool with: search_query: "${outputs.build_query.query + ' news'}" max_results: "${state.limit}" ``` * Address code review comments * Address code review comments * Fix linting offenses * Address code review comments * Fix scrapgraph issue
1 parent 2444895 commit 6ad821b

15 files changed

Lines changed: 2719 additions & 1894 deletions

lib/crewai-core/tests/test_smoke.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
user_data,
1414
version,
1515
)
16-
import pytest
1716
from opentelemetry.sdk.trace import TracerProvider
17+
import pytest
1818

1919

2020
def test_version_returns_string() -> None:

lib/crewai-tools/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ spider-client = [
6363
"spider-client>=0.1.25",
6464
]
6565
scrapegraph-py = [
66-
"scrapegraph-py>=1.9.0",
66+
"scrapegraph-py>=1.9.0,<2",
6767
]
6868
linkup-sdk = [
6969
"linkup-sdk>=0.2.2",

lib/crewai/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ dependencies = [
3333
"appdirs~=1.4.4",
3434
"jsonref~=1.1.0",
3535
"json-repair~=0.25.2",
36+
"cel-python>=0.5.0,<0.6",
3637
"tomli-w~=1.1.0",
3738
"tomli~=2.0.2",
3839
"json5~=0.10.0",

lib/crewai/src/crewai/events/event_listener.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,6 @@ def __init__(self) -> None:
158158
trace_listener.formatter = self.formatter
159159

160160
def setup_listeners(self, crewai_event_bus: CrewAIEventsBus) -> None:
161-
162161
@crewai_event_bus.on(CCEnvEvent)
163162
def on_cc_env(_: Any, event: CCEnvEvent) -> None:
164163
self._telemetry.env_context_span(event.type)

lib/crewai/src/crewai/experimental/conversational_mixin.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ def _copy_and_serialize_state(self) -> dict[str, Any]:
146146
def kickoff(self, *args: Any, **kwargs: Any) -> Any:
147147
pass
148148

149+
@property
150+
def method_outputs(self) -> list[Any]:
151+
pass
152+
149153
def conversation_start(self) -> str | None:
150154
"""Return the current user message for conversational route selection.
151155
@@ -1033,7 +1037,8 @@ def finalize_session_traces(self) -> None:
10331037
# of warning about an empty scope stack.
10341038
started_id = getattr(self, "_deferred_flow_started_event_id", None)
10351039
if started_id:
1036-
last_output = self._method_outputs[-1] if self._method_outputs else None
1040+
method_outputs = self.method_outputs
1041+
last_output = method_outputs[-1] if method_outputs else None
10371042
restore_event_scope(((started_id, "flow_started"),))
10381043
try:
10391044
crewai_event_bus.emit(

lib/crewai/src/crewai/flow/flow_definition.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"FlowDefinition",
3636
"FlowDefinitionCondition",
3737
"FlowDefinitionDiagnostic",
38+
"FlowExpressionActionDefinition",
3839
"FlowHumanFeedbackDefinition",
3940
"FlowMethodDefinition",
4041
"FlowPersistenceDefinition",
@@ -163,7 +164,18 @@ class FlowToolActionDefinition(BaseModel):
163164
with_: dict[str, Any] | None = Field(default=None, alias="with")
164165

165166

166-
FlowActionDefinition = FlowCodeActionDefinition | FlowToolActionDefinition
167+
class FlowExpressionActionDefinition(BaseModel):
168+
"""A Flow method action that evaluates a CEL expression."""
169+
170+
model_config = ConfigDict(extra="forbid")
171+
172+
call: TypingLiteral["expression"]
173+
expr: str
174+
175+
176+
FlowActionDefinition = (
177+
FlowCodeActionDefinition | FlowToolActionDefinition | FlowExpressionActionDefinition
178+
)
167179

168180

169181
class FlowMethodDefinition(BaseModel):

lib/crewai/src/crewai/flow/runtime/__init__.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -962,7 +962,12 @@ def _restore_from_checkpoint(self) -> None:
962962
}
963963
self._restored_from_checkpoint = True
964964
if self.checkpoint_method_outputs is not None:
965-
self._method_outputs = list(self.checkpoint_method_outputs)
965+
self._method_outputs = [
966+
entry
967+
if isinstance(entry, dict) and "method" in entry and "output" in entry
968+
else {"method": "", "output": entry}
969+
for entry in self.checkpoint_method_outputs
970+
]
966971
if self.checkpoint_method_counts is not None:
967972
self._method_execution_counts = {
968973
FlowMethodName(k): v for k, v in self.checkpoint_method_counts.items()
@@ -1649,6 +1654,11 @@ async def _resume_async_body(self, feedback: str = "") -> Any:
16491654
metadata=context.metadata,
16501655
)
16511656
collapsed_outcome = result.outcome
1657+
resumed_method_output = (
1658+
result.output
1659+
if emit and isinstance(result, HumanFeedbackResult)
1660+
else result
1661+
)
16521662

16531663
self._completed_methods.add(FlowMethodName(context.method_name))
16541664

@@ -1677,9 +1687,12 @@ async def _resume_async_body(self, feedback: str = "") -> Any:
16771687
# This allows methods to re-execute in loops (e.g., implement_changes → suggest_changes → implement_changes)
16781688
self._is_execution_resuming = False
16791689

1690+
self._method_outputs.append(
1691+
{"method": context.method_name, "output": resumed_method_output}
1692+
)
1693+
16801694
try:
16811695
if emit and collapsed_outcome:
1682-
self._method_outputs.append(collapsed_outcome)
16831696
await self._execute_listeners(
16841697
FlowMethodName(collapsed_outcome),
16851698
result,
@@ -1725,7 +1738,12 @@ async def _resume_async_body(self, feedback: str = "") -> Any:
17251738
return e
17261739
raise
17271740

1728-
final_result = self._method_outputs[-1] if self._method_outputs else result
1741+
method_outputs = self.method_outputs
1742+
final_result = (
1743+
method_outputs[-1]
1744+
if method_outputs
1745+
else (resumed_method_output if emit else result)
1746+
)
17291747

17301748
if self._event_futures:
17311749
await asyncio.gather(
@@ -1906,7 +1924,13 @@ def state(self) -> T:
19061924
@property
19071925
def method_outputs(self) -> list[Any]:
19081926
"""Returns the list of all outputs from executed methods."""
1909-
return self._method_outputs
1927+
outputs: list[Any] = []
1928+
for entry in self._method_outputs:
1929+
if isinstance(entry, dict) and "output" in entry:
1930+
outputs.append(entry["output"])
1931+
else:
1932+
outputs.append(entry)
1933+
return outputs
19101934

19111935
@property
19121936
def flow_id(self) -> str:
@@ -2540,7 +2564,8 @@ async def run_flow() -> None:
25402564
# Clear the resumption flag after initial execution completes
25412565
self._is_execution_resuming = False
25422566

2543-
final_output = self._method_outputs[-1] if self._method_outputs else None
2567+
method_outputs = self.method_outputs
2568+
final_output = method_outputs[-1] if method_outputs else None
25442569

25452570
if self._event_futures:
25462571
await asyncio.gather(
@@ -2695,7 +2720,8 @@ async def _execute_start_method(self, start_method_name: FlowMethodName) -> None
26952720
if start_method_name in self._completed_methods:
26962721
if self._is_execution_resuming:
26972722
# During resumption, skip execution but continue listeners
2698-
last_output = self._method_outputs[-1] if self._method_outputs else None
2723+
method_outputs = self.method_outputs
2724+
last_output = method_outputs[-1] if method_outputs else None
26992725
await self._execute_listeners(start_method_name, last_output)
27002726
return
27012727
# For cyclic flows, clear from completed to allow re-execution
@@ -2825,16 +2851,16 @@ async def _execute_method(
28252851
method_name, method_definition.human_feedback, result
28262852
)
28272853

2828-
self._method_outputs.append(result)
2854+
self._method_outputs.append({"method": str(method_name), "output": result})
28292855

28302856
# For @human_feedback methods with emit, the result is the collapsed outcome
28312857
# (e.g., "approved") used for routing. But we want the actual method output
28322858
# to be the stored result (for final flow output). Replace the last entry
28332859
# if a stashed output exists. Dict-based stash is concurrency-safe and
28342860
# handles None return values (presence in dict = stashed, not value).
28352861
if method_name in self._human_feedback_method_outputs:
2836-
self._method_outputs[-1] = self._human_feedback_method_outputs.pop(
2837-
method_name
2862+
self._method_outputs[-1]["output"] = (
2863+
self._human_feedback_method_outputs.pop(method_name)
28382864
)
28392865

28402866
self._method_execution_counts[method_name] = (
@@ -3560,7 +3586,6 @@ async def _finalize_human_feedback(
35603586
def _resolve_feedback_provider(
35613587
self, feedback_definition: FlowHumanFeedbackDefinition
35623588
) -> Any:
3563-
35643589
provider = feedback_definition.provider
35653590
if isinstance(provider, str):
35663591
provider = resolve_instance_ref(provider, field="human_feedback.provider")
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Runtime expression support for FlowDefinition CEL expressions."""
2+
3+
from __future__ import annotations
4+
5+
import copy
6+
import dataclasses
7+
from itertools import pairwise
8+
import json
9+
import re
10+
from typing import TYPE_CHECKING, Any, cast
11+
12+
from pydantic import BaseModel
13+
14+
15+
if TYPE_CHECKING:
16+
from crewai.flow.runtime import Flow
17+
18+
19+
_EXPRESSION_PATTERN = re.compile(r"\$\{([^{}]*)\}")
20+
21+
__all__ = ["FlowExpressionError", "evaluate_expression", "render_with_block"]
22+
23+
24+
class FlowExpressionError(ValueError):
25+
"""A FlowDefinition expression failed to parse or evaluate."""
26+
27+
28+
def render_with_block(flow: Flow[Any], value: Any) -> Any:
29+
"""Render CEL expressions inside a FlowDefinition ``with:`` payload."""
30+
context = _expression_context(flow)
31+
return _render_value(value, context)
32+
33+
34+
def evaluate_expression(flow: Flow[Any], expression: str) -> Any:
35+
"""Evaluate a FlowDefinition CEL expression against runtime context."""
36+
expression = expression.strip()
37+
if not expression:
38+
raise FlowExpressionError("empty CEL expression")
39+
return _eval_cel(expression, _expression_context(flow))
40+
41+
42+
def _expression_context(flow: Flow[Any]) -> dict[str, Any]:
43+
return {
44+
"state": flow._copy_and_serialize_state(),
45+
"outputs": _outputs_by_name(flow._method_outputs),
46+
}
47+
48+
49+
def _outputs_by_name(method_outputs: list[Any]) -> dict[str, Any]:
50+
outputs: dict[str, Any] = {}
51+
for entry in method_outputs:
52+
method = ""
53+
output = entry
54+
if isinstance(entry, dict) and "output" in entry:
55+
method = str(entry.get("method", ""))
56+
output = entry["output"]
57+
output = copy.deepcopy(output)
58+
if isinstance(output, BaseModel):
59+
output = output.model_dump(mode="json")
60+
elif dataclasses.is_dataclass(output) and not isinstance(output, type):
61+
output = dataclasses.asdict(output)
62+
outputs[method] = output
63+
return outputs
64+
65+
66+
def _render_value(value: Any, context: dict[str, Any]) -> Any:
67+
if isinstance(value, str):
68+
return _render_string(value, context)
69+
if isinstance(value, dict):
70+
return {key: _render_value(item, context) for key, item in value.items()}
71+
if isinstance(value, list):
72+
return [_render_value(item, context) for item in value]
73+
return value
74+
75+
76+
def _render_string(value: str, context: dict[str, Any]) -> Any:
77+
matches = list(_EXPRESSION_PATTERN.finditer(value))
78+
if not matches:
79+
_raise_for_invalid_interpolation(value)
80+
return value
81+
82+
_raise_for_literal_braces(value[: matches[0].start()])
83+
for previous, current in pairwise(matches):
84+
_raise_for_literal_braces(value[previous.end() : current.start()])
85+
_raise_for_literal_braces(value[matches[-1].end() :])
86+
87+
if len(matches) == 1 and matches[0].span() == (0, len(value)):
88+
expression = matches[0].group(1).strip()
89+
if not expression:
90+
raise FlowExpressionError("empty CEL expression in with block")
91+
return _eval_cel(expression, context)
92+
93+
rendered: list[str] = []
94+
position = 0
95+
for match in matches:
96+
start, end = match.span()
97+
literal = value[position:start]
98+
rendered.append(literal)
99+
100+
expression = match.group(1).strip()
101+
if not expression:
102+
raise FlowExpressionError("empty CEL expression in with block")
103+
result = _eval_cel(expression, context)
104+
rendered.append(result if isinstance(result, str) else json.dumps(result))
105+
position = end
106+
107+
literal = value[position:]
108+
rendered.append(literal)
109+
110+
return "".join(rendered)
111+
112+
113+
def _raise_for_invalid_interpolation(value: str) -> None:
114+
if "${" not in value:
115+
return
116+
raise FlowExpressionError(
117+
"invalid CEL interpolation in with block: expressions must be enclosed "
118+
"as ${...} and cannot contain braces"
119+
)
120+
121+
122+
def _raise_for_literal_braces(value: str) -> None:
123+
if "{" not in value and "}" not in value:
124+
return
125+
raise FlowExpressionError(
126+
"invalid CEL interpolation in with block: expressions must be enclosed "
127+
"as ${...} and cannot contain braces"
128+
)
129+
130+
131+
def _eval_cel(expression: str, context: dict[str, Any]) -> Any:
132+
try:
133+
from celpy import Environment
134+
from celpy.adapter import CELJSONEncoder, json_to_cel
135+
from celpy.evaluation import Context
136+
137+
environment = Environment()
138+
program = environment.program(environment.compile(expression))
139+
result = program.evaluate(cast(Context, json_to_cel(context)))
140+
return json.loads(json.dumps(result, cls=CELJSONEncoder))
141+
except Exception as e:
142+
raise FlowExpressionError(
143+
f"failed to evaluate CEL expression {expression!r}: {e}"
144+
) from e

lib/crewai/src/crewai/flow/runtime/_resolvers.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
from crewai.flow.flow_definition import (
1717
FlowActionDefinition,
1818
FlowCodeActionDefinition,
19+
FlowExpressionActionDefinition,
1920
FlowToolActionDefinition,
2021
)
22+
from crewai.flow.runtime._expressions import evaluate_expression, render_with_block
2123

2224

2325
if TYPE_CHECKING:
@@ -68,7 +70,7 @@ def _resolve_code_action(
6870

6971

7072
def _resolve_tool_action(
71-
_flow: Flow[Any], action: FlowToolActionDefinition
73+
flow: Flow[Any], action: FlowToolActionDefinition
7274
) -> Callable[..., Any]:
7375
target = resolve_ref(action.ref, field="do")
7476
from crewai.tools import BaseTool
@@ -89,15 +91,26 @@ def _resolve_tool_action(
8991
tool_kwargs = action.with_ or {}
9092

9193
def run_tool(*_args: Any, **_kwargs: Any) -> Any:
92-
return tool.run(**tool_kwargs)
94+
return tool.run(**render_with_block(flow, tool_kwargs))
9395

9496
return run_tool
9597

9698

99+
def _resolve_expression_action(
100+
flow: Flow[Any], action: FlowExpressionActionDefinition
101+
) -> Callable[..., Any]:
102+
def run_expression(*_args: Any, **_kwargs: Any) -> Any:
103+
return evaluate_expression(flow, action.expr)
104+
105+
return run_expression
106+
107+
97108
def resolve_action(flow: Flow[Any], action: FlowActionDefinition) -> Callable[..., Any]:
98109
"""Turn one `do:` action into the callable the flow runs for that node."""
99110
if action.call == "code":
100111
return _resolve_code_action(flow, action)
101112
if action.call == "tool":
102113
return _resolve_tool_action(flow, action)
114+
if action.call == "expression":
115+
return _resolve_expression_action(flow, action)
103116
raise ValueError(f"unknown call type {action.call!r}")

0 commit comments

Comments
 (0)