diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index bfde7930c10..79d3fef0ef8 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -661,10 +661,16 @@ def __init__( # Defer schema generation to avoid issues with forward references self._cached_parameters: dict[str, Any] | None = None self.approval_mode = approval_mode or "never_require" - if max_invocations is not None and max_invocations < 1: - raise ValueError("max_invocations must be at least 1 or None.") - if max_invocation_exceptions is not None and max_invocation_exceptions < 1: - raise ValueError("max_invocation_exceptions must be at least 1 or None.") + if max_invocations is not None and ( + isinstance(max_invocations, bool) or not isinstance(max_invocations, int) or max_invocations < 1 + ): + raise ValueError("max_invocations must be an integer of at least 1 or None.") + if max_invocation_exceptions is not None and ( + isinstance(max_invocation_exceptions, bool) + or not isinstance(max_invocation_exceptions, int) + or max_invocation_exceptions < 1 + ): + raise ValueError("max_invocation_exceptions must be an integer of at least 1 or None.") self.max_invocations = max_invocations self.invocation_count = 0 self.max_invocation_exceptions = max_invocation_exceptions diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 3071e191513..8f02a8a58b2 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -686,6 +686,39 @@ async def fail_later() -> str: assert failing_sync_awaitable_tool.invocation_exception_count == 1 +def test_function_tool_max_invocations_limit() -> None: + """Test that max_invocations stops further calls after reaching the configured limit.""" + from agent_framework.exceptions import ToolException + + @tool(name="counted_tool", max_invocations=2) + def counted_tool(x: int) -> int: + return x * 2 + + assert counted_tool(1) == 2 + assert counted_tool(2) == 4 + assert counted_tool.invocation_count == 2 + + with pytest.raises(ToolException, match="maximum invocation limit"): + counted_tool(3) + + +@pytest.mark.parametrize( + "invalid_val", + [0, -1, True, False, 1.5, "2"], +) +def test_function_tool_invocation_limits_reject_invalid_types(invalid_val: Any) -> None: + """Test that FunctionTool rejects non-integer, boolean, or sub-1 values for invocation limits.""" + + def dummy() -> None: + pass + + with pytest.raises(ValueError, match="max_invocations must be an integer of at least 1 or None"): + FunctionTool(name="dummy", func=dummy, max_invocations=invalid_val) # type: ignore[arg-type] + + with pytest.raises(ValueError, match="max_invocation_exceptions must be an integer of at least 1 or None"): + FunctionTool(name="dummy", func=dummy, max_invocation_exceptions=invalid_val) # type: ignore[arg-type] + + def test_tool_decorator_in_class(): """Test the tool decorator."""