Skip to content

feat(core): introduce ToolSchema as root schema cache; replace TypedDict conversion with TypeAdapter#37103

Merged
Sydney Runkle (sydney-runkle) merged 3 commits into
perf/tool-schema-refactorfrom
feat/tool-schema
May 1, 2026
Merged

feat(core): introduce ToolSchema as root schema cache; replace TypedDict conversion with TypeAdapter#37103
Sydney Runkle (sydney-runkle) merged 3 commits into
perf/tool-schema-refactorfrom
feat/tool-schema

Conversation

@sydney-runkle
Copy link
Copy Markdown
Collaborator

Builds on #37101.


Two changes in one commit, both motivated by the same principle: a single, clean owner for everything schema-related on a tool.

ToolSchema — the root cache

Previously BaseTool had three independent cached_property slots (tool_call_schema, args, _approximate_schema_chars) that all computed overlapping data and each needed individual invalidation. This PR replaces them with a single ToolSchema dataclass and one tool_schema cached property that is the sole root:

@dataclass
class ToolSchema:
    name: str
    description: str
    validator: TypeAdapter      # validates tool call inputs
    json_schema: dict           # sent to LLMs
    pydantic_schema: Any        # model class or dict (backward compat)
    args: dict                  # properties from json_schema
    approximate_chars: int      # precomputed for token estimation

BaseTool.tool_call_schema, BaseTool.args, and BaseTool._approximate_schema_chars are now plain @property delegates to tool_schema. __setattr__ only needs to pop one key on mutation instead of four. The is-identity caching tests still pass because all delegates read from the same cached ToolSchema object.

ToolSchema is exported from langchain_core.tools and can be used directly by integrations that want to consume both the validator and the schema without going through BaseTool.

TypeAdapter-based TypedDict conversion

_convert_any_typed_dicts_to_pydantic was a ~70-line recursive function that converted TypedDicts to throwaway pydantic v1 model classes just to call .schema(). Replaced with:

adapter = TypeAdapter(typed_dict)
schema = adapter.json_schema()

Pydantic v2's TypeAdapter handles everything the old code did — nested TypedDicts, generic containers, Annotated metadata — and also correctly handles NotRequired and Required annotations, which the v1 path did not. A new test test__convert_typed_dict_not_required verifies this:

class Tool(TypedDict):
    required_field: str
    optional_field: NotRequired[int]

result = _convert_typed_dict_to_openai_function(Tool)
assert "required_field" in result["parameters"]["required"]
assert "optional_field" not in result["parameters"]["required"]

Field descriptions from Google-style docstrings and Annotated[T, ..., "description"] metadata are preserved by post-processing the schema after generation.

The old test__convert_typed_dict_to_openai_function_fail test expected a TypeError for MutableSet because pydantic v1 didn't support it. pydantic v2 does; the test is updated to verify successful conversion instead.

What stays unchanged

  • All public BaseTool API signatures — tool_call_schema, args, get_input_schema() all have the same signatures and return types as before.
  • pydantic.v1 acceptance for args_schema — tools with v1 model schemas continue to work.

AI-agent assisted contribution.

@github-actions github-actions Bot added core `langchain-core` package issues & PRs feature For PRs that implement a new feature; NOT A FEATURE REQUEST internal size: L 500-999 LOC labels Apr 30, 2026
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 30, 2026

Merging this PR will not alter performance

✅ 13 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing feat/tool-schema (b82e263) with perf/tool-schema-refactor (c832f7c)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

… tests

`TypeAdapter` requires `typing_extensions.TypedDict` on Python < 3.12.
Switch all test fixtures and parametrized cases to use
`typing_extensions.TypedDict` so the suite passes on Python 3.10/3.11.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@sydney-runkle Sydney Runkle (sydney-runkle) merged commit dc7a009 into perf/tool-schema-refactor May 1, 2026
92 checks passed
@sydney-runkle Sydney Runkle (sydney-runkle) deleted the feat/tool-schema branch May 1, 2026 13:25
self.__dict__.pop("_approximate_schema_chars", None)
super().__setattr__(name, value)

@functools.cached_property
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we turn this into an LRU instead of a cached_property to reduce chance that user code has a memory leak? (or do we assume that the memory foot print is similar to the foot print of a tool instance?

description=self.description or "",
validator=TypeAdapter(self.get_input_schema()),
json_schema=json_schema,
pydantic_schema=pydantic_schema,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'll need to be able to distinguish input schema from output schema in general. It's not needed everywhere, but I think it's generally a good thing to do and be clear about



@dataclass
class ToolSchema:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Could we distinguish inputs from outputs for schema? (OK not to introduce outputs yet if we don't support, but probably helpful to have the attributes named well so it's clear what is input vs. output
  2. I think we need to be more explicit about injected arguments, so it's easy to determine which arguments are injected?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core `langchain-core` package issues & PRs feature For PRs that implement a new feature; NOT A FEATURE REQUEST internal size: L 500-999 LOC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants