跳至正文
浏览章节

用 Rust 写插件

Plugin trait、一个插件可以注册的十一种贡献,以及最先该读的那个完整例子。

插件是什么

一个 crate,依赖 bingo-sdk 且不依赖比它更重的东西,声明一份静态清单,并同步地注册 贡献。

#[async_trait]
pub trait Plugin: Send + Sync + 'static {
    fn manifest(&self) -> &'static PluginManifest;

    /// Synchronous, in dependency order. Only registers; does no I/O.
    fn register(&self, registrar: &mut Registrar) -> Result<(), PluginError>;

    /// After every plugin has registered. May spawn tasks.
    async fn start(&self, _host: HostHandle) -> Result<(), PluginError> { Ok(()) }

    async fn stop(&self) -> Result<(), PluginError> { Ok(()) }
}

三个阶段,按顺序,每个只做一件事。register 不许做 I/O——它是在宿主被装配的过程中被 同步调用的。start 才是插件生成任务、读一个目录或打开一条连接的地方。stop 把 start 拿走的东西还回去。

清单

static MANIFEST: PluginManifest = PluginManifest {
    id: "bingo.tools.fs",
    version: env!("CARGO_PKG_VERSION"),
    sdk: "^0.1",
    provides: &["tool:Read", "tool:Edit", "tool:Write"],
    requires: &[],
    config: None,
};

id 是反向点分的。provides 和 requires 是 kind:name 字符串——tool:Read、 provider:anthropic、service:bingo.checkpoint。一个缺失的依赖会带着一条通知停用这 个插件;它绝不会把宿主搞崩。sdk 是一个在启动时检查的 semver 要求。

config 认领设置键,并说明每一个键跨层如何合并:

config: Some(ConfigClaim {
    keys: &[
        ("permissions.defaultMode", Merge::Replace),
        ("permissions.allow", Merge::Accumulate),
    ],
    schema,
}),

内核绝不去反序列化一个插件的设置。registrar.config::<T>() 把合并好的那一片交回来, 类型由认领它的插件来定,所以加一个键不需要改内核——而一个没人认领的键会在启动时被报出 来,不会被忽略。

可以注册什么

十种贡献,各自躲在一个 sdk trait 后面,另外还有一种为其中六种服务的、延迟解析的来源:

贡献trait它是什么
ToolTool模型可以调用的东西
ProviderProvider一个模型后端
PolicyPermissionPolicy对一次过闸调用的裁决
HookHook内核生命周期点上的一个处理器
ContextContextContributor进入提示词的东西
CommandCommand人或客户端运行的一条 /name
SurfaceSurface整个宿主的一个客户端
StoreSessionStore日志住在哪里
CompactorCompactor一次压缩背后的策略
Service任意类型,外加一个可选的 WireService另一个插件按键查到的一个值
各个 …Source 变体ToolSource、CommandSource、ContextSource、ProviderSource、CompactorSource、HookSource同样这些种类,延迟解析

来源之所以存在,是因为注册是同步的,而有些贡献只有在 I/O 之后才知道——一个 MCP 服务器 的工具、一个目录里的技能、一个外部进程的任何东西。来源是同步注册的,并用它此刻手上有 的东西作答;答以空手从来都不算错(ADR-0009)。内核在它需要这个集合的那一刻才去读 来源:一个回合在开始时收集它的工具,而当一个名字不在静态表里时,actor 才去问命令来源。 一个会阻塞的来源会拖住一个回合的开始,所以来源要从缓存作答,把它的 I/O 放在别处做。

这些 trait,简述

Tool —— 一份 spec 和一个 call。其余全都有默认实现:

fn spec(&self) -> ToolSpec;
fn traits(&self, input: &Value) -> ToolTraits;         // fail closed by default
fn subjects(&self, input: &Value, cwd: &Path) -> Vec<Subject>;  // what a rule matches on
fn confirm(&self, input: &Value) -> Option<String>;    // only a person may decide
fn preview(&self, input: &Value, cwd: &Path) -> Option<Preview>; // reads, never writes
async fn call(&self, input: Value, cx: &ToolContext) -> Result<ToolOutput, ToolError>;

ToolTraits::default() 是那个失败即拒绝的读法——不是并发安全的,不是只读的,不受信任, 且 Interrupt::Block。只有当它确实为真时才另说。subjects 是权限语法拿来匹配的东西: Bash 产出命令,Edit 产出路径,WebFetch 产出 url。preview 是权限卡片所展示的东西,这 让那张卡片成为一个会写东西的工具的提案步骤。

