diff --git a/README.md b/README.md index 0828854a..af3e4b40 100644 --- a/README.md +++ b/README.md @@ -603,6 +603,40 @@ agent["greeter"].send("Good Evening!") # Dictionary access is supported ) ``` +### Function Tools + +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.agent(name="writer", instruction="You write things.") +async def writer(): ... + +@writer.tool +def translate(text: str, language: str) -> str: + """Translate text to the given language.""" + return f"[{language}] {text}" + +@writer.tool(name="summarize", description="Produce a one-line summary") +def summarize(text: str) -> str: + return f"Summary: {text[:80]}..." +``` + +**Global tools (`@fast.tool`)** — available to all agents that don't declare their own tools: + +```python +@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 (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/basic.py b/examples/function-tools/basic.py new file mode 100644 index 00000000..6478be77 --- /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 00000000..64d77407 --- /dev/null +++ b/examples/function-tools/scoping.py @@ -0,0 +1,60 @@ +""" +@agent.tool scoping example. + +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 +""" + +import asyncio + +from fast_agent import FastAgent + +fast = FastAgent("Tool Scoping Example") + + +@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}" + + +@writer.tool +def summarize(text: str) -> str: + """Produce a one-line summary.""" + return f"Summary: {text[:80]}..." + + +@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()) + + +async def main() -> None: + async with fast.run() as agent: + # "writer" sees translate and summarize (its own @writer.tool tools) + # "analyst" sees only word_count (its own @analyst.tool tool) + await agent.interactive() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/fast_agent/core/direct_decorators.py b/src/fast_agent/core/direct_decorators.py index bf966810..8e34a575 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, @@ -263,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 @@ -280,8 +327,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 6df8b707..23332833 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 (including an empty list), + only those are used. Otherwise, globally registered tools from ``@fast.tool`` + are provided. + """ + 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: + 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 0d174954..19808dfe 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/src/fast_agent/tools/function_tool_loader.py b/src/fast_agent/tools/function_tool_loader.py index 29906b74..9b62a455 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 new file mode 100644 index 00000000..e5e60c59 --- /dev/null +++ b/tests/unit/core/test_tool_decorator.py @@ -0,0 +1,306 @@ +"""Tests for the @fast.tool and @agent.tool decorators.""" + +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] = [] + + +# --------------------------------------------------------------------------- +# 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() + + @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" + + +# =================================================================== +# @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."