From 55e1991e0205781e2d829cda7e4f6f33aeddeacd Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 1 Jun 2026 11:17:45 +0200 Subject: [PATCH 1/4] Python: progressive tool exposure via FunctionInvocationContext Add first-class progressive tool exposure to the Python core function-calling loop. Tools can now add or remove real FunctionTool schemas at runtime via the injected FunctionInvocationContext, taking effect on the next iteration of the loop. - FunctionInvocationContext gains a live `tools` list plus experimental `add_tools()` / `remove_tools()` helpers (feature: PROGRESSIVE_TOOLS). - The function-calling loop establishes a run-local, normalized tools list and threads it into the context at both invocation paths so mutations propagate. - Add a sample (dynamic_tool_exposure.py) and a tools samples README, including a note that CodeAct providers (Monty/Hyperlight) use their own provider-level tool management instead. Supersedes #3877. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/AGENTS.md | 2 +- .../core/agent_framework/_feature_stage.py | 1 + .../core/agent_framework/_middleware.py | 108 +++++ .../packages/core/agent_framework/_tools.py | 16 + .../core/test_function_invocation_logic.py | 399 ++++++++++++++++++ python/samples/02-agents/tools/README.md | 75 ++++ .../02-agents/tools/dynamic_tool_exposure.py | 75 ++++ 7 files changed, 675 insertions(+), 1 deletion(-) create mode 100644 python/samples/02-agents/tools/README.md create mode 100644 python/samples/02-agents/tools/dynamic_tool_exposure.py diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index ffb6b3e2c5..a0f4c81b56 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -56,7 +56,7 @@ agent_framework/ - **`AgentMiddleware`** - Intercepts agent `run()` calls - **`ChatMiddleware`** - Intercepts chat client `get_response()` calls - **`FunctionMiddleware`** - Intercepts function/tool invocations -- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware +- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware. A tool can declare a `FunctionInvocationContext` parameter to receive it; `context.tools` is the live, mutable tools list for the run, and `context.add_tools(...)` / `context.remove_tools(...)` enable progressive tool exposure (changes apply on the next function-calling iteration). ### Sessions (`_sessions.py`) diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py index 55d4ac1096..8ebabfa977 100644 --- a/python/packages/core/agent_framework/_feature_stage.py +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -57,6 +57,7 @@ class ExperimentalFeature(str, Enum): FOUNDRY_PREVIEW_TOOLS = "FOUNDRY_PREVIEW_TOOLS" FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS" HARNESS = "HARNESS" + PROGRESSIVE_TOOLS = "PROGRESSIVE_TOOLS" SKILLS = "SKILLS" TO_PROMPT_AGENT = "TO_PROMPT_AGENT" diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 4f90030368..eeab8b060d 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, cast, overload from ._clients import SupportsChatGetResponse +from ._feature_stage import ExperimentalFeature, experimental from ._types import ( AgentResponse, AgentResponseUpdate, @@ -214,6 +215,12 @@ class FunctionInvocationContext: result: Function execution result. Can be observed after calling ``call_next()`` to see the actual execution result or can be set to override the execution result. kwargs: Additional runtime keyword arguments forwarded to the function invocation. + tools: The live, mutable list of tools available to the model for the current + agent run, or ``None`` when the function is invoked outside of a + function-calling loop (for example via ``FunctionTool.invoke`` directly). + Tools can add or remove tools during execution using :meth:`add_tools` + and :meth:`remove_tools` (progressive tool exposure). Mutations take + effect on the **next** model iteration, not the in-flight batch. Examples: .. code-block:: python @@ -232,6 +239,18 @@ async def process(self, context: FunctionInvocationContext, call_next): # Continue execution await call_next() + + Progressive tool exposure from inside a tool: + + .. code-block:: python + + from agent_framework import FunctionInvocationContext, tool + + + @tool(approval_mode="never_require") + def load_math_tools(ctx: FunctionInvocationContext) -> str: + ctx.add_tools([factorial, fibonacci]) + return "Math tools are now available." """ def __init__( @@ -242,6 +261,7 @@ def __init__( metadata: Mapping[str, Any] | None = None, result: Any = None, kwargs: Mapping[str, Any] | None = None, + tools: list[ToolTypes] | None = None, ) -> None: """Initialize the FunctionInvocationContext. @@ -252,6 +272,9 @@ def __init__( metadata: Metadata dictionary for sharing data between function middleware. result: Function execution result. kwargs: Additional runtime keyword arguments forwarded to the function invocation. + tools: The live, mutable list of tools for the current agent run. When provided, + this is the same list object the model sees on the next iteration, so + appending or removing tools changes the model's available tools. """ self.function = function self.arguments = arguments @@ -259,6 +282,91 @@ def __init__( self.metadata: dict[str, Any] = dict(metadata) if metadata is not None else {} self.result = result self.kwargs: dict[str, Any] = dict(kwargs) if kwargs is not None else {} + self.tools = tools + + @experimental(feature_id=ExperimentalFeature.PROGRESSIVE_TOOLS) + def add_tools( + self, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]], + ) -> None: + """Add one or more tools to the current agent run (progressive tool exposure). + + Callable inputs are converted to :class:`FunctionTool`, and tool collections are + flattened, using the same normalization as the rest of the framework. Added tools + become available to the model on the **next** iteration of the function-calling + loop; they do not affect tool calls already requested in the in-flight batch. + + Adding a tool whose name already exists is a no-op when it is the same object, and + raises ``ValueError`` when it is a different object with a duplicate name. + + Args: + tools: A single tool/callable or a sequence of tools/callables to add. + + Raises: + RuntimeError: If the context has no live tools list (for example when the + function is invoked outside of a function-calling loop). + ValueError: If a different tool with a duplicate name is added. + """ + from ._tools import _append_unique_tools, normalize_tools # type: ignore[reportPrivateUsage] + + if self.tools is None: + raise RuntimeError( + "Cannot add tools: this FunctionInvocationContext is not bound to a live " + "agent run. add_tools is only available for functions invoked within an " + "agent's function-calling loop." + ) + _append_unique_tools(self.tools, normalize_tools(tools)) + + def remove_tools( + self, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | str | Sequence[str], + ) -> None: + """Remove one or more tools from the current agent run (progressive tool exposure). + + Tools may be specified by name, by tool object, or by the original callable. Names + that are not currently present are ignored. Removals take effect on the **next** + iteration of the function-calling loop; tool calls already requested in the + in-flight batch still execute. + + Args: + tools: A tool name, tool/callable, or a sequence of any of these to remove. + + Raises: + RuntimeError: If the context has no live tools list (for example when the + function is invoked outside of a function-calling loop). + """ + from ._tools import _get_tool_name, normalize_tools # type: ignore[reportPrivateUsage] + + if self.tools is None: + raise RuntimeError( + "Cannot remove tools: this FunctionInvocationContext is not bound to a live " + "agent run. remove_tools is only available for functions invoked within an " + "agent's function-calling loop." + ) + + names_to_remove: set[str] = set() + raw_items: list[Any] + if isinstance(tools, str): + raw_items = [tools] + elif isinstance(tools, Sequence) and not isinstance(tools, (bytes, bytearray)): + raw_items = list(cast("Sequence[Any]", tools)) + else: + raw_items = [tools] + for item in raw_items: + if isinstance(item, str): + names_to_remove.add(item) + continue + for normalized in normalize_tools(item): + if name := _get_tool_name(normalized): # type: ignore[reportPrivateUsage] + names_to_remove.add(name) + + if not names_to_remove: + return + self.tools[:] = [ + tool + for tool in self.tools + if _get_tool_name(tool) not in names_to_remove # type: ignore[reportPrivateUsage] + ] class ChatContext: diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 93722a8987..6b660c7834 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1418,6 +1418,7 @@ async def _auto_invoke_function( sequence_index: int | None = None, request_index: int | None = None, middleware_pipeline: FunctionMiddlewarePipeline | None = None, + live_tools: list[ToolTypes] | None = None, ) -> Content: """Invoke a function call requested by the agent, applying middleware that is defined. @@ -1432,6 +1433,8 @@ async def _auto_invoke_function( sequence_index: The index of the function call in the sequence. request_index: The index of the request iteration. middleware_pipeline: Optional middleware pipeline to apply during execution. + live_tools: The live, mutable tools list for the current agent run, exposed on + the FunctionInvocationContext so tools can add/remove tools at runtime. Returns: The function result content. @@ -1523,6 +1526,7 @@ async def _auto_invoke_function( arguments=args, session=invocation_session, kwargs=runtime_kwargs.copy(), + tools=live_tools, ) function_result = await tool.invoke( arguments=args, @@ -1552,6 +1556,7 @@ async def _auto_invoke_function( arguments=args, session=invocation_session, kwargs=runtime_kwargs.copy(), + tools=live_tools, ) call_id = function_call_content.call_id @@ -1659,6 +1664,9 @@ async def _try_execute_function_calls( from ._types import Content tool_map = _get_tool_map(tools) + # The live tools list (when tools is the run-local list) is exposed on the + # FunctionInvocationContext so tools can add/remove tools during the run. + live_tools: list[ToolTypes] | None = cast("list[ToolTypes]", tools) if isinstance(tools, list) else None 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", @@ -1733,6 +1741,7 @@ async def invoke_with_termination_handling( request_index=attempt_idx, middleware_pipeline=middleware_pipeline, config=config, + live_tools=live_tools, ) return (result, False) except MiddlewareTermination as exc: @@ -2371,6 +2380,13 @@ def get_response( function_invocation_kwargs=function_invocation_kwargs, client_kwargs=filtered_kwargs, ) + # Establish a single, run-local mutable tools list so that tools can add or remove + # tools during the run (progressive tool exposure). A fresh list is created via + # normalize_tools so the caller's original tools container is never mutated, while + # the same list object is shared with the model (options["tools"]) and the tool map + # rebuilt on every loop iteration. + if mutable_options.get("tools"): + mutable_options["tools"] = normalize_tools(mutable_options["tools"]) if not stream: async def _get_response() -> ChatResponse[Any]: diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 3d20a26080..a54862c4fc 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3975,3 +3975,402 @@ def empty_request(task: str) -> str: ] assert len(function_results) >= 1 assert any("user input" in (fr.result or "").lower() for fr in function_results) + + +# region Progressive tool exposure (FunctionInvocationContext.add_tools / remove_tools) + + +def _pte_function_call_response(call_id: str, name: str, arguments: str = "{}") -> ChatResponse: + return ChatResponse( + messages=Message( + role="assistant", + contents=[Content.from_function_call(call_id=call_id, name=name, arguments=arguments)], + ) + ) + + +def _pte_text_response(text: str = "done") -> ChatResponse: + return ChatResponse(messages=Message(role="assistant", contents=[text])) + + +@tool(name="factorial", approval_mode="never_require") +def _pte_factorial(n: int) -> int: + """Compute the factorial of n.""" + result = 1 + for value in range(2, n + 1): + result *= value + return result + + +async def test_context_exposes_live_tools(chat_client_base: SupportsChatGetResponse): + from agent_framework import FunctionTool + + seen_names: list[str] = [] + + @tool(name="inspect_tools", approval_mode="never_require") + def inspect_tools(ctx: FunctionInvocationContext) -> str: + assert ctx.tools is not None + seen_names.extend(t.name for t in ctx.tools if isinstance(t, FunctionTool)) + return "inspected" + + chat_client_base.run_responses = [ + _pte_function_call_response("1", "inspect_tools"), + _pte_text_response(), + ] + await chat_client_base.get_response( + [Message(role="user", contents=["hi"])], + options={"tool_choice": "auto", "tools": [inspect_tools]}, + ) + assert "inspect_tools" in seen_names + + +async def test_add_tools_available_next_iteration(chat_client_base: SupportsChatGetResponse): + exec_counter = 0 + + @tool(name="factorial", approval_mode="never_require") + def factorial(n: int) -> int: + nonlocal exec_counter + exec_counter += 1 + return 120 + + @tool(name="load_math", approval_mode="never_require") + def load_math(ctx: FunctionInvocationContext) -> str: + ctx.add_tools(factorial) + return "math tools loaded" + + chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined] + chat_client_base.run_responses = [ + _pte_function_call_response("1", "load_math"), + _pte_function_call_response("2", "factorial", '{"n": 5}'), + _pte_text_response(), + ] + response = await chat_client_base.get_response( + [Message(role="user", contents=["compute 5!"])], + options={"tool_choice": "auto", "tools": [load_math]}, + ) + assert exec_counter == 1 + assert response.messages[-1].text == "done" + + +async def test_add_tools_model_sees_added_tools_in_options(chat_client_base: SupportsChatGetResponse): + from agent_framework import FunctionTool + + recorded: list[list[str]] = [] + client_cls = type(chat_client_base) + original = client_cls._get_non_streaming_response + + async def recording(self: Any, *, messages: Any, options: dict[str, Any], **kwargs: Any) -> ChatResponse: + tools = options.get("tools") or [] + recorded.append([t.name for t in tools if isinstance(t, FunctionTool)]) + return await original(self, messages=messages, options=options, **kwargs) + + @tool(name="load_math", approval_mode="never_require") + def load_math(ctx: FunctionInvocationContext) -> str: + ctx.add_tools(_pte_factorial) + return "loaded" + + chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined] + chat_client_base.run_responses = [ + _pte_function_call_response("1", "load_math"), + _pte_function_call_response("2", "factorial", '{"n": 5}'), + _pte_text_response(), + ] + monkey = pytest.MonkeyPatch() + monkey.setattr(client_cls, "_get_non_streaming_response", recording) + try: + await chat_client_base.get_response( + [Message(role="user", contents=["compute 5!"])], + options={"tool_choice": "auto", "tools": [load_math]}, + ) + finally: + monkey.undo() + + assert recorded[0] == ["load_math"] + assert "factorial" in recorded[1] + + +async def test_remove_tools_next_iteration(chat_client_base: SupportsChatGetResponse): + from agent_framework import FunctionTool + + recorded: list[list[str]] = [] + client_cls = type(chat_client_base) + original = client_cls._get_non_streaming_response + + async def recording(self: Any, *, messages: Any, options: dict[str, Any], **kwargs: Any) -> ChatResponse: + tools = options.get("tools") or [] + recorded.append([t.name for t in tools if isinstance(t, FunctionTool)]) + return await original(self, messages=messages, options=options, **kwargs) + + @tool(name="get_weather", approval_mode="never_require") + def get_weather(location: str) -> str: + return "sunny" + + @tool(name="drop_weather", approval_mode="never_require") + def drop_weather(ctx: FunctionInvocationContext) -> str: + ctx.remove_tools("get_weather") + return "removed" + + chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined] + chat_client_base.run_responses = [ + _pte_function_call_response("1", "drop_weather"), + _pte_text_response(), + ] + monkey = pytest.MonkeyPatch() + monkey.setattr(client_cls, "_get_non_streaming_response", recording) + try: + await chat_client_base.get_response( + [Message(role="user", contents=["hi"])], + options={"tool_choice": "auto", "tools": [get_weather, drop_weather]}, + ) + finally: + monkey.undo() + + assert set(recorded[0]) == {"get_weather", "drop_weather"} + assert "get_weather" not in recorded[1] + + +async def test_add_tools_does_not_mutate_caller_tools_list(chat_client_base: SupportsChatGetResponse): + @tool(name="load_math", approval_mode="never_require") + def load_math(ctx: FunctionInvocationContext) -> str: + ctx.add_tools(_pte_factorial) + return "loaded" + + original_tools: list[Any] = [load_math] + chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined] + chat_client_base.run_responses = [ + _pte_function_call_response("1", "load_math"), + _pte_text_response(), + ] + await chat_client_base.get_response( + [Message(role="user", contents=["hi"])], + options={"tool_choice": "auto", "tools": original_tools}, + ) + assert original_tools == [load_math] + + +async def test_add_tools_persists_across_iterations(chat_client_base: SupportsChatGetResponse): + from agent_framework import FunctionTool + + recorded: list[list[str]] = [] + client_cls = type(chat_client_base) + original = client_cls._get_non_streaming_response + + async def recording(self: Any, *, messages: Any, options: dict[str, Any], **kwargs: Any) -> ChatResponse: + tools = options.get("tools") or [] + recorded.append([t.name for t in tools if isinstance(t, FunctionTool)]) + return await original(self, messages=messages, options=options, **kwargs) + + @tool(name="load_math", approval_mode="never_require") + def load_math(ctx: FunctionInvocationContext) -> str: + ctx.add_tools(_pte_factorial) + return "loaded" + + chat_client_base.function_invocation_configuration["max_iterations"] = 4 # type: ignore[attr-defined] + chat_client_base.run_responses = [ + _pte_function_call_response("1", "load_math"), + _pte_function_call_response("2", "factorial", '{"n": 5}'), + _pte_function_call_response("3", "factorial", '{"n": 3}'), + _pte_text_response(), + ] + monkey = pytest.MonkeyPatch() + monkey.setattr(client_cls, "_get_non_streaming_response", recording) + try: + await chat_client_base.get_response( + [Message(role="user", contents=["hi"])], + options={"tool_choice": "auto", "tools": [load_math]}, + ) + finally: + monkey.undo() + + assert "factorial" in recorded[1] + assert "factorial" in recorded[2] + + +async def test_add_tools_through_function_middleware(chat_client_base: SupportsChatGetResponse): + exec_counter = 0 + + class PassthroughMiddleware(FunctionMiddleware): + async def process(self, context: FunctionInvocationContext, call_next: Any) -> None: + await call_next() + + @tool(name="factorial", approval_mode="never_require") + def factorial(n: int) -> int: + nonlocal exec_counter + exec_counter += 1 + return 120 + + @tool(name="load_math", approval_mode="never_require") + def load_math(ctx: FunctionInvocationContext) -> str: + ctx.add_tools(factorial) + return "loaded" + + chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined] + chat_client_base.run_responses = [ + _pte_function_call_response("1", "load_math"), + _pte_function_call_response("2", "factorial", '{"n": 5}'), + _pte_text_response(), + ] + await chat_client_base.get_response( + [Message(role="user", contents=["hi"])], + options={"tool_choice": "auto", "tools": [load_math]}, + middleware=[PassthroughMiddleware()], + ) + assert exec_counter == 1 + + +async def test_add_tools_with_approval_required_tool(chat_client_base: SupportsChatGetResponse): + @tool(name="secure_tool", approval_mode="always_require") + def secure_tool(value: str) -> str: + return f"secure: {value}" + + @tool(name="load_secure", approval_mode="never_require") + def load_secure(ctx: FunctionInvocationContext) -> str: + ctx.add_tools(secure_tool) + return "loaded" + + chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined] + chat_client_base.run_responses = [ + _pte_function_call_response("1", "load_secure"), + _pte_function_call_response("2", "secure_tool", '{"value": "x"}'), + _pte_text_response(), + ] + response = await chat_client_base.get_response( + [Message(role="user", contents=["hi"])], + options={"tool_choice": "auto", "tools": [load_secure]}, + ) + assert any(item.type == "function_approval_request" for msg in response.messages for item in msg.contents) + + +async def test_add_tools_accepts_plain_callable(chat_client_base: SupportsChatGetResponse): + exec_counter = 0 + + def plain_factorial(n: int) -> int: + """Compute factorial.""" + nonlocal exec_counter + exec_counter += 1 + return 120 + + @tool(name="load_math", approval_mode="never_require") + def load_math(ctx: FunctionInvocationContext) -> str: + ctx.add_tools(plain_factorial) + return "loaded" + + chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined] + chat_client_base.run_responses = [ + _pte_function_call_response("1", "load_math"), + _pte_function_call_response("2", "plain_factorial", '{"n": 5}'), + _pte_text_response(), + ] + await chat_client_base.get_response( + [Message(role="user", contents=["hi"])], + options={"tool_choice": "auto", "tools": [load_math]}, + ) + assert exec_counter == 1 + + +async def test_add_tools_streaming(chat_client_base: SupportsChatGetResponse): + exec_counter = 0 + + @tool(name="factorial", approval_mode="never_require") + def factorial(n: int) -> int: + nonlocal exec_counter + exec_counter += 1 + return 120 + + @tool(name="load_math", approval_mode="never_require") + def load_math(ctx: FunctionInvocationContext) -> str: + ctx.add_tools(factorial) + return "loaded" + + chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined] + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[Content.from_function_call(call_id="1", name="load_math", arguments="{}")], + role="assistant", + ) + ], + [ + ChatResponseUpdate( + contents=[Content.from_function_call(call_id="2", name="factorial", arguments='{"n": 5}')], + role="assistant", + ) + ], + [ChatResponseUpdate(contents=[Content.from_text("done")], role="assistant", finish_reason="stop")], + ] + async for _ in chat_client_base.get_response( + [Message(role="user", contents=["hi"])], + stream=True, + options={"tool_choice": "auto", "tools": [load_math]}, + ): + pass + assert exec_counter == 1 + + +def test_add_tools_duplicate_same_object_is_noop(): + @tool(name="dup", approval_mode="never_require") + def dup(x: int) -> int: + return x + + ctx = FunctionInvocationContext(function=dup, arguments={}, tools=[dup]) + ctx.add_tools(dup) + assert ctx.tools is not None + assert len(ctx.tools) == 1 + + +def test_add_tools_duplicate_name_different_object_raises(): + @tool(name="dup", approval_mode="never_require") + def dup_a(x: int) -> int: + return x + + @tool(name="dup", approval_mode="never_require") + def dup_b(x: int) -> int: + return x + + ctx = FunctionInvocationContext(function=dup_a, arguments={}, tools=[dup_a]) + with pytest.raises(ValueError): + ctx.add_tools(dup_b) + + +def test_remove_tools_by_name_and_object(): + @tool(name="a", approval_mode="never_require") + def a(x: int) -> int: + return x + + @tool(name="b", approval_mode="never_require") + def b(x: int) -> int: + return x + + ctx = FunctionInvocationContext(function=a, arguments={}, tools=[a, b]) + ctx.remove_tools("a") + assert ctx.tools is not None + assert [t.name for t in ctx.tools] == ["b"] + ctx.remove_tools(b) + assert ctx.tools == [] + + +def test_remove_tools_unknown_name_is_noop(): + @tool(name="a", approval_mode="never_require") + def a(x: int) -> int: + return x + + ctx = FunctionInvocationContext(function=a, arguments={}, tools=[a]) + ctx.remove_tools("nonexistent") + assert ctx.tools is not None + assert [t.name for t in ctx.tools] == ["a"] + + +def test_progressive_tools_helpers_raise_without_live_tools(): + @tool(name="a", approval_mode="never_require") + def a(x: int) -> int: + return x + + ctx = FunctionInvocationContext(function=a, arguments={}) + assert ctx.tools is None + with pytest.raises(RuntimeError): + ctx.add_tools(a) + with pytest.raises(RuntimeError): + ctx.remove_tools("a") + + +# endregion diff --git a/python/samples/02-agents/tools/README.md b/python/samples/02-agents/tools/README.md new file mode 100644 index 0000000000..ad07d86ecd --- /dev/null +++ b/python/samples/02-agents/tools/README.md @@ -0,0 +1,75 @@ +# Tools + +Samples that show how to define, configure, and control function tools for an +agent — from basic declarations to approvals, invocation limits, session +injection, and dynamic (progressive) tool exposure. + +## Function tools + +| File | Demonstrates | +|------|--------------| +| [`function_tool_with_explicit_schema.py`](function_tool_with_explicit_schema.py) | Defining a tool with an explicit JSON schema. | +| [`function_tool_declaration_only.py`](function_tool_declaration_only.py) | A declaration-only tool (schema without a local implementation). | +| [`function_tool_with_kwargs.py`](function_tool_with_kwargs.py) | Passing extra keyword arguments into a tool. | +| [`function_tool_from_dict_with_dependency_injection.py`](function_tool_from_dict_with_dependency_injection.py) | Dependency injection into a tool defined from a dict. | +| [`function_tool_with_session_injection.py`](function_tool_with_session_injection.py) | Injecting the session into a tool. | +| [`tool_in_class.py`](tool_in_class.py) | Using a method on a class as a tool. | +| [`agent_as_tool_with_session_propagation.py`](agent_as_tool_with_session_propagation.py) | Exposing an agent as a tool with session propagation. | + +## Approvals & invocation control + +| File | Demonstrates | +|------|--------------| +| [`function_tool_with_approval.py`](function_tool_with_approval.py) | Requiring human approval before a tool runs. | +| [`function_tool_with_approval_and_sessions.py`](function_tool_with_approval_and_sessions.py) | Tool approvals combined with sessions. | +| [`function_invocation_configuration.py`](function_invocation_configuration.py) | Configuring function-invocation settings (e.g. max iterations). | +| [`control_total_tool_executions.py`](control_total_tool_executions.py) | All the ways to cap how many times tools run. | +| [`function_tool_with_max_invocations.py`](function_tool_with_max_invocations.py) | Limiting the number of invocations per tool. | +| [`function_tool_with_max_exceptions.py`](function_tool_with_max_exceptions.py) | Limiting the number of exceptions a tool may raise. | +| [`function_tool_recover_from_failures.py`](function_tool_recover_from_failures.py) | Returning errors so the agent can recover from tool failures. | + +## Progressive tool exposure (dynamic loading) + +| File | Demonstrates | +|------|--------------| +| [`dynamic_tool_exposure.py`](dynamic_tool_exposure.py) | A "loader" tool that adds more tools at runtime via `FunctionInvocationContext`. | + +Frontloading a model with hundreds of tools hurts tool-selection accuracy, +bloats context, and raises cost. Instead, start with a small set of loader +tools and let the model pull in more on demand. Inside a tool, the injected +`ctx: FunctionInvocationContext` exposes a live `ctx.tools` list plus +`ctx.add_tools(...)` / `ctx.remove_tools(...)` helpers. Tools added or removed +take effect on the **next iteration** of the function-calling loop. + +> [!NOTE] +> Progressive tool exposure applies to the standard function-calling loop. It +> does **not** apply to CodeAct providers (`agent-framework-monty`, +> `agent-framework-hyperlight`). In CodeAct the model only sees a single +> `execute_code` tool, and host tools are exposed *inside the sandbox* as typed +> Python functions rather than as model tool-schemas. Host tools there are +> invoked without a `FunctionInvocationContext`, so `ctx.add_tools()` is not +> available; the helpers fail fast with a clear `RuntimeError` instead of +> silently doing nothing. To change a CodeAct agent's tool set, use the +> provider's own `add_tools` / `remove_tool` / `clear_tools` methods (applied +> between runs). The recommended provider-driven path for Monty and Hyperlight +> is shown in [`../context_providers/code_act/`](../context_providers/code_act/) +> ([`code_act.py`](../context_providers/code_act/code_act.py) for Hyperlight, +> [`monty_code_act.py`](../context_providers/code_act/monty_code_act.py) for +> Monty). + +## Local shell & code interpreters + +| Path | Demonstrates | +|------|--------------| +| [`local_shell_with_allowlist.py`](local_shell_with_allowlist.py) | `LocalShellTool` restricted by a strict command allow-list. | +| [`local_shell_with_environment_provider.py`](local_shell_with_environment_provider.py) | `LocalShellTool` wired with a `ShellEnvironmentProvider`. | +| [`local_code_interpreter/`](local_code_interpreter/) | Hyperlight-backed sandboxed code interpreter (standalone tool — *extra* pattern). | +| [`monty_code_interpreter/`](monty_code_interpreter/) | Monty-backed sandboxed code interpreter (standalone tool — *extra* pattern). | + +> [!TIP] +> The `local_code_interpreter/` and `monty_code_interpreter/` samples show the +> standalone-tool wiring and are provided as *extra* reference. For most +> Monty/Hyperlight use cases the **recommended** path is the provider-driven +> CodeAct setup in +> [`../context_providers/code_act/`](../context_providers/code_act/), which adds +> dynamic tool / capability management. diff --git a/python/samples/02-agents/tools/dynamic_tool_exposure.py b/python/samples/02-agents/tools/dynamic_tool_exposure.py new file mode 100644 index 0000000000..15b1eb8926 --- /dev/null +++ b/python/samples/02-agents/tools/dynamic_tool_exposure.py @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated + +from agent_framework import Agent, FunctionInvocationContext, tool +from agent_framework.openai import OpenAIChatClient +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Dynamic Tool Exposure (Progressive Tool Loading) Example + +This example demonstrates "progressive tool exposure": a tool that adds more tools to +the agent at runtime, in the same run, via ``FunctionInvocationContext``. + +Frontloading a model with hundreds of tools hurts tool-selection accuracy, bloats +context, and raises cost. Instead, you can start with a small set of "loader" tools and +let the model pull in additional tools on demand. Tools added with ``ctx.add_tools(...)`` +(or removed with ``ctx.remove_tools(...)``) become available to the model on the next +iteration of the function-calling loop. +""" + + +# These math tools are not registered on the agent up front. They are added on demand by +# the ``load_math_tools`` tool below, and only then become callable by the model. +@tool(approval_mode="never_require") +def factorial(n: Annotated[int, Field(description="A non-negative integer.")]) -> str: + """Compute the factorial of n.""" + result = 1 + for value in range(2, n + 1): + result *= value + return f"{n}! = {result}" + + +@tool(approval_mode="never_require") +def fibonacci(n: Annotated[int, Field(description="The 0-based index in the Fibonacci sequence.")]) -> str: + """Compute the n-th Fibonacci number.""" + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return f"fib({n}) = {a}" + + +# The only tool the agent starts with. When called, it exposes the math tools above so the +# model can use them on the next turn. Note the ``ctx`` parameter is injected by the +# framework and is not visible to the model. +@tool(approval_mode="never_require") +def load_math_tools(ctx: FunctionInvocationContext) -> str: + """Load additional math tools (factorial, fibonacci) so they can be used.""" + ctx.add_tools([factorial, fibonacci]) + return "Loaded math tools: factorial, fibonacci. You can now call them." + + +async def main() -> None: + agent = Agent( + client=OpenAIChatClient(), + name="MathAgent", + instructions=( + "You are a math assistant. If you need math capabilities that are not yet " + "available, call load_math_tools first, then use the newly available tools." + ), + tools=[load_math_tools], + ) + + # The agent starts with only ``load_math_tools``. To answer the question it must first + # load the math tools, then call ``factorial`` on the next iteration. + print(f"Agent: {await agent.run('What is 5 factorial?')}") + + +if __name__ == "__main__": + asyncio.run(main()) From e88e6eb054541e6273519212662482f09cfebe01 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 1 Jun 2026 16:07:35 +0200 Subject: [PATCH 2/4] Validate non-negative input in dynamic_tool_exposure sample tools Address review feedback: factorial and fibonacci now return an error message for negative n instead of producing incorrect results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/samples/02-agents/tools/dynamic_tool_exposure.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/samples/02-agents/tools/dynamic_tool_exposure.py b/python/samples/02-agents/tools/dynamic_tool_exposure.py index 15b1eb8926..2886399486 100644 --- a/python/samples/02-agents/tools/dynamic_tool_exposure.py +++ b/python/samples/02-agents/tools/dynamic_tool_exposure.py @@ -30,6 +30,8 @@ @tool(approval_mode="never_require") def factorial(n: Annotated[int, Field(description="A non-negative integer.")]) -> str: """Compute the factorial of n.""" + if n < 0: + return "Error: n must be a non-negative integer." result = 1 for value in range(2, n + 1): result *= value @@ -39,6 +41,8 @@ def factorial(n: Annotated[int, Field(description="A non-negative integer.")]) - @tool(approval_mode="never_require") def fibonacci(n: Annotated[int, Field(description="The 0-based index in the Fibonacci sequence.")]) -> str: """Compute the n-th Fibonacci number.""" + if n < 0: + return "Error: n must be a non-negative integer." a, b = 0, 1 for _ in range(n): a, b = b, a + b From 2bd3c15f8b83aa5a75c3deaf08cccef6959b0a85 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 2 Jun 2026 09:15:25 +0200 Subject: [PATCH 3/4] Make add_tools atomic and surface swallowed function errors Address review feedback on progressive tool exposure: - add_tools now validates the full batch against a throwaway copy before committing, so a duplicate-name clash partway through a sequence leaves the live tool list unchanged (all-or-nothing). - _auto_invoke_function now logs a warning (with traceback) when a tool raises, so contract errors such as a duplicate-name ValueError from add_tools are debuggable without enabling include_detailed_errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/_middleware.py | 6 ++++- .../packages/core/agent_framework/_tools.py | 12 ++++++++++ .../core/test_function_invocation_logic.py | 23 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index eeab8b060d..331c4c97ca 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -315,7 +315,11 @@ def add_tools( "agent run. add_tools is only available for functions invoked within an " "agent's function-calling loop." ) - _append_unique_tools(self.tools, normalize_tools(tools)) + # Validate the whole batch against a throwaway copy first, so a duplicate-name + # clash partway through the batch raises before the live tool list is mutated + # (all-or-nothing semantics). + merged = _append_unique_tools(list(self.tools), normalize_tools(tools)) + self.tools[:] = merged def remove_tools( self, diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 6b660c7834..e8757c7eec 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1541,6 +1541,12 @@ async def _auto_invoke_function( except UserInputRequiredException: raise except Exception as exc: + logger.warning( + "Function '%s' raised an exception; returning an error result to the model. " + "Set include_detailed_errors=True or inspect this log for details.", + tool.name, + exc_info=exc, + ) message = "Error: Function failed." if config.get("include_detailed_errors", False): message = f"{message} Exception: {exc}" @@ -1613,6 +1619,12 @@ async def final_function_handler(context_obj: Any) -> Any: except UserInputRequiredException: raise except Exception as exc: + logger.warning( + "Function '%s' raised an exception; returning an error result to the model. " + "Set include_detailed_errors=True or inspect this log for details.", + tool.name, + exc_info=exc, + ) message = "Error: Function failed." if config.get("include_detailed_errors", False): message = f"{message} Exception: {exc}" diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index a54862c4fc..f96ca99ad5 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -4332,6 +4332,29 @@ def dup_b(x: int) -> int: ctx.add_tools(dup_b) +def test_add_tools_batch_with_duplicate_is_atomic(): + """A duplicate-name clash partway through a batch must leave the live list unchanged.""" + + @tool(name="existing", approval_mode="never_require") + def existing(x: int) -> int: + return x + + @tool(name="fresh", approval_mode="never_require") + def fresh(x: int) -> int: + return x + + @tool(name="existing", approval_mode="never_require") + def clashing(x: int) -> int: + return x + + ctx = FunctionInvocationContext(function=existing, arguments={}, tools=[existing]) + with pytest.raises(ValueError): + ctx.add_tools([fresh, clashing]) + assert ctx.tools is not None + # The valid "fresh" tool must not have been committed before the clash raised. + assert ctx.tools == [existing] + + def test_remove_tools_by_name_and_object(): @tool(name="a", approval_mode="never_require") def a(x: int) -> int: From f2da22363f8c803df84004cb8ee56739e39dc1d8 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 2 Jun 2026 09:56:19 +0200 Subject: [PATCH 4/4] Avoid retaining tracebacks when logging swallowed function errors Logging with exc_info=exc fed the exception traceback to the logging machinery, whose frame references created reference cycles collected lazily by the cyclic GC. On Windows that could drop a hyperlight WasmSandbox on a non-owning thread ("unsendable, dropped on another thread"), crashing the xdist worker. Log a pre-formatted message with the exception repr instead, so no traceback object is retained. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_tools.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index e8757c7eec..5237cf62ba 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1542,10 +1542,8 @@ async def _auto_invoke_function( raise except Exception as exc: logger.warning( - "Function '%s' raised an exception; returning an error result to the model. " - "Set include_detailed_errors=True or inspect this log for details.", - tool.name, - exc_info=exc, + f"Function '{tool.name}' raised an exception; returning an error result to the " + f"model. Set include_detailed_errors=True for the full detail. Exception: {exc!r}" ) message = "Error: Function failed." if config.get("include_detailed_errors", False): @@ -1620,10 +1618,8 @@ async def final_function_handler(context_obj: Any) -> Any: raise except Exception as exc: logger.warning( - "Function '%s' raised an exception; returning an error result to the model. " - "Set include_detailed_errors=True or inspect this log for details.", - tool.name, - exc_info=exc, + f"Function '{tool.name}' raised an exception; returning an error result to the " + f"model. Set include_detailed_errors=True for the full detail. Exception: {exc!r}" ) message = "Error: Function failed." if config.get("include_detailed_errors", False):