Skip to content

fix(core): resolve postponed annotations in StructuredTool._injected_args_keys - #39602

Merged
Mason Daugherty (mdrxy) merged 10 commits into
masterfrom
mdrxy/core/structured-tool-postponed-annotations
Aug 19, 2026
Merged

fix(core): resolve postponed annotations in StructuredTool._injected_args_keys#39602
Mason Daugherty (mdrxy) merged 10 commits into
masterfrom
mdrxy/core/structured-tool-postponed-annotations

Conversation

@mdrxy

@mdrxy Mason Daugherty (mdrxy) commented Aug 11, 2026

Copy link
Copy Markdown
Member

Closes #39568

Related: #33999

Warning

This PR expands the surface area for arbitrary code execution during tool setup. Detecting injected arguments now requires calling typing.get_type_hints, which evaluates string annotations (e.g. those created by from __future__ import annotations or quoted forward references) as Python expressions. As with existing type-hint resolution paths, wrapped tool callables must be trusted application code — never point StructuredTool at a callable whose module or annotations come from an untrusted source.

Tools with a custom args_schema could drop injected arguments such as ToolRuntime when the wrapped function's module uses postponed annotations. The injected value was removed during input validation, so an otherwise valid tool call failed at invocation.


StructuredTool now resolves annotations with typing.get_type_hints(..., include_extras=True) before identifying injected parameters. If an unrelated forward reference prevents resolving the complete signature, each string annotation is resolved independently so resolvable injected arguments are still preserved. Callable wrappers resolve annotations from the source of their effective signature, honoring __wrapped__ and __signature__, while other callable objects use their __call__ method. functools.partial callables retain their effective signature so already-bound injected arguments remain excluded.

Before/after: injected arg dropped under from __future__ import annotations

With postponed annotations, every annotation is stored as a plain string. Previously _injected_args_keys read the raw signature() annotations, so runtime was never recognized as injected and was stripped during args_schema validation:

from __future__ import annotations  # all annotations become strings

from pydantic import BaseModel
from langchain_core.tools import tool, ToolRuntime

class InputSchema(BaseModel):
    query: str

@tool(args_schema=InputSchema)
def my_tool(query: str, runtime: ToolRuntime) -> str:
    """Echo the query."""
    return query
Behavior
Before runtime not detected as injected → removed during validation → tool call fails at invocation
After runtime detected via get_type_hints → survives validation and is injected at invocation; hidden from the model-facing schema
Before/after: one unresolvable annotation disabling injection for the whole signature

get_type_hints resolves all annotations at once and raises on the first failure. A single unresolvable forward reference — even on an unrelated parameter — previously meant no hints were available, so the resolvable injected arg was dropped too:

@tool(args_schema=InputSchema)
def my_tool(
    query: "SomeTypeThatDoesNotExist",  # unresolvable forward reference
    runtime: "ToolRuntime",             # resolvable injected arg
) -> str:
    """Echo the query."""
    return query
Behavior
Before get_type_hints raises on query → all hints discarded → runtime not detected as injected
After each annotation is retried independently → query falls back to its raw string (not injected), runtime still resolves and is injected
Before/after: callable objects and wrappers

For non-function callables, the annotations now come from the source of the effective signature: __call__ for callable objects, and the wrapped function for wrappers (__wrapped__ / __signature__):

class MyCallableTool:
    def __call__(self, query: str, runtime: ToolRuntime) -> str:
        return query

tool = StructuredTool.from_function(
    func=MyCallableTool(),
    name="my_tool",
    description="Echo the query.",
    args_schema=InputSchema,
)
Behavior
Before annotations read from the wrong callable (or left as unresolved strings) → runtime dropped
After annotations resolved from __call__ / the unwrapped function → runtime injected correctly
Unchanged: functools.partial with an already-bound injected arg

A partial that already binds an injected argument keeps its effective signature — the bound parameter is absent, so nothing is re-injected over it:

from functools import partial

def fn(x: int, runtime: ToolRuntime, y: int) -> int:
    return x + y

