Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions astrbot/core/agent/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ class FunctionTool(ToolSchema, Generic[TContext]):
Declare this tool as a background task. Background tasks return immediately
with a task identifier while the real work continues asynchronously.
"""
declared_permission_type: str | None = None
"""
The permission level declared by the tool author via ``@llm_tool(permission_type=...)``.
One of ``"admin"`` / ``"member"`` / ``None``.

This is only a *default*: it is used when the dashboard has no explicit
per-tool permission override configured for this tool. It lets plugin
authors ship a sane default permission requirement (e.g. ADMIN for a
dangerous tool) without requiring the bot owner to visit the WebUI panel.
An explicit override saved via the dashboard always takes precedence.
This field is a special field for AstrBot; you can ignore it when
integrating with other frameworks.
"""

def __repr__(self) -> str:
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
Expand Down
53 changes: 43 additions & 10 deletions astrbot/core/provider/func_tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ def spec_to_func(
func_args: list[dict],
desc: str,
handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]],
declared_permission_type: str | None = None,
) -> FuncTool:
params = {
"type": "object", # hard-coded here
Expand All @@ -358,6 +359,7 @@ def spec_to_func(
parameters=params,
description=desc,
handler=handler,
declared_permission_type=declared_permission_type,
)

def add_func(
Expand All @@ -366,13 +368,17 @@ def add_func(
func_args: list,
desc: str,
handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]],
declared_permission_type: str | None = None,
) -> None:
"""添加函数调用工具

@param name: 函数名
@param func_args: 函数参数列表,格式为 [{"type": "string", "name": "arg_name", "description": "arg_description"}, ...]
@param desc: 函数描述
@param func_obj: 处理函数
@param declared_permission_type: 插件作者通过 ``@llm_tool(permission_type=...)``
声明的默认权限(``"admin"`` / ``"member"`` / ``None``)。仅在该工具尚未
在 WebUI 面板中被显式配置过权限时生效,作为默认值使用。
"""
# check if the tool has been added before
self.remove_func(name)
Expand All @@ -383,6 +389,7 @@ def add_func(
func_args=func_args,
desc=desc,
handler=handler,
declared_permission_type=declared_permission_type,
),
)
logger.info(f"Added llm tool: {name}")
Expand All @@ -396,14 +403,9 @@ def remove_func(self, name: str) -> None:

def get_func(self, name) -> FuncTool | None:
# 优先返回已激活的工具(后加载的覆盖前面的,与 ToolSet.add_tool 保持一致)
# 使用 getattr(..., True) 与 ToolSet.add_tool 保持一致:没有 active 属性的工具视为已激活
for f in reversed(self.func_list):
if f.name == name and getattr(f, "active", True):
return f
# 退化则拿最后一个同名工具
for f in reversed(self.func_list):
if f.name == name:
return f
tool = self._lookup_in_func_list(name)
if tool is not None:
return tool
if isinstance(name, str):
try:
builtin_tool = self.get_builtin_tool(name)
Expand All @@ -414,6 +416,25 @@ def get_func(self, name) -> FuncTool | None:
return builtin_tool
return None

def _lookup_in_func_list(self, name) -> FuncTool | None:
"""Find a tool in ``func_list`` by name, preferring the active copy.

Shared by :meth:`get_func` and :meth:`_default_permission` so the
precedence rule (active copy wins; otherwise fall back to the
most-recently-added inactive copy) only lives in one place. Never
falls through to builtin tools -- callers that need that do it
themselves, since loading builtins is a side effect that not every
caller wants (notably ``_default_permission``, which must never
trigger builtin-tool loading)."""
fallback = None
for f in reversed(self.func_list):
if f.name == name:
if getattr(f, "active", True):
return f
if fallback is None:
fallback = f
return fallback

def get_builtin_tool(self, tool: str | type[FuncTool]) -> FuncTool:
ensure_builtin_tools_loaded()

Expand Down Expand Up @@ -451,8 +472,20 @@ def is_builtin_tool(self, name: str) -> bool:
def _default_permission(self, tool_name: str) -> str:
"""Compute the fallback permission for a non-builtin tool.

