Skip to content

Feat/tool decorator#744

Open
danieldagot wants to merge 5 commits intoevalstate:mainfrom
danieldagot:feat/tool-decorator
Open

Feat/tool decorator#744
danieldagot wants to merge 5 commits intoevalstate:mainfrom
danieldagot:feat/tool-decorator

Conversation

@danieldagot
Copy link
Copy Markdown

Context

Currently, attaching Python functions as tools to agents requires either external MCP servers or passing function_tools=["file.py:func"] string specs. There is no way to define tools inline alongside agent code using a decorator.

This PR adds a @fast.tool decorator that lets users register Python functions as agent tools directly in code, matching the declarative style of @fast.agent, @fast.chain, etc.

Changes

src/fast_agent/core/direct_decorators.py

  • Add tool() method to DecoratorMixin with @overload for type safety
  • Supports bare (@fast.tool) and parameterized (@fast.tool(name=..., description=...)) usage
  • Uses build_default_function_tool from the existing function tool loader
  • Returns the original function unchanged so it remains callable as normal Python

src/fast_agent/core/fastagent.py

  • Add _registered_tools: list[FunctionTool] to FastAgent.__init__
  • Wire the registry into the build context before agent creation

src/fast_agent/core/direct_factory.py

  • Add _resolve_function_tools_with_globals() implementing the scoping logic
  • Replace _load_configured_function_tools calls at all 3 injection points (basic agent, smart agent, agents-as-tools)

AGENTS.md

  • Add contributor note documenting the @fast.tool architecture

README.md

  • Add "Function Tools" section under Agent Features

examples/function-tools/

  • basic.py -- bare and parameterized @fast.tool with a single agent
  • scoping.py -- global tools vs explicit function_tools opt-out

tests/unit/core/test_tool_decorator.py

  • 10 unit tests covering bare decorator, parameterized decorator, multiple registration, original function preservation, and sync/async tool execution

How to use it

from fast_agent import FastAgent

fast = FastAgent("My App")

@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

@fast.agent(instruction="You are a helpful assistant.")
async def main():
    async with fast.run() as agent:
        await agent.interactive()

Both sync and async functions are supported. Name and description default to the function name and docstring.

Scoping behavior

  • @fast.tool registers tools globally -- all agents receive them by default
  • If an agent declares function_tools=[...], only those explicit tools apply; globals are skipped
  • MCP server tools take priority over @fast.tool tools with the same name (existing list_tools merge behavior)

Design decisions

Why on DecoratorMixin? Keeps the API consistent with @fast.agent, @fast.chain, etc. -- all decorators live in one place.

Why global by default? Single-agent apps (the common case) should just work with @fast.tool and no extra wiring. Multi-agent apps can opt out per-agent with function_tools=.

Why getattr for _registered_tools? The build context receives Core (not FastAgent), so the registry is attached dynamically. getattr with a default keeps the factory code safe for non-FastAgent callers (e.g. create_basic_agents_in_dependency_order).

Test plan

  • uv run scripts/lint.py passes
  • uv run scripts/typecheck.py passes
  • All 10 new unit tests pass (tests/unit/core/test_tool_decorator.py)
  • Full unit test suite (2593 tests) passes with no new failures
  • End-to-end verification with passthrough model confirms global and scoped tool injection

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
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
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.
@evalstate
Copy link
Copy Markdown
Owner

Thanks very much for this PR; it's a great idea; before merge though I do want to consider the global-by-default. I'm in complete agreement that for quick single agent programs this is the right thing to do - but think it might be too surprising behaviour for any multi-agent setup. I'm trying to think of options that give us the best of both worlds.

…les. 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.
@danieldagot
Copy link
Copy Markdown
Author

Hi, thanks for the comment — that's a fair concern and I completely agree.

In order to have the best of both worlds, I've updated the PR to add an agent-specific tool decorator (@agent.tool) alongside the existing @fast.tool. Here's how it works:

Per-agent scoping with @agent.tool

Tools can now be scoped to a specific agent by decorating off the agent function itself:

@fast.agent(name="writer", instruction="You write things.")
async def writer(): ...

@fast.agent(name="analyst", instruction="You analyse things.")
async def analyst(): ...

@writer.tool
def translate(text: str, language: str) -> str:
    """Translate text to the given language."""
    return f"[{language}] {text}"

@analyst.tool(name="word_count", description="Count words in text")
def count_words(text: str) -> int:
    return len(text.split())
  • writer only sees translate
  • analyst only sees word_count
  • No surprises in multi-agent setups

@fast.tool remains for single-agent convenience

For quick single-agent scripts, @fast.tool still broadcasts globally — so the zero-config experience is preserved:

@fast.tool
def get_weather(city: str) -> str:
    """Return the current weather for a city."""
    return f"Sunny in {city}"

@fast.agent(instruction="You are a helpful assistant.")
async def main(): ...

How they compose

  • @agent.tool sets config.function_tools on that agent, which naturally opts it out of @fast.tool globals
  • function_tools=[] on @fast.agent explicitly opts out with no tools (also fixed a bug where empty list was treated as "not specified")
  • Agents without any @agent.tool or function_tools= still receive @fast.tool globals

Bug fix

Also fixed a bug where function_tools=[] didn't actually opt out of global tools — the check was if explicit_tools: which is falsy for []. Changed to check config.function_tools is not None so an empty list is respected as an explicit "no tools" declaration.

What changed

  • direct_decorators.py.tool closure attached to decorated agent functions in _decorator_impl
  • direct_factory.py — fixed _resolve_function_tools_with_globals opt-out logic
  • function_tool_loader.py — metadata passthrough for custom name/description from @agent.tool
  • test_tool_decorator.py — 11 new tests (21 total) covering agent-scoped tools, scoping isolation, opt-out, and metadata passthrough
  • scoping.py example — updated to demonstrate @writer.tool / @analyst.tool pattern
  • README.md — updated Function Tools section documenting both patterns

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants