写一个工具插件¶
工具插件给月璃增加她能主动调用的能力。宿主在启动期扫目录、读清单、导入入口文件、
找出其中唯一的 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() |
bind_config 之后、on_load 之前 |
因此工具声明不得依赖 on_load 建立的状态;可以按 self.config 调整声明 |
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。
适配器的写法见适配器模块。
主程序版本区间¶
第三方工具插件必须在清单 host_application 中同时声明 min_version 与
max_version,均为 x.y.z 三段非负整数,闭区间且下界不得高于上界。
内置插件与程序同时发布,可以省略区间。清单解析只检查格式,宿主在执行入口前
比较当前版本:低于下界、主次版本超过上界时跳过,原因与清单格式错误分别展示。
只有补丁号超过上界且主次版本相同时按兼容模式加载,每个进程只提示一次。
0.x 期间,插件门面、清单与上下文的不兼容变化只在次版本更新时发生,补丁版本
保持兼容。features.toml [advanced] force_compatibility 默认关闭;应急打开时
可放行版本不符或缺少区间的第三方插件,各插件均会告警,不影响清单格式校验。
该开关也放行 MCP 协议版本区间限制,但不放行 SDK 包版本限制。
调用 MCP 工具¶
插件只从 src.plugin_system 导入 McpToolResult、McpContentItem、
McpUnavailableError 与 PluginMcpAccess,通过
await self.ctx.mcp.call_tool(服务名, 工具原名, 参数字典) 调用。
工具必须列在宿主的 MCP 配置中、已确认只读、服务已连接且尚有今日额度。
expose = false 只限制她的可选项,不限制插件;两种调用共用同一计数。
不可用时抛出带中文原因的 McpUnavailableError;工具内部或服务器请求失败时
返回 is_error=True。结果 items 保留内容块,text 按顺序拼接全部文本,
structured 提供原有结构化内容,插件自行选择是否按 JSON 解析。
识别聊天图片与视频¶
self.ctx.media(PluginMediaAccess)把宿主识别入站图片、视频的同一套服务开放给
插件:await describe_image(来源) 与 await describe_video(直链, 内容文件名) 返回与
聊天正文同格式的替换文本([图片:…]、[视频:…] 或视频超出时长上限的写法),
下载、解码、模型或读时长失败时返回 None,原因由宿主服务记日志。缓存、在途去重、
视频时长上限与模型路由都由宿主处理,插件拿不到服务对象本身。
images_available() / videos_available() 反映主配置开关与模型是否可用,调用前
应先检查;宿主没有装配对应服务时直接调用会抛 RuntimeError。
describe_video 不套用入站时的视频观看范围。要与顶层视频同口径,用
videos_watched_on_arrival(入站消息) 判断这条消息里的视频是否属于收到即看的范围
(范围为 all,或私聊、@ 她、叫她名字、回复她);是否识别、何时识别由插件自己
决定并承担费用。
每一项的超时由宿主服务自己管:max_wait_s(images=…, videos=…) 报告所选类型里
按来源识别一项最长要等多久(图片是下载截止加识别截止,视频是识别截止)。截止在
服务构造时定下、配置热重载不重算,工具要等识别时可以在 tools() 里据此声明
timeout_ms,不必另写一个需要手工保持「大于宿主截止」的常量。
宿主的在途去重让同一内容的后来者直接等先发起者的任务,等待方被取消会把这个共享
任务一起取消。工具执行可能被取消(回复途中被打断会取消整个回合),所以要在工具里
等识别时,把 describe_* 放进插件自己创建的任务,再用 asyncio.shield 或
asyncio.wait 等它。真实用法见合并转发插件:收到转发时在后台为所有层发起识别,
读取时只等结果。
密钥字段与配置保存¶
密钥必须在配置模型中显式标注,例如
api_key: str = Field('', json_schema_extra={'secret': True})。
管理页读取时置空并显示是否已设置;保存时空串沿用磁盘原值。
普通字符串字段不凭名称猜测为密钥。secret_fields(model) 返回声明的密钥字段集合。
宿主 save_plugin_config(directory, model, values) 按实际模型整份校验,任一非法字段
拒绝整次保存并返回字段与原因;通过后按与配置生成相同的渲染器原子替换。