Python: Add max_function_calls to FunctionInvocationConfiguration (#2329) (#4175)

* Add max_function_calls to FunctionInvocationConfiguration (#2329)

Add a new per-request max_function_calls setting to FunctionInvocationConfiguration
that limits the total number of individual function invocations across all iterations
within a single get_response call. This complements max_iterations (which limits LLM
roundtrips) by providing a hard cap on actual tool executions regardless of parallelism.

- Add max_function_calls field to FunctionInvocationConfiguration (default: None/unlimited)
- Track cumulative function call count in both streaming and non-streaming tool loops
- Force tool_choice='none' when the limit is reached
- Add validation in normalize_function_invocation_configuration
- Improve docstrings for FunctionInvocationConfiguration, FunctionTool, and @tool
  to clarify semantics of max_iterations vs max_function_calls vs max_invocations
- Add tests for parallel calls, single calls, unlimited mode, and config validation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add sample for controlling total tool executions

Showcases all three mechanisms for limiting tool executions:
1. max_iterations — caps LLM roundtrips
2. max_function_calls — caps total individual function invocations per request
3. max_invocations — lifetime cap on a specific tool instance
Plus a combined scenario demonstrating defense in depth.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Suppress ruff E305/fmt in hosting sample to preserve XML doc tags

The XML snippet tags (# <create_agent> / # </create_agent>) are used for
docs extraction and must stay adjacent to the code they wrap. Both ruff
check (E305) and ruff format add blank lines after the function definition,
pushing the closing tag away. Suppress with ruff: noqa: E305 and fmt: off.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add per-agent tool wrapping scenario to control_total_tool_executions sample

Show that wrapping the same callable with @tool multiple times creates
independent FunctionTool instances with separate invocation counters,
enabling per-agent max_invocations budgets for shared functions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Clarify max_function_calls is a best-effort limit

The limit is checked after each batch of parallel calls completes, so the
current batch always runs to completion even if it overshoots the limit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review: fix docstring reference, clarify best-effort in sample

- Fix malformed Sphinx :attr: role in FunctionTool docstring — use plain
  backtick reference instead
- Update sample to say 'best-effort cap' instead of 'hard cap' for
  max_function_calls, noting it's checked between iterations
- Parametrize pattern is correct (fixture override, matching existing tests)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* clarify max_invocations limits

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-02-24 02:00:25 +01:00
committed by GitHub
Unverified
parent 11628c3166
commit 55398e21df
4 changed files with 614 additions and 31 deletions
@@ -880,6 +880,143 @@ async def test_max_iterations_limit(chat_client_base: SupportsChatGetResponse):
assert response.messages[-1].text == "I broke out of the function invocation loop..." # Failsafe response
@pytest.mark.parametrize("max_iterations", [10])
async def test_max_function_calls_limits_parallel_invocations(chat_client_base: SupportsChatGetResponse):
"""Test that max_function_calls caps total function invocations across iterations with parallel calls."""
exec_counter = 0
@tool(name="search", approval_mode="never_require")
def search_func(query: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Result for {query}"
# Each iteration returns 3 parallel tool calls
chat_client_base.run_responses = [
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="1a", name="search", arguments='{"query": "q1"}'),
Content.from_function_call(call_id="1b", name="search", arguments='{"query": "q2"}'),
Content.from_function_call(call_id="1c", name="search", arguments='{"query": "q3"}'),
],
)
),
# Second iteration: 3 more parallel calls (total would be 6, exceeding limit of 5)
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="2a", name="search", arguments='{"query": "q4"}'),
Content.from_function_call(call_id="2b", name="search", arguments='{"query": "q5"}'),
Content.from_function_call(call_id="2c", name="search", arguments='{"query": "q6"}'),
],
)
),
# Final response after tool_choice="none" is forced
ChatResponse(messages=Message(role="assistant", text="done")),
]
# Allow many iterations but cap total function calls at 5
chat_client_base.function_invocation_configuration["max_function_calls"] = 5
response = await chat_client_base.get_response(
[Message(role="user", text="search")], options={"tool_choice": "auto", "tools": [search_func]}
)
# First iteration executes 3 calls (total=3, under limit).
# Second iteration executes 3 more (total=6, reaches limit) then forces tool_choice="none".
# The loop completes the current batch before stopping.
assert exec_counter == 6
assert "broke out" in response.messages[-1].text
@pytest.mark.parametrize("max_iterations", [10])
async def test_max_function_calls_single_calls_per_iteration(chat_client_base: SupportsChatGetResponse):
"""Test that max_function_calls works with single tool calls per iteration."""
exec_counter = 0
@tool(name="lookup", approval_mode="never_require")
def lookup_func(key: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Value for {key}"
chat_client_base.run_responses = [
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="1", name="lookup", arguments='{"key": "a"}'),
],
)
),
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="2", name="lookup", arguments='{"key": "b"}'),
],
)
),
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="3", name="lookup", arguments='{"key": "c"}'),
],
)
),
# After limit is reached
ChatResponse(messages=Message(role="assistant", text="all done")),
]
chat_client_base.function_invocation_configuration["max_function_calls"] = 2
response = await chat_client_base.get_response(
[Message(role="user", text="look up keys")], options={"tool_choice": "auto", "tools": [lookup_func]}
)
# 2 single calls executed, then limit reached, tool_choice="none" forced
assert exec_counter == 2
assert "broke out" in response.messages[-1].text
@pytest.mark.parametrize("max_iterations", [10])
async def test_max_function_calls_none_means_unlimited(chat_client_base: SupportsChatGetResponse):
"""Test that max_function_calls=None (default) allows unlimited function calls."""
exec_counter = 0
@tool(name="do_thing", approval_mode="never_require")
def do_thing_func(arg: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Done {arg}"
chat_client_base.run_responses = [
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id=str(i), name="do_thing", arguments=f'{{"arg": "v{i}"}}'),
],
)
)
for i in range(5)
] + [ChatResponse(messages=Message(role="assistant", text="finished"))]
# Explicitly set to None (default) — should not limit
chat_client_base.function_invocation_configuration["max_function_calls"] = None
response = await chat_client_base.get_response(
[Message(role="user", text="do things")], options={"tool_choice": "auto", "tools": [do_thing_func]}
)
assert exec_counter == 5
assert response.messages[-1].text == "finished"
async def test_function_invocation_config_enabled_false(chat_client_base: SupportsChatGetResponse):
"""Test that setting enabled=False disables function invocation."""
exec_counter = 0
@@ -1236,6 +1373,33 @@ async def test_function_invocation_config_validation_max_consecutive_errors():
normalize_function_invocation_configuration({"max_consecutive_errors_per_request": -1})
async def test_function_invocation_config_validation_max_function_calls():
"""Test that max_function_calls validation works correctly."""
from agent_framework import normalize_function_invocation_configuration
# Default is None (unlimited)
config = normalize_function_invocation_configuration(None)
assert config["max_function_calls"] is None
# Valid values
config = normalize_function_invocation_configuration({"max_function_calls": 1})
assert config["max_function_calls"] == 1
config = normalize_function_invocation_configuration({"max_function_calls": 100})
assert config["max_function_calls"] == 100
# None is valid (unlimited)
config = normalize_function_invocation_configuration({"max_function_calls": None})
assert config["max_function_calls"] is None
# Invalid value (less than 1)
with pytest.raises(ValueError, match="max_function_calls must be at least 1 or None"):
normalize_function_invocation_configuration({"max_function_calls": 0})
with pytest.raises(ValueError, match="max_function_calls must be at least 1 or None"):
normalize_function_invocation_configuration({"max_function_calls": -1})
async def test_argument_validation_error_with_detailed_errors(chat_client_base: SupportsChatGetResponse):
"""Test that argument validation errors include details when include_detailed_errors=True."""