Skip to content

Commit 7bb9bc7

Browse files
vinibrslclaude
andauthored
Discriminate FlowDefinition state types (#6196)
Replace the single FlowStateDefinition model with a `type`-discriminated union of FlowDictStateDefinition, FlowPydanticStateDefinition, FlowJsonSchemaStateDefinition, and FlowUnknownStateDefinition. Each branch only carries the fields it actually uses and forbids extras, so an invalid combination like a `dict` state with a `ref` now fails validation instead of being silently accepted. The runtime reads `ref` and `json_schema` defensively since they no longer exist on every branch. ```yaml state: type: json_schema json_schema: type: object properties: topic: type: string ``` Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ee4f853 commit 7bb9bc7

4 files changed

Lines changed: 160 additions & 23 deletions

File tree

lib/crewai/src/crewai/flow/dsl/_utils.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515
FlowConversationalRouterDefinition,
1616
FlowDefinition,
1717
FlowDefinitionDiagnostic,
18+
FlowDictStateDefinition,
1819
FlowHumanFeedbackDefinition,
1920
FlowMethodDefinition,
2021
FlowPersistenceDefinition,
22+
FlowPydanticStateDefinition,
2123
FlowStateDefinition,
24+
FlowUnknownStateDefinition,
2225
_object_ref,
2326
)
2427
from crewai.flow.flow_wrappers import (
@@ -185,12 +188,11 @@ def _build_state_definition(
185188
default = None
186189
if isinstance(state_value, dict):
187190
default = _serialize_static_value(state_value, diagnostics, "state.default")
188-
return FlowStateDefinition(type="dict", default=default)
191+
return FlowDictStateDefinition(default=default)
189192
if isinstance(state_value, type) and issubclass(state_value, PydanticBaseModel):
190-
return FlowStateDefinition(type="pydantic", ref=_state_ref(state_value))
193+
return FlowPydanticStateDefinition(ref=_state_ref(state_value))
191194
if isinstance(state_value, PydanticBaseModel):
192-
return FlowStateDefinition(
193-
type="pydantic",
195+
return FlowPydanticStateDefinition(
194196
ref=_state_ref(state_value),
195197
default=_serialize_static_value(state_value, diagnostics, "state.default"),
196198
)
@@ -201,7 +203,7 @@ def _build_state_definition(
201203
message=f"could not serialize state type {_object_ref(state_value)}",
202204
)
203205
)
204-
return FlowStateDefinition(type="unknown", ref=_state_ref(state_value))
206+
return FlowUnknownStateDefinition(ref=_state_ref(state_value))
205207

206208

207209
def _build_config_definition(

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

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import json
1313
import logging
1414
import re
15-
from typing import Any, Literal as TypingLiteral
15+
from typing import Annotated, Any, Literal as TypingLiteral, TypeAlias
1616

1717
from pydantic import (
1818
BaseModel,
@@ -46,14 +46,18 @@
4646
"FlowDefinition",
4747
"FlowDefinitionCondition",
4848
"FlowDefinitionDiagnostic",
49+
"FlowDictStateDefinition",
4950
"FlowEachActionDefinition",
5051
"FlowEachInnerActionDefinition",
5152
"FlowExpressionActionDefinition",
5253
"FlowHumanFeedbackDefinition",
54+
"FlowJsonSchemaStateDefinition",
5355
"FlowMethodDefinition",
5456
"FlowPersistenceDefinition",
57+
"FlowPydanticStateDefinition",
5558
"FlowStateDefinition",
5659
"FlowToolActionDefinition",
60+
"FlowUnknownStateDefinition",
5761
]
5862

5963

@@ -74,13 +78,114 @@ class FlowDefinitionDiagnostic(BaseModel):
7478
path: str | None = None
7579

7680

77-
class FlowStateDefinition(BaseModel):
78-
"""Static description of a Flow state contract."""
81+
class FlowDictStateDefinition(BaseModel):
82+
"""Static description of a plain dictionary Flow state contract."""
7983

80-
type: TypingLiteral["dict", "pydantic", "json_schema", "unknown"] = "dict"
81-
ref: str | None = None
82-
json_schema: dict[str, Any] | None = None
83-
default: dict[str, Any] | None = None
84+
model_config = ConfigDict(extra="forbid")
85+
86+
type: TypingLiteral["dict"] = Field(
87+
default="dict",
88+
description="Plain dictionary state with optional default values.",
89+
examples=["dict"],
90+
)
91+
default: dict[str, Any] | None = Field(
92+
default=None,
93+
description="Default state values applied before kickoff inputs.",
94+
examples=[{"topic": "AI agents", "limit": 3}],
95+
)
96+
97+
98+
class FlowPydanticStateDefinition(BaseModel):
99+
"""Static description of an importable Pydantic Flow state contract."""
100+
101+
model_config = ConfigDict(extra="forbid")
102+
103+
type: TypingLiteral["pydantic"] = Field(
104+
default="pydantic",
105+
description="Importable Pydantic model used as the Flow state type.",
106+
examples=["pydantic"],
107+
)
108+
ref: str | None = Field(
109+
default=None,
110+
description="Import reference for the state model, formatted as module:qualname.",
111+
examples=["my_project.flows:ResearchState"],
112+
)
113+
json_schema: dict[str, Any] | None = Field(
114+
default=None,
115+
description=(
116+
"Fallback JSON Schema used when the Pydantic state ref is unavailable."
117+
),
118+
examples=[
119+
{
120+
"type": "object",
121+
"properties": {"topic": {"type": "string"}},
122+
"required": ["topic"],
123+
}
124+
],
125+
)
126+
default: dict[str, Any] | None = Field(
127+
default=None,
128+
description="Default state values applied before kickoff inputs.",
129+
examples=[{"topic": "AI agents", "limit": 3}],
130+
)
131+
132+
133+
class FlowJsonSchemaStateDefinition(BaseModel):
134+
"""Static description of an inline JSON Schema Flow state contract."""
135+
136+
model_config = ConfigDict(extra="forbid")
137+
138+
type: TypingLiteral["json_schema"] = Field(
139+
default="json_schema",
140+
description="Inline JSON Schema used as the Flow state contract.",
141+
examples=["json_schema"],
142+
)
143+
json_schema: dict[str, Any] = Field(
144+
description="JSON Schema used to validate and document flow state.",
145+
examples=[
146+
{
147+
"type": "object",
148+
"properties": {"topic": {"type": "string"}},
149+
"required": ["topic"],
150+
}
151+
],
152+
)
153+
default: dict[str, Any] | None = Field(
154+
default=None,
155+
description="Default state values applied before kickoff inputs.",
156+
examples=[{"topic": "AI agents", "limit": 3}],
157+
)
158+
159+
160+
class FlowUnknownStateDefinition(BaseModel):
161+
"""Static description of a state contract that could not be serialized."""
162+
163+
model_config = ConfigDict(extra="forbid")
164+
165+
type: TypingLiteral["unknown"] = Field(
166+
default="unknown",
167+
description="Unknown state representation; runtime falls back to dictionary state.",
168+
examples=["unknown"],
169+
)
170+
ref: str | None = Field(
171+
default=None,
172+
description="Best-effort import reference for the unknown state type.",
173+
examples=["my_project.flows:CustomState"],
174+
)
175+
default: dict[str, Any] | None = Field(
176+
default=None,
177+
description="Default state values applied before kickoff inputs.",
178+
examples=[{"topic": "AI agents", "limit": 3}],
179+
)
180+
181+
182+
FlowStateDefinition: TypeAlias = Annotated[
183+
FlowDictStateDefinition
184+
| FlowPydanticStateDefinition
185+
| FlowJsonSchemaStateDefinition
186+
| FlowUnknownStateDefinition,
187+
Field(discriminator="type"),
188+
]
84189

85190

86191
class FlowConfigDefinition(BaseModel):

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

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,26 +193,24 @@ def _build_definition_state_model(
193193
kwargs = dict(state_definition.default or {})
194194

195195
model_class: type[BaseModel] | None = None
196-
if state_definition.ref:
196+
state_ref = getattr(state_definition, "ref", None)
197+
if state_ref:
197198
try:
198-
resolved: Any = resolve_ref(state_definition.ref, field="state")
199+
resolved: Any = resolve_ref(state_ref, field="state")
199200
except Exception:
200-
logger.warning(
201-
"Could not import state ref %r", state_definition.ref, exc_info=True
202-
)
201+
logger.warning("Could not import state ref %r", state_ref, exc_info=True)
203202
else:
204203
if isinstance(resolved, type) and issubclass(resolved, BaseModel):
205204
model_class = resolved
206205
else:
207-
logger.warning(
208-
"State ref %r is not a pydantic model", state_definition.ref
209-
)
206+
logger.warning("State ref %r is not a pydantic model", state_ref)
210207

211-
if model_class is None and state_definition.json_schema:
208+
json_schema = getattr(state_definition, "json_schema", None)
209+
if model_class is None and json_schema:
212210
from crewai.utilities.pydantic_schema_utils import create_model_from_schema
213211

214212
try:
215-
model_class = create_model_from_schema(state_definition.json_schema)
213+
model_class = create_model_from_schema(json_schema)
216214
except Exception:
217215
logger.warning(
218216
"Could not build a state model from the declared json_schema",

lib/crewai/tests/test_flow_definition.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Annotated, Literal
99

1010
import pytest
11-
from pydantic import BaseModel
11+
from pydantic import BaseModel, ValidationError
1212

1313
import crewai.flow.dsl as flow_dsl
1414
import crewai.flow.flow_definition as flow_definition
@@ -45,19 +45,51 @@ def test_flow_public_exports_are_explicit():
4545
"FlowDefinition",
4646
"FlowDefinitionCondition",
4747
"FlowDefinitionDiagnostic",
48+
"FlowDictStateDefinition",
4849
"FlowEachActionDefinition",
4950
"FlowEachInnerActionDefinition",
5051
"FlowExpressionActionDefinition",
5152
"FlowHumanFeedbackDefinition",
53+
"FlowJsonSchemaStateDefinition",
5254
"FlowMethodDefinition",
5355
"FlowPersistenceDefinition",
56+
"FlowPydanticStateDefinition",
5457
"FlowStateDefinition",
5558
"FlowToolActionDefinition",
59+
"FlowUnknownStateDefinition",
5660
}
5761
assert "build_flow_structure" in flow_visualization.__all__
5862
assert "calculate_node_levels" not in flow_visualization.__all__
5963

6064

65+
def test_flow_state_definition_uses_discriminated_branches():
66+
definition = flow_definition.FlowDefinition.model_validate(
67+
{
68+
"name": "TypedStateFlow",
69+
"state": {
70+
"type": "json_schema",
71+
"json_schema": {"type": "object"},
72+
},
73+
}
74+
)
75+
76+
assert isinstance(
77+
definition.state,
78+
flow_definition.FlowJsonSchemaStateDefinition,
79+
)
80+
81+
with pytest.raises(ValidationError, match="extra_forbidden"):
82+
flow_definition.FlowDefinition.model_validate(
83+
{
84+
"name": "InvalidStateFlow",
85+
"state": {
86+
"type": "dict",
87+
"ref": "my_project.flows:ResearchState",
88+
},
89+
}
90+
)
91+
92+
6193
def test_condition_combinators_return_nested_runtime_tree():
6294
condition = and_("event_a", "event_b", or_("event_c"))
6395

0 commit comments

Comments
 (0)