fix: serialize guardrail callables as null for JSON checkpointing

This commit is contained in:
Greyson LaLonde
2026-04-28 14:57:49 +08:00
committed by GitHub
parent 6ae1d1951f
commit 7a0a8cf56f
6 changed files with 210 additions and 7 deletions

View File

@@ -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",
)

View File

@@ -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",
)

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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

2
uv.lock generated
View File

@@ -13,7 +13,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-04-28T07:00:00Z"
exclude-newer = "2026-04-27T16:00:00Z"
[manifest]
members = [