Skip to content

Commit cb96347

Browse files
giles17CopilotCopilot
authored
Python: Fix PydanticSchemaGenerationError when using from __future__ import annotations with @tool (microsoft#4822)
* Fix PydanticSchemaGenerationError with PEP 563 annotations in @tool _resolve_input_model used raw param.annotation from inspect.signature(), which returns string annotations when 'from __future__ import annotations' is active (PEP 563). This caused Pydantic's create_model to fail for complex types like Optional[int] or FunctionInvocationContext. Use typing.get_type_hints() to resolve annotations to actual types before passing them to create_model, matching the approach already used by _discover_injected_parameters. Fixes microsoft#4809 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * Remove reproduction report and unused test imports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): strengthen PEP 563 regression tests per review feedback (microsoft#4809) - Verify type correctness in schema assertions (not just key presence) - Fix ctx annotation to FunctionInvocationContext | None for type consistency - Add test for Optional[CustomType] pattern (original bug trigger) - Add test for get_type_hints() fallback with unresolvable forward refs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * Address review feedback for microsoft#4809: Python: [Bug]: PydanticSchemaGenerationError in FunctionInvocationContext --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5070c67 commit cb96347

2 files changed

Lines changed: 141 additions & 1 deletion

File tree

python/packages/core/agent_framework/_tools.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,9 +466,15 @@ def _resolve_input_model(self, input_model: type[BaseModel] | None) -> type[Base
466466
if func is None:
467467
return create_model(f"{self.name}_input")
468468
sig = inspect.signature(func)
469+
try:
470+
type_hints = typing.get_type_hints(func, include_extras=True)
471+
except Exception:
472+
type_hints = {}
469473
fields: dict[str, Any] = {
470474
pname: (
471-
_parse_annotation(param.annotation) if param.annotation is not inspect.Parameter.empty else str,
475+
_parse_annotation(type_hints.get(pname, param.annotation))
476+
if type_hints.get(pname, param.annotation) is not inspect.Parameter.empty
477+
else str,
472478
param.default if param.default is not inspect.Parameter.empty else ...,
473479
)
474480
for pname, param in sig.parameters.items()
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
"""Tests for @tool with PEP 563 (from __future__ import annotations).
4+
5+
When ``from __future__ import annotations`` is active, all annotations
6+
become strings. _resolve_input_model must resolve them via
7+
typing.get_type_hints() before passing them to Pydantic's create_model.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from pydantic import BaseModel
13+
14+
from agent_framework import tool
15+
from agent_framework._middleware import FunctionInvocationContext
16+
17+
18+
class SearchConfig(BaseModel):
19+
max_results: int = 10
20+
21+
22+
def test_tool_with_context_parameter():
23+
"""FunctionInvocationContext parameter is excluded from schema under PEP 563."""
24+
25+
@tool
26+
def get_weather(location: str, ctx: FunctionInvocationContext) -> str:
27+
"""Get the weather for a given location."""
28+
return f"Weather in {location}"
29+
30+
params = get_weather.parameters()
31+
assert "ctx" not in params.get("properties", {})
32+
assert "location" in params["properties"]
33+
34+
35+
def test_tool_with_context_parameter_first():
36+
"""FunctionInvocationContext as the first parameter is excluded under PEP 563."""
37+
38+
@tool
39+
def get_weather(ctx: FunctionInvocationContext, location: str) -> str:
40+
"""Get the weather for a given location."""
41+
return f"Weather in {location}"
42+
43+
params = get_weather.parameters()
44+
assert "ctx" not in params.get("properties", {})
45+
assert "location" in params["properties"]
46+
47+
48+
def test_tool_with_optional_param():
49+
"""Optional[int] is resolved to the actual type, not left as a string."""
50+
51+
@tool
52+
def search(query: str, limit: int | None = None) -> str:
53+
"""Search for something."""
54+
return query
55+
56+
params = search.parameters()
57+
assert params["properties"]["query"]["type"] == "string"
58+
limit_schema = params["properties"]["limit"]
59+
limit_types = {t["type"] for t in limit_schema["anyOf"]}
60+
assert limit_types == {"integer", "null"}
61+
62+
63+
def test_tool_with_optional_param_and_context():
64+
"""Optional param + FunctionInvocationContext both work under PEP 563."""
65+
66+
@tool
67+
def search(query: str, limit: int | None = None, ctx: FunctionInvocationContext | None = None) -> str:
68+
"""Search for something."""
69+
return query
70+
71+
params = search.parameters()
72+
assert params["properties"]["query"]["type"] == "string"
73+
limit_schema = params["properties"]["limit"]
74+
limit_types = {t["type"] for t in limit_schema["anyOf"]}
75+
assert limit_types == {"integer", "null"}
76+
assert "ctx" not in params.get("properties", {})
77+
78+
79+
def test_tool_with_optional_custom_type():
80+
"""Optional[CustomType] is resolved under PEP 563 (original bug pattern)."""
81+
82+
@tool
83+
def search(query: str, config: SearchConfig | None = None) -> str:
84+
"""Search for something."""
85+
return query
86+
87+
params = search.parameters()
88+
assert params["properties"]["query"]["type"] == "string"
89+
config_schema = params["properties"]["config"]
90+
config_types = [t.get("type") for t in config_schema["anyOf"]]
91+
assert "null" in config_types
92+
93+
94+
def test_tool_with_unresolvable_forward_ref():
95+
"""Fallback to raw annotations when get_type_hints() fails."""
96+
import types
97+
98+
# Build a function in an isolated namespace so get_type_hints() cannot resolve
99+
# the forward reference, exercising the except-branch fallback.
100+
ns: dict = {}
101+
exec(
102+
"def greet(name: str = 'world') -> str:\n '''Greet someone.'''\n return f'Hello {name}'\n",
103+
ns,
104+
)
105+
func = ns["greet"]
106+
# Place the function in a throwaway module so get_type_hints() will fail on
107+
# any non-builtin forward ref while still having a valid __module__.
108+
mod = types.ModuleType("_phantom")
109+
func.__module__ = mod.__name__
110+
111+
t = tool(func)
112+
params = t.parameters()
113+
assert params["properties"]["name"]["type"] == "string"
114+
115+
116+
async def test_tool_invoke_with_context():
117+
"""Full invocation with FunctionInvocationContext under PEP 563."""
118+
119+
@tool
120+
def get_weather(location: str, ctx: FunctionInvocationContext) -> str:
121+
"""Get the weather for a given location."""
122+
user = ctx.kwargs.get("user", "anon")
123+
return f"Weather in {location} for {user}"
124+
125+
params = get_weather.parameters()
126+
assert "ctx" not in params.get("properties", {})
127+
128+
context = FunctionInvocationContext(
129+
function=get_weather,
130+
arguments=get_weather.input_model(location="Seattle"),
131+
kwargs={"user": "test_user"},
132+
)
133+
result = await get_weather.invoke(context=context)
134+
assert result[0].text == "Weather in Seattle for test_user"

0 commit comments

Comments
 (0)