From 39d7b9b9142e2bc7100109e08683578728518d28 Mon Sep 17 00:00:00 2001 From: danieldagot Date: Thu, 26 Mar 2026 12:36:57 +0200 Subject: [PATCH 1/9] add @fast.tool decorator for inline function tool registration Allows users to register Python functions as tools using @fast.tool (bare or parameterized with name/description). Global tools are available to all agents by default; agents with explicit function_tools only see those tools. Made-with: Cursor --- src/fast_agent/core/direct_decorators.py | 51 +++++++- src/fast_agent/core/direct_factory.py | 27 ++++- src/fast_agent/core/fastagent.py | 5 + test_tool_decorator.py | 94 +++++++++++++++ tests/unit/core/test_tool_decorator.py | 142 +++++++++++++++++++++++ 5 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 test_tool_decorator.py create mode 100644 tests/unit/core/test_tool_decorator.py diff --git a/src/fast_agent/core/direct_decorators.py b/src/fast_agent/core/direct_decorators.py index bf9668109..d5348f735 100644 --- a/src/fast_agent/core/direct_decorators.py +++ b/src/fast_agent/core/direct_decorators.py @@ -7,6 +7,7 @@ from collections.abc import Coroutine from pathlib import Path from typing import ( + TYPE_CHECKING, Any, Awaitable, Callable, @@ -14,11 +15,15 @@ ParamSpec, Protocol, TypeVar, + overload, ) from mcp.client.session import ElicitationFnT from pydantic import AnyUrl +if TYPE_CHECKING: + from fastmcp.tools import FunctionTool + from fast_agent.agents.agent_types import ( AgentConfig, AgentType, @@ -280,8 +285,52 @@ class DecoratorMixin: agent configurations. """ - # Type hint for the agents dict (provided by host class) + # Type hints for attributes provided by host class agents: dict[str, Any] + _registered_tools: "list[FunctionTool]" + + @overload + def tool(self, func: Callable[..., Any], /) -> Callable[..., Any]: ... + + @overload + def tool( + self, + *, + name: str | None = None, + description: str | None = None, + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... + + def tool( + self, + func: Callable[..., Any] | None = None, + /, + *, + name: str | None = None, + description: str | None = None, + ) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]: + """Register a Python function as a tool available to agents. + + Supports both bare and parameterized usage:: + + @fast.tool + def greet(name: str) -> str: ... + + @fast.tool(name="add", description="Add two numbers") + def add_numbers(a: int, b: int) -> int: ... + + Tools registered this way are available to all agents that do not + declare an explicit ``function_tools`` list. + """ + from fast_agent.tools.function_tool_loader import build_default_function_tool + + def _register(fn: Callable[..., Any]) -> Callable[..., Any]: + tool = build_default_function_tool(fn, name=name, description=description) + self._registered_tools.append(tool) + return fn + + if func is not None: + return _register(func) + return _register def agent( self, diff --git a/src/fast_agent/core/direct_factory.py b/src/fast_agent/core/direct_factory.py index 6df8b7073..f214338dd 100644 --- a/src/fast_agent/core/direct_factory.py +++ b/src/fast_agent/core/direct_factory.py @@ -121,6 +121,27 @@ def _load_configured_function_tools( return load_function_tools(tools_config, base_path) +def _resolve_function_tools_with_globals( + config: AgentConfig, + agent_data: Mapping[str, Any], + build_ctx: "AgentBuildContext", +) -> list[FunctionTool]: + """Load per-agent function tools, falling back to global @fast.tool tools. + + If the agent has explicit function_tools configured, only those are used. + Otherwise, globally registered tools from ``@fast.tool`` are provided. + """ + explicit_tools = _load_configured_function_tools(config, agent_data) + if explicit_tools: + return explicit_tools + + global_tools = getattr(build_ctx.app_instance, "_registered_tools", None) + if global_tools: + return list(global_tools) + + return [] + + def _register_loaded_agent( result_agents: AgentDict, name: str, @@ -226,7 +247,7 @@ def _build_agents_as_tools_inputs( options = _build_agents_as_tools_options(agent_data) return AgentsAsToolsBuildInputs( config=config, - function_tools=_load_configured_function_tools(config, agent_data), + function_tools=_resolve_function_tools_with_globals(config, agent_data, build_ctx), child_agents=_resolve_child_agents( name, child_names, @@ -603,7 +624,7 @@ async def _create_basic_agent( child_message_files=inputs.child_message_files, ) else: - function_tools = _load_configured_function_tools(config, agent_data) + function_tools = _resolve_function_tools_with_globals(config, agent_data, build_ctx) agent = _create_agent_with_ui_if_needed( McpAgent, config, @@ -644,7 +665,7 @@ async def _create_smart_agent( child_message_files=inputs.child_message_files, ) else: - function_tools = _load_configured_function_tools(config, agent_data) + function_tools = _resolve_function_tools_with_globals(config, agent_data, build_ctx) from fast_agent.agents.smart_agent import SmartAgent, SmartAgentWithUI diff --git a/src/fast_agent/core/fastagent.py b/src/fast_agent/core/fastagent.py index 0d1749541..19808dfe0 100644 --- a/src/fast_agent/core/fastagent.py +++ b/src/fast_agent/core/fastagent.py @@ -67,6 +67,8 @@ from fast_agent.ui.usage_display import display_usage_report if TYPE_CHECKING: + from fastmcp.tools import FunctionTool + from fast_agent.config import MCPServerSettings from fast_agent.context import Context from fast_agent.core.agent_card_loader import LoadedAgentCard @@ -432,6 +434,8 @@ def __init__( # Dictionary to store agent configurations from decorators self.agents: dict[str, AgentCardData] = {} + # Global tool registry populated by @fast.tool decorator + self._registered_tools: list[FunctionTool] = [] # Tracking for AgentCard-loaded agents self._agent_card_sources: dict[str, Path] = {} self._agent_card_roots: dict[Path, set[str]] = {} @@ -1491,6 +1495,7 @@ async def _instantiate_agent_instance( app_override: AgentApp | None = None, ) -> AgentInstance: async with runtime.instance_lock: + self.app._registered_tools = self._registered_tools # type: ignore[attr-defined] agents_map = await create_agents_in_dependency_order( self.app, self.agents, diff --git a/test_tool_decorator.py b/test_tool_decorator.py new file mode 100644 index 000000000..2385d1d85 --- /dev/null +++ b/test_tool_decorator.py @@ -0,0 +1,94 @@ +""" +Quick test script for the @fast.tool decorator. + +Demonstrates: + 1. Bare @fast.tool — uses function name and docstring + 2. Parameterized @fast.tool(name=..., description=...) — custom name/description + 3. Global tools are automatically available to all agents + 4. Agents with explicit function_tools only see those tools (opt-out of globals) + +Uses the 'passthrough' model so no API keys are needed. +Run with: uv run test_tool_decorator.py +""" + +import asyncio + +from fast_agent import FastAgent + +fast = FastAgent("Tool Test", parse_cli_args=False, quiet=True) + + +# --- Global tools (available to all agents without explicit function_tools) --- + + +@fast.tool +def say_hello(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + +@fast.tool(name="add_numbers", description="Add two integers together") +def add(a: int, b: int) -> int: + return a + b + + +# --- A standalone function used as an explicit function_tool --- + + +def multiply(a: int, b: int) -> int: + """Multiply two numbers.""" + return a * b + + +# --- Agents --- + + +@fast.agent(name="assistant", model="passthrough", instruction="You are helpful.") +@fast.agent( + name="calculator", + model="passthrough", + instruction="You do math.", + function_tools=[multiply], +) +async def main(): + async with fast.run() as agent: + # --- Test 1: Global tools on "assistant" --- + print("=== assistant (global @fast.tool tools) ===") + tools = await agent.assistant.list_tools() + tool_names = [t.name for t in tools.tools] + print(" Available tools:", tool_names) + + assert "say_hello" in tool_names, "say_hello tool not found on assistant!" + assert "add_numbers" in tool_names, "add_numbers tool not found on assistant!" + + result = await agent.assistant.call_tool("say_hello", {"name": "World"}) + print(f" say_hello result: {result.content[0].text}") + assert result.content[0].text == "Hello, World!" + + result = await agent.assistant.call_tool("add_numbers", {"a": 3, "b": 7}) + print(f" add_numbers result: {result.content[0].text}") + assert result.content[0].text == "10" + + # --- Test 2: Explicit function_tools on "calculator" (no globals) --- + print("\n=== calculator (explicit function_tools only) ===") + calc_tools = await agent.calculator.list_tools() + calc_tool_names = [t.name for t in calc_tools.tools] + print(" Available tools:", calc_tool_names) + + assert "multiply" in calc_tool_names, "multiply tool not found on calculator!" + assert "say_hello" not in calc_tool_names, ( + "say_hello should NOT be on calculator (explicit function_tools overrides globals)!" + ) + assert "add_numbers" not in calc_tool_names, ( + "add_numbers should NOT be on calculator (explicit function_tools overrides globals)!" + ) + + result = await agent.calculator.call_tool("multiply", {"a": 4, "b": 5}) + print(f" multiply result: {result.content[0].text}") + assert result.content[0].text == "20" + + print("\nAll tests passed!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/unit/core/test_tool_decorator.py b/tests/unit/core/test_tool_decorator.py new file mode 100644 index 000000000..0dd524aa1 --- /dev/null +++ b/tests/unit/core/test_tool_decorator.py @@ -0,0 +1,142 @@ +"""Tests for the @fast.tool decorator on DecoratorMixin.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from fast_agent.core.direct_decorators import DecoratorMixin + +if TYPE_CHECKING: + from fastmcp.tools import FunctionTool + + +class _FakeFastAgent(DecoratorMixin): + """Minimal host providing the attributes DecoratorMixin expects.""" + + def __init__(self): + self.agents: dict = {} + self._registered_tools: list[FunctionTool] = [] + + +class TestToolDecoratorBare: + def test_bare_decorator_registers_tool(self): + fast = _FakeFastAgent() + + @fast.tool + def greet(name: str) -> str: + """Say hello.""" + return f"Hello, {name}!" + + assert len(fast._registered_tools) == 1 + assert fast._registered_tools[0].name == "greet" + + def test_bare_decorator_uses_docstring_as_description(self): + fast = _FakeFastAgent() + + @fast.tool + def ping() -> str: + """Check connectivity.""" + return "pong" + + assert fast._registered_tools[0].description == "Check connectivity." + + def test_bare_decorator_returns_original_function(self): + fast = _FakeFastAgent() + + @fast.tool + def add(a: int, b: int) -> int: + return a + b + + assert add(2, 3) == 5 + + +class TestToolDecoratorParameterized: + def test_custom_name(self): + fast = _FakeFastAgent() + + @fast.tool(name="sum_numbers") + def add(a: int, b: int) -> int: + return a + b + + assert fast._registered_tools[0].name == "sum_numbers" + + def test_custom_description(self): + fast = _FakeFastAgent() + + @fast.tool(description="Add two integers together") + def add(a: int, b: int) -> int: + return a + b + + assert fast._registered_tools[0].description == "Add two integers together" + + def test_custom_name_and_description(self): + fast = _FakeFastAgent() + + @fast.tool(name="my_add", description="Custom addition") + def add(a: int, b: int) -> int: + return a + b + + tool = fast._registered_tools[0] + assert tool.name == "my_add" + assert tool.description == "Custom addition" + + def test_parameterized_decorator_returns_original_function(self): + fast = _FakeFastAgent() + + @fast.tool(name="multiply") + def mul(a: int, b: int) -> int: + return a * b + + assert mul(3, 4) == 12 + + +class TestToolDecoratorMultiple: + def test_multiple_tools_registered(self): + fast = _FakeFastAgent() + + @fast.tool + def tool_a() -> str: + """First.""" + return "a" + + @fast.tool(name="tool_b") + def second() -> str: + return "b" + + assert len(fast._registered_tools) == 2 + names = [t.name for t in fast._registered_tools] + assert names == ["tool_a", "tool_b"] + + +class TestToolDecoratorExecution: + @pytest.mark.asyncio + async def test_registered_tool_can_run(self): + fast = _FakeFastAgent() + + @fast.tool + def double(x: int) -> int: + """Double a number.""" + return x * 2 + + tool = fast._registered_tools[0] + result = await tool.run({"x": 5}) + from fast_agent.mcp.helpers.content_helpers import get_text + + assert get_text(result.content[0]) == "10" + + @pytest.mark.asyncio + async def test_async_tool_can_run(self): + fast = _FakeFastAgent() + + @fast.tool + async def async_double(x: int) -> int: + """Async double.""" + return x * 2 + + tool = fast._registered_tools[0] + result = await tool.run({"x": 7}) + from fast_agent.mcp.helpers.content_helpers import get_text + + assert get_text(result.content[0]) == "14" From a4b1dff7e3ee42885b056efb142c9d54d405a505 Mon Sep 17 00:00:00 2001 From: danieldagot Date: Thu, 26 Mar 2026 12:40:29 +0200 Subject: [PATCH 2/9] remove root test script in favor of proper unit tests The demonstration script was committed to the repo root; the proper unit tests live in tests/unit/core/test_tool_decorator.py. Made-with: Cursor --- test_tool_decorator.py | 94 ------------------------------------------ 1 file changed, 94 deletions(-) delete mode 100644 test_tool_decorator.py diff --git a/test_tool_decorator.py b/test_tool_decorator.py deleted file mode 100644 index 2385d1d85..000000000 --- a/test_tool_decorator.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Quick test script for the @fast.tool decorator. - -Demonstrates: - 1. Bare @fast.tool — uses function name and docstring - 2. Parameterized @fast.tool(name=..., description=...) — custom name/description - 3. Global tools are automatically available to all agents - 4. Agents with explicit function_tools only see those tools (opt-out of globals) - -Uses the 'passthrough' model so no API keys are needed. -Run with: uv run test_tool_decorator.py -""" - -import asyncio - -from fast_agent import FastAgent - -fast = FastAgent("Tool Test", parse_cli_args=False, quiet=True) - - -# --- Global tools (available to all agents without explicit function_tools) --- - - -@fast.tool -def say_hello(name: str) -> str: - """Greet someone by name.""" - return f"Hello, {name}!" - - -@fast.tool(name="add_numbers", description="Add two integers together") -def add(a: int, b: int) -> int: - return a + b - - -# --- A standalone function used as an explicit function_tool --- - - -def multiply(a: int, b: int) -> int: - """Multiply two numbers.""" - return a * b - - -# --- Agents --- - - -@fast.agent(name="assistant", model="passthrough", instruction="You are helpful.") -@fast.agent( - name="calculator", - model="passthrough", - instruction="You do math.", - function_tools=[multiply], -) -async def main(): - async with fast.run() as agent: - # --- Test 1: Global tools on "assistant" --- - print("=== assistant (global @fast.tool tools) ===") - tools = await agent.assistant.list_tools() - tool_names = [t.name for t in tools.tools] - print(" Available tools:", tool_names) - - assert "say_hello" in tool_names, "say_hello tool not found on assistant!" - assert "add_numbers" in tool_names, "add_numbers tool not found on assistant!" - - result = await agent.assistant.call_tool("say_hello", {"name": "World"}) - print(f" say_hello result: {result.content[0].text}") - assert result.content[0].text == "Hello, World!" - - result = await agent.assistant.call_tool("add_numbers", {"a": 3, "b": 7}) - print(f" add_numbers result: {result.content[0].text}") - assert result.content[0].text == "10" - - # --- Test 2: Explicit function_tools on "calculator" (no globals) --- - print("\n=== calculator (explicit function_tools only) ===") - calc_tools = await agent.calculator.list_tools() - calc_tool_names = [t.name for t in calc_tools.tools] - print(" Available tools:", calc_tool_names) - - assert "multiply" in calc_tool_names, "multiply tool not found on calculator!" - assert "say_hello" not in calc_tool_names, ( - "say_hello should NOT be on calculator (explicit function_tools overrides globals)!" - ) - assert "add_numbers" not in calc_tool_names, ( - "add_numbers should NOT be on calculator (explicit function_tools overrides globals)!" - ) - - result = await agent.calculator.call_tool("multiply", {"a": 4, "b": 5}) - print(f" multiply result: {result.content[0].text}") - assert result.content[0].text == "20" - - print("\nAll tests passed!") - - -if __name__ == "__main__": - asyncio.run(main()) From fb5e99c4e146a247ffb8e409af2640b142bf16ab Mon Sep 17 00:00:00 2001 From: danieldagot Date: Thu, 26 Mar 2026 12:59:40 +0200 Subject: [PATCH 3/9] Add detailed documentation for the @fast.tool decorator in README.md This update includes examples of registering Python functions as tools using the @fast.tool decorator, highlighting both synchronous and asynchronous support. It also explains the global availability of tools and how to restrict them to specific agents using the function_tools parameter. --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 0828854a6..635b5c063 100644 --- a/README.md +++ b/README.md @@ -603,6 +603,36 @@ agent["greeter"].send("Good Evening!") # Dictionary access is supported ) ``` +### Function Tools (`@fast.tool`) + +Register Python functions as tools directly in code using the `@fast.tool` decorator. These tools are available to all agents by default, without needing an MCP server or external file. + +```python +@fast.tool +def get_weather(city: str) -> str: + """Return the current weather for a city.""" + return f"Sunny in {city}" + +@fast.tool(name="add", description="Add two numbers") +def add_numbers(a: int, b: int) -> int: + return a + b +``` + +Both sync and async functions are supported. The function name and docstring are used as the tool name and description by default, or you can override them with `name=` and `description=`. + +**Scoping:** `@fast.tool` tools are global by default. If an agent declares an explicit `function_tools=` list, only those tools are available to that agent: + +```python +def multiply(a: int, b: int) -> int: + return a * b + +@fast.agent(name="assistant", instruction="You are helpful.") +# assistant gets get_weather and add (global @fast.tool tools) + +@fast.agent(name="calc", instruction="Math only.", function_tools=[multiply]) +# calc only gets multiply (explicit list overrides globals) +``` + ### Multimodal Support Add Resources to prompts using either the inbuilt `prompt-server` or MCP Types directly. Convenience class are made available to do so simply, for example: From 211e548d7454990f8a654ada66e15f42a06bc7af Mon Sep 17 00:00:00 2001 From: danieldagot Date: Thu, 26 Mar 2026 15:02:48 +0200 Subject: [PATCH 4/9] Add examples for using @fast.tool decorator in README.md --- examples/function-tools/basic.py | 35 +++++++++++++++++++ examples/function-tools/scoping.py | 54 ++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 examples/function-tools/basic.py create mode 100644 examples/function-tools/scoping.py diff --git a/examples/function-tools/basic.py b/examples/function-tools/basic.py new file mode 100644 index 000000000..6478be773 --- /dev/null +++ b/examples/function-tools/basic.py @@ -0,0 +1,35 @@ +""" +Basic @fast.tool example. + +Register Python functions as tools using the @fast.tool decorator. +Tools are automatically available to all agents. + +Run with: uv run examples/function-tools/basic.py +""" + +import asyncio + +from fast_agent import FastAgent + +fast = FastAgent("Function Tools Example") + + +@fast.tool +def get_weather(city: str) -> str: + """Return the current weather for a city.""" + return f"Currently sunny and 22°C in {city}" + + +@fast.tool(name="add", description="Add two numbers together") +def add_numbers(a: int, b: int) -> int: + return a + b + + +@fast.agent(instruction="You are a helpful assistant with access to tools.") +async def main() -> None: + async with fast.run() as agent: + await agent.interactive() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/function-tools/scoping.py b/examples/function-tools/scoping.py new file mode 100644 index 000000000..2840ff0d3 --- /dev/null +++ b/examples/function-tools/scoping.py @@ -0,0 +1,54 @@ +""" +@fast.tool scoping example. + +Demonstrates how global @fast.tool tools are available to all agents, +but agents with an explicit function_tools= list only see those tools. + +Run with: uv run examples/function-tools/scoping.py +""" + +import asyncio + +from fast_agent import FastAgent + +fast = FastAgent("Tool Scoping Example") + + +# Global tools -- available to any agent without explicit function_tools +@fast.tool +def translate(text: str, language: str) -> str: + """Translate text to the given language.""" + return f"[{language}] {text}" + + +@fast.tool +def summarize(text: str) -> str: + """Produce a one-line summary.""" + return f"Summary: {text[:80]}..." + + +# A standalone function used as an explicit function_tool +def word_count(text: str) -> int: + """Count the number of words in text.""" + return len(text.split()) + + +@fast.agent( + name="writer", + instruction="You are a writing assistant with translation and summarization tools.", + default=True, +) +@fast.agent( + name="analyst", + instruction="You analyse text. You can only count words.", + function_tools=[word_count], +) +async def main() -> None: + async with fast.run() as agent: + # "writer" sees translate and summarize (global tools) + # "analyst" sees only word_count (explicit list overrides globals) + await agent.interactive() + + +if __name__ == "__main__": + asyncio.run(main()) From 0306f3b7b3a80bc47609a6d293b7ded3b4780451 Mon Sep 17 00:00:00 2001 From: danieldagot Date: Mon, 30 Mar 2026 13:44:59 +0300 Subject: [PATCH 5/9] Enhance documentation for agent-specific tools in README.md and examples. Introduce @agent.tool decorator for scoping tools to individual agents, clarifying the distinction between global and agent-specific tools. Update examples to reflect new functionality and improve clarity on tool registration and usage. --- README.md | 40 +++-- examples/function-tools/scoping.py | 46 ++--- src/fast_agent/core/direct_decorators.py | 42 +++++ src/fast_agent/core/direct_factory.py | 10 +- src/fast_agent/tools/function_tool_loader.py | 6 +- tests/unit/core/test_tool_decorator.py | 166 ++++++++++++++++++- 6 files changed, 265 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 635b5c063..af3e4b40b 100644 --- a/README.md +++ b/README.md @@ -603,36 +603,40 @@ agent["greeter"].send("Good Evening!") # Dictionary access is supported ) ``` -### Function Tools (`@fast.tool`) +### Function Tools -Register Python functions as tools directly in code using the `@fast.tool` decorator. These tools are available to all agents by default, without needing an MCP server or external file. +Register Python functions as tools directly in code — no MCP server or external file needed. Both sync and async functions are supported. The function name and docstring are used as the tool name and description by default, or you can override them with `name=` and `description=`. + +**Per-agent tools (`@agent.tool`)** — scope a tool to a specific agent: ```python -@fast.tool -def get_weather(city: str) -> str: - """Return the current weather for a city.""" - return f"Sunny in {city}" +@fast.agent(name="writer", instruction="You write things.") +async def writer(): ... -@fast.tool(name="add", description="Add two numbers") -def add_numbers(a: int, b: int) -> int: - return a + b -``` +@writer.tool +def translate(text: str, language: str) -> str: + """Translate text to the given language.""" + return f"[{language}] {text}" -Both sync and async functions are supported. The function name and docstring are used as the tool name and description by default, or you can override them with `name=` and `description=`. +@writer.tool(name="summarize", description="Produce a one-line summary") +def summarize(text: str) -> str: + return f"Summary: {text[:80]}..." +``` -**Scoping:** `@fast.tool` tools are global by default. If an agent declares an explicit `function_tools=` list, only those tools are available to that agent: +**Global tools (`@fast.tool`)** — available to all agents that don't declare their own tools: ```python -def multiply(a: int, b: int) -> int: - return a * b +@fast.tool +def get_weather(city: str) -> str: + """Return the current weather for a city.""" + return f"Sunny in {city}" @fast.agent(name="assistant", instruction="You are helpful.") -# assistant gets get_weather and add (global @fast.tool tools) - -@fast.agent(name="calc", instruction="Math only.", function_tools=[multiply]) -# calc only gets multiply (explicit list overrides globals) +# assistant gets get_weather (global @fast.tool) ``` +Agents with `@agent.tool` or `function_tools=` only see their own tools — globals are not injected. Use `function_tools=[]` to explicitly opt out of globals with no tools. + ### Multimodal Support Add Resources to prompts using either the inbuilt `prompt-server` or MCP Types directly. Convenience class are made available to do so simply, for example: diff --git a/examples/function-tools/scoping.py b/examples/function-tools/scoping.py index 2840ff0d3..64d77407d 100644 --- a/examples/function-tools/scoping.py +++ b/examples/function-tools/scoping.py @@ -1,8 +1,8 @@ """ -@fast.tool scoping example. +@agent.tool scoping example. -Demonstrates how global @fast.tool tools are available to all agents, -but agents with an explicit function_tools= list only see those tools. +Demonstrates how tools can be scoped to individual agents using +@agent_func.tool, and how @fast.tool broadcasts globally. Run with: uv run examples/function-tools/scoping.py """ @@ -14,39 +14,45 @@ fast = FastAgent("Tool Scoping Example") -# Global tools -- available to any agent without explicit function_tools -@fast.tool +@fast.agent( + name="writer", + instruction="You are a writing assistant with translation and summarization tools.", + default=True, +) +async def writer() -> None: + pass + + +@fast.agent( + name="analyst", + instruction="You analyse text. You can only count words.", +) +async def analyst() -> None: + pass + + +@writer.tool def translate(text: str, language: str) -> str: """Translate text to the given language.""" return f"[{language}] {text}" -@fast.tool +@writer.tool def summarize(text: str) -> str: """Produce a one-line summary.""" return f"Summary: {text[:80]}..." -# A standalone function used as an explicit function_tool -def word_count(text: str) -> int: +@analyst.tool(name="word_count", description="Count words in text") +def count_words(text: str) -> int: """Count the number of words in text.""" return len(text.split()) -@fast.agent( - name="writer", - instruction="You are a writing assistant with translation and summarization tools.", - default=True, -) -@fast.agent( - name="analyst", - instruction="You analyse text. You can only count words.", - function_tools=[word_count], -) async def main() -> None: async with fast.run() as agent: - # "writer" sees translate and summarize (global tools) - # "analyst" sees only word_count (explicit list overrides globals) + # "writer" sees translate and summarize (its own @writer.tool tools) + # "analyst" sees only word_count (its own @analyst.tool tool) await agent.interactive() diff --git a/src/fast_agent/core/direct_decorators.py b/src/fast_agent/core/direct_decorators.py index d5348f735..8e34a5750 100644 --- a/src/fast_agent/core/direct_decorators.py +++ b/src/fast_agent/core/direct_decorators.py @@ -268,6 +268,48 @@ def decorator(func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, Coroutin for key, value in extra_kwargs.items(): setattr(func, f"_{key}", value) + @overload + def _agent_tool(fn: Callable[..., Any], /) -> Callable[..., Any]: ... + + @overload + def _agent_tool( + *, name: str | None = None, description: str | None = None + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... + + def _agent_tool( + fn: Callable[..., Any] | None = None, + /, + *, + name: str | None = None, + description: str | None = None, + ) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]: + """Register a tool scoped to this agent. + + Supports bare and parameterized usage:: + + @my_agent.tool + def helper() -> str: ... + + @my_agent.tool(name="add", description="Add two numbers") + def add(a: int, b: int) -> int: ... + """ + + def _register(f: Callable[..., Any]) -> Callable[..., Any]: + if name is not None: + f._fast_tool_name = name # type: ignore[attr-defined] + if description is not None: + f._fast_tool_description = description # type: ignore[attr-defined] + if config.function_tools is None: + config.function_tools = [] + config.function_tools.append(f) + return f + + if fn is not None: + return _register(fn) + return _register + + func.tool = _agent_tool # type: ignore[attr-defined] + return func return decorator diff --git a/src/fast_agent/core/direct_factory.py b/src/fast_agent/core/direct_factory.py index f214338dd..233328338 100644 --- a/src/fast_agent/core/direct_factory.py +++ b/src/fast_agent/core/direct_factory.py @@ -128,12 +128,12 @@ def _resolve_function_tools_with_globals( ) -> list[FunctionTool]: """Load per-agent function tools, falling back to global @fast.tool tools. - If the agent has explicit function_tools configured, only those are used. - Otherwise, globally registered tools from ``@fast.tool`` are provided. + If the agent has explicit function_tools configured (including an empty list), + only those are used. Otherwise, globally registered tools from ``@fast.tool`` + are provided. """ - explicit_tools = _load_configured_function_tools(config, agent_data) - if explicit_tools: - return explicit_tools + if config.function_tools is not None or agent_data.get("function_tools") is not None: + return _load_configured_function_tools(config, agent_data) global_tools = getattr(build_ctx.app_instance, "_registered_tools", None) if global_tools: diff --git a/src/fast_agent/tools/function_tool_loader.py b/src/fast_agent/tools/function_tool_loader.py index 29906b74a..9b62a4556 100644 --- a/src/fast_agent/tools/function_tool_loader.py +++ b/src/fast_agent/tools/function_tool_loader.py @@ -167,7 +167,11 @@ def load_function_tools( for tool_spec in tools_config: try: if callable(tool_spec): - result.append(build_default_function_tool(tool_spec)) + tool_name = getattr(tool_spec, "_fast_tool_name", None) + tool_desc = getattr(tool_spec, "_fast_tool_description", None) + result.append( + build_default_function_tool(tool_spec, name=tool_name, description=tool_desc) + ) elif isinstance(tool_spec, str): result.append( build_default_function_tool(load_function_from_spec(tool_spec, base_path)) diff --git a/tests/unit/core/test_tool_decorator.py b/tests/unit/core/test_tool_decorator.py index 0dd524aa1..e5e60c594 100644 --- a/tests/unit/core/test_tool_decorator.py +++ b/tests/unit/core/test_tool_decorator.py @@ -1,4 +1,4 @@ -"""Tests for the @fast.tool decorator on DecoratorMixin.""" +"""Tests for the @fast.tool and @agent.tool decorators.""" from __future__ import annotations @@ -20,6 +20,19 @@ def __init__(self): self._registered_tools: list[FunctionTool] = [] +# --------------------------------------------------------------------------- +# Helper to create an agent-decorated function via _FakeFastAgent +# --------------------------------------------------------------------------- +def _make_agent(fast: _FakeFastAgent, name: str = "test_agent"): + """Return the decorated async function from ``@fast.agent(name=...)``.""" + + @fast.agent(name=name, instruction="test") + async def _agent_fn(): + pass + + return _agent_fn + + class TestToolDecoratorBare: def test_bare_decorator_registers_tool(self): fast = _FakeFastAgent() @@ -140,3 +153,154 @@ async def async_double(x: int) -> int: from fast_agent.mcp.helpers.content_helpers import get_text assert get_text(result.content[0]) == "14" + + +# =================================================================== +# @agent.tool — per-agent scoped tool decorator +# =================================================================== + + +class TestAgentToolBare: + def test_bare_agent_tool_appends_to_config(self): + fast = _FakeFastAgent() + writer = _make_agent(fast, "writer") + + @writer.tool + def helper() -> str: + """Help.""" + return "ok" + + config = fast.agents["writer"]["config"] + assert config.function_tools is not None + assert helper in config.function_tools + + def test_bare_agent_tool_returns_original_function(self): + fast = _FakeFastAgent() + writer = _make_agent(fast, "writer") + + @writer.tool + def add(a: int, b: int) -> int: + return a + b + + assert add(1, 2) == 3 + + def test_bare_agent_tool_does_not_register_globally(self): + fast = _FakeFastAgent() + writer = _make_agent(fast, "writer") + + @writer.tool + def local_tool() -> str: + return "local" + + assert len(fast._registered_tools) == 0 + + +class TestAgentToolParameterized: + def test_custom_name_stored_on_function(self): + fast = _FakeFastAgent() + writer = _make_agent(fast, "writer") + + @writer.tool(name="custom_name") + def helper() -> str: + return "ok" + + assert helper._fast_tool_name == "custom_name" + + def test_custom_description_stored_on_function(self): + fast = _FakeFastAgent() + writer = _make_agent(fast, "writer") + + @writer.tool(description="A custom description") + def helper() -> str: + return "ok" + + assert helper._fast_tool_description == "A custom description" + + def test_parameterized_agent_tool_returns_original_function(self): + fast = _FakeFastAgent() + writer = _make_agent(fast, "writer") + + @writer.tool(name="mul") + def multiply(a: int, b: int) -> int: + return a * b + + assert multiply(3, 4) == 12 + + +class TestAgentToolScoping: + def test_tool_only_on_target_agent(self): + fast = _FakeFastAgent() + writer = _make_agent(fast, "writer") + _make_agent(fast, "analyst") + + @writer.tool + def writer_helper() -> str: + return "w" + + writer_config = fast.agents["writer"]["config"] + analyst_config = fast.agents["analyst"]["config"] + + assert writer_config.function_tools is not None + assert writer_helper in writer_config.function_tools + assert analyst_config.function_tools is None + + def test_multiple_agent_tools_accumulate(self): + fast = _FakeFastAgent() + writer = _make_agent(fast, "writer") + + @writer.tool + def tool_a() -> str: + return "a" + + @writer.tool + def tool_b() -> str: + return "b" + + config = fast.agents["writer"]["config"] + assert len(config.function_tools) == 2 + + +class TestEmptyFunctionToolsOptOut: + """function_tools=[] should opt out of global tools.""" + + def test_empty_list_keeps_config_not_none(self): + fast = _FakeFastAgent() + + @fast.agent(name="isolated", instruction="test", function_tools=[]) + async def isolated(): + pass + + config = fast.agents["isolated"]["config"] + assert config.function_tools is not None + assert config.function_tools == [] + + +class TestAgentToolMetadataPassthrough: + """Custom name/description set via @agent.tool are picked up by load_function_tools.""" + + def test_metadata_passthrough(self): + from fast_agent.tools.function_tool_loader import load_function_tools + + def raw_fn(x: int) -> int: + """Original doc.""" + return x + + raw_fn._fast_tool_name = "custom" # type: ignore[attr-defined] + raw_fn._fast_tool_description = "Custom desc" # type: ignore[attr-defined] + + tools = load_function_tools([raw_fn]) + assert len(tools) == 1 + assert tools[0].name == "custom" + assert tools[0].description == "Custom desc" + + def test_no_metadata_uses_defaults(self): + from fast_agent.tools.function_tool_loader import load_function_tools + + def plain_fn(x: int) -> int: + """Plain doc.""" + return x + + tools = load_function_tools([plain_fn]) + assert len(tools) == 1 + assert tools[0].name == "plain_fn" + assert tools[0].description == "Plain doc." From 573df2c3254b5924004fd06a2472a26f2693dd9d Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:20:50 +0100 Subject: [PATCH 6/9] small tidy-ups for type safety, custom agent decorator handling --- src/fast_agent/agents/agent_types.py | 8 +- src/fast_agent/core/direct_decorators.py | 193 ++++++++++++------ src/fast_agent/core/direct_factory.py | 18 ++ src/fast_agent/core/function_tool_support.py | 46 +++++ .../function_tools/test_function_tools.py | 56 +++++ tests/unit/core/test_tool_decorator.py | 82 ++++++++ 6 files changed, 335 insertions(+), 68 deletions(-) create mode 100644 src/fast_agent/core/function_tool_support.py diff --git a/src/fast_agent/agents/agent_types.py b/src/fast_agent/agents/agent_types.py index 53f9b89dd..38f011d2e 100644 --- a/src/fast_agent/agents/agent_types.py +++ b/src/fast_agent/agents/agent_types.py @@ -76,9 +76,9 @@ class AgentConfig: description: str | None = None tool_input_schema: dict[str, Any] | None = None servers: list[str] = field(default_factory=list) - tools: dict[str, list[str]] = field(default_factory=dict) # filters for tools - resources: dict[str, list[str]] = field(default_factory=dict) # filters for resources - prompts: dict[str, list[str]] = field(default_factory=dict) # filters for prompts + tools: dict[str, list[str]] = field(default_factory=dict) # MCP tool filters by server + resources: dict[str, list[str]] = field(default_factory=dict) # MCP resource filters by server + prompts: dict[str, list[str]] = field(default_factory=dict) # MCP prompt filters by server skills: SkillConfig = SKILLS_DEFAULT skill_manifests: list[SkillManifest] = field(default_factory=list, repr=False) model: str | None = None @@ -90,7 +90,7 @@ class AgentConfig: tool_only: bool = False elicitation_handler: ElicitationFnT | None = None api_key: str | None = None - function_tools: FunctionToolsConfig = None + function_tools: FunctionToolsConfig = None # Local Python function tools shell: bool = False cwd: Path | None = None tool_hooks: ToolHooksConfig = None diff --git a/src/fast_agent/core/direct_decorators.py b/src/fast_agent/core/direct_decorators.py index 8e34a5750..8ed788fdb 100644 --- a/src/fast_agent/core/direct_decorators.py +++ b/src/fast_agent/core/direct_decorators.py @@ -9,12 +9,12 @@ from typing import ( TYPE_CHECKING, Any, - Awaitable, Callable, Literal, ParamSpec, Protocol, TypeVar, + cast, overload, ) @@ -35,6 +35,11 @@ ROUTING_SYSTEM_INSTRUCTION, ) from fast_agent.constants import DEFAULT_AGENT_INSTRUCTION, SMART_AGENT_INSTRUCTION +from fast_agent.core.exceptions import AgentConfigError +from fast_agent.core.function_tool_support import ( + custom_class_supports_function_tools, + decorator_supports_scoped_function_tools, +) from fast_agent.core.template_escape import protect_escaped_braces, restore_escaped_braces from fast_agent.skills import SKILLS_DEFAULT from fast_agent.types import RequestParams @@ -44,6 +49,21 @@ R = TypeVar("R", covariant=True) # Return type +class ScopedToolDecoratorProtocol(Protocol): + """Protocol for per-agent ``.tool`` decorators.""" + + @overload + def __call__(self, fn: Callable[..., Any], /) -> Callable[..., Any]: ... + + @overload + def __call__( + self, + *, + name: str | None = None, + description: str | None = None, + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... + + # Protocol for decorated agent functions class DecoratedAgentProtocol(Protocol[P, R]): """Protocol defining the interface of a decorated agent function.""" @@ -51,7 +71,13 @@ class DecoratedAgentProtocol(Protocol[P, R]): _agent_type: AgentType _agent_config: AgentConfig - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Awaitable[R]: ... + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, R]: ... + + +class DecoratedToolCapableAgentProtocol(DecoratedAgentProtocol[P, R], Protocol): + """Protocol for decorated agent functions that expose ``.tool``.""" + + tool: ScopedToolDecoratorProtocol # Protocol for orchestrator functions @@ -226,6 +252,18 @@ def _decorator_impl( """ def decorator(func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, Coroutine[Any, Any, R]]: + if agent_type == AgentType.CUSTOM: + custom_cls = extra_kwargs.get("agent_class") or extra_kwargs.get("cls") + if ( + extra_kwargs.get("function_tools") is not None + and not custom_class_supports_function_tools(custom_cls) + ): + raise AgentConfigError( + "Custom agent does not accept function tools", + f"Custom agent '{name}' cannot use function_tools because " + f"{getattr(custom_cls, '__name__', custom_cls)!r} does not accept tools=.", + ) + # Create agent configuration config = AgentConfig( name=name, @@ -308,7 +346,11 @@ def _register(f: Callable[..., Any]) -> Callable[..., Any]: return _register(fn) return _register - func.tool = _agent_tool # type: ignore[attr-defined] + if decorator_supports_scoped_function_tools( + agent_type, + custom_cls=extra_kwargs.get("agent_class") or extra_kwargs.get("cls"), + ): + func.tool = _agent_tool # type: ignore[attr-defined] return func @@ -360,8 +402,9 @@ def greet(name: str) -> str: ... @fast.tool(name="add", description="Add two numbers") def add_numbers(a: int, b: int) -> int: ... - Tools registered this way are available to all agents that do not - declare an explicit ``function_tools`` list. + Tools registered this way are local Python function tools. They are + available to agents that support ``function_tools`` and do not declare + an explicit ``function_tools`` list. """ from fast_agent.tools.function_tool_loader import build_default_function_tool @@ -399,7 +442,10 @@ def agent( max_parallel: int | None = None, child_timeout_sec: int | None = None, max_display_instances: int | None = None, - ) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]: + ) -> Callable[ + [Callable[P, Coroutine[Any, Any, R]]], + DecoratedToolCapableAgentProtocol[P, R], + ]: """ Decorator to create and register a standard agent with type-safe signature. @@ -408,10 +454,10 @@ def agent( instruction_or_kwarg: Optional positional parameter for instruction instruction: Base instruction for the agent (keyword arg) servers: List of server names the agent should connect to - tools: Optional list of tool names or patterns to include - resources: Optional list of resource names or patterns to include - prompts: Optional list of prompt names or patterns to include - function_tools: Optional list of Python function tools to include + tools: Optional MCP tool filters by server name + resources: Optional MCP resource filters by server name + prompts: Optional MCP prompt filters by server name + function_tools: Optional local Python function tools to include model: Model specification string use_history: Whether to maintain conversation history request_params: Additional request parameters for the LLM @@ -428,32 +474,35 @@ def agent( ) final_instruction = _resolve_instruction(final_instruction_raw) - return _decorator_impl( - self, - AgentType.BASIC, - name=name, - instruction=final_instruction, - child_agents=agents, - servers=servers, - model=model, - use_history=use_history, - request_params=request_params, - human_input=human_input, - default=default, - elicitation_handler=elicitation_handler, - tools=tools, - resources=resources, - prompts=prompts, - skills=skills, - function_tools=function_tools, - api_key=api_key, - agents_as_tools_options={ - "history_source": history_source, - "history_merge_target": history_merge_target, - "max_parallel": max_parallel, - "child_timeout_sec": child_timeout_sec, - "max_display_instances": max_display_instances, - }, + return cast( + "Any", + _decorator_impl( + self, + AgentType.BASIC, + name=name, + instruction=final_instruction, + child_agents=agents, + servers=servers, + model=model, + use_history=use_history, + request_params=request_params, + human_input=human_input, + default=default, + elicitation_handler=elicitation_handler, + tools=tools, + resources=resources, + prompts=prompts, + skills=skills, + function_tools=function_tools, + api_key=api_key, + agents_as_tools_options={ + "history_source": history_source, + "history_merge_target": history_merge_target, + "max_parallel": max_parallel, + "child_timeout_sec": child_timeout_sec, + "max_display_instances": max_display_instances, + }, + ), ) def smart( @@ -481,39 +530,49 @@ def smart( max_parallel: int | None = None, child_timeout_sec: int | None = None, max_display_instances: int | None = None, - ) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]: - """Decorator to create and register a smart agent.""" + ) -> Callable[ + [Callable[P, Coroutine[Any, Any, R]]], + DecoratedToolCapableAgentProtocol[P, R], + ]: + """Decorator to create and register a smart agent. + + ``tools`` / ``resources`` / ``prompts`` filter MCP-discovered capabilities. + ``function_tools`` adds local Python tools exposed directly by the agent. + """ final_instruction_raw = ( instruction_or_kwarg if instruction_or_kwarg is not None else instruction ) final_instruction = _resolve_instruction(final_instruction_raw) - return _decorator_impl( - self, - AgentType.SMART, - name=name, - instruction=final_instruction, - child_agents=agents, - servers=servers, - model=model, - use_history=use_history, - request_params=request_params, - human_input=human_input, - default=default, - elicitation_handler=elicitation_handler, - tools=tools, - resources=resources, - prompts=prompts, - skills=skills, - function_tools=function_tools, - api_key=api_key, - agents_as_tools_options={ - "history_source": history_source, - "history_merge_target": history_merge_target, - "max_parallel": max_parallel, - "child_timeout_sec": child_timeout_sec, - "max_display_instances": max_display_instances, - }, + return cast( + "Any", + _decorator_impl( + self, + AgentType.SMART, + name=name, + instruction=final_instruction, + child_agents=agents, + servers=servers, + model=model, + use_history=use_history, + request_params=request_params, + human_input=human_input, + default=default, + elicitation_handler=elicitation_handler, + tools=tools, + resources=resources, + prompts=prompts, + skills=skills, + function_tools=function_tools, + api_key=api_key, + agents_as_tools_options={ + "history_source": history_source, + "history_merge_target": history_merge_target, + "max_parallel": max_parallel, + "child_timeout_sec": child_timeout_sec, + "max_display_instances": max_display_instances, + }, + ), ) @@ -530,6 +589,7 @@ def custom( resources: dict[str, list[str]] | None = None, prompts: dict[str, list[str]] | None = None, skills: SkillConfig = SKILLS_DEFAULT, + function_tools: FunctionToolsConfig = None, model: str | None = None, use_history: bool = True, request_params: RequestParams | None = None, @@ -546,11 +606,15 @@ def custom( instruction_or_kwarg: Optional positional parameter for instruction instruction: Base instruction for the agent (keyword arg) servers: List of server names the agent should connect to + tools: Optional MCP tool filters by server name + resources: Optional MCP resource filters by server name + prompts: Optional MCP prompt filters by server name model: Model specification string use_history: Whether to maintain conversation history request_params: Additional request parameters for the LLM human_input: Whether to enable human input capabilities elicitation_handler: Custom elicitation handler function (ElicitationFnT) + function_tools: Optional local Python function tools to include Returns: A decorator that registers the agent with proper type annotations @@ -579,6 +643,7 @@ def custom( resources=resources, prompts=prompts, skills=skills, + function_tools=function_tools, ) diff --git a/src/fast_agent/core/direct_factory.py b/src/fast_agent/core/direct_factory.py index 233328338..85a46674e 100644 --- a/src/fast_agent/core/direct_factory.py +++ b/src/fast_agent/core/direct_factory.py @@ -24,6 +24,7 @@ from fast_agent.core import Core from fast_agent.core.agent_card_types import AgentCardData from fast_agent.core.exceptions import AgentConfigError, ModelConfigError +from fast_agent.core.function_tool_support import custom_class_supports_function_tools from fast_agent.core.logging.logger import get_logger from fast_agent.core.model_resolution import ( HARDCODED_DEFAULT_MODEL, @@ -711,10 +712,27 @@ async def _create_custom_agent( f"Custom agent '{name}' missing class reference ('agent_class' or 'cls')" ) + explicit_function_tools = ( + config.function_tools is not None or agent_data.get("function_tools") is not None + ) + function_tools = _resolve_function_tools_with_globals(config, agent_data, build_ctx) + custom_supports_function_tools = custom_class_supports_function_tools(cls) + if function_tools and explicit_function_tools and not custom_supports_function_tools: + raise AgentConfigError( + "Custom agent does not accept function tools", + f"Custom agent '{name}' cannot use function_tools because " + f"{getattr(cls, '__name__', cls)!r} does not accept tools=.", + ) + + create_kwargs: dict[str, Any] = {} + if function_tools and custom_supports_function_tools: + create_kwargs["tools"] = function_tools + agent = _create_agent_with_ui_if_needed( cls, config, build_ctx.app_instance.context, + **create_kwargs, ) await _initialize_agent_with_llm(agent, config, build_ctx.model_factory_func) diff --git a/src/fast_agent/core/function_tool_support.py b/src/fast_agent/core/function_tool_support.py new file mode 100644 index 000000000..79c6a0bd0 --- /dev/null +++ b/src/fast_agent/core/function_tool_support.py @@ -0,0 +1,46 @@ +"""Helpers for determining which agents support local function tools.""" + +from __future__ import annotations + +import inspect +from typing import Any + +from fast_agent.agents.agent_types import AgentType + + +def _callable_accepts_keyword_arg(callable_obj: Any, arg_name: str) -> bool: + """Return whether a callable accepts a named keyword argument.""" + if callable_obj is None: + return False + + try: + signature = inspect.signature(callable_obj) + except (TypeError, ValueError): + return False + + if arg_name in signature.parameters: + return True + + return any( + parameter.kind == inspect.Parameter.VAR_KEYWORD + for parameter in signature.parameters.values() + ) + + +def custom_class_supports_function_tools(cls: Any) -> bool: + """Return whether a custom agent class accepts ``tools=`` during construction.""" + init = getattr(cls, "__init__", None) + return _callable_accepts_keyword_arg(init, "tools") + + +def decorator_supports_scoped_function_tools( + agent_type: AgentType, + *, + custom_cls: Any = None, +) -> bool: + """Return whether a decorated agent function should expose ``.tool``.""" + if agent_type in {AgentType.BASIC, AgentType.SMART}: + return True + if agent_type == AgentType.CUSTOM: + return custom_class_supports_function_tools(custom_cls) + return False diff --git a/tests/integration/function_tools/test_function_tools.py b/tests/integration/function_tools/test_function_tools.py index 704f3b865..3ac900bed 100644 --- a/tests/integration/function_tools/test_function_tools.py +++ b/tests/integration/function_tools/test_function_tools.py @@ -1,5 +1,7 @@ import pytest +from fast_agent.agents import McpAgent + @pytest.mark.integration @pytest.mark.asyncio @@ -50,3 +52,57 @@ async def calc_decorator(): assert summarize_result.structuredContent is None assert summarize_result.content is not None assert summarize_result.content[0].text == '{"status":"ok"}' + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_function_tools_from_custom_decorator(fast_agent): + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + @fast_agent.custom( + McpAgent, + name="custom_calc_decorator", + model="passthrough", + function_tools=[add], + ) + async def custom_calc_decorator(): + return None + + async with fast_agent.run() as agent: + tools = await agent.custom_calc_decorator.list_tools() + assert any(t.name == "add" for t in tools.tools) + + add_result = await agent.custom_calc_decorator.call_tool("add", {"a": 6, "b": 7}) + assert add_result.isError is False + assert add_result.structuredContent is None + assert add_result.content is not None + assert add_result.content[0].text == "13" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_global_function_tools_are_available_to_supported_custom_agents(fast_agent): + @fast_agent.tool + def ping() -> str: + """Return a marker string.""" + return "pong" + + @fast_agent.custom( + McpAgent, + name="custom_global_tools", + model="passthrough", + ) + async def custom_global_tools(): + return None + + async with fast_agent.run() as agent: + tools = await agent.custom_global_tools.list_tools() + assert any(t.name == "ping" for t in tools.tools) + + ping_result = await agent.custom_global_tools.call_tool("ping", {}) + assert ping_result.isError is False + assert ping_result.structuredContent is None + assert ping_result.content is not None + assert ping_result.content[0].text == "pong" diff --git a/tests/unit/core/test_tool_decorator.py b/tests/unit/core/test_tool_decorator.py index e5e60c594..962dc02aa 100644 --- a/tests/unit/core/test_tool_decorator.py +++ b/tests/unit/core/test_tool_decorator.py @@ -7,6 +7,7 @@ import pytest from fast_agent.core.direct_decorators import DecoratorMixin +from fast_agent.core.exceptions import AgentConfigError if TYPE_CHECKING: from fastmcp.tools import FunctionTool @@ -33,6 +34,16 @@ async def _agent_fn(): return _agent_fn +def _make_custom(fast: _FakeFastAgent, cls, name: str = "custom_agent"): + """Return the decorated async function from ``@fast.custom(...)``.""" + + @fast.custom(cls, name=name, instruction="test") + async def _agent_fn(): + pass + + return _agent_fn + + class TestToolDecoratorBare: def test_bare_decorator_registers_tool(self): fast = _FakeFastAgent() @@ -304,3 +315,74 @@ def plain_fn(x: int) -> int: assert len(tools) == 1 assert tools[0].name == "plain_fn" assert tools[0].description == "Plain doc." + + +class TestAgentToolExposure: + def test_supported_custom_agent_exposes_tool(self): + class SupportedCustomAgent: + def __init__(self, config, context=None, tools=()): + self.config = config + self.context = context + self.tools = tools + + fast = _FakeFastAgent() + custom = _make_custom(fast, SupportedCustomAgent, "supported_custom") + + @custom.tool + def helper() -> str: + return "ok" + + config = fast.agents["supported_custom"]["config"] + assert config.function_tools is not None + assert helper in config.function_tools + + def test_unsupported_custom_agent_does_not_expose_tool(self): + class UnsupportedCustomAgent: + def __init__(self, config, context=None): + self.config = config + self.context = context + + fast = _FakeFastAgent() + custom = _make_custom(fast, UnsupportedCustomAgent, "unsupported_custom") + + assert "tool" not in vars(custom) + + def test_router_does_not_expose_tool(self): + fast = _FakeFastAgent() + + @fast.router(name="router", agents=["writer"], instruction="route") + async def router(): + pass + + assert "tool" not in vars(router) + + def test_parallel_does_not_expose_tool(self): + fast = _FakeFastAgent() + + @fast.parallel(name="parallel", fan_out=["writer"], fan_in="aggregator") + async def parallel(): + pass + + assert "tool" not in vars(parallel) + + def test_unsupported_custom_agent_rejects_explicit_function_tools(self): + class UnsupportedCustomAgent: + def __init__(self, config, context=None): + self.config = config + self.context = context + + fast = _FakeFastAgent() + + def helper() -> str: + return "ok" + + with pytest.raises(AgentConfigError, match="does not accept function tools"): + + @fast.custom( + UnsupportedCustomAgent, + name="bad_custom", + instruction="test", + function_tools=[helper], + ) + async def bad_custom(): + pass From e0b7befcd1f7bb801ef08b3a5f8e1553f305937d Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:40:26 +0100 Subject: [PATCH 7/9] stop shared function overwrites --- src/fast_agent/agents/agent_types.py | 11 ++++- src/fast_agent/core/direct_decorators.py | 16 +++++--- src/fast_agent/core/direct_factory.py | 6 +-- src/fast_agent/tools/function_tool_loader.py | 13 +++++- tests/unit/core/test_tool_decorator.py | 40 +++++++++++++++++-- .../tools/test_function_tool_loader.py | 24 +++++++++++ 6 files changed, 95 insertions(+), 15 deletions(-) diff --git a/src/fast_agent/agents/agent_types.py b/src/fast_agent/agents/agent_types.py index 38f011d2e..8b4a61b6f 100644 --- a/src/fast_agent/agents/agent_types.py +++ b/src/fast_agent/agents/agent_types.py @@ -46,7 +46,16 @@ class AgentType(StrEnum): # Function tools can be: # - A callable (Python function) # - A string spec like "module.py:function_name" (for dynamic loading) -FunctionToolConfig: TypeAlias = Callable[..., Any] | str +@dataclass(frozen=True) +class ScopedFunctionToolConfig: + """A single local Python tool registration with scoped metadata.""" + + function: Callable[..., Any] + name: str | None = None + description: str | None = None + + +FunctionToolConfig: TypeAlias = Callable[..., Any] | str | ScopedFunctionToolConfig FunctionToolsConfig: TypeAlias = list[FunctionToolConfig] | None diff --git a/src/fast_agent/core/direct_decorators.py b/src/fast_agent/core/direct_decorators.py index 8ed788fdb..0e5554a84 100644 --- a/src/fast_agent/core/direct_decorators.py +++ b/src/fast_agent/core/direct_decorators.py @@ -28,6 +28,7 @@ AgentConfig, AgentType, FunctionToolsConfig, + ScopedFunctionToolConfig, SkillConfig, ) from fast_agent.agents.workflow.iterative_planner import ITERATIVE_PLAN_SYSTEM_PROMPT_TEMPLATE @@ -333,13 +334,18 @@ def add(a: int, b: int) -> int: ... """ def _register(f: Callable[..., Any]) -> Callable[..., Any]: - if name is not None: - f._fast_tool_name = name # type: ignore[attr-defined] - if description is not None: - f._fast_tool_description = description # type: ignore[attr-defined] if config.function_tools is None: config.function_tools = [] - config.function_tools.append(f) + if name is not None or description is not None: + config.function_tools.append( + ScopedFunctionToolConfig( + function=f, + name=name, + description=description, + ) + ) + else: + config.function_tools.append(f) return f if fn is not None: diff --git a/src/fast_agent/core/direct_factory.py b/src/fast_agent/core/direct_factory.py index 85a46674e..8a4530dec 100644 --- a/src/fast_agent/core/direct_factory.py +++ b/src/fast_agent/core/direct_factory.py @@ -11,7 +11,7 @@ from fastmcp.tools import FunctionTool from fast_agent.agents import McpAgent -from fast_agent.agents.agent_types import AgentConfig, AgentType +from fast_agent.agents.agent_types import AgentConfig, AgentType, FunctionToolConfig from fast_agent.agents.llm_agent import LlmAgent from fast_agent.agents.workflow.evaluator_optimizer import ( EvaluatorOptimizerAgent, @@ -108,11 +108,11 @@ def _load_configured_function_tools( if tools_config_raw is None: tools_config_raw = agent_data.get("function_tools") - tools_config: list[Callable[..., Any] | str] | None = None + tools_config: list[FunctionToolConfig] | None = None if isinstance(tools_config_raw, str): tools_config = [tools_config_raw] elif isinstance(tools_config_raw, list): - tools_config = cast("list[Callable[..., Any] | str]", tools_config_raw) + tools_config = cast("list[FunctionToolConfig]", tools_config_raw) if not tools_config: return [] diff --git a/src/fast_agent/tools/function_tool_loader.py b/src/fast_agent/tools/function_tool_loader.py index 9b62a4556..883ec0160 100644 --- a/src/fast_agent/tools/function_tool_loader.py +++ b/src/fast_agent/tools/function_tool_loader.py @@ -14,6 +14,7 @@ from fastmcp.tools import FunctionTool, ToolResult +from fast_agent.agents.agent_types import ScopedFunctionToolConfig from fast_agent.core.exceptions import AgentConfigError from fast_agent.core.logging.logger import get_logger @@ -145,7 +146,7 @@ def load_function_from_spec(spec: str, base_path: Path | None = None) -> Callabl def load_function_tools( - tools_config: list[Callable[..., Any] | str] | None, + tools_config: list[Callable[..., Any] | str | ScopedFunctionToolConfig] | None, base_path: Path | None = None, ) -> list[FunctionTool]: """ @@ -166,7 +167,15 @@ def load_function_tools( result: list[FunctionTool] = [] for tool_spec in tools_config: try: - if callable(tool_spec): + if isinstance(tool_spec, ScopedFunctionToolConfig): + result.append( + build_default_function_tool( + tool_spec.function, + name=tool_spec.name, + description=tool_spec.description, + ) + ) + elif callable(tool_spec): tool_name = getattr(tool_spec, "_fast_tool_name", None) tool_desc = getattr(tool_spec, "_fast_tool_description", None) result.append( diff --git a/tests/unit/core/test_tool_decorator.py b/tests/unit/core/test_tool_decorator.py index 962dc02aa..3671da183 100644 --- a/tests/unit/core/test_tool_decorator.py +++ b/tests/unit/core/test_tool_decorator.py @@ -6,6 +6,7 @@ import pytest +from fast_agent.agents.agent_types import ScopedFunctionToolConfig from fast_agent.core.direct_decorators import DecoratorMixin from fast_agent.core.exceptions import AgentConfigError @@ -207,7 +208,7 @@ def local_tool() -> str: class TestAgentToolParameterized: - def test_custom_name_stored_on_function(self): + def test_custom_name_stored_on_scoped_registration(self): fast = _FakeFastAgent() writer = _make_agent(fast, "writer") @@ -215,9 +216,12 @@ def test_custom_name_stored_on_function(self): def helper() -> str: return "ok" - assert helper._fast_tool_name == "custom_name" + config = fast.agents["writer"]["config"] + assert isinstance(config.function_tools[0], ScopedFunctionToolConfig) + assert config.function_tools[0].function is helper + assert config.function_tools[0].name == "custom_name" - def test_custom_description_stored_on_function(self): + def test_custom_description_stored_on_scoped_registration(self): fast = _FakeFastAgent() writer = _make_agent(fast, "writer") @@ -225,7 +229,10 @@ def test_custom_description_stored_on_function(self): def helper() -> str: return "ok" - assert helper._fast_tool_description == "A custom description" + config = fast.agents["writer"]["config"] + assert isinstance(config.function_tools[0], ScopedFunctionToolConfig) + assert config.function_tools[0].function is helper + assert config.function_tools[0].description == "A custom description" def test_parameterized_agent_tool_returns_original_function(self): fast = _FakeFastAgent() @@ -316,6 +323,31 @@ def plain_fn(x: int) -> int: assert tools[0].name == "plain_fn" assert tools[0].description == "Plain doc." + def test_scoped_metadata_does_not_bleed_across_shared_helpers(self): + from fast_agent.tools.function_tool_loader import load_function_tools + + fast = _FakeFastAgent() + writer = _make_agent(fast, "writer") + analyst = _make_agent(fast, "analyst") + + def shared_helper() -> str: + """Shared helper doc.""" + return "ok" + + writer.tool(name="writer_helper", description="Writer helper")(shared_helper) + analyst.tool(name="analyst_helper", description="Analyst helper")(shared_helper) + + writer_config = fast.agents["writer"]["config"] + analyst_config = fast.agents["analyst"]["config"] + + writer_tools = load_function_tools(writer_config.function_tools) + analyst_tools = load_function_tools(analyst_config.function_tools) + + assert writer_tools[0].name == "writer_helper" + assert writer_tools[0].description == "Writer helper" + assert analyst_tools[0].name == "analyst_helper" + assert analyst_tools[0].description == "Analyst helper" + class TestAgentToolExposure: def test_supported_custom_agent_exposes_tool(self): diff --git a/tests/unit/fast_agent/tools/test_function_tool_loader.py b/tests/unit/fast_agent/tools/test_function_tool_loader.py index 888494c2d..93ac6252f 100644 --- a/tests/unit/fast_agent/tools/test_function_tool_loader.py +++ b/tests/unit/fast_agent/tools/test_function_tool_loader.py @@ -4,6 +4,7 @@ import pytest from fastmcp.tools import FunctionTool, ToolResult +from fast_agent.agents.agent_types import ScopedFunctionToolConfig from fast_agent.mcp.helpers.content_helpers import get_text from fast_agent.tools.function_tool_loader import build_default_function_tool, load_function_tools @@ -45,6 +46,29 @@ def shout(value: str) -> dict[str, str]: assert result.structured_content is None +@pytest.mark.asyncio +async def test_loader_uses_scoped_function_tool_metadata() -> None: + def shout(value: str) -> dict[str, str]: + return {"value": value.upper()} + + tool = load_function_tools( + [ + ScopedFunctionToolConfig( + function=shout, + name="custom_shout", + description="Uppercase a value", + ) + ] + )[0] + + result = await tool.run({"value": "hello"}) + + assert tool.name == "custom_shout" + assert tool.description == "Uppercase a value" + assert get_text(result.content[0]) == '{"value":"HELLO"}' + assert result.structured_content is None + + @pytest.mark.asyncio async def test_async_function_tool_still_runs_inline() -> None: async def async_add(a: int, b: int) -> int: From aec6786c967df7aff7aa4864c8be32775916a1e9 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:49:24 +0100 Subject: [PATCH 8/9] tweak custom --- src/fast_agent/core/direct_decorators.py | 3 ++- tests/unit/core/test_tool_decorator.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/fast_agent/core/direct_decorators.py b/src/fast_agent/core/direct_decorators.py index 0e5554a84..477c30bb4 100644 --- a/src/fast_agent/core/direct_decorators.py +++ b/src/fast_agent/core/direct_decorators.py @@ -255,8 +255,9 @@ def _decorator_impl( def decorator(func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, Coroutine[Any, Any, R]]: if agent_type == AgentType.CUSTOM: custom_cls = extra_kwargs.get("agent_class") or extra_kwargs.get("cls") + explicit_function_tools = extra_kwargs.get("function_tools") if ( - extra_kwargs.get("function_tools") is not None + explicit_function_tools and not custom_class_supports_function_tools(custom_cls) ): raise AgentConfigError( diff --git a/tests/unit/core/test_tool_decorator.py b/tests/unit/core/test_tool_decorator.py index 3671da183..e307cd527 100644 --- a/tests/unit/core/test_tool_decorator.py +++ b/tests/unit/core/test_tool_decorator.py @@ -418,3 +418,23 @@ def helper() -> str: ) async def bad_custom(): pass + + def test_unsupported_custom_agent_allows_empty_function_tools_opt_out(self): + class UnsupportedCustomAgent: + def __init__(self, config, context=None): + self.config = config + self.context = context + + fast = _FakeFastAgent() + + @fast.custom( + UnsupportedCustomAgent, + name="isolated_custom", + instruction="test", + function_tools=[], + ) + async def isolated_custom(): + pass + + config = fast.agents["isolated_custom"]["config"] + assert config.function_tools == [] From 6f981384e5a73c791a973ae411e5f85d186340da Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:27:25 +0100 Subject: [PATCH 9/9] add naming notes to add future refactor --- src/fast_agent/agents/agent_types.py | 9 ++++++++- src/fast_agent/agents/tool_agent.py | 13 +++++++++++++ src/fast_agent/core/direct_decorators.py | 10 ++++++++++ src/fast_agent/core/direct_factory.py | 9 ++++++++- src/fast_agent/core/fastagent.py | 3 ++- src/fast_agent/core/function_tool_support.py | 7 ++++++- 6 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/fast_agent/agents/agent_types.py b/src/fast_agent/agents/agent_types.py index 8b4a61b6f..c46b44537 100644 --- a/src/fast_agent/agents/agent_types.py +++ b/src/fast_agent/agents/agent_types.py @@ -78,7 +78,14 @@ class MCPConnectTarget: @dataclass class AgentConfig: - """Configuration for an Agent instance""" + """Configuration for an Agent instance. + + Naming note: + - ``tools`` filters MCP-discovered tools by server name. + - ``function_tools`` configures local Python function tools. + - Runtime constructors such as ``ToolAgent(..., tools=...)`` use ``tools`` + for the resolved executable function-tool objects, not these MCP filters. + """ name: str instruction: str = DEFAULT_AGENT_INSTRUCTION diff --git a/src/fast_agent/agents/tool_agent.py b/src/fast_agent/agents/tool_agent.py index 3aea7a6c3..2244ccd4f 100644 --- a/src/fast_agent/agents/tool_agent.py +++ b/src/fast_agent/agents/tool_agent.py @@ -92,6 +92,11 @@ class ToolAgent(LlmAgent, _ToolLoopAgent): Pass either: - native FastMCP FunctionTool objects - regular Python functions (wrapped as FunctionTools) + + Naming note: + ``tools`` here means executable local/function tools available to the + agent. It does not refer to ``AgentConfig.tools``, which is the MCP + filter map used by ``McpAgent``. """ def __init__( @@ -100,6 +105,14 @@ def __init__( tools: Sequence[FunctionTool | Callable[..., Any]] = (), context: Context | None = None, ) -> None: + """Create a tool-capable agent. + + Args: + config: Agent configuration. ``config.tools`` remains the MCP + filter map; it is separate from this ``tools`` argument. + tools: Executable local/function tools to expose on the agent. + context: Optional runtime context. + """ super().__init__(config=config, context=context) self._execution_tools: dict[str, FunctionTool] = {} diff --git a/src/fast_agent/core/direct_decorators.py b/src/fast_agent/core/direct_decorators.py index 477c30bb4..03a47c8ab 100644 --- a/src/fast_agent/core/direct_decorators.py +++ b/src/fast_agent/core/direct_decorators.py @@ -475,6 +475,11 @@ def agent( Returns: A decorator that registers the agent with proper type annotations + + Naming note: + ``tools`` here is the MCP filter map, while ``function_tools`` is the + local Python tool configuration. Custom agent constructors still use a + runtime kwarg named ``tools=`` for the resolved function-tool objects. """ final_instruction_raw = ( instruction_or_kwarg if instruction_or_kwarg is not None else instruction @@ -625,6 +630,11 @@ def custom( Returns: A decorator that registers the agent with proper type annotations + + Naming note: + ``tools`` here is the MCP filter map. If ``function_tools`` are + configured for a custom class, the resolved tool objects are passed to + the class constructor as ``tools=`` for compatibility. """ final_instruction_raw = ( instruction_or_kwarg if instruction_or_kwarg is not None else instruction diff --git a/src/fast_agent/core/direct_factory.py b/src/fast_agent/core/direct_factory.py index 8a4530dec..269edf7fc 100644 --- a/src/fast_agent/core/direct_factory.py +++ b/src/fast_agent/core/direct_factory.py @@ -130,8 +130,13 @@ def _resolve_function_tools_with_globals( """Load per-agent function tools, falling back to global @fast.tool tools. If the agent has explicit function_tools configured (including an empty list), - only those are used. Otherwise, globally registered tools from ``@fast.tool`` + only those are used. Otherwise, globally registered tools from ``@fast.tool`` are provided. + + Naming note: + the returned value is a list of resolved executable function tools. In the + custom-agent path, these are later passed to the constructor as ``tools=``, + which is distinct from ``AgentConfig.tools`` MCP filter settings. """ if config.function_tools is not None or agent_data.get("function_tools") is not None: return _load_configured_function_tools(config, agent_data) @@ -726,6 +731,8 @@ async def _create_custom_agent( create_kwargs: dict[str, Any] = {} if function_tools and custom_supports_function_tools: + # Custom agent constructors follow the existing ToolAgent/McpAgent + # convention: resolved function tools are passed as ``tools=``. create_kwargs["tools"] = function_tools agent = _create_agent_with_ui_if_needed( diff --git a/src/fast_agent/core/fastagent.py b/src/fast_agent/core/fastagent.py index 19808dfe0..3d3f36508 100644 --- a/src/fast_agent/core/fastagent.py +++ b/src/fast_agent/core/fastagent.py @@ -434,7 +434,8 @@ def __init__( # Dictionary to store agent configurations from decorators self.agents: dict[str, AgentCardData] = {} - # Global tool registry populated by @fast.tool decorator + # Global function-tool registry populated by @fast.tool. + # These are local Python tools, not AgentConfig.tools MCP filter maps. self._registered_tools: list[FunctionTool] = [] # Tracking for AgentCard-loaded agents self._agent_card_sources: dict[str, Path] = {} diff --git a/src/fast_agent/core/function_tool_support.py b/src/fast_agent/core/function_tool_support.py index 79c6a0bd0..9f452179c 100644 --- a/src/fast_agent/core/function_tool_support.py +++ b/src/fast_agent/core/function_tool_support.py @@ -28,7 +28,12 @@ def _callable_accepts_keyword_arg(callable_obj: Any, arg_name: str) -> bool: def custom_class_supports_function_tools(cls: Any) -> bool: - """Return whether a custom agent class accepts ``tools=`` during construction.""" + """Return whether a custom agent class accepts ``tools=`` during construction. + + ``function_tools`` is the user-facing configuration name, but custom agent + classes receive the resolved tool objects via a constructor kwarg named + ``tools`` for compatibility with existing ``ToolAgent``/``McpAgent`` APIs. + """ init = getattr(cls, "__init__", None) return _callable_accepts_keyword_arg(init, "tools")