mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into fix/mcp-allowed-tools-empty-list-handling
This commit is contained in:
@@ -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`)
|
||||
|
||||
|
||||
@@ -58,6 +58,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"
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from enum import Enum
|
||||
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 @@ class FunctionInvocationContext:
|
||||
|
||||
# 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 @@ class FunctionInvocationContext:
|
||||
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 @@ class FunctionInvocationContext:
|
||||
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,96 @@ class FunctionInvocationContext:
|
||||
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."
|
||||
)
|
||||
# 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
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.PROGRESSIVE_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:
|
||||
|
||||
@@ -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,
|
||||
@@ -1537,6 +1541,10 @@ async def _auto_invoke_function(
|
||||
except UserInputRequiredException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
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):
|
||||
message = f"{message} Exception: {exc}"
|
||||
@@ -1552,6 +1560,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
|
||||
@@ -1608,6 +1617,10 @@ async def _auto_invoke_function(
|
||||
except UserInputRequiredException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
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):
|
||||
message = f"{message} Exception: {exc}"
|
||||
@@ -1659,6 +1672,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 +1749,7 @@ async def _try_execute_function_calls(
|
||||
request_index=attempt_idx,
|
||||
middleware_pipeline=middleware_pipeline,
|
||||
config=config,
|
||||
live_tools=live_tools,
|
||||
)
|
||||
return (result, False)
|
||||
except MiddlewareTermination as exc:
|
||||
@@ -2371,6 +2388,13 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
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]:
|
||||
|
||||
@@ -3975,3 +3975,425 @@ async def test_user_input_request_empty_contents_returns_fallback(chat_client_ba
|
||||
]
|
||||
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_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:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user