Schema 与协议
两份已提交的 JSON Schema、它们所带的版本,以及 bingo 会说的三种连线格式。
两份 schema,都是生成的
bingo 在仓库根部提交了两份 JSON Schema 文档。两份都由 schemars 从 sdk 自己的类型生成, 所以 schema 就是代码,而不是对代码的一段描述。
| 文件 | 标题 | 协议 | 它描述什么 |
|---|---|---|---|
schema/rpc.json | bingo rpc | 1 | bingo serve 所说的那个 JSON-RPC 界面 |
schema/plugin.json | bingo plugin | 5 | 一个跨进程插件所说的那条线 |
每一份都为每个连线类型持有 $defs——rpc.json 里 81 个,plugin.json 里 110 个——外加
一张 methods 表和一张 notifications 表,它们的条目是指向那些定义的 $ref。
plugin.json 还带着一个 manifest 引用和一个 hostService 块。
有两个测试让它们保持诚实。一个重新生成文档,一有差异就失败,并说出更新它的那条命令:
BINGO_UPDATE_SCHEMA=1 cargo test -p bingo-surface-rpc
BINGO_UPDATE_SCHEMA=1 cargo test -p bingo-plugin-rpc
另一个断言每个属性名都是 camelCase,而这正是日志、RPC 界面和插件连线三者一致同意的 写法。
当你要对着这两条线中的任何一条写东西时,读 schema,不要读这一页。下面这些是给你定位 方向用的。
RPC 界面
JSON-RPC 2.0,一行一条消息,UTF-8,走 stdin 和 stdout(ADR-0007)。
方法表与内核的宿主 API 一一对应,另加一次握手。initialize 必须最先来——别的都会得到
NOT_INITIALIZED。
| 方法 | 用途 |
|---|---|
initialize | 握手:进去的是客户端身份和协议,出来的是名字、版本和能力 |
shutdown | 结束这个服务器 |
session/list | 匹配某个过滤条件的会话 |
session/open | 附着:返回这个会话和一份快照 |
session/close | 解除附着;会话继续跑 |
session/delete | 删掉它 |
session/history | 给一段长对话记录翻页 |
session/events | 重新同步:某个 since 之后的帧会被重发 |
session/submit | 一次输入 |
session/interrupt | 停下一个回合 |
session/answer | 回答一个打开着的交互 |
session/deliver | 投递到另一个会话的队列里 |
session/extend | 发布持久的插件状态 |
session/signal | 发布瞬时的插件状态 |
catalog/read | models、providers、tools、commands、skills、plugins 之一 |
gateway/subscribe | 网关层面的事件 |
两个通知:event,原样携带 sdk 序列化出来的一个 Frame;以及
gateway/event。
写客户端之前有三条性质值得知道:
- 事件是原样的。 客户端用
SessionState::apply折叠它们——就是内核所跑的那个 reducer——并导出自己的视图。session/open的响应是 seq 为 N 的一份快照,它会写在该 会话任何 seq 大于 N 的event之前;同一个会话的帧按 seq 顺序到达;而Lagged { from, to }的意思是“带着你的 seq 调用session/events”。 - 写操作返回
{}。submit、interrupt和answer在邮箱收下它们的那一刻就作答。 结果作为IntentAck事件到达,它的 intent 就是客户端生成的那个 ULID,那也是幂等键。 - 错误分两层。 协议层面的毛病用 JSON-RPC 自己的码——
-32700解析、-32600无效 请求、-32601未知方法、-32602无效参数——而每一个内核错误都是-32000,它那个 稳定的字符串走error.data.code,它的文字是error.message:
{"jsonrpc":"2.0","id":3,"error":{"code":-32000,"message":"no such session",
"data":{"code":"SESSION_NOT_FOUND"}}}
一个 stdio 服务器服务一个客户端。同一个会话上的两个进程会被存储的锁拒绝。一个服务器 的并发客户端会随 WebSocket 传输一起到来,那会承载同样的字节。
插件连线
同样是 NDJSON 上的 JSON-RPC 2.0,同样是 sdk 自己的类型作为 JSON——桥只加信封,从不加 形状。
九个方法:initialize、tool/call、command/run、command/complete、
context/contribute、compactor/compact、provider/stream、hook/decide,以及
service/call——那个双向旅行的。五个通知:tool/progress、tool/cancel、
provider/delta、provider/cancel、hook/observe。
PROTOCOL 版本是 5,在握手里发出并被回显。宿主不会说的大版本会被拒绝并给出一条
通知,而不是去猜。方法的数目不再是一个字面量了——它是从已提交的 schema 推出来的一个钉
子,所以开一项新能力意味着重新生成那份文档并抬高协议版本。
schema 里的 hostService 点明桥所保留的那一个服务 bingo.host,以及它背后的两个方法。
见连线服务。
stream-json 信封
--print --output-format stream-json 是一个有损的兼容编码器,不是第三套协议
(ADR-0007 §8)。它把同一批帧投影到 Claude Code 的方言上,好让一个已经会说那套方言的
宿主不用插件就能驱动 bingo。内核不知道它存在。
行的种类:先是 subtype 为 init 的 system,然后是回合运行期间的 assistant 和
user 行,最后是一行 result。
{"type":"system","subtype":"init","session_id":"…","cwd":"…","tools":[…],
"model":"…","permissionMode":"default","apiKeySource":"none"}
{"type":"result","subtype":"success","is_error":false,"duration_ms":8412,
"duration_api_ms":0,"num_turns":3,"result":"…","session_id":"…",
"total_cost_usd":0.0,"usage":{…}}
result 只存在于成功那一支;出错的那几支在它的位置上带着 errors。bingo 填不了的
字段是略去而不是编造——uuid、mcp_servers、slash_commands、modelUsage、
permission_denials,以及 result 行的 stop_reason。写出来但恒定不变的东西写得
诚实:apiKeySource 是 none,total_cost_usd 是 0.0,因为这里没有任何东西给一个
回合定价;duration_api_ms 是 0,因为只有整个回合被计时;而一条消息的 usage 是零,
因为 bingo 按轮计 token,并在 result 行里把总数报一次。
与之对应的输入方向 --input-format stream-json,文档在
无头运行。
要的东西不止是兼容的话,--output-format json 给你帧本身。
日志格式
磁盘上的一个会话就是它的日志被写了下来(ADR-0005)。第 1 行是一个头部:
{"format":"bingo-journal","version":1,"session":"<id>"}
之后的每一行都是一个持久的 Frame,就是它序列化出来的样子,按 seq 顺序,逐帧追加并
刷盘,绝不重写。瞬时的帧绝不写下。
由此得出三条规则。撕裂的最后一行——写到一半崩了——会被丢掉,回放止于最后一个完整的帧。
别处任何一行读不出来,都是一个点名那一行的 STORAGE 错误:损坏是要报出来的,不是跳过
去的。而一个比读取方更新的头部版本会被拒绝,因为版本 1 绝不就地编辑。格式变更是一个
新版本和一个重新折叠的迁移器。
它旁边的两个文件是 .lock——唯一的所有权声明——以及 summary.json,后者是推导出来的:
缺了一份会从日志重建,而把它们全删掉,除了 list 的速度以外什么也不会失去。