tool = StructuredTool.from_function(
    func=partial(fn, 1, bound_runtime),
    name="fn",
    description="Add two numbers.",
    args_schema=InputSchema,
)

Before & after: runtime is already bound by the partial → excluded from the signature → the bound value is used as-is

Co-authored-by: Soban Shankar 165470467+Soban-2004@users.noreply.github.com

@github-actions github-actions Bot added core `langchain-core` package issues & PRs fix For PRs that implement a fix internal size: S 50-199 LOC labels Aug 11, 2026

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Open SWE Review found 1 potential issue.

Open in WebView Open SWE trace

Comment thread libs/core/langchain_core/tools/structured.py Outdated
@codspeed-hq

codspeed-hq Bot commented Aug 11, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 15 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing mdrxy/core/structured-tool-postponed-annotations (27f3264) with master (e92c6db)2

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.

  2. No successful run was found on master (2019bf5) during the generation of this report, so e92c6db was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@github-actions github-actions Bot added size: M 200-499 LOC and removed size: S 50-199 LOC labels Aug 11, 2026

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Open SWE Review found 1 potential issue.

Open in WebView Open SWE trace

Comment thread libs/core/langchain_core/tools/base.py Outdated

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.

a few initial questions, this stuff is rather confusing :/

return func


def _get_injected_args_keys_from_signature(func: Callable[..., Any]) -> frozenset[str]:

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 have similar logic for this in tool node code right? does that handle forward refs appropriately?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I checked ToolNode and it calls get_type_hints(func) with no error handling, so an unresolvable forward ref raises at init instead of degrading (no per-annotation retry like the one I'm adding in this PR).

e.g. a tool like:

@tool
def search(query: "SomeTypeFromLocalScope", runtime: ToolRuntime) -> str: ...

would blow up ToolNode.__init__ on the query annotation even though runtime resolves fine.

One thing that softens this is that ToolNode looks for injected args in two places — the function's own annotations, and the tool's Pydantic input schema (a tool's schema carries parameter types too, since they're needed for validation). So even when the function-side lookup fails, a runtime: ToolRuntime param usually still gets detected from the schema.

The schema path is also why this PR helps ToolNode indirectly: @tool builds that schema using the core resolution logic fixed here. But ToolNode's direct get_type_hints(func) call can still raise before either path runs, so there's a remaining langgraph-side gap.

Comment thread libs/core/langchain_core/tools/base.py
Comment thread libs/core/langchain_core/tools/base.py
@github-actions github-actions Bot added size: L 500-999 LOC and removed size: M 200-499 LOC labels Aug 18, 2026
`_get_type_hints_source` returned a class as its own annotation owner, so
`get_type_hints` yielded class-attribute annotations while the parameter
names came from `signature(cls)` -- i.e. the constructor. Matching two
different namespaces by name dropped injected args whose name collided
with a class attribute, and marked model-facing params injected when the
collision ran the other way.

Resolve a class to its constructor instead, mirroring `signature`
precedence (metaclass `__call__`, then own `__new__`/`__init__`, then an
inherited one). Fold the wholesale and per-annotation hint paths into a
single loop so a parameter left uncovered by `get_type_hints` -- rather
than only a wholesale failure -- also falls back to resolving its own
annotation; an empty hints `dict` no longer counts as full coverage.

Also add the parameter name and owning callable to the annotation
resolution debug logs, which previously named neither.

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.

i would like to refactor how we parse annotations out of tool schemas (should do more pydantic native stuff) but it seems like this unblocks an annoying bug in the meantime

@mdrxy
Mason Daugherty (mdrxy) merged commit 5c3538e into master Aug 19, 2026
100 checks passed
@mdrxy
Mason Daugherty (mdrxy) deleted the mdrxy/core/structured-tool-postponed-annotations branch August 19, 2026 15:38
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: L 500-999 LOC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

StructuredTool._injected_args_keys doesn't resolve postponed/forward-ref annotations, causing TypeError

2 participants