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(...)
What happened
A plugin tool registered via
Context.add_llm_tools()can be exposed to the model successfully, but calling it returns:This happens for dataclass-style
FunctionToolsubclasses that implementrun()instead ofcall()orhandler.Example shape:
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
Expected behavior
Dataclass-style
FunctionToolsubclasses that implementrun()should continue to work when registered withContext.add_llm_tools(), including after the per-tool permission wrapper is applied.Suspected cause
FunctionToolExecutor._execute_local()supports tools withhandler, overriddencall(), orrun():However,
FunctionToolManager.get_full_tool_set()wraps non-builtin tools in_PermissionGuardedTool, and_PermissionGuardedTool.call()currently only delegates to:self._wrapped.handlerself._wrapped.call()ifcall()is overriddenIt 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: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 usingFunctionToolwithasync def run(...), andFunctionToolExecutor._execute_local()still supports this contract.The incompatibility appears specifically after
_PermissionGuardedToolwas introduced for non-builtin tools.Possible fix
Update
_PermissionGuardedTool.call()to mirror_execute_local()delegation behavior and supportrun()as a fallback afterhandlerand overriddencall().Pseudo-fix:
The exact event/context argument handling should match the existing
call_local_llm_tool(..., method_name="run")behavior.Environment
v4.25.5Context.add_llm_tools(ListMCPServersTool(...))FunctionToolwithasync def run(...)