fix(core): make `StructuredTool` JSON-serializable (#39631)
## Problem
`StructuredTool` cannot be dumped to JSON:
```python
@tool
def write_file(file_path: str, content: str) -> str:
"""Write content to the given path."""
return "ok"
write_file.model_dump(mode="json")
# PydanticSerializationError: Unable to serialize unknown type:
# <class 'pydantic._internal._model_construction.ModelMetaclass'>
```
`args_schema` holds a Pydantic model class, and `func` / `coroutine`
hold callables. None of them have a JSON form. Python-mode
`model_dump()` works; only the JSON modes raise.
This also costs tracing performance. The [LangSmith
SDK](https://github.com/langchain-ai/langsmith-sdk/blob/main/python/langsmith/_internal/_serde.py)
catches the error, dumps again in Python mode, and then sends every
class and function left in the result through its own `default` hook.
## Change
A `PlainSerializer(..., when_used="json-unless-none")` on the three
fields.
- `args_schema` dumps as its own JSON schema: `model_json_schema()` for
a Pydantic v2 class, `schema()` for a v1 one. A dict schema passes
through unchanged. A schema holding an arbitrary type has no JSON schema
at all, so it falls back to its repr instead of raising.
- `func` and `coroutine` dump as strings.
- Python-mode dumps are unchanged — still the live schema class and
callables.
- `exclude` / `include` / `exclude_none` keep working.
- Attached with `Annotated`, not `@field_serializer`. A field has only
one serializer slot, so `@field_serializer` would break any subclass
that declares its own serializer for the same field.
- Schema generation is cached per class. Pydantic does not memoize it,
and tracing would pay for it on every run.
## Result
The dump is JSON-native, and the schema it carries has the same shape a
dict `args_schema` already has, so it validates back into a working
tool.
Measured on the 8 filesystem tools of a deepagents agent, dumped through
the LangSmith serializer:
| | master | this PR |
|---|---|---|
| time per dump | 0.119 ms | **0.025 ms** |
| payload | 7,706 B | 12,213 B |
| objects reaching the SDK's `default` hook | 32 | 8 |
The payload grows because `args_schema` now carries the real schema
instead of `"<class ...>"`. Only the tool itself still enters the
`default` hook; the class and functions inside it no longer do. Without
the per-class cache the same dump takes 1.10 ms, so the cache is what
makes this a win rather than a regression.
## Why not on `BaseTool`
`args_schema` is declared there as well, so `Tool` and custom subclasses
hit the same error. But an `Annotated` serializer only applies where the
field is declared, and `StructuredTool` redeclares `args_schema` — it
would not inherit one from `BaseTool`.
## Tests
In `libs/core/tests/unit_tests/test_tools.py`: JSON round trip, Python
mode unchanged, dump options respected, dict `args_schema` preserved,
Pydantic v1 schema class, arbitrary-type fallback, and a subclass
declaring its own serializers for the same fields. E
Emil F committed
13b1b2feae476fdcebc0a285a723d5cbfed9df2e
Parent: 3b3b308
Committed by GitHub <noreply@github.com>
on 8/27/2026, 3:50:53 PM