diff --git a/README.md b/README.md index 0828854a6..af3e4b40b 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 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..64d77407d --- /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/agents/agent_types.py b/src/fast_agent/agents/agent_types.py index 53f9b89dd..c46b44537 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 @@ -69,16 +78,23 @@ 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 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 +106,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/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 bf9668109..03a47c8ab 100644 --- a/src/fast_agent/core/direct_decorators.py +++ b/src/fast_agent/core/direct_decorators.py @@ -7,22 +7,28 @@ from collections.abc import Coroutine from pathlib import Path from typing import ( + TYPE_CHECKING, Any, - Awaitable, Callable, Literal, ParamSpec, Protocol, TypeVar, + cast, + 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, FunctionToolsConfig, + ScopedFunctionToolConfig, SkillConfig, ) from fast_agent.agents.workflow.iterative_planner import ITERATIVE_PLAN_SYSTEM_PROMPT_TEMPLATE @@ -30,6 +36,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 @@ -39,6 +50,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.""" @@ -46,7 +72,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 @@ -221,6 +253,19 @@ 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 ( + explicit_function_tools + 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, @@ -263,6 +308,57 @@ 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 config.function_tools is None: + config.function_tools = [] + 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: + return _register(fn) + return _register + + 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 return decorator @@ -280,8 +376,53 @@ 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 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 + + 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, @@ -308,7 +449,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. @@ -317,10 +461,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 @@ -331,38 +475,46 @@ 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 ) 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( @@ -390,39 +542,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, + }, + ), ) @@ -439,6 +601,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, @@ -455,14 +618,23 @@ 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 + + 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 @@ -488,6 +660,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 6df8b7073..269edf7fc 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, @@ -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, @@ -107,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 [] @@ -121,6 +122,32 @@ 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. + + 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) + + 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 +253,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 +630,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 +671,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 @@ -690,10 +717,29 @@ 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: + # 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( 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/fastagent.py b/src/fast_agent/core/fastagent.py index 0d1749541..3d3f36508 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,9 @@ def __init__( # Dictionary to store agent configurations from decorators self.agents: dict[str, AgentCardData] = {} + # 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] = {} self._agent_card_roots: dict[Path, set[str]] = {} @@ -1491,6 +1496,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/core/function_tool_support.py b/src/fast_agent/core/function_tool_support.py new file mode 100644 index 000000000..9f452179c --- /dev/null +++ b/src/fast_agent/core/function_tool_support.py @@ -0,0 +1,51 @@ +"""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. + + ``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") + + +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/src/fast_agent/tools/function_tool_loader.py b/src/fast_agent/tools/function_tool_loader.py index 29906b74a..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,8 +167,20 @@ def load_function_tools( result: list[FunctionTool] = [] for tool_spec in tools_config: try: - if callable(tool_spec): - result.append(build_default_function_tool(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( + 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/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 new file mode 100644 index 000000000..e307cd527 --- /dev/null +++ b/tests/unit/core/test_tool_decorator.py @@ -0,0 +1,440 @@ +"""Tests for the @fast.tool and @agent.tool decorators.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +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 + +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 + + +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() + + @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_scoped_registration(self): + fast = _FakeFastAgent() + writer = _make_agent(fast, "writer") + + @writer.tool(name="custom_name") + def helper() -> str: + return "ok" + + 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_scoped_registration(self): + fast = _FakeFastAgent() + writer = _make_agent(fast, "writer") + + @writer.tool(description="A custom description") + def helper() -> str: + return "ok" + + 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() + 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." + + 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): + 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 + + 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 == [] 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: