跳至正文
浏览章节

跨进程插件

用任何语言,在 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 到达,那个键是桥注册的。一个问题就是 一个问题,仅此而已——一次权限提问是宿主自己才能打开的。

一个插件想向另一个插件要的一切,都改走 连线服务。