Command —— 一份 spec、一个 run,以及可选的补全。结果是四者之一: Applied { message }、View { view }、Prompt { text }(它会在这条命令自己的 intent 下变成一个回合),或者 Record { body }(对话记录里的一个已完成条目)。标了 instant 的命令即使在一个回合进行中也照跑;其余的都在它后面排队。

Hook —— 一个 id、一个匹配器、四个决策点和四个观察点:

async fn on_submit(&self, input: &mut Input, cx: &HookContext) -> HookOutcome;
async fn before_tool(&self, call: &mut ToolCall, cx: &HookContext) -> HookOutcome;
async fn after_tool(&self, call: &ToolCall, out: &ToolOutput, cx: &HookContext) -> HookOutcome;
async fn on_stop(&self, cx: &HookContext) -> HookOutcome;

async fn on_turn(&self, phase: Phase, turn: &TurnId, items: &[Item], cx: &HookContext);
async fn on_compact(&self, phase: Phase, cx: &HookContext);
async fn on_session(&self, phase: Phase, cx: &HookContext);
async fn on_event(&self, frame: &Frame, cx: &HookContext);

HookOutcome 是 Continue、Deny、Ask、Block 或 Redirect。没有 Allow。 钩子永远只能收紧发生的事,绝不能放宽——闸门是策略的,而钩子不是它。

ContextContributor —— 一个 id、一个 Placement(System { order }、 RoundStart 或 Barrier),以及一个返回若干片段的 contribute。查询把整个宿主交了 过去,所以一个贡献者可以读一个会话的 extensions,甚至读另一个完整的会话。

PermissionPolicy —— decide,一个可选的 on_verdict(它装上人所接受的那条会话 范围的规则),以及一个 describe,内核会在它变化时把它作为 ConfigView.plugins[id] 发布出去。策略自己的那张映射仍然是唯一的事实;视图是它的投影。

Provider —— 一个 id、一个 family、一个失败即拒绝的 endpoint(model) 能力集、 一个 stream,以及可选的 count_tokens、models、auth、login 和 logout。

SessionStore —— create、append、replay、list、delete,以及为所有权声明 准备的 acquire/release(ADR-0005)。

Surface —— 一个 id、一个 kind,和一个把宿主交给它的 run。全部就这些:界面不 持有会话状态,像其他任何客户端一样折叠帧,并在渲染时导出自己的视图。

库与插件

一个插件不许依赖另一个插件。当两个插件需要同一份代码时,它就搬进一个库:一个声明 了 [package.metadata.bingo] tier = "library" 的 crate,什么都不注册,且只依赖 bingo-sdk 和外部 crate。bingo-auth-oauth 是那个做过的案例——PKCE、设备码、凭据存储、 单飞刷新——被不止一个提供方插件使用,而它们谁也没导入谁。

当两个插件需要的是彼此的行为而不是彼此的代码时,那就是一个服务:一个注册在某个字符 串键下、经由注册表查到的值。跨过一个进程边界,同样的想法就变成 连线服务。

最先该读的例子

crates/bingo-demo-ui 是三条 UI 通路的参考实现,也是一个插件在想把丰富的东西放到一块 它一无所知的屏幕上时该有的形状。它很小,除非 --demo-ui 打开它否则是关着的,而它存在 就是为了被读。

bingo --demo-ui

然后是 /board,以及 DemoProgress 工具。它的核心部分被编译成一个 doc test,所以设计 文档里的那个例子是一个能构建的例子:

/// The block lane: a person reads the board, the model reads the text.
fn block() -> ToolOutput {
    ToolOutput {
        parts: vec![ContentPart::text("3 rows")],
        is_error: false,
        display: Some(Board::default().view()),
    }
}

/// The panel lane: journaled, and back after `--continue`.
async fn panel(host: &HostHandle, session: &SessionId) -> Result<(), KernelError> {
    let view = serde_json::to_value(Board::default().view()).unwrap_or_default();
    host.extend(session, "bingo.demo.ui", "board", view).await
}

/// The live lane: never journaled, gone on a resume, `Null` removes it.
async fn live(host: &HostHandle, session: &SessionId, step: u64) -> Result<(), KernelError> {
    let bar = View::Progress { value: step, total: Some(15), label: Some("cargo test".into()) };
    let payload = serde_json::to_value(bar).unwrap_or_default();
    host.signal(session, "bingo.demo.ui", "progress", payload).await
}

如果你压根不想写 Rust,同样这些工具和命令也能跨过一个进程边界——见 跨进程插件。