Skip to content

fix(core): fail fast when tool schemas can't resolve forward refs during serialization - #39570

Merged
Hunter Lovell (hntrl) merged 3 commits into
masterfrom
hunter/tool-schema-forward-refs
Aug 18, 2026
Merged

fix(core): fail fast when tool schemas can't resolve forward refs during serialization#39570
Hunter Lovell (hntrl) merged 3 commits into
masterfrom
hunter/tool-schema-forward-refs

Conversation

@hntrl

@hntrl Hunter Lovell (hntrl) commented Aug 10, 2026

Copy link
Copy Markdown
Member

fixes #39099

We currently allow forward refs in pydantic v2 schemas upon creation:

class Container(BaseModel):
    rows: list["Row"] = [] # "Row" is declared below, after the tool is decorated

@tool
def my_tool(container: Container):
    """A tool whose schema depends on a forward reference that is not resolvable yet."""
    return "ok"

class Row(BaseModel):
    name: str

When it comes time to introspect the tool schema (notably in count_tokens_approximately and convert_to_openai_tool), we rely on signature introspection to extract the tool's input schema. If that contains invalid forward references, there's no schema fields to extract which results in an empty dict:

Invalid forward reference MRE
from __future__ import annotations

import inspect

from pydantic import BaseModel, Field
from pydantic.errors import PydanticUndefinedAnnotation

from langchain_core.tools.base import get_all_basemodel_annotations
from langchain_core.utils.pydantic import _create_subset_model, model_json_schema


class Container(BaseModel):
    """A model with a nested forward reference that can never resolve."""

    rows: list["UndefinedRow"] = Field(default_factory=list)


def main() -> None:
    """Print the field-selection inputs and their zero-field subset result."""
    selected_annotations = get_all_basemodel_annotations(Container)
    subset_schema = _create_subset_model(
        "ContainerSubset",
        Container,
        list(selected_annotations),
        fn_description=Container.__doc__,
    )

    print(f"Pydantic complete: {Container.__pydantic_complete__}")
    print(f"Pydantic fields: {list(Container.model_fields)}")
    print(f"inspect.signature: {inspect.signature(Container)}")
    print(f"Fields selected by get_all_basemodel_annotations: {selected_annotations}")
    print(f"Subset properties: {model_json_schema(subset_schema)['properties']}")


if __name__ == "__main__":
    main()
Pydantic complete: False
Pydantic fields: ['rows']
inspect.signature: (**data: 'Any') -> 'None'
Fields selected by get_all_basemodel_annotations: {}
Subset properties: {}
Valid forward reference MRE
from __future__ import annotations

import inspect

from pydantic import BaseModel, Field
from pydantic.errors import PydanticUndefinedAnnotation

from langchain_core.tools.base import get_all_basemodel_annotations
from langchain_core.utils.pydantic import _create_subset_model, model_json_schema


class Container(BaseModel):
    """A model with a nested forward reference that can never resolve."""

    rows: list["UndefinedRow"] = Field(default_factory=list)

class UndefinedRow(BaseModel):
    name: str = Field()


def main() -> None:
    """Print the field-selection inputs and their zero-field subset result."""
    Container.model_rebuild()
    selected_annotations = get_all_basemodel_annotations(Container)
    subset_schema = _create_subset_model(
        "ContainerSubset",
        Container,
        list(selected_annotations),
        fn_description=Container.__doc__,
    )

    print(f"Pydantic complete: {Container.__pydantic_complete__}")
    print(f"Pydantic fields: {list(Container.model_fields)}")
    print(f"inspect.signature: {inspect.signature(Container)}")
    print(f"Fields selected by get_all_basemodel_annotations: {selected_annotations}")
    print(f"Subset properties: {model_json_schema(subset_schema)['properties']}")


if __name__ == "__main__":
    main()
Pydantic complete: True
Pydantic fields: ['rows']
inspect.signature: (*, rows: list[__main__.UndefinedRow] = <factory>) -> None
Fields selected by get_all_basemodel_annotations: {'rows': list[__main__.UndefinedRow]}
Subset properties: {'rows': {'items': {'$ref': '#/$defs/UndefinedRow'}, 'title': 'Rows', 'type': 'array'}}

The fix is to

  • at introspection time, resolve forward references using .model_rebuild() that raises a pydantic exception if forward references cant be resolved
  • i'm also widening a pydantic utility to use a type guard instead of having to use bool + cast

I'm intentionally not rebuilding pydantic v1 schemas in the same way since

  • forward references are specified by explicitly passing names into update_forward_refs
  • pydantic v1 is old news

@github-actions github-actions Bot added core `langchain-core` package issues & PRs fix For PRs that implement a fix internal size: XS < 50 LOC labels Aug 10, 2026
@hntrl
Hunter Lovell (hntrl) force-pushed the hunter/tool-schema-forward-refs branch from 943dd70 to ebdce17 Compare August 10, 2026 22:53
@codspeed-hq

codspeed-hq Bot commented Aug 10, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 15 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing hunter/tool-schema-forward-refs (5212f11) with master (72fb009)

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.

…ing serialization

Co-authored-by: João Gabriel Lacerda Romão <49662646+jgabriellacerda@users.noreply.github.com># Please enter the commit message for your changes. Lines star
@hntrl
Hunter Lovell (hntrl) force-pushed the hunter/tool-schema-forward-refs branch from 1dfda6e to 5212f11 Compare August 18, 2026 20:51
@hntrl
Hunter Lovell (hntrl) merged commit b5e8e2e into master Aug 18, 2026
100 checks passed
@hntrl
Hunter Lovell (hntrl) deleted the hunter/tool-schema-forward-refs branch August 18, 2026 21:08
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 fix For PRs that implement a fix internal size: XS < 50 LOC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tool silently exposes no arguments when args_schema is incomplete

2 participants