|
1 | 1 | # Copyright (c) Microsoft. All rights reserved. |
2 | | -from typing import Any |
| 2 | +from typing import Annotated, Any, Literal |
3 | 3 | from unittest.mock import Mock |
4 | 4 |
|
5 | 5 | import pytest |
|
14 | 14 | ToolProtocol, |
15 | 15 | ai_function, |
16 | 16 | ) |
17 | | -from agent_framework._tools import _parse_inputs |
| 17 | +from agent_framework._tools import _parse_annotation, _parse_inputs |
18 | 18 | from agent_framework.exceptions import ToolException |
19 | 19 | from agent_framework.observability import OtelAttr |
20 | 20 |
|
@@ -128,6 +128,95 @@ def test_tool(self, x: int, y: int) -> int: |
128 | 128 | assert test_tool(1, 2) == 3 |
129 | 129 |
|
130 | 130 |
|
| 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 | + |
131 | 220 | async def test_ai_function_decorator_shared_state(): |
132 | 221 | """Test that decorated methods maintain shared state across multiple calls and tool usage.""" |
133 | 222 |
|
@@ -1368,3 +1457,70 @@ def tool_with_kwargs(x: int, **kwargs: Any) -> str: |
1368 | 1457 | arguments=tool_with_kwargs.input_model(x=10), |
1369 | 1458 | ) |
1370 | 1459 | 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