Skip to content
16 changes: 12 additions & 4 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1913,8 +1913,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
Expand Down Expand Up @@ -1960,8 +1962,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
Expand Down Expand Up @@ -2106,8 +2110,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
Expand Down Expand Up @@ -2177,8 +2183,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -72,6 +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 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!"

Expand Down Expand Up @@ -154,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"},
)
Expand All @@ -167,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:
Expand Down Expand Up @@ -208,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",
):
Expand All @@ -221,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"
1 change: 1 addition & 0 deletions python/samples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
148 changes: 148 additions & 0 deletions python/samples/getting_started/tools/dynamic_tool_loading.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# 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=<your-deployment> && export AZURE_OPENAI_ENDPOINT=<your-endpoint> && uv run python samples/getting_started/tools/dynamic_tool_loading.py

"""

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 DefaultAzureCredential

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:
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this sample, if not tools_list: will treat an empty list as missing. Since the framework can legitimately pass an empty tools list, this should distinguish None/missing from an empty list (and ideally validate the type before branching) so the error path isn’t triggered incorrectly.

Suggested change
if not tools_list:
if tools_list is None:

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if the suggestion is valid in this instance.
Since we are checking in the tool itself, framework should never pass is an empty i.e. there should be at least 1 tool?

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:
"""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=DefaultAzureCredential())
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())