Skip to content

[bug] permission-wrapped FunctionTool run() tools return no callable handler #8788

@zouyonghe

Description

@zouyonghe

What happened

A plugin tool registered via Context.add_llm_tools() can be exposed to the model successfully, but calling it returns:

error: tool has no callable handler

This happens for dataclass-style FunctionTool subclasses that implement run() instead of call() or handler.

Example shape:

from dataclasses import dataclass, field

from astrbot.api import FunctionTool
from astrbot.api.event import AstrMessageEvent


@dataclass
class ListMCPServersTool(FunctionTool):
    name: str = "list_mcp_servers"
    description: str = "List MCP servers."
    parameters: dict = field(default_factory=lambda: {"type": "object", "properties": {}})

    async def run(self, event: AstrMessageEvent):
        return "ok"

The tool appears in the available function-calling tools and the model can call it, but execution returns error: tool has no callable handler.

Log evidence

Agent 使用工具: ['list_mcp_servers']
使用工具:list_mcp_servers,参数:{}
Tool `list_mcp_servers` Result: error: tool has no callable handler

Expected behavior

Dataclass-style FunctionTool subclasses that implement run() should continue to work when registered with Context.add_llm_tools(), including after the per-tool permission wrapper is applied.

Suspected cause

FunctionToolExecutor._execute_local() supports tools with handler, overridden call(), or run():

if not tool.handler and not hasattr(tool, "run") and not is_override_call:
    raise ValueError("Tool must have a valid handler or override 'run' method.")
...
elif hasattr(tool, "run"):
    awaitable = getattr(tool, "run")
    method_name = "run"

However, FunctionToolManager.get_full_tool_set() wraps non-builtin tools in _PermissionGuardedTool, and _PermissionGuardedTool.call() currently only delegates to:

  • self._wrapped.handler
  • self._wrapped.call() if call() is overridden

It does not delegate to self._wrapped.run().

As a result, any plugin tool that relies on run() becomes a wrapped tool with no recognized callable path and returns:

return "error: tool has no callable handler"

Why this is not just a plugin bug

The documented/dataclass-style plugin pattern supports run() implementations. A related historical issue (#8143) includes an example using FunctionTool with async def run(...), and FunctionToolExecutor._execute_local() still supports this contract.

The incompatibility appears specifically after _PermissionGuardedTool was introduced for non-builtin tools.

Possible fix

Update _PermissionGuardedTool.call() to mirror _execute_local() delegation behavior and support run() as a fallback after handler and overridden call().

Pseudo-fix:

run = getattr(self._wrapped, "run", None)
if run is not None:
    result = run(context.context.event, **kwargs)
    if inspect.isasyncgen(result):
        last = None
        async for item in result:
            last = item
        return last
    if inspect.isawaitable(result):
        return await result
    return result

The exact event/context argument handling should match the existing call_local_llm_tool(..., method_name="run") behavior.

Environment

  • AstrBot version observed in logs: v4.25.5
  • Tool registration path: third-party plugin using Context.add_llm_tools(ListMCPServersTool(...))
  • Tool implementation style: dataclass subclass of FunctionTool with async def run(...)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingfeature:pluginThe bug / feature is about AstrBot plugin system.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions