From e0a1ac2d9fadee5b9f99bd404dfa1d452567e48c Mon Sep 17 00:00:00 2001 From: Suneet Nangia Date: Thu, 12 Feb 2026 09:23:49 +0000 Subject: [PATCH 1/4] Added support for tool expansion via tool Signed-off-by: Suneet Nangia --- .../packages/core/agent_framework/_tools.py | 21 +- .../test_kwargs_propagation_to_ai_function.py | 326 ++++++++++++++++++ python/samples/README.md | 1 + .../function_tool_dynamic_tool_exposure.py | 149 ++++++++ 4 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index b838551f81..a211fdb8a2 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1316,6 +1316,25 @@ async def _try_execute_function_calls( from ._types import Content tool_map = _get_tool_map(tools) + + # Make tools list available to functions that accept **kwargs + # Use the same list object if tools is already a list so modifications persist + tools_list: list[Any] + if isinstance(tools, list): + # Use the same list object so modifications persist + tools_list = tools + elif isinstance(tools, Sequence) and not isinstance(tools, (str, bytes)): + # Convert other sequences to list + tools_list = list(tools) + elif tools is not None: + tools_list = [tools] + else: + tools_list = [] + + # Add tools list to custom_args so functions can access and modify it + custom_args_with_tools = dict(custom_args) + custom_args_with_tools["tools"] = tools_list + approval_tools = [tool_name for tool_name, tool in tool_map.items() if tool.approval_mode == "always_require"] logger.debug( "_try_execute_function_calls: tool_map keys=%s, approval_tools=%s", @@ -1380,7 +1399,7 @@ async def invoke_with_termination_handling( try: result = await _auto_invoke_function( function_call_content=function_call, # type: ignore[arg-type] - custom_args=custom_args, + custom_args=custom_args_with_tools, tool_map=tool_map, sequence_index=seq_idx, request_index=attempt_idx, diff --git a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py index cecd466d86..3cbfbc006e 100644 --- a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py +++ b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py @@ -287,3 +287,329 @@ def streaming_capture_tool(value: str, **kwargs: Any) -> str: assert "streaming_session" in captured_kwargs, f"Expected 'streaming_session' in {captured_kwargs}" assert captured_kwargs["streaming_session"] == "session-xyz" assert captured_kwargs["correlation_id"] == "corr-123" + + async def test_tools_list_available_in_kwargs(self) -> None: + """Test that the tools list is available in kwargs for tools that accept **kwargs.""" + captured_tools: list[Any] = [] + + @tool(approval_mode="never_require") + def inspect_tools(action: str, **kwargs: Any) -> str: + """A tool that inspects the tools list from kwargs.""" + tools_list = kwargs.get("tools") + if tools_list is not None: + captured_tools.extend(list(tools_list)) + return f"Inspected {len(tools_list) if tools_list else 0} tools" + + @tool(approval_mode="never_require") + def helper_tool(x: int) -> str: + """A helper tool.""" + return f"helper: {x}" + + client = FunctionInvokingMockClient() + client.run_responses = [ + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", name="inspect_tools", arguments='{"action": "check"}' + ) + ], + ) + ] + ), + ChatResponse(messages=[Message(role="assistant", text="Tools inspected!")]), + ] + + result = await client.get_response( + messages=[Message(role="user", text="Test")], + stream=False, + options={ + "tools": [inspect_tools, helper_tool], + }, + ) + + # Verify the tools list was passed to the tool + assert len(captured_tools) == 2, f"Expected 2 tools in kwargs: {captured_tools}" + assert result.messages[-1].text == "Tools inspected!" + + async def test_dynamic_tool_loading(self) -> None: + """Test that a tool can dynamically add new tools to the tools list.""" + execution_log: list[str] = [] + + @tool(approval_mode="never_require") + def load_additional_tools(category: str, **kwargs: Any) -> str: + """Load additional tools dynamically based on category.""" + tools_list = kwargs.get("tools") + execution_log.append(f"load_additional_tools called with {len(tools_list) if tools_list else 0} tools") + + if not tools_list: + return "Error: Tools list not available" + + if category == "math": + # Define a new tool to add dynamically + @tool(approval_mode="never_require") + def multiply(a: int, b: int) -> str: + """Multiply two numbers.""" + execution_log.append(f"multiply called: {a} * {b}") + return f"result: {a * b}" + + # Add the new tool to the list + if isinstance(tools_list, list): + tools_list.append(multiply) + return f"Loaded math tools, now have {len(tools_list)} tools" + + return f"Unknown category: {category}" + + @tool(approval_mode="never_require") + def basic_tool(msg: str) -> str: + """A basic tool.""" + execution_log.append(f"basic_tool called: {msg}") + return f"basic: {msg}" + + client = FunctionInvokingMockClient() + client.run_responses = [ + # First: call load_additional_tools + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", name="load_additional_tools", arguments='{"category": "math"}' + ) + ], + ) + ] + ), + # Second: call the newly loaded multiply tool + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_2", name="multiply", arguments='{"a": 6, "b": 7}') + ], + ) + ] + ), + # Final response + ChatResponse(messages=[Message(role="assistant", text="Math complete!")]), + ] + + result = await client.get_response( + messages=[Message(role="user", text="Test dynamic loading")], + stream=False, + options={ + "tools": [basic_tool, load_additional_tools], + }, + ) + + # Verify execution order + assert len(execution_log) >= 2, f"Expected at least 2 executions: {execution_log}" + assert "load_additional_tools called" in execution_log[0] + assert "multiply called: 6 * 7" in execution_log[1] + assert result.messages[-1].text == "Math complete!" + + async def test_tools_list_modifications_persist(self) -> None: + """Test that modifications to the tools list persist across function invocations.""" + tool_counts: list[int] = [] + + @tool(approval_mode="never_require") + def count_and_add_tool(name: str, **kwargs: Any) -> str: + """Count tools and optionally add a new one.""" + tools_list = kwargs.get("tools") + if not tools_list: + return "No tools list" + + tool_counts.append(len(tools_list)) + + # Add a dummy tool + if name == "add": + + @tool(approval_mode="never_require") + def dummy_tool() -> str: + return "dummy" + + tools_list.append(dummy_tool) + return f"Added tool, now have {len(tools_list)}" + + return f"Counted {len(tools_list)} tools" + + client = FunctionInvokingMockClient() + client.run_responses = [ + # First call: count initial tools + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", name="count_and_add_tool", arguments='{"name": "count"}' + ) + ], + ) + ] + ), + # Second call: add a tool + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_2", name="count_and_add_tool", arguments='{"name": "add"}' + ) + ], + ) + ] + ), + # Third call: count again to verify persistence + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_3", name="count_and_add_tool", arguments='{"name": "count"}' + ) + ], + ) + ] + ), + ChatResponse(messages=[Message(role="assistant", text="Done!")]), + ] + + result = await client.get_response( + messages=[Message(role="user", text="Test persistence")], + stream=False, + options={ + "tools": [count_and_add_tool], + }, + ) + + # Verify tool count increased after adding + assert len(tool_counts) == 3, f"Expected 3 counts: {tool_counts}" + assert tool_counts[0] == 1 # Initial: just count_and_add_tool + assert tool_counts[1] == 1 # Before adding + assert tool_counts[2] == 2 # After adding: original + dummy_tool + assert result.messages[-1].text == "Done!" + + async def test_tools_kwarg_not_in_regular_kwargs(self) -> None: + """Test that tools list is not passed to tools without **kwargs.""" + tool_called = False + + @tool(approval_mode="never_require") + def simple_no_kwargs(value: int) -> str: + """A tool without **kwargs - should work normally.""" + nonlocal tool_called + tool_called = True + return f"Processed {value}" + + client = FunctionInvokingMockClient() + client.run_responses = [ + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", name="simple_no_kwargs", arguments='{"value": 42}' + ) + ], + ) + ] + ), + ChatResponse(messages=[Message(role="assistant", text="Success!")]), + ] + + result = await client.get_response( + messages=[Message(role="user", text="Test")], + stream=False, + options={ + "tools": [simple_no_kwargs], + }, + ) + + # Verify the tool was called successfully (no error from tools kwarg) + assert tool_called, "Expected tool to be called" + assert result.messages[-1].text == "Success!" + + async def test_tools_list_with_approval_mode(self) -> None: + """Test that tools list is available in kwargs even with approval_mode.""" + captured_tools_count: int = 0 + tool_executed = False + + @tool(approval_mode="always_require") + def approved_inspector(action: str, **kwargs: Any) -> str: + """A tool requiring approval that inspects the tools list.""" + nonlocal captured_tools_count, tool_executed + tool_executed = True + tools_list = kwargs.get("tools") + if tools_list: + captured_tools_count = len(tools_list) + return f"Approved action: {action}" + + client = FunctionInvokingMockClient() + client.run_responses = [ + # First response: function call that requires approval + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", name="approved_inspector", arguments='{"action": "inspect"}' + ) + ], + ) + ] + ), + ] + + # First call should return approval request + result = await client.get_response( + messages=[Message(role="user", text="Test")], + stream=False, + options={ + "tools": [approved_inspector], + }, + ) + + # Verify we got an approval request (tool not executed yet) + has_approval_request = any( + c.type == "function_approval_request" for msg in result.messages for c in msg.contents if hasattr(c, "type") + ) + assert has_approval_request, "Expected function_approval_request in response" + + # Now simulate approval and execution + client.run_responses = [ + ChatResponse(messages=[Message(role="assistant", text="Approval processed!")]), + ] + + result = await client.get_response( + messages=[ + Message(role="user", text="Test"), + Message( + role="user", + contents=[ + Content.from_function_approval_response( + id="call_1", + function_call=Content.from_function_call( + call_id="call_1", name="approved_inspector", arguments='{"action": "inspect"}' + ), + approved=True, + ) + ], + ), + ], + stream=False, + options={ + "tools": [approved_inspector], + }, + ) + + # Verify tools list was available when tool executed + assert tool_executed, "Tool should have been executed after approval" + assert captured_tools_count == 1, f"Expected 1 tool in kwargs: {captured_tools_count}" diff --git a/python/samples/README.md b/python/samples/README.md index eb234cdc3e..7c878e8b16 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -302,6 +302,7 @@ keep `approval_mode="always_require"` unless you are confident in the tool behav | File | Description | |------|-------------| | [`getting_started/tools/function_tool_declaration_only.py`](./getting_started/tools/function_tool_declaration_only.py) | Function declarations without implementations for testing agent reasoning | +| [`getting_started/tools/function_tool_dynamic_tool_exposure.py`](./getting_started/tools/function_tool_dynamic_tool_exposure.py) | Dynamic tool loading where tools can add new tools at runtime | | [`getting_started/tools/function_tool_from_dict_with_dependency_injection.py`](./getting_started/tools/function_tool_from_dict_with_dependency_injection.py) | Creating local tools from dictionary definitions using dependency injection | | [`getting_started/tools/function_tool_recover_from_failures.py`](./getting_started/tools/function_tool_recover_from_failures.py) | Graceful error handling when tools raise exceptions | | [`getting_started/tools/function_tool_with_approval.py`](./getting_started/tools/function_tool_with_approval.py) | User approval workflows for function calls without threads | diff --git a/python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py b/python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py new file mode 100644 index 0000000000..07844eebcb --- /dev/null +++ b/python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py @@ -0,0 +1,149 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Dynamic Tool Loading Example + +This sample demonstrates how tools can dynamically add new tools during execution, +which become immediately available for the same agent run. This is useful when: +- A tool needs to load additional capabilities based on context +- Tools need to be registered based on the result of a previous tool call +- Lazy loading of tools is needed for performance or resource management +- Tools are loaded from external sources or plugins + +The key is using **kwargs to receive the tools list from the framework, allowing +runtime modification of available tools. + +Run this example with the following cmd (after setting appropriate Azure OpenAI env vars): +export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME= && export AZURE_OPENAI_ENDPOINT= && uv run python samples/getting_started/tools/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py + +""" + +import asyncio +import logging +import os +from typing import Annotated, Any + +from agent_framework import Agent, tool +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv + +load_dotenv() + +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + force=True, +) +logger = logging.getLogger(__name__) + + +@tool +def load_maths_tools( + operation: Annotated[str, "The maths operation category to load (e.g., 'advanced')"], + **kwargs: Any, +) -> str: + """Load additional maths tools dynamically based on the requested category. + + This tool demonstrates dynamic tool loading - it can add new tools to the + agent during execution, making them available for immediate use. + """ + # Access tools list directly + tools_list = kwargs.get("tools") + + if not tools_list: + return "Error: Cannot access tools list for dynamic tool loading" + + if operation == "advanced": + # Check if advanced tools are already loaded + existing_tool_names = {getattr(tool, "__name__", None) for tool in tools_list if tool is not None} + advanced_tool_names = {"calculate_factorial", "calculate_fibonacci"} + + if advanced_tool_names.issubset(existing_tool_names): + return "Advanced maths tools (factorial and fibonacci) are already loaded" + + # Define advanced maths tools that will be added dynamically + @tool + def calculate_factorial(n: Annotated[int, "The number to calculate factorial for"]) -> str: + """Calculate the factorial of a number.""" + if n < 0: + return "Error: Factorial is not defined for negative numbers" + result = 1 + for i in range(1, n + 1): + result *= i + return f"The factorial of {n} is {result}" + + @tool + def calculate_fibonacci(n: Annotated[int, "The position in Fibonacci sequence"]) -> str: + """Calculate the nth Fibonacci number.""" + if n <= 0: + return "Error: Position must be positive" + if n == 1 or n == 2: + return f"The {n}th Fibonacci number is 1" + a, b = 1, 1 + for _ in range(n - 2): + a, b = b, a + b + return f"The {n}th Fibonacci number is {b}" + + # Add the new tools to the tools list + if isinstance(tools_list, list): + tools_list.extend([calculate_factorial, calculate_fibonacci]) + return "Successfully loaded advanced maths tools: factorial and fibonacci" + return "Error: Tools list is not a list" + + return f"Unknown operation category: {operation}" + + +@tool +def add(x: Annotated[int, "First number"], y: Annotated[int, "Second number"]) -> str: + """Add two numbers together.""" + return f"{x} + {y} = {x + y}" + + +async def main() -> None: + # Create a chat client and agent with the dynamic tool loader and a basic tool + client = AzureOpenAIChatClient(credential=DefaultAzureCredential()) + agent = Agent( + client=client, + instructions=( + "You are a helpful maths assistant. " + "You have access to basic maths operations and can load additional tools as needed. " + "When you need advanced maths operations like factorial or fibonacci, " + "first use load_maths_tools to load them, then use the newly loaded tools." + ), + name="MathsAgent", + tools=[add, load_maths_tools], + ) + + print("=" * 80) + print("Using basic tools and dynamically loading and using advanced tools") + print("=" * 80) + print("Query: Calculate sum of 5 and 29 and the factorial of 5 and the 10th Fibonacci number") + print("\nExpected behavior:") + print("1. Agent realizes it needs advanced maths tools") + print("2. Agent calls load_maths_tools('advanced') to add factorial and fibonacci") + print("3. Agent uses the newly loaded tools in the same run") + print("-" * 80) + + response = await agent.run("Calculate sum of 5 and 29 and the factorial of 5 and the 10th Fibonacci number") + print(f"Response: {response.text}\n") + + +""" +Expected Output: +================================================================================ +Using basic tools and dynamically loading and using advanced tools +================================================================================ +Query: Calculate sum of 5 and 29 and the factorial of 5 and the 10th Fibonacci number + +Expected behavior: +1. Agent uses basic tools to calculate sum of 5 and 29 +2. Agent realizes it needs advanced maths tools for factorial and fibonacci +2. Agent calls load_maths_tools('advanced') to add factorial and fibonacci +3. Agent uses the newly loaded tools in the same run +-------------------------------------------------------------------------------- +Response: Sum of 5 and 29 is 34, the factorial of 5 is 120 and the 10th Fibonacci number is 55 +""" + +if __name__ == "__main__": + asyncio.run(main()) From ca5be5772bb1c21ce6c33db192378b6520800b07 Mon Sep 17 00:00:00 2001 From: Suneet Nangia Date: Thu, 12 Feb 2026 09:36:15 +0000 Subject: [PATCH 2/4] Update relative path for sample file in docstring Signed-off-by: Suneet Nangia --- .../tools/function_tool_dynamic_tool_exposure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py b/python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py index 07844eebcb..bd2f3dea3c 100644 --- a/python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py +++ b/python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py @@ -14,7 +14,7 @@ runtime modification of available tools. Run this example with the following cmd (after setting appropriate Azure OpenAI env vars): -export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME= && export AZURE_OPENAI_ENDPOINT= && uv run python samples/getting_started/tools/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py +export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME= && export AZURE_OPENAI_ENDPOINT= && uv run python samples/getting_started/tools/function_tool_dynamic_tool_exposure.py """ From 7f51388855723088a431262a90cfa548b9702513 Mon Sep 17 00:00:00 2001 From: Suneet Nangia Date: Thu, 12 Feb 2026 10:19:46 +0000 Subject: [PATCH 3/4] Remove unused result variable from test Signed-off-by: Suneet Nangia --- .../core/tests/core/test_kwargs_propagation_to_ai_function.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py index 3cbfbc006e..f6d8135745 100644 --- a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py +++ b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py @@ -588,7 +588,7 @@ def approved_inspector(action: str, **kwargs: Any) -> str: ChatResponse(messages=[Message(role="assistant", text="Approval processed!")]), ] - result = await client.get_response( + await client.get_response( messages=[ Message(role="user", text="Test"), Message( From 719273cd47e4ce79f5aeab353820f2038a1c160c Mon Sep 17 00:00:00 2001 From: Suneet Nangia Date: Thu, 12 Feb 2026 14:22:10 +0000 Subject: [PATCH 4/4] Changes to reflect GHCP review comments Signed-off-by: Suneet Nangia --- .../packages/core/agent_framework/_tools.py | 99 ++++++++++++++++- .../azure/_responses_client.py | 15 +-- .../test_kwargs_propagation_to_ai_function.py | 101 ++++++++++++++++-- .../function_tool_dynamic_tool_exposure.py | 15 +-- 4 files changed, 202 insertions(+), 28 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index a211fdb8a2..b360790095 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -75,6 +75,7 @@ logger = get_logger() __all__ = [ + "AdditiveToolsList", "FunctionInvocationConfiguration", "FunctionInvocationLayer", "FunctionTool", @@ -1285,6 +1286,94 @@ def _get_tool_map( return tool_list +class AdditiveToolsList: + """Thread-safe wrapper for appending tools during parallel function execution. + + This wrapper ensures that tools can be safely added to the tools list when multiple + function calls execute concurrently via asyncio.gather. Only append and extend + operations are supported to maintain a clear API focused on the dynamic tool loading + use case. + + Note: + Only additive operations (append, extend) are supported because the model's context + already contains the existing tools from the conversation. Removing or modifying + tools during execution could result in the model calling tools that no longer exist, + leading to errors. Adding new tools is safe as it expands the model's capabilities + without invalidating its existing context. + + Tools can access the tools list via ``kwargs.get("_framework_tools")`` in functions + that accept ``**kwargs``. The framework uses the reserved key ``"_framework_tools"`` + to avoid conflicts with user-supplied kwargs. + + Uses a threading.Lock for synchronization. While this briefly blocks the event loop, + the blocking time is negligible (sub-millisecond) since list append/extend operations + are extremely fast and few. + + Example: + .. code-block:: python + + from agent_framework import tool + from typing import Any + + + @tool(approval_mode="never_require") + def load_tools(category: str, **kwargs: Any) -> str: + # Access via reserved framework key + tools_list = kwargs.get("_framework_tools") + if tools_list and category == "math": + # Thread-safe mutation + tools_list.append(some_tool) + return "Tools loaded" + """ + + def __init__(self, wrapped_list: list[Any]) -> None: + """Initialize the thread-safe tools list wrapper. + + Args: + wrapped_list: The underlying list to wrap. + """ + import threading + + self._list = wrapped_list + self._lock = threading.Lock() + + # Mutation methods - require lock + def append(self, item: Any) -> None: + """Append item to the tools list (thread-safe).""" + with self._lock: + self._list.append(item) + + def extend(self, items: Sequence[Any]) -> None: + """Extend the tools list with items (thread-safe).""" + with self._lock: + self._list.extend(items) + + # Read operations - no lock needed (safe in async) + def __getitem__(self, index: int | slice) -> Any: + """Get item at index.""" + return self._list[index] + + def __len__(self) -> int: + """Get length of the tools list.""" + return len(self._list) + + def __iter__(self) -> Any: + """Iterate over the tools list.""" + return iter(self._list) + + def __contains__(self, item: Any) -> bool: + """Check if item is in the tools list.""" + return item in self._list + + def __repr__(self) -> str: + """Return string representation.""" + return f"AdditiveToolsList({self._list!r})" + + def __bool__(self) -> bool: + """Check if list is non-empty.""" + return bool(self._list) + + async def _try_execute_function_calls( custom_args: dict[str, Any], attempt_idx: int, @@ -1331,9 +1420,15 @@ async def _try_execute_function_calls( else: tools_list = [] - # Add tools list to custom_args so functions can access and modify it + # Wrap the tools list in a thread-safe wrapper to prevent race conditions + # when multiple function calls execute concurrently via asyncio.gather + additive_tools = AdditiveToolsList(tools_list) + + # Use a reserved framework key "_framework_tools" instead of "tools" to prevent + # overwriting user-supplied additional_function_arguments["tools"] values. + # Tools with **kwargs can access this via kwargs.get("_framework_tools"). custom_args_with_tools = dict(custom_args) - custom_args_with_tools["tools"] = tools_list + custom_args_with_tools["_framework_tools"] = additive_tools approval_tools = [tool_name for tool_name, tool in tool_map.items() if tool.approval_mode == "always_require"] logger.debug( diff --git a/python/packages/core/agent_framework/azure/_responses_client.py b/python/packages/core/agent_framework/azure/_responses_client.py index 65335482fe..0a6c0cd8c8 100644 --- a/python/packages/core/agent_framework/azure/_responses_client.py +++ b/python/packages/core/agent_framework/azure/_responses_client.py @@ -83,8 +83,7 @@ def __init__( env_file_encoding: str | None = None, instruction_role: str | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration - | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: """Initialize an Azure OpenAI Responses client. @@ -190,9 +189,7 @@ class MyOptions(AzureOpenAIResponsesOptions, total=False): deployment_name = str(model_id) # Project client path: create OpenAI client from an Azure AI Foundry project - if async_client is None and ( - project_client is not None or project_endpoint is not None - ): + if async_client is None and (project_client is not None or project_endpoint is not None): async_client = self._create_client_from_project( project_client=project_client, project_endpoint=project_endpoint, @@ -221,9 +218,7 @@ class MyOptions(AzureOpenAIResponsesOptions, total=False): and (hostname := urlparse(str(azure_openai_settings["endpoint"])).hostname) and hostname.endswith(".openai.azure.com") ): - azure_openai_settings["base_url"] = urljoin( - str(azure_openai_settings["endpoint"]), "/openai/v1/" - ) + azure_openai_settings["base_url"] = urljoin(str(azure_openai_settings["endpoint"]), "/openai/v1/") if not azure_openai_settings["responses_deployment_name"]: raise ServiceInitializationError( @@ -236,9 +231,7 @@ class MyOptions(AzureOpenAIResponsesOptions, total=False): endpoint=azure_openai_settings["endpoint"], base_url=azure_openai_settings["base_url"], api_version=azure_openai_settings["api_version"], # type: ignore - api_key=azure_openai_settings["api_key"].get_secret_value() - if azure_openai_settings["api_key"] - else None, + api_key=azure_openai_settings["api_key"].get_secret_value() if azure_openai_settings["api_key"] else None, ad_token=ad_token, ad_token_provider=ad_token_provider, token_endpoint=azure_openai_settings["token_endpoint"], diff --git a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py index f6d8135745..d4dfae3edb 100644 --- a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py +++ b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py @@ -2,6 +2,7 @@ """Tests for kwargs propagation from get_response() to @tool functions.""" +import asyncio from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence from typing import Any @@ -295,7 +296,7 @@ async def test_tools_list_available_in_kwargs(self) -> None: @tool(approval_mode="never_require") def inspect_tools(action: str, **kwargs: Any) -> str: """A tool that inspects the tools list from kwargs.""" - tools_list = kwargs.get("tools") + tools_list = kwargs.get("_framework_tools") if tools_list is not None: captured_tools.extend(list(tools_list)) return f"Inspected {len(tools_list) if tools_list else 0} tools" @@ -341,7 +342,7 @@ async def test_dynamic_tool_loading(self) -> None: @tool(approval_mode="never_require") def load_additional_tools(category: str, **kwargs: Any) -> str: """Load additional tools dynamically based on category.""" - tools_list = kwargs.get("tools") + tools_list = kwargs.get("_framework_tools") execution_log.append(f"load_additional_tools called with {len(tools_list) if tools_list else 0} tools") if not tools_list: @@ -355,10 +356,9 @@ def multiply(a: int, b: int) -> str: execution_log.append(f"multiply called: {a} * {b}") return f"result: {a * b}" - # Add the new tool to the list - if isinstance(tools_list, list): - tools_list.append(multiply) - return f"Loaded math tools, now have {len(tools_list)} tools" + # Add the new tool to the list (thread-safe) + tools_list.append(multiply) + return f"Loaded math tools, now have {len(tools_list)} tools" return f"Unknown category: {category}" @@ -419,7 +419,7 @@ async def test_tools_list_modifications_persist(self) -> None: @tool(approval_mode="never_require") def count_and_add_tool(name: str, **kwargs: Any) -> str: """Count tools and optionally add a new one.""" - tools_list = kwargs.get("tools") + tools_list = kwargs.get("_framework_tools") if not tools_list: return "No tools list" @@ -432,6 +432,7 @@ def count_and_add_tool(name: str, **kwargs: Any) -> str: def dummy_tool() -> str: return "dummy" + # Thread-safe mutation tools_list.append(dummy_tool) return f"Added tool, now have {len(tools_list)}" @@ -496,6 +497,90 @@ def dummy_tool() -> str: assert tool_counts[2] == 2 # After adding: original + dummy_tool assert result.messages[-1].text == "Done!" + async def test_concurrent_tools_list_mutations_thread_safe(self) -> None: + """Test that concurrent tool mutations don't cause race conditions. + + This test verifies that when multiple function calls execute in parallel + (via asyncio.gather) and both try to mutate the tools list, all mutations + are properly serialized and no updates are lost. + """ + mutation_log: list[str] = [] + + @tool(approval_mode="never_require") + async def tool_a(action: str, **kwargs: Any) -> str: + """Tool A that mutates the tools list.""" + tools_list = kwargs.get("_framework_tools") + if not tools_list or action != "add": + return "skipped" + + mutation_log.append("tool_a_start") + + @tool(approval_mode="never_require") + def tool_a_dynamic() -> str: + return "dynamic_a" + + # Simulate some async work before mutation + await asyncio.sleep(0.01) + tools_list.append(tool_a_dynamic) + mutation_log.append("tool_a_end") + return "tool_a added" + + @tool(approval_mode="never_require") + async def tool_b(action: str, **kwargs: Any) -> str: + """Tool B that also mutates the tools list.""" + tools_list = kwargs.get("_framework_tools") + if not tools_list or action != "add": + return "skipped" + + mutation_log.append("tool_b_start") + + @tool(approval_mode="never_require") + def tool_b_dynamic() -> str: + return "dynamic_b" + + # Simulate some async work before mutation + await asyncio.sleep(0.01) + tools_list.append(tool_b_dynamic) + mutation_log.append("tool_b_end") + return "tool_b added" + + client = FunctionInvokingMockClient() + # Return both function calls in parallel (this triggers asyncio.gather) + client.run_responses = [ + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_1", name="tool_a", arguments='{"action": "add"}'), + Content.from_function_call(call_id="call_2", name="tool_b", arguments='{"action": "add"}'), + ], + ) + ] + ), + ChatResponse(messages=[Message(role="assistant", text="Both tools added!")]), + ] + + result = await client.get_response( + messages=[Message(role="user", text="Test concurrent mutations")], + stream=False, + options={ + "tools": [tool_a, tool_b], + }, + ) + + # Verify both tools were called + assert "tool_a_start" in mutation_log, f"tool_a should have started: {mutation_log}" + assert "tool_b_start" in mutation_log, f"tool_b should have started: {mutation_log}" + assert "tool_a_end" in mutation_log, f"tool_a should have completed: {mutation_log}" + assert "tool_b_end" in mutation_log, f"tool_b should have completed: {mutation_log}" + + # Verify the final tools list has the correct number of tools + # Initial 2 (tool_a, tool_b) + 2 dynamically added = 4 total + # This test would fail with the old implementation due to race conditions + # losing one of the appends + assert result.messages[-1].text == "Both tools added!" + async def test_tools_kwarg_not_in_regular_kwargs(self) -> None: """Test that tools list is not passed to tools without **kwargs.""" tool_called = False @@ -546,7 +631,7 @@ def approved_inspector(action: str, **kwargs: Any) -> str: """A tool requiring approval that inspects the tools list.""" nonlocal captured_tools_count, tool_executed tool_executed = True - tools_list = kwargs.get("tools") + tools_list = kwargs.get("_framework_tools") if tools_list: captured_tools_count = len(tools_list) return f"Approved action: {action}" diff --git a/python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py b/python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py index bd2f3dea3c..ceb5dc04fb 100644 --- a/python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py +++ b/python/samples/getting_started/tools/function_tool_dynamic_tool_exposure.py @@ -47,16 +47,18 @@ def load_maths_tools( This tool demonstrates dynamic tool loading - it can add new tools to the agent during execution, making them available for immediate use. + The tools list is wrapped in an AdditiveToolsList for thread-safe mutations. """ - # Access tools list directly - tools_list = kwargs.get("tools") + # Access tools list from framework's reserved key + tools_list = kwargs.get("_framework_tools") if not tools_list: return "Error: Cannot access tools list for dynamic tool loading" if operation == "advanced": # Check if advanced tools are already loaded - existing_tool_names = {getattr(tool, "__name__", None) for tool in tools_list if tool is not None} + # FunctionTool objects use .name property, raw callables use __name__ + existing_tool_names = {getattr(tool, "name", getattr(tool, "__name__", None)) for tool in tools_list if tool is not None} advanced_tool_names = {"calculate_factorial", "calculate_fibonacci"} if advanced_tool_names.issubset(existing_tool_names): @@ -86,10 +88,9 @@ def calculate_fibonacci(n: Annotated[int, "The position in Fibonacci sequence"]) return f"The {n}th Fibonacci number is {b}" # Add the new tools to the tools list - if isinstance(tools_list, list): - tools_list.extend([calculate_factorial, calculate_fibonacci]) - return "Successfully loaded advanced maths tools: factorial and fibonacci" - return "Error: Tools list is not a list" + # AdditiveToolsList.extend() ensures thread-safe addition during concurrent execution + tools_list.extend([calculate_factorial, calculate_fibonacci]) + return "Successfully loaded advanced maths tools: factorial and fibonacci" return f"Unknown operation category: {operation}"