Skip to content

Commit 40d633f

Browse files
authored
Fix Pydantic error when using Literal type for tool params (microsoft#2893)
1 parent d7e2858 commit 40d633f

2 files changed

Lines changed: 166 additions & 2 deletions

File tree

python/packages/core/agent_framework/_tools.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,8 @@ def _parse_annotation(annotation: Any) -> Any:
886886
If the second annotation (after the type) is a string, then we convert that to a Pydantic Field description.
887887
The rest are returned as-is, allowing for multiple annotations.
888888
889+
Literal types are returned as-is to preserve their enum-like values.
890+
889891
Args:
890892
annotation: The type annotation to parse.
891893
@@ -894,6 +896,12 @@ def _parse_annotation(annotation: Any) -> Any:
894896
"""
895897
origin = get_origin(annotation)
896898
if origin is not None:
899+
# Literal types should be returned as-is - their args are the allowed values,
900+
# not type annotations to be parsed. For example, Literal["Data", "Security"]
901+
# has args ("Data", "Security") which are the valid string values.
902+
if origin is Literal:
903+
return annotation
904+
897905
args = get_args(annotation)
898906
# For other generics, return the origin type (e.g., list for List[int])
899907
if len(args) > 1 and isinstance(args[1], str):

python/packages/core/tests/core/test_tools.py

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Copyright (c) Microsoft. All rights reserved.
2-
from typing import Any
2+
from typing import Annotated, Any, Literal
33
from unittest.mock import Mock
44

55
import pytest
@@ -14,7 +14,7 @@
1414
ToolProtocol,
1515
ai_function,
1616
)
17-
from agent_framework._tools import _parse_inputs
17+
from agent_framework._tools import _parse_annotation, _parse_inputs
1818
from agent_framework.exceptions import ToolException
1919
from agent_framework.observability import OtelAttr
2020

@@ -128,6 +128,95 @@ def test_tool(self, x: int, y: int) -> int:
128128
assert test_tool(1, 2) == 3
129129

130130

131+
def test_ai_function_with_literal_type_parameter():
132+
"""Test ai_function decorator with Literal type parameter (issue #2891)."""
133+
134+
@ai_function
135+
def search_flows(category: Literal["Data", "Security", "Network"], issue: str) -> str:
136+
"""Search flows by category."""
137+
return f"{category}: {issue}"
138+
139+
assert isinstance(search_flows, AIFunction)
140+
schema = search_flows.parameters()
141+
assert schema == {
142+
"properties": {
143+
"category": {"enum": ["Data", "Security", "Network"], "title": "Category", "type": "string"},
144+
"issue": {"title": "Issue", "type": "string"},
145+
},
146+
"required": ["category", "issue"],
147+
"title": "search_flows_input",
148+
"type": "object",
149+
}
150+
# Verify invocation works
151+
assert search_flows("Data", "test issue") == "Data: test issue"
152+
153+
154+
def test_ai_function_with_literal_type_in_class_method():
155+
"""Test ai_function decorator with Literal type parameter in a class method (issue #2891)."""
156+
157+
class MyTools:
158+
@ai_function
159+
def search_flows(self, category: Literal["Data", "Security", "Network"], issue: str) -> str:
160+
"""Search flows by category."""
161+
return f"{category}: {issue}"
162+
163+
tools = MyTools()
164+
search_tool = tools.search_flows
165+
assert isinstance(search_tool, AIFunction)
166+
schema = search_tool.parameters()
167+
assert schema == {
168+
"properties": {
169+
"category": {"enum": ["Data", "Security", "Network"], "title": "Category", "type": "string"},
170+
"issue": {"title": "Issue", "type": "string"},
171+
},
172+
"required": ["category", "issue"],
173+
"title": "search_flows_input",
174+
"type": "object",
175+
}
176+
# Verify invocation works
177+
assert search_tool("Security", "test issue") == "Security: test issue"
178+
179+
180+
def test_ai_function_with_literal_int_type():
181+
"""Test ai_function decorator with Literal int type parameter."""
182+
183+
@ai_function
184+
def set_priority(priority: Literal[1, 2, 3], task: str) -> str:
185+
"""Set priority for a task."""
186+
return f"Priority {priority}: {task}"
187+
188+
assert isinstance(set_priority, AIFunction)
189+
schema = set_priority.parameters()
190+
assert schema == {
191+
"properties": {
192+
"priority": {"enum": [1, 2, 3], "title": "Priority", "type": "integer"},
193+
"task": {"title": "Task", "type": "string"},
194+
},
195+
"required": ["priority", "task"],
196+
"title": "set_priority_input",
197+
"type": "object",
198+
}
199+
assert set_priority(1, "important task") == "Priority 1: important task"
200+
201+
202+
def test_ai_function_with_literal_and_annotated():
203+
"""Test ai_function decorator with Literal type combined with Annotated for description."""
204+
205+
@ai_function
206+
def categorize(
207+
category: Annotated[Literal["A", "B", "C"], "The category to assign"],
208+
name: str,
209+
) -> str:
210+
"""Categorize an item."""
211+
return f"{category}: {name}"
212+
213+
assert isinstance(categorize, AIFunction)
214+
schema = categorize.parameters()
215+
# Literal type inside Annotated should preserve enum values
216+
assert schema["properties"]["category"]["enum"] == ["A", "B", "C"]
217+
assert categorize("A", "test") == "A: test"
218+
219+
131220
async def test_ai_function_decorator_shared_state():
132221
"""Test that decorated methods maintain shared state across multiple calls and tool usage."""
133222

@@ -1368,3 +1457,70 @@ def tool_with_kwargs(x: int, **kwargs: Any) -> str:
13681457
arguments=tool_with_kwargs.input_model(x=10),
13691458
)
13701459
assert result_default == "x=10, user=unknown"
1460+
1461+
1462+
# region _parse_annotation tests
1463+
1464+
1465+
def test_parse_annotation_with_literal_type():
1466+
"""Test that _parse_annotation returns Literal types unchanged (issue #2891)."""
1467+
from typing import get_args, get_origin
1468+
1469+
# Literal with string values
1470+
literal_annotation = Literal["Data", "Security", "Network"]
1471+
result = _parse_annotation(literal_annotation)
1472+
assert result is literal_annotation
1473+
assert get_origin(result) is Literal
1474+
assert get_args(result) == ("Data", "Security", "Network")
1475+
1476+
1477+
def test_parse_annotation_with_literal_int_type():
1478+
"""Test that _parse_annotation returns Literal int types unchanged."""
1479+
from typing import get_args, get_origin
1480+
1481+
literal_annotation = Literal[1, 2, 3]
1482+
result = _parse_annotation(literal_annotation)
1483+
assert result is literal_annotation
1484+
assert get_origin(result) is Literal
1485+
assert get_args(result) == (1, 2, 3)
1486+
1487+
1488+
def test_parse_annotation_with_literal_bool_type():
1489+
"""Test that _parse_annotation returns Literal bool types unchanged."""
1490+
from typing import get_args, get_origin
1491+
1492+
literal_annotation = Literal[True, False]
1493+
result = _parse_annotation(literal_annotation)
1494+
assert result is literal_annotation
1495+
assert get_origin(result) is Literal
1496+
assert get_args(result) == (True, False)
1497+
1498+
1499+
def test_parse_annotation_with_simple_types():
1500+
"""Test that _parse_annotation returns simple types unchanged."""
1501+
assert _parse_annotation(str) is str
1502+
assert _parse_annotation(int) is int
1503+
assert _parse_annotation(float) is float
1504+
assert _parse_annotation(bool) is bool
1505+
1506+
1507+
def test_parse_annotation_with_annotated_and_literal():
1508+
"""Test that Annotated[Literal[...], description] works correctly."""
1509+
from typing import get_args, get_origin
1510+
1511+
# When Literal is inside Annotated, it should still be preserved
1512+
annotated_literal = Annotated[Literal["A", "B", "C"], "The category"]
1513+
result = _parse_annotation(annotated_literal)
1514+
1515+
# The Annotated type should be preserved
1516+
origin = get_origin(result)
1517+
assert origin is Annotated
1518+
1519+
args = get_args(result)
1520+
# First arg is the Literal type
1521+
literal_type = args[0]
1522+
assert get_origin(literal_type) is Literal
1523+
assert get_args(literal_type) == ("A", "B", "C")
1524+
1525+
1526+
# endregion

0 commit comments

Comments
 (0)