From 554445d4dc7e0fe2abd55531d0a1bb09077597c6 Mon Sep 17 00:00:00 2001 From: Suneet Nangia Date: Thu, 22 Jan 2026 22:10:03 +0000 Subject: [PATCH 1/3] feat: enable dynamic tool loading by passing tools list in kwargs --- .../packages/core/agent_framework/_tools.py | 8 +- .../test_kwargs_propagation_to_ai_function.py | 3 + .../tools/dynamic_tool_loading.py | 135 ++++++++++++++++++ 3 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 python/samples/getting_started/tools/dynamic_tool_loading.py diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 3ea7d33e72..2116851adc 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1945,8 +1945,10 @@ async def function_invocation_wrapper( if function_calls and tools: # Use the stored middleware pipeline instead of extracting from kwargs # because kwargs may have been modified by the underlying function + # Pass tools list to allow tools to dynamically add more tools + custom_args_with_tools = {**kwargs, "tools": tools} function_call_results, should_terminate = await _try_execute_function_calls( - custom_args=kwargs, + custom_args=custom_args_with_tools, attempt_idx=attempt_idx, function_calls=function_calls, tools=tools, # type: ignore @@ -2151,8 +2153,10 @@ async def streaming_function_invocation_wrapper( if function_calls and tools: # Use the stored middleware pipeline instead of extracting from kwargs # because kwargs may have been modified by the underlying function + # Pass tools list to allow tools to dynamically add more tools + custom_args_with_tools = {**kwargs, "tools": tools} function_call_results, should_terminate = await _try_execute_function_calls( - custom_args=kwargs, + custom_args=custom_args_with_tools, attempt_idx=attempt_idx, function_calls=function_calls, tools=tools, # type: ignore 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 1a206d9646..b9f27eaeb3 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 @@ -72,6 +72,9 @@ async def mock_get_response(self, messages, **kwargs): assert captured_kwargs["session_token"] == "secret-token" assert "custom_data" in captured_kwargs assert captured_kwargs["custom_data"] == {"key": "value"} + # Verify tools list is also present in kwargs + assert "tools" in captured_kwargs, f"Expected 'tools' in captured kwargs: {captured_kwargs}" + assert isinstance(captured_kwargs["tools"], list) # Verify result assert result.messages[-1].text == "Done!" diff --git a/python/samples/getting_started/tools/dynamic_tool_loading.py b/python/samples/getting_started/tools/dynamic_tool_loading.py new file mode 100644 index 0000000000..6954e35cfa --- /dev/null +++ b/python/samples/getting_started/tools/dynamic_tool_loading.py @@ -0,0 +1,135 @@ +# 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 + +The key is using **kwargs to receive the tools list from the framework. +""" + +import asyncio +import logging +import os +from typing import Annotated, Any + +from dotenv import load_dotenv + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential + +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__) + +@ai_function +def load_math_tools( + operation: Annotated[str, "The math operation category to load (e.g., 'advanced')"], + **kwargs: Any, +) -> str: + """Load additional math 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": + # Define advanced math tools that will be added dynamically + @ai_function + 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}" + + @ai_function + 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 math tools: factorial and fibonacci" + return "Error: Tools list is not a list" + + return f"Unknown operation category: {operation}" + + +@ai_function +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=AzureCliCredential()) + agent = ChatAgent( + chat_client=client, + instructions=( + "You are a helpful math assistant. " + "You have access to basic math operations and can load additional tools as needed. " + "When you need advanced math operations like factorial or fibonacci, " + "first use load_math_tools to load them, then use the newly loaded tools." + ), + name="MathAgent", + tools=[add, load_math_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 math tools") + print("2. Agent calls load_math_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 math tools for factorial and fibonacci +2. Agent calls load_math_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 5400f5996fe6451a8d11ad5af2bcd6e2b2205681 Mon Sep 17 00:00:00 2001 From: Suneet Nangia Date: Fri, 23 Jan 2026 00:00:49 +0000 Subject: [PATCH 2/3] fix: pass tools list to approval-handling execution path - Fixed inconsistency where approved tool executions didn't receive kwargs['tools'] - Both async and streaming approval paths now use custom_args_with_tools - This ensures dynamic tool loading works consistently with approvals - Added comprehensive test coverage for tools identity checks - All tests verify tools list is the same object reference for mutation --- .../packages/core/agent_framework/_tools.py | 8 ++++++-- .../test_kwargs_propagation_to_ai_function.py | 19 +++++++++++++++---- python/samples/README.md | 1 + .../tools/dynamic_tool_loading.py | 14 +++++++++++--- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 2116851adc..57b3057c01 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1898,8 +1898,10 @@ async def function_invocation_wrapper( approved_responses = [resp for resp in fcc_todo.values() if resp.approved] approved_function_results: list[Content] = [] if approved_responses: + # Pass tools list to allow tools to dynamically add more tools + custom_args_with_tools = {**kwargs, "tools": tools} results, _ = await _try_execute_function_calls( - custom_args=kwargs, + custom_args=custom_args_with_tools, attempt_idx=attempt_idx, function_calls=approved_responses, tools=tools, # type: ignore @@ -2093,8 +2095,10 @@ async def streaming_function_invocation_wrapper( approved_responses = [resp for resp in fcc_todo.values() if resp.approved] approved_function_results: list[Content] = [] if approved_responses: + # Pass tools list to allow tools to dynamically add more tools + custom_args_with_tools = {**kwargs, "tools": tools} results, _ = await _try_execute_function_calls( - custom_args=kwargs, + custom_args=custom_args_with_tools, attempt_idx=attempt_idx, function_calls=approved_responses, tools=tools, # type: ignore 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 b9f27eaeb3..581308478f 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 @@ -56,10 +56,11 @@ async def mock_get_response(self, messages, **kwargs): # Call with custom kwargs that should propagate to the tool # Note: tools are passed in options dict, custom kwargs are passed separately + tools_list = [capture_kwargs_tool] result = await wrapped( mock_client, messages=[], - options={"tools": [capture_kwargs_tool]}, + options={"tools": tools_list}, user_id="user-123", session_token="secret-token", custom_data={"key": "value"}, @@ -72,9 +73,10 @@ async def mock_get_response(self, messages, **kwargs): assert captured_kwargs["session_token"] == "secret-token" assert "custom_data" in captured_kwargs assert captured_kwargs["custom_data"] == {"key": "value"} - # Verify tools list is also present in kwargs + # Verify tools list is also present in kwargs and is the SAME object (identity check) assert "tools" in captured_kwargs, f"Expected 'tools' in captured kwargs: {captured_kwargs}" assert isinstance(captured_kwargs["tools"], list) + assert captured_kwargs["tools"] is tools_list, "tools should be the same list object for mutation" # Verify result assert result.messages[-1].text == "Done!" @@ -157,10 +159,11 @@ async def mock_get_response(self, messages, **kwargs): wrapped = _handle_function_calls_response(mock_get_response) # Call with kwargs + tools_list = [tracking_tool] result = await wrapped( mock_client, messages=[], - options={"tools": [tracking_tool]}, + options={"tools": tools_list}, request_id="req-001", trace_context={"trace_id": "abc"}, ) @@ -170,6 +173,9 @@ async def mock_get_response(self, messages, **kwargs): for kwargs in invocation_kwargs: assert kwargs.get("request_id") == "req-001" assert kwargs.get("trace_context") == {"trace_id": "abc"} + # Verify tools list is present and is the SAME object (identity check) + assert "tools" in kwargs, f"Expected 'tools' in captured kwargs: {kwargs}" + assert kwargs["tools"] is tools_list, "tools should be the same list object for mutation" assert result.messages[-1].text == "All done!" async def test_streaming_response_kwargs_propagation(self) -> None: @@ -211,10 +217,11 @@ async def mock_get_streaming_response(self, messages, **kwargs): # Collect streaming updates updates: list[ChatResponseUpdate] = [] + tools_list = [streaming_capture_tool] async for update in wrapped( mock_client, messages=[], - options={"tools": [streaming_capture_tool]}, + options={"tools": tools_list}, streaming_session="session-xyz", correlation_id="corr-123", ): @@ -224,3 +231,7 @@ async def mock_get_streaming_response(self, messages, **kwargs): 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" + # Verify tools list is also present in kwargs and is the SAME object (identity check) + assert "tools" in captured_kwargs, f"Expected 'tools' in captured kwargs: {captured_kwargs}" + assert isinstance(captured_kwargs["tools"], list) + assert captured_kwargs["tools"] is tools_list, "tools should be the same list object for mutation" diff --git a/python/samples/README.md b/python/samples/README.md index b877be0f2b..82982dcfd7 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -282,6 +282,7 @@ The recommended way to use Ollama is via the native `OllamaChatClient` from the | [`getting_started/tools/ai_function_declaration_only.py`](./getting_started/tools/ai_function_declaration_only.py) | Function declarations without implementations for testing agent reasoning | | [`getting_started/tools/ai_function_from_dict_with_dependency_injection.py`](./getting_started/tools/ai_function_from_dict_with_dependency_injection.py) | Creating AI functions from dictionary definitions using dependency injection | | [`getting_started/tools/ai_function_recover_from_failures.py`](./getting_started/tools/ai_function_recover_from_failures.py) | Graceful error handling when tools raise exceptions | +| [`getting_started/tools/dynamic_tool_loading.py`](./getting_started/tools/dynamic_tool_loading.py) | Dynamic tool loading during agent execution - tools that add new tools at runtime | | [`getting_started/tools/ai_function_with_approval.py`](./getting_started/tools/ai_function_with_approval.py) | User approval workflows for function calls without threads | | [`getting_started/tools/ai_function_with_approval_and_threads.py`](./getting_started/tools/ai_function_with_approval_and_threads.py) | Tool approval workflows using threads for conversation history management | | [`getting_started/tools/ai_function_with_max_exceptions.py`](./getting_started/tools/ai_function_with_max_exceptions.py) | Limiting tool failure exceptions using max_invocation_exceptions | diff --git a/python/samples/getting_started/tools/dynamic_tool_loading.py b/python/samples/getting_started/tools/dynamic_tool_loading.py index 6954e35cfa..a7c5d4922f 100644 --- a/python/samples/getting_started/tools/dynamic_tool_loading.py +++ b/python/samples/getting_started/tools/dynamic_tool_loading.py @@ -17,11 +17,10 @@ import os from typing import Annotated, Any -from dotenv import load_dotenv - from agent_framework import ChatAgent, ai_function from agent_framework.azure import AzureOpenAIChatClient from azure.identity import AzureCliCredential +from dotenv import load_dotenv load_dotenv() @@ -32,6 +31,7 @@ ) logger = logging.getLogger(__name__) + @ai_function def load_math_tools( operation: Annotated[str, "The math operation category to load (e.g., 'advanced')"], @@ -49,6 +49,13 @@ def load_math_tools( 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 math tools (factorial and fibonacci) are already loaded" + # Define advanced math tools that will be added dynamically @ai_function def calculate_factorial(n: Annotated[int, "The number to calculate factorial for"]) -> str: @@ -100,7 +107,7 @@ async def main() -> None: ), name="MathAgent", tools=[add, load_math_tools], - ) + ) print("=" * 80) print("Using basic tools and dynamically loading and using advanced tools") @@ -115,6 +122,7 @@ async def main() -> None: 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: ================================================================================ From 48b90118d25e5e260ff7f644c6427e1d15a08e4b Mon Sep 17 00:00:00 2001 From: Suneet Nangia Date: Thu, 29 Jan 2026 18:28:49 +0000 Subject: [PATCH 3/3] Updated to use default Azure credentials. Signed-off-by: Suneet Nangia --- .../tools/dynamic_tool_loading.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/python/samples/getting_started/tools/dynamic_tool_loading.py b/python/samples/getting_started/tools/dynamic_tool_loading.py index a7c5d4922f..cc52cc099d 100644 --- a/python/samples/getting_started/tools/dynamic_tool_loading.py +++ b/python/samples/getting_started/tools/dynamic_tool_loading.py @@ -7,9 +7,15 @@ 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 +- 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/dynamic_tool_loading.py -The key is using **kwargs to receive the tools list from the framework. """ import asyncio @@ -17,10 +23,11 @@ import os from typing import Annotated, Any +from dotenv import load_dotenv + from agent_framework import ChatAgent, ai_function from agent_framework.azure import AzureOpenAIChatClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv +from azure.identity import DefaultAzureCredential load_dotenv() @@ -31,7 +38,6 @@ ) logger = logging.getLogger(__name__) - @ai_function def load_math_tools( operation: Annotated[str, "The math operation category to load (e.g., 'advanced')"], @@ -52,7 +58,7 @@ def load_math_tools( # 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 math tools (factorial and fibonacci) are already loaded" @@ -96,7 +102,7 @@ def add(x: Annotated[int, "First number"], y: Annotated[int, "Second number"]) - async def main() -> None: # Create a chat client and agent with the dynamic tool loader and a basic tool - client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIChatClient(credential=DefaultAzureCredential()) agent = ChatAgent( chat_client=client, instructions=( @@ -122,7 +128,6 @@ async def main() -> None: 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: ================================================================================