写一个工具插件¶
工具插件给月璃增加她能主动调用的能力。宿主在启动期扫目录、读清单、导入入口文件、
找出其中唯一的 ToolPlugin 子类,接线就完成了——不需要改动任何主体代码。
照着 src/plugins/built_in/hello-yueli/ 抄一份是最快的路径,那个目录就是为此存在的。
必要的四件事¶
一、目录放在被扫描的根下¶
目录里两份必需文件:_manifest.json 与 plugin.py。config.toml 不要手写,它由宿主按插件声明的配置模型生成。
两个根都会被扫,内置排在前面。同一个 id 出现在两处时先扫到的生效,后者的入口 代码根本不会执行。
二、_manifest.json¶
{
"manifest_version": 1,
"id": "example.hello-yueli",
"plugin_type": "tool",
"name": "Hello-YueLi",
"version": "0.1.0",
"description": "一句话说明这个插件是干什么的"
}
六个字段全部必填,少一个或者 manifest_version 不是 1 都会被拒绝加载。
id 是全局唯一标识,两个根目录里撞名时靠它去重,启动日志也打印它。
三、plugin.py 里恰好一个 ToolPlugin 子类¶
from src.plugin_system import PluginManifest, ToolPlugin, tool
class MyPlugin(ToolPlugin):
def __init__(self, manifest: PluginManifest) -> None:
super().__init__(manifest)
必须只有一个。 加载器用「模块内唯一实现」定位入口类,出现第二个直接报错—— 多入口意味着加载顺序决定行为,那是最难查的一类问题。
构造函数只接受清单一个参数。需要可调参数时在 on_load 里读配置,不能加到构造签名上。
四、用 @tool 声明工具¶
@tool(
name='hello_yueli',
description='当用户明确要求演示插件工具时调用,返回一句问候。',
parameters={
'type': 'object',
'properties': {'name': {'type': 'string', 'description': '称呼'}},
'required': [],
'additionalProperties': False,
},
side_effect='readonly',
)
async def hello_yueli(self, invocation, context):
return ToolExecutionResult(
tool_name=invocation.tool_name,
success=True,
observation='……',
)
声明和实现写在同一个方法上:装饰器产出声明,方法本身就是执行体。几条硬规矩:
- 执行体必须是
async,同步方法在导入期就报错。 description的口径是「什么时候用它」,不是复述工具名——模型只凭这一句决定要不要调。- 失败必须给
error_message,ToolExecutionResult会拒绝构造没有原因的失败。 side_effect三档:readonly默认可用,reversible需显式启用,irreversible一律被注册表拒绝登记。- 参数来自模型输出,长度和类型都要自己兜住。
observation会原样回灌进下一轮 提示词,不设上限等于把提示词预算交给模型决定。
生命周期¶
| 钩子 | 时机 | 约束 |
|---|---|---|
tools() |
构造之后、on_load 之前 |
因此工具声明不得依赖 on_load 建立的状态 |
on_load() |
启动期 | 不应发起网络 I/O;抛异常只导致本插件被移出注册表 |
on_unload() |
停机与重载 | 必须幂等,重复调用不得抛异常 |
tools() 早于 on_load 是刻意的:宿主必须先把工具登记进注册表,才能把注册表交给
对话代理构造,而 on_load 允许做 I/O、只能在事件循环里执行。
配置与开关¶
配置声明在代码里,config.toml 由宿主生成——声明只有模型这一份,手写一份初始
TOML 就是第二份声明,两份必然随演进漂移。
from pydantic import Field
from src.plugin_system import PluginConfig, ToolPlugin
class MyConfig(PluginConfig):
enabled: bool = Field(default=True, description='是否启用本插件')
greeting: str = Field(default='你好', description='问候语,会出现在回复开头')
class MyPlugin(ToolPlugin):
config_model = MyConfig
首次发现时,宿主在插件目录里生成:
# 本文件由宿主按插件声明的配置模型自动生成,可以直接编辑。
# 删掉它会在下次启动时重新生成一份默认配置。
[plugin]
# 是否启用本插件
enabled = true
# 问候语,会出现在回复开头
greeting = "你好"
每个字段的 description 成为它上方的注释——那是用户唯一能看到的字段解释,要写成
给人看的话。PluginConfig 基类自带 enabled,不声明 config_model 的插件也有开关,
默认启用。
读到的配置由宿主在 on_load 之前注入 self.config,类型就是 config_model:
不走构造函数是因为构造签名是插件契约的一部分:加载器统一以 plugin_class(manifest)
构造,加一个位置参数会让所有既有插件失效。
开关的判定分两阶段¶
| 阶段 | 做什么 | 为什么 |
|---|---|---|
| 一 | 只读 config.toml 里的 [plugin] enabled,不导入插件代码 |
已生成过配置的插件在这里就能挡下,入口写坏了也能靠开关绕开,不必删目录 |
| 二 | 文件不存在时按模型生成,然后完整校验 | 声明写在代码里,不导入就读不到;首次安装走这条路 |
代价是首次发现必然执行一次插件的入口模块顶层代码。没有别的办法。第二次启动之后 就走不到这条路了。
第一阶段是保守的快路径:只在看到明确的 false 时才敢短路,其余一律交给第二阶段。
所以 enabled = "no" 这类可强制转换的写法仍然会被正确判成关闭,只是多付一次导入。
两边不写同一套判据是刻意的——在快路径里抄一份 Pydantic 的布尔强转表,就是第二份真相源。
读不懂的配置按启用处理(文件损坏、值无法校验),只记一条 warning。关闭是显式 意图,坏文件表达不了意图;把「读不懂」当成「要关掉」,等于让一个笔误悄悄拿掉一项能力。
写不进去也不影响运行(只读安装、权限不足),记 warning 后以模型默认值运行。
反馈都在启动日志:生成配置打印「已生成插件配置」,加载成功打印「工具插件已加载」, 被关闭打印「工具插件已在自身配置中关闭」。
生成出的 config.toml 是运行时产物,已被 .gitignore 排除——默认值的真相源是代码里的
config_model,不是那份文件。
两个进阶挂载点¶
ToolPlugin 还有两个有默认实现的方法,多数插件用不上:
observe_inbound(stream_id, message_id, inbound)—— 观察每条已入库的入站消息, 供需要会话缓存的工具建索引。不得阻塞入站路径,也不得抛异常。stream_capabilities(stream_id)—— 按会话贡献平台能力,决定工具在该会话是否 进入声明。用来表达「这个工具在这个会话里有没有东西可读」——没有可读内容时让工具 声明出现,只会诱导模型调用后必败。
两者的真实用法见 src/plugins/built_in/forward-message/plugin.py。
故障隔离¶
发现、加载、入站观察、能力查询任何一步的单插件失败都只影响该插件自身:记 error、 从注册表移除,其余插件与主体照常运行。第三方插件目录里混着一个坏插件时整个 Bot 起不来,是不可接受的。
适配器插件不走这条路¶
plugin_type 还有 adapter,但适配器互斥、跑在独立进程,由进程入口按名字加载,
刻意不并入扫目录这条路径。把适配器清单放进插件根目录只会得到一条 warning。
适配器的写法见适配器模块。