fix(core): exclude args_schema from tool dumps

Of the accepted schema forms -- a `pydantic.BaseModel` subclass, a
`pydantic.v1.BaseModel` subclass, or a JSON schema dict -- only the dict has a
JSON form, so dumping the field is what raises `PydanticSerializationError`.
Mark it `exclude=True`, matching how `BaseTool.callbacks` and the chat model
`cache`/`rate_limiter` fields already handle values that only mean something in
this process.

`exclude` applies to every dump mode at once; pydantic has no per-mode variant,
and `exclude_if` (2.12+) is both above this package's floor and blind to the
mode. So a dict `args_schema`, which serialized fine before, is dropped too, and
`model_dump()` no longer round-trips into `StructuredTool(**dumped)`. A subclass
that wants the field in its dumps can re-declare it.
This commit is contained in:
Emil Filipov committed 2026-08-19 12:34:49 +02:00
1 parent 1d993ca683
commit 69b4da868f
2 files changed
+21 -24

No files matched your search

+7 -17
View File
@@ -46,25 +46,10 @@ def _serialize_as_str(value: Any) -> str:
return str(value)
def _serialize_args_schema(args_schema: ArgsSchema) -> Any:
"""Represent a schema class as a string when dumping to JSON.
A Pydantic model class has no JSON form, so leaving it to the default
serializer raises `PydanticSerializationError`. A dict schema is already
JSON-compatible and is returned unchanged.
"""
if isinstance(args_schema, dict):
return args_schema
return str(args_schema)
# Attached to the fields via `Annotated` rather than declared with
# `@field_serializer`, which would take the field's one serializer slot and make
# any subclass that declares its own serializer for the same field fail at class
# creation with `PydanticUserError: Multiple field serializer functions ...`.
_JsonSchemaFallback = PlainSerializer(
_serialize_args_schema, when_used="json-unless-none"
)
_JsonCallableFallback = PlainSerializer(_serialize_as_str, when_used="json-unless-none")
@@ -73,8 +58,13 @@ class StructuredTool(BaseTool):
description: str = ""
args_schema: Annotated[ArgsSchema, SkipValidation(), _JsonSchemaFallback] = Field(
..., description="The tool schema."
# Of the accepted schema forms -- a v1 or v2 model class, or a JSON schema
# dict -- only the dict has a JSON form, so dumping the field raises
# `PydanticSerializationError` unless it is left out. `exclude` applies to
# every mode at once (pydantic has no per-mode variant), so the dict form is
# dropped alongside the classes.
args_schema: Annotated[ArgsSchema, SkipValidation()] = Field(
..., description="The tool schema.", exclude=True
)
"""The input arguments' schema."""
+14 -7
View File
@@ -29,6 +29,7 @@ from pydantic import (
ConfigDict,
Field,
RootModel,
SkipValidation,
ValidationError,
field_serializer,
)
@@ -4381,14 +4382,16 @@ def test_structured_tool_json_dump() -> None:
dumped = write_file.model_dump(mode="json")
# Round-trips through the stdlib encoder, i.e. it really is JSON-native.
json.dumps(dumped)
assert dumped["args_schema"].startswith("<class ")
assert "args_schema" not in dumped
assert "write_file" in dumped["func"]
json.loads(write_file.model_dump_json())
# Python-mode dumps still hand out the live objects.
# Python-mode dumps still hand out the live callable, and excluding
# `args_schema` from dumps must not affect the tool itself.
native = write_file.model_dump()
assert callable(native["func"])
assert isinstance(native["args_schema"], type)
assert "args_schema" not in native
assert set(write_file.args) == {"file_path", "content"}
def test_structured_tool_json_dump_respects_options() -> None:
@@ -4410,8 +4413,8 @@ def test_structured_tool_json_dump_respects_options() -> None:
assert "coroutine" not in write_file.model_dump(mode="json", exclude_none=True)
def test_structured_tool_json_dump_keeps_dict_args_schema() -> None:
"""A dict schema is already JSON-native and must be passed through as-is."""
def test_structured_tool_json_dump_drops_dict_args_schema() -> None:
"""A dict schema is JSON-native, but `exclude` has no per-mode variant."""
schema = {"type": "object", "properties": {"a": {"type": "string"}}}
dict_tool = StructuredTool(
name="d",
@@ -4419,7 +4422,8 @@ def test_structured_tool_json_dump_keeps_dict_args_schema() -> None:
func=lambda **kwargs: "x",
args_schema=schema,
)
assert dict_tool.model_dump(mode="json")["args_schema"] == schema
assert "args_schema" not in dict_tool.model_dump(mode="json")
assert dict_tool.args_schema == schema
def test_structured_tool_subclass_can_override_json_serializers() -> None:
@@ -4427,10 +4431,13 @@ def test_structured_tool_subclass_can_override_json_serializers() -> None:
A subclass declaring its own `@field_serializer` for the same fields used to
fail at class creation with `PydanticUserError: Multiple field serializer
functions were defined`.
functions were defined`. `args_schema` has to be re-declared to undo its
exclusion before a serializer for it can have any effect.
"""
class MyTool(StructuredTool):
args_schema: Annotated[ArgsSchema, SkipValidation()] = Field(...)
@field_serializer("func", when_used="json-unless-none")
def _my_func_repr(self, func: Any) -> str:
return "custom-func"