From 7a0a8cf56f94133574d11aa8a395cbc3a8ab587e Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 28 Apr 2026 14:57:49 +0800 Subject: [PATCH] fix: serialize guardrail callables as null for JSON checkpointing --- lib/crewai/src/crewai/agent/core.py | 11 +- lib/crewai/src/crewai/lite_agent.py | 13 +- lib/crewai/src/crewai/task.py | 20 ++- lib/crewai/src/crewai/utilities/guardrail.py | 41 ++++++ .../tests/test_guardrail_serialization.py | 130 ++++++++++++++++++ uv.lock | 2 +- 6 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 lib/crewai/tests/test_guardrail_serialization.py diff --git a/lib/crewai/src/crewai/agent/core.py b/lib/crewai/src/crewai/agent/core.py index 113adbea8..dbd89f02c 100644 --- a/lib/crewai/src/crewai/agent/core.py +++ b/lib/crewai/src/crewai/agent/core.py @@ -96,7 +96,7 @@ from crewai.utilities.agent_utils import ( from crewai.utilities.constants import TRAINED_AGENTS_DATA_FILE, TRAINING_DATA_FILE from crewai.utilities.converter import Converter, ConverterError from crewai.utilities.env import get_env_context -from crewai.utilities.guardrail import process_guardrail +from crewai.utilities.guardrail import process_guardrail, serialize_guardrail_for_json from crewai.utilities.guardrail_types import GuardrailCallable, GuardrailType from crewai.utilities.i18n import I18N_DEFAULT from crewai.utilities.llm_utils import create_llm @@ -285,7 +285,14 @@ class Agent(BaseAgent): default=None, description="The Agent's role to be used from your repository.", ) - guardrail: GuardrailType | None = Field( + guardrail: Annotated[ + GuardrailType | None, + PlainSerializer( + serialize_guardrail_for_json, + return_type=str | None, + when_used="json", + ), + ] = Field( default=None, description="Function or string description of a guardrail to validate agent output", ) diff --git a/lib/crewai/src/crewai/lite_agent.py b/lib/crewai/src/crewai/lite_agent.py index 5ddddc89e..fbc9cf0b5 100644 --- a/lib/crewai/src/crewai/lite_agent.py +++ b/lib/crewai/src/crewai/lite_agent.py @@ -9,6 +9,7 @@ import time from types import MethodType from typing import ( TYPE_CHECKING, + Annotated, Any, Literal, cast, @@ -25,6 +26,7 @@ from pydantic import ( field_validator, model_validator, ) +from pydantic.functional_serializers import PlainSerializer from typing_extensions import Self, deprecated @@ -86,7 +88,7 @@ from crewai.utilities.converter import ( Converter, ConverterError, ) -from crewai.utilities.guardrail import process_guardrail +from crewai.utilities.guardrail import process_guardrail, serialize_guardrail_for_json from crewai.utilities.guardrail_types import GuardrailCallable, GuardrailType from crewai.utilities.i18n import I18N_DEFAULT from crewai.utilities.llm_utils import create_llm @@ -235,7 +237,14 @@ class LiteAgent(FlowTrackable, BaseModel): verbose: bool = Field( default=False, description="Whether to print execution details" ) - guardrail: GuardrailType | None = Field( + guardrail: Annotated[ + GuardrailType | None, + PlainSerializer( + serialize_guardrail_for_json, + return_type=str | None, + when_used="json", + ), + ] = Field( default=None, description="Function or string description of a guardrail to validate agent output", ) diff --git a/lib/crewai/src/crewai/task.py b/lib/crewai/src/crewai/task.py index 04bbf3718..ff8f9f1b1 100644 --- a/lib/crewai/src/crewai/task.py +++ b/lib/crewai/src/crewai/task.py @@ -76,6 +76,8 @@ except ImportError: from crewai.types.callback import SerializableCallable from crewai.utilities.guardrail import ( process_guardrail, + serialize_guardrail_for_json, + serialize_guardrails_for_json, ) from crewai.utilities.guardrail_types import ( GuardrailCallable, @@ -235,11 +237,25 @@ class Task(BaseModel): default=None, ) processed_by_agents: set[str] = Field(default_factory=set) - guardrail: GuardrailType | None = Field( + guardrail: Annotated[ + GuardrailType | None, + PlainSerializer( + serialize_guardrail_for_json, + return_type=str | None, + when_used="json", + ), + ] = Field( default=None, description="Function or string description of a guardrail to validate task output before proceeding to next task", ) - guardrails: GuardrailsType | None = Field( + guardrails: Annotated[ + GuardrailsType | None, + PlainSerializer( + serialize_guardrails_for_json, + return_type=list[str] | str | None, + when_used="json", + ), + ] = Field( default=None, description="List of guardrails to validate task output before proceeding to next task. Also supports a single guardrail function or string description of a guardrail to validate task output before proceeding to next task", ) diff --git a/lib/crewai/src/crewai/utilities/guardrail.py b/lib/crewai/src/crewai/utilities/guardrail.py index b9828cfba..faf27fa9f 100644 --- a/lib/crewai/src/crewai/utilities/guardrail.py +++ b/lib/crewai/src/crewai/utilities/guardrail.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any +import warnings from pydantic import BaseModel, Field, field_validator from typing_extensions import Self @@ -8,6 +9,46 @@ from typing_extensions import Self from crewai.utilities.guardrail_types import GuardrailCallable +def serialize_guardrail_for_json( + value: Any, field_name: str = "guardrail" +) -> str | None: + """Serialize a single guardrail value for JSON checkpointing. + + String descriptions are preserved; callable references cannot be + JSON-serialized and are dropped with a warning so users know the + guardrail will not be present after a checkpoint restore. + """ + if value is None or isinstance(value, str): + return value + if callable(value): + warnings.warn( + f"Callable {field_name!r} cannot be JSON-serialized and will be dropped " + f"during checkpointing; restored checkpoints will not run this guardrail.", + UserWarning, + stacklevel=2, + ) + return None + return None + + +def serialize_guardrails_for_json( + value: Any, field_name: str = "guardrails" +) -> list[str] | str | None: + """Serialize a guardrails value (single or sequence) for JSON checkpointing. + + Dropped callables are filtered out of lists rather than emitted as ``None``; + a ``None`` entry would fail validation against ``GuardrailCallable | str`` + on checkpoint restore. + """ + if isinstance(value, (list, tuple)): + return [ + item + for item in (serialize_guardrail_for_json(g, field_name) for g in value) + if item is not None + ] + return serialize_guardrail_for_json(value, field_name) + + if TYPE_CHECKING: from crewai.agents.agent_builder.base_agent import BaseAgent from crewai.lite_agent import LiteAgent diff --git a/lib/crewai/tests/test_guardrail_serialization.py b/lib/crewai/tests/test_guardrail_serialization.py new file mode 100644 index 000000000..e5b9ea66f --- /dev/null +++ b/lib/crewai/tests/test_guardrail_serialization.py @@ -0,0 +1,130 @@ +"""Tests for JSON serialization of guardrail fields on Task, Agent, and LiteAgent. + +Guardrails accept either string descriptions or callables. Callables cannot be +JSON-serialized, so the checkpoint path must drop them rather than raise. +""" + +import pytest + +from crewai import Agent, Task +from crewai.lite_agent import LiteAgent +from crewai.utilities.guardrail import ( + serialize_guardrail_for_json, + serialize_guardrails_for_json, +) + + +def _example_guardrail(output): + return True, output + + +def test_serialize_guardrail_preserves_string() -> None: + assert serialize_guardrail_for_json("validate output") == "validate output" + + +def test_serialize_guardrail_returns_none_for_none() -> None: + assert serialize_guardrail_for_json(None) is None + + +def test_serialize_guardrail_drops_callable_with_warning() -> None: + with pytest.warns(UserWarning, match="cannot be JSON-serialized"): + assert serialize_guardrail_for_json(_example_guardrail) is None + + +def test_serialize_guardrails_drops_callables_from_list() -> None: + with pytest.warns(UserWarning): + result = serialize_guardrails_for_json(["check size", _example_guardrail]) + assert result == ["check size"] + + +def test_serialize_guardrails_all_callables_returns_empty_list() -> None: + with pytest.warns(UserWarning): + result = serialize_guardrails_for_json([_example_guardrail, _example_guardrail]) + assert result == [] + + +def test_serialize_guardrails_handles_single_string() -> None: + assert serialize_guardrails_for_json("only check this") == "only check this" + + +def test_serialize_guardrails_handles_single_callable() -> None: + with pytest.warns(UserWarning): + assert serialize_guardrails_for_json(_example_guardrail) is None + + +def test_task_model_dump_json_with_string_guardrail() -> None: + agent = Agent(role="r", goal="g", backstory="b") + task = Task( + description="Do the thing", + expected_output="A thing", + agent=agent, + guardrail="output must be non-empty", + ) + dumped = task.model_dump(mode="json") + assert dumped["guardrail"] == "output must be non-empty" + + +def test_task_model_dump_json_with_callable_guardrail_does_not_raise() -> None: + agent = Agent(role="r", goal="g", backstory="b") + task = Task( + description="Do the thing", + expected_output="A thing", + agent=agent, + guardrail=_example_guardrail, + ) + with pytest.warns(UserWarning, match="cannot be JSON-serialized"): + dumped = task.model_dump(mode="json") + assert dumped["guardrail"] is None + + +def test_task_model_dump_json_with_callable_guardrails_list() -> None: + agent = Agent(role="r", goal="g", backstory="b") + task = Task( + description="Do the thing", + expected_output="A thing", + agent=agent, + guardrails=[_example_guardrail, "also check this"], + ) + with pytest.warns(UserWarning): + dumped = task.model_dump(mode="json") + assert dumped["guardrails"] == ["also check this"] + + +def test_task_guardrails_round_trip_through_model_validate() -> None: + """Serialized guardrails must round-trip — None entries would fail validation.""" + agent = Agent(role="r", goal="g", backstory="b") + task = Task( + description="Do the thing", + expected_output="A thing", + agent=agent, + guardrails=[_example_guardrail, "also check this"], + ) + with pytest.warns(UserWarning): + dumped = task.model_dump(mode="json", exclude={"id"}) + if isinstance(dumped.get("agent"), dict): + dumped["agent"].pop("id", None) + Task.model_validate(dumped) + + +def test_agent_model_dump_json_with_callable_guardrail() -> None: + agent = Agent( + role="r", + goal="g", + backstory="b", + guardrail=_example_guardrail, + ) + with pytest.warns(UserWarning, match="cannot be JSON-serialized"): + dumped = agent.model_dump(mode="json") + assert dumped["guardrail"] is None + + +def test_lite_agent_model_dump_json_with_callable_guardrail() -> None: + agent = LiteAgent( + role="r", + goal="g", + backstory="b", + guardrail=_example_guardrail, + ) + with pytest.warns(UserWarning, match="cannot be JSON-serialized"): + dumped = agent.model_dump(mode="json") + assert dumped["guardrail"] is None diff --git a/uv.lock b/uv.lock index c2e8b55b2..0015fccb0 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-28T07:00:00Z" +exclude-newer = "2026-04-27T16:00:00Z" [manifest] members = [