All non-builtin tools default to ``"member"`` (no restriction).
Builtin tools are never routed through this method."""
If the tool's author declared a default via
``@llm_tool(permission_type=...)``, that value is used. Otherwise
non-builtin tools default to ``"member"`` (no restriction).
Builtin tools are never routed through this method.

Uses ``getattr`` rather than direct attribute access: third-party
tools registered via ``add_llm_tools()`` are only type-hinted as
``FunctionTool`` but not enforced at runtime, so an older or
custom tool object that doesn't inherit ``FunctionTool`` may not
carry this attribute at all."""
tool = self._lookup_in_func_list(tool_name)
declared = getattr(tool, "declared_permission_type", None)
if declared in ("admin", "member"):
return declared
return "member"

def _check_tool_permission(
Expand Down
107 changes: 101 additions & 6 deletions astrbot/core/star/register/star_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,59 @@ def decorator(awaitable):
return decorator


def register_llm_tool(name: str | None = None, **kwargs):
def _normalize_permission_declaration(
name: object,
permission_type: PermissionType | None,
registering_agent: object,
) -> str | None:
"""Validate the ``name`` / ``permission_type`` combination passed to
``register_llm_tool`` and map ``permission_type`` to the raw string
stored on ``FunctionTool.declared_permission_type``.

Returns ``None`` when no permission was declared. Raises ``ValueError``
on any of the three misuse patterns this feature has to guard against:
forgetting ``name=``/``permission_type=`` and passing a ``PermissionType``
member positionally (it would otherwise silently bind to ``name``),
passing something that isn't a ``PermissionType`` member, or declaring a
permission on a tool registered via ``Agent.llm_tool`` (which never goes
through the panel-configurable permission system)."""
if isinstance(name, PermissionType):
raise ValueError(
"看起来你把 PermissionType 作为第一个位置参数传给了 name(很可能是忘了写"
"name= 或 permission_type=)。正确写法:"
'@llm_tool(name="xxx", permission_type=filter.PermissionType.ADMIN)。'
"如果不需要自定义工具名,直接用 @llm_tool(permission_type=...) 即可"
"(permission_type 现在是仅限关键字参数)。",
)
if permission_type is not None and not isinstance(permission_type, PermissionType):
raise ValueError(
"permission_type 必须为 astrbot.api.event.filter.PermissionType 的成员(ADMIN / MEMBER)。",
)
if registering_agent is not None and permission_type is not None:
raise ValueError(
"通过 Agent.llm_tool 注册的工具不支持 permission_type 声明,因为它们不经过"
"面板可配置的工具管理系统(不会被写入 func_list,也不受"
"_default_permission / 面板权限覆盖的约束)。请改用"
"@filter.llm_tool(不经过 Agent)来声明默认权限,或者在工具内部自行"
"实现权限校验。",
)
if permission_type is None:
return None
if permission_type == PermissionType.ADMIN:
return "admin"
# PermissionType.MEMBER (or any non-ADMIN flag) means "no restriction",
# which is already the implicit default, but we still record it
# explicitly so a dashboard can distinguish "declared member" from
# "never declared anything".
return "member"


def register_llm_tool(
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
name: str | None = None,
*,
permission_type: PermissionType | None = None,
**kwargs,
):
Comment thread
lingyun14beta marked this conversation as resolved.
"""为函数调用(function-calling / tools-use)添加工具。

请务必按照以下格式编写一个工具(包括函数注释,AstrBot 会尝试解析该函数注释)
Expand Down Expand Up @@ -609,11 +661,42 @@ async def get_weather(event: AstrMessageEvent, location: str):
yield
```

权限声明 / Permission declaration:
可以通过 ``permission_type`` 参数为该工具声明一个默认权限要求,效果类似于
``@filter.command()`` 配合 ``@filter.permission_type()`` 的用法。这样即便
机器人主人从未在 WebUI 面板里手动配置过该工具的权限,工具也会默认拥有这层
防护:

```
@llm_tool(name="restart_server", permission_type=filter.PermissionType.ADMIN)
async def restart_server(event: AstrMessageEvent):
\'\'\'重启服务器。\'\'\'
# 处理逻辑
```

- 该参数只是一个**默认值**:如果机器人主人之后在 WebUI 面板里手动为该工具
设置了权限,面板的设置会覆盖这里声明的默认值。
- 不传或传 ``None`` 时行为与之前完全一致(默认所有人可用)。
- ``permission_type`` 是仅限关键字参数,必须写成
``permission_type=filter.PermissionType.ADMIN``,不能位置传参。如果你
忘了写 ``name=`` 或 ``permission_type=``、直接把 ``PermissionType`` 成员
当作第一个位置参数传入(例如错写成
``@llm_tool(filter.PermissionType.ADMIN)``),会抛出 ``ValueError``,
而不是被静默当成工具名。
- 通过 ``registering_agent``(即 ``Agent.llm_tool``)注册的工具不支持权限
声明,因为它们不会被写入 ``func_list``,不经过面板可配置的工具管理系统,
也不受 ``_default_permission`` / 面板权限覆盖的约束。同时传入
``registering_agent`` 和 ``permission_type`` 会抛出 ``ValueError``,
而不是静默忽略你的权限声明。

"""
name_ = name
registering_agent = None
if kwargs.get("registering_agent"):
registering_agent = kwargs["registering_agent"]
registering_agent = kwargs.get("registering_agent")
declared_permission = _normalize_permission_declaration(
name,
permission_type,
registering_agent,
)

def decorator(
awaitable: Callable[
Expand Down Expand Up @@ -661,15 +744,27 @@ def decorator(
if not registering_agent:
doc_desc = docstring.description.strip() if docstring.description else ""
md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent)
llm_tools.add_func(llm_tool_name, args, doc_desc, md.handler)
llm_tools.add_func(
llm_tool_name,
args,
doc_desc,
md.handler,
declared_permission_type=declared_permission,
)
else:
assert isinstance(registering_agent, RegisteringAgent)
# print(f"Registering tool {llm_tool_name} for agent", registering_agent._agent.name)
if registering_agent._agent.tools is None:
registering_agent._agent.tools = []

desc = docstring.description.strip() if docstring.description else ""
tool = llm_tools.spec_to_func(llm_tool_name, args, desc, awaitable)
tool = llm_tools.spec_to_func(
llm_tool_name,
args,
desc,
awaitable,
declared_permission_type=declared_permission,
)
registering_agent._agent.tools.append(tool)

return awaitable
Expand Down
9 changes: 8 additions & 1 deletion astrbot/dashboard/services/tools_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,14 @@ def _serialize_tool(self, tool, config_entries: list[dict]) -> dict:
perms_store.get("_default", {}) if isinstance(perms_store, dict) else {}
)
configured = tool.name in defaults
permission = defaults[tool.name] if configured else "member"
permission = (
defaults[tool.name]
if configured
else self.tool_mgr._default_permission(tool.name)
)
tool_info["permission"] = permission
tool_info["permission_configured"] = configured
tool_info["declared_permission_type"] = getattr(
tool, "declared_permission_type", None
)
return tool_info
7 changes: 7 additions & 0 deletions dashboard/src/components/extension/componentPanel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,11 @@ export interface ToolItem {
permission?: 'admin' | 'member';
/** True when permission was explicitly configured rather than a fallback default. */
permission_configured?: boolean;
/**
* Default permission declared by the plugin author via
* `@llm_tool(permission_type=...)`. `null` when the tool declared no
* default. Distinct from `permission_configured`, which reflects an
* explicit WebUI override that always takes precedence over this value.
*/
declared_permission_type?: 'admin' | 'member' | null;
}
45 changes: 45 additions & 0 deletions docs/en/dev/star/guides/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,51 @@ Supported types: `string`, `number`, `object`, `boolean`, `array`. Since v4.5.7,
>
> Additionally, passing `parameters=...` directly to the decorator is **not supported** and will be silently ignored. If you need manual control over the schema, use the `@dataclass` + `add_llm_tools()` approach above.

### Declaring a Default Permission for Tools

> [!TIP]
> Added in v4.X.X

Just as `@filter.command()` can be restricted to admins with `@filter.permission_type(filter.PermissionType.ADMIN)`, `@filter.llm_tool()` supports the same idea through a `permission_type` parameter, letting you declare a default permission for the tool:

```py
from astrbot.api.event import filter, AstrMessageEvent

@filter.llm_tool(name="restart_server", permission_type=filter.PermissionType.ADMIN)
async def restart_server(self, event: AstrMessageEvent):
'''Restart the server.'''
# handler logic
```

`permission_type` accepts `filter.PermissionType.ADMIN` or `filter.PermissionType.MEMBER`. If omitted, the previous behavior is unchanged (the tool is available to everyone).

If you define a tool via `@dataclass` + `FunctionTool` (see [Defining Tools](#defining-tools) above), you can declare a default permission the same way by adding a `declared_permission_type` field to the dataclass:

```py
from pydantic import Field
from pydantic.dataclasses import dataclass

from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext


@dataclass
class RestartServerTool(FunctionTool[AstrAgentContext]):
name: str = "restart_server"
description: str = "Restart the server."
parameters: dict = Field(default_factory=lambda: {"type": "object", "properties": {}})
declared_permission_type: str | None = "admin" # "admin" / "member" / None

async def call(self, context: ContextWrapper[AstrAgentContext], **kwargs) -> ToolExecResult:
# handler logic
return "ok"
```

> [!WARNING]
> - `permission_type` / `declared_permission_type` only sets the tool's **default permission**. If the bot owner has explicitly configured a permission for this tool in the WebUI panel (Extensions -> Components -> Tool Management), that configuration **overrides** the default declared in the plugin's code.
> - The point of this mechanism is that plugin authors can ship a sane default safeguard for dangerous tools (e.g. restarting a service, running shell commands) without relying on the bot owner to ever open the WebUI panel.

## Invoking Agents

> [!TIP]
Expand Down
45 changes: 45 additions & 0 deletions docs/zh/dev/star/guides/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,51 @@ async def get_weather(self, event: AstrMessageEvent, location: str) -> MessageEv
>
> 此外,装饰器**不支持**通过 `parameters=...` 显式传入参数 schema,该写法会被忽略。如需手动控制 schema,请使用上方的 `@dataclass` + `add_llm_tools()` 方式。

### 为 Tool 声明默认权限

> [!TIP]
> 在 v4.X.X 时加入

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (typo): Consider a more idiomatic phrasing for the version note.

The wording "在 v4.X.X 时加入" is slightly awkward for written technical docs. Prefer a more standard phrasing such as "在 v4.X.X 中加入" or "在 v4.X.X 中添加".

Suggested change
> 在 v4.X.X 时加入
> 在 v4.X.X 中加入


`@filter.command()` 可以通过 `@filter.permission_type(filter.PermissionType.ADMIN)` 限制指令仅管理员可用,`@filter.llm_tool()` 也支持类似的写法,通过 `permission_type` 参数为工具声明一个默认权限:

```py
from astrbot.api.event import filter, AstrMessageEvent

@filter.llm_tool(name="restart_server", permission_type=filter.PermissionType.ADMIN)
async def restart_server(self, event: AstrMessageEvent):
'''重启服务器。'''
# 处理逻辑
```

`permission_type` 可选 `filter.PermissionType.ADMIN` 或 `filter.PermissionType.MEMBER`,不传则保持原有行为(所有人可用)。

如果你是通过 `@dataclass` + `FunctionTool` 的方式定义 Tool(见上方[定义 Tool](#定义-tool)一节),也可以用同样的方式声明默认权限,只需要在 dataclass 里加上 `declared_permission_type` 字段:

```py
from pydantic import Field
from pydantic.dataclasses import dataclass

from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext


@dataclass
class RestartServerTool(FunctionTool[AstrAgentContext]):
name: str = "restart_server"
description: str = "Restart the server."
parameters: dict = Field(default_factory=lambda: {"type": "object", "properties": {}})
declared_permission_type: str | None = "admin" # 可选 "admin" / "member" / None

async def call(self, context: ContextWrapper[AstrAgentContext], **kwargs) -> ToolExecResult:
# 处理逻辑
return "ok"
```

> [!WARNING]
> - `permission_type` / `declared_permission_type` 只是工具的**默认权限**。如果机器人主人在 WebUI 面板(扩展 -> 组件 -> 工具管理)里为该工具手动配置过权限,面板上的配置会**覆盖**插件代码里声明的默认值。
> - 这个机制的意义在于:即便机器人主人从未打开过 WebUI 面板配置任何东西,插件作者依然可以为自己写的危险工具(例如重启服务、执行 shell 命令等)提供一层默认的安全防护,而不必依赖用户主动去配置。

## 调用 Agent

> [!TIP]
Expand Down
Loading
Loading