跨进程插件
用任何语言,在 stdio 上走 JSON-RPC,交付一个 bingo 原生的工具、命令、钩子、贡献者、压缩器或提供方。
这座桥
bingo-plugin-rpc 托管外部插件进程,并把它们的回答变成普通的贡献(ADR-0015)。内核
从不知道某个工具是远程的:桥为每种能力持有一个代理结构体,实现的是 sdk 自己的 trait,
而它的方法体是连线调用。这里没有一套平行的远程 trait 体系,因为那会是同一份契约的第二
种表示。
这个设计的验收标准就在仓库里:examples/plugins/wordcount/ —— Python 3,只用标准库,
一个工具和一条命令,由一个穿过真实二进制的黑盒测试端到端地驱动。
安装一个
一个插件就是一个装着 plugin.json 的目录。两层:
~/.bingo/plugins/<name>/ yours, in every project
<project>/.bingo/plugins/<name>/ this repository's — and it wins the name
目录的名字就是插件的名字,而且必须与清单里的 name 一致:路径就是一层覆盖另一层
所依据的东西,所以两个拼法就会是“这是哪个插件”的两个答案。
清单
{
"name": "wordcount",
"version": "0.1.0",
"entry": {
"command": "python3",
"args": ["${PLUGIN_ROOT}/main.py"]
}
}
entry.env 是往宿主的环境上加,而不是把它换掉。${PLUGIN_ROOT}——可以用在 command
里、任何参数里和任何环境变量值里——是读到这份清单的那个目录,一份清单正是这样在不知道
自己被装到哪里的情况下,指明它旁边的解释器和脚本。
一个可选的 config 字段装着插件自己那份设置的 JSON Schema:人写在 plugins.<name>
底下的那一片,会以 initialize.config 到达这个进程。它是给写设置的人看的文档;工作区
里没有任何东西拿它去校验一份文档。
连线
JSON-RPC 2.0,一行一条消息,UTF-8,在进程的 stdin 和 stdout 上。stdout 上只有消息,
别无他物;插件本来要打印的任何别的东西都走 stderr,宿主把它写进
~/.bingo/data/logs/plugin-<name>.log——绝不写到终端。
那条线上的类型就是内核自己的类型。ToolSpec、CommandSpec、ToolOutput、
CommandOutcome、Completion 和 View 本来就可序列化、本来就有 schema;桥只加信封,
从不加形状。仓库根部的 schema/plugin.json 由这些类型生成并已提交,而它就是非 Rust
作者对着写的那份文档。
握手
宿主发出 initialize {protocol, pluginRoot, config, env}。插件回答它是什么,以及它贡献
的一切:
{
"protocol": 5,
"name": "wordcount",
"version": "0.1.0",
"tools": [ … ],
"commands": [ … ]
}
tools、commands、contributors、compactors、providers、hooks 和 services
每一个都是可选的——声明你有的种类,其余的不写。宿主不会说的 protocol 会被拒绝并给出
一条通知,而不是去猜。
方法与通知
| 方法 | 方向 | 用途 |
|---|---|---|
initialize | 宿主 → 插件 | 握手 |
tool/call | 宿主 → 插件 | 运行一个工具:{callId, name, input, cwd, session, turn} → {output} |
command/run | 宿主 → 插件 | {name, args, cwd, session} → {outcome} |
command/complete | 宿主 → 插件 | {name, partial, cwd} → {completions} |
context/contribute | 宿主 → 插件 | {id, query} → {pieces} |
compactor/compact | 宿主 → 插件 | {id, context, reason} → {compaction} |
provider/stream | 宿主 → 插件 | 一次模型响应,以通知的形式流式送回 |
hook/decide | 宿主 → 插件 | {id, site, point, payload} → {outcome, value?} |
service/call | 双向 | {key, method, params} → {result} |
| 通知 | 方向 | 用途 |
|---|---|---|
tool/progress | 插件 → 宿主 | {callId, tail} —— 这次调用的实时输出行 |
tool/cancel | 宿主 → 插件 | 回合被打断了 |
provider/delta | 插件 → 宿主 | 一次流式响应的一块 |
provider/cancel | 宿主 → 插件 | 停止流式输出 |
hook/observe | 宿主 → 插件 | 一个观察点;没有东西等它 |
宿主仍然会等一次已取消的调用的回答,所以一个忽略 tool/cancel 的插件只是慢,从来不算
坏掉。
一个进程说自己什么都不算数
桥上的工具穿着 ToolTraits::default()——不受信任、不是只读、不是并发安全、打断行为是
Block——不管插件声称了什么。闸门对每一次调用都要问。工具名会被改写成
plugin__<name>__<tool>,好让权限语法能指到它们:
{ "permissions": { "allow": ["plugin__wordcount__count"] } }
能力来自握手时的声明,而不认识的一律答 false。一次查询以它可序列化的投影跨过去:
外部贡献者读到的是查询,不是宿主。
每一次跨越都有期限。超时的贡献者会被从那一轮里丢掉,并给出一条通知——回合绝不会被 挡住。超时的压缩器、提供方或钩子会让那次调用失败,用的是它的 trait 本来就会说的那个 错误,而一个错过期限的钩子什么也决定不了。
一个进程是可以死的
这里没有健康检查,也没有守护者。一个死掉的进程会让它的那些来源什么也答不出,并抛出 一条通知;下一次读来源时会带退避地把它重新拉起。答以空手从来都不算错,所以一个崩掉的 插件只让这个回合损失它的那些贡献,别的什么也不损失。
什么会跨过去,什么不会
工具、命令、上下文贡献者、压缩策略、提供方、钩子和服务会跨过去(ADR-0015、ADR-0030、 ADR-0031、ADR-0032)。
策略不会。 裁决那一层留在进程内。钩子之所以被允许跨过去,恰恰是因为 HookOutcome
没有 Allow:一个外部钩子可以 Continue、Deny、Ask、Block 或 Redirect,所以
它只能收紧发生的事,绝不能放宽。
存储不会。 每一帧的追加是最热的写路径,而且锁的语义还得挺过进程死亡。
界面不会,因为它们的门本来就有:一个外部客户端对某个界面说 JSON-RPC 或宿主协议, 而那条通路不归插件连线来重复一遍。
wordcount 走一遍
三个文件,其中一个还是 README。
examples/plugins/wordcount/
plugin.json the manifest above
main.py 186 lines of Python 3, standard library only
README.md the contract, in a page
main.py 是一个在 stdin 上的循环。它的握手声明了一个工具和一条命令:
def handshake():
return {
"protocol": PROTOCOL,
"name": "wordcount",
"version": "0.1.0",
"tools": [{
"name": "count",
"description": "Counts the words, lines and characters in a file.",
"inputSchema": {
"type": "object",
"properties": {"path": {"type": "string", "description": "…"}},
"required": ["path"],
},
}],
"commands": [{
"name": "wordcount",
"hint": "count the words in a file",
"args": {"kind": "free", "hint": "<path>"},
"instant": True,
"family": "plugin",
}],
}
这个工具一次答两样东西——给模型的文本,和给人的一个 View:
answer(request_id, {
"output": {
"parts": [{"type": "text", "text": said}],
"display": table(path, counts),
}
})
display 是一个 View,所以终端界面画出一张真的表格,--print 把它折成文本,而一个
IM 频道发出这个折叠结果。同一个值也就是 /wordcount <path> 作为 CommandOutcome
返回的东西:
answer(request_id, {"outcome": {"kind": "view", "view": table(path, counts)}})
补全是又一个方法——工作目录里以已打出的内容开头的那些文件——而进度是一条通知,界面把它 显示成这次调用的实时输出行:
notify("tool/progress", {"callId": params["callId"], "tail": "reading %s" % path})
装上它,两者都能用:
cp -r examples/plugins/wordcount ~/.bingo/plugins/wordcount
bingo "how many words are in notes.txt?"
bingo "/wordcount notes.txt"
向宿主要东西
一个插件进程恰好够得到两扇宿主的门,再多没有(ADR-0033):
ask {call, question}在一个该插件当前正在跑的callId上,向人提出一个问题。 它骑的是进程内工具提问时所骑的同一套提问机器。一次已经结束的调用,以及一次不属于你 的调用,都会被用话拒绝掉。notice {level, message}在任何时候,以插件自己的名义说一行。它不需要任何授予, 花掉的也只有一行。
两者都作为对保留键 bingo.host 的 service/call 到达,那个键是桥注册的。一个问题就是
一个问题,仅此而已——一次权限提问是宿主自己才能打开的。
一个插件想向另一个插件要的一切,都改走 连线服务。