fix(core): resolve postponed annotations in StructuredTool._injected_args_keys - #39602
Conversation
Merging this PR will not alter performance
Comparing Footnotes
|
Sydney Runkle (sydney-runkle)
left a comment
There was a problem hiding this comment.
a few initial questions, this stuff is rather confusing :/
| return func | ||
|
|
||
|
|
||
| def _get_injected_args_keys_from_signature(func: Callable[..., Any]) -> frozenset[str]: |
There was a problem hiding this comment.
we have similar logic for this in tool node code right? does that handle forward refs appropriately?
There was a problem hiding this comment.
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.
`_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.
Sydney Runkle (sydney-runkle)
left a comment
There was a problem hiding this comment.
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
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 byfrom __future__ import annotationsor quoted forward references) as Python expressions. As with existing type-hint resolution paths, wrapped tool callables must be trusted application code — never pointStructuredToolat a callable whose module or annotations come from an untrusted source.Tools with a custom
args_schemacould drop injected arguments such asToolRuntimewhen 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.StructuredToolnow resolves annotations withtyping.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.partialcallables retain their effective signature so already-bound injected arguments remain excluded.Before/after: injected arg dropped under
from __future__ import annotationsWith postponed annotations, every annotation is stored as a plain string. Previously
_injected_args_keysread the rawsignature()annotations, soruntimewas never recognized as injected and was stripped duringargs_schemavalidation:runtimenot detected as injected → removed during validation → tool call fails at invocationruntimedetected viaget_type_hints→ survives validation and is injected at invocation; hidden from the model-facing schemaBefore/after: one unresolvable annotation disabling injection for the whole signature
get_type_hintsresolves 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:get_type_hintsraises onquery→ all hints discarded →runtimenot detected as injectedqueryfalls back to its raw string (not injected),runtimestill resolves and is injectedBefore/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__):runtimedropped__call__/ the unwrapped function →runtimeinjected correctlyUnchanged:
functools.partialwith an already-bound injected argA
partialthat already binds an injected argument keeps its effective signature — the bound parameter is absent, so nothing is re-injected over it:Before & after:
runtimeis already bound by thepartial→ excluded from the signature → the bound value is used as-isCo-authored-by: Soban Shankar 165470467+Soban-2004@users.noreply.github.com