Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

编写 Channel 插件

通道插件是一种消息平台集成:它将代理的响应投递到某个平台,并将该平台的消息呈现给代理。它是最复杂的插件类型,因为通道是长生命周期、有状态的,并通过一个 27 个函数的接口与运行时交互,其中只有 5 个是必需的。

本指南假设你已经构建了 tool plugin,并理解 crate 设置、__config 规则、日志记录和安装。它已针对 wit/v0/channel.witcrates/zeroclaw-plugins/src/wasm_channel.rs 中的主机适配器进行检查。

接入状态。 频道插件由正在运行的守护进程构建。通过 [channels.plugin.<alias>] 绑定的已安装软件包会在启动时被纳入,并接受与原生频道完全相同的监管。请参阅下面的激活频道插件

生命周期

通道插件的运行时形态与工具的运行时形态在三个基本方面不同,而每一点都会推动你代码中的一个设计决策:

  1. 插件生命周期内使用一个热存储。主机只实例化一次你的组件(WasmChannel::from_wasm),并在异步互斥锁保护下持有该存储。组件可以在调用之间保留由来宾拥有的协议状态,但操作员配置仍由主机持有。符合规范的插件必须在每个需要它们的操作中调用 config.getsecrets.get,并且不得将其结果复制到来宾的热状态中。主机会在每次调用后丢弃其物化视图,但无法阻止恶意来宾代码保留返回的 JSON 或明文。主机会在每次调用前为存储重新注入燃料(component.rs 中的 call_channel!),因此长期存活的通道每次调用都会获得全新的燃料预算,而不是在其整个生命周期内逐渐耗尽。
  2. 配置会在使用点请求。 宿主在加载时调用不带参数的 configure 导出,且仅调用一次,并在任何其他导出之前。调用 config.get 获取根据清单的 config_schema 验证的类型化公共 JSON 对象;标记为 x-secret = true 的属性会被省略,必须通过 secrets.get 读取。在 configure 中或任何后续操作导出中进行的公共配置和机密读取,共享同一个已解析的配置修订版本。因此,同一绑定中的公共配置和凭据轮换会在下一次操作中一并可见。实例化和静态发现期间的调用会返回 unavailable,不会解析配置。静态发现包括 nameplugin-infoget-channel-capabilitiesself-handleself-addressed-mentionmulti-message-delay-ms;更改机器人/账户身份或其他静态元数据需要重建通道生命周期。
  3. 你不监听;主机向你转发。 WASI 上下文没有网络监听能力。入站流量通过导入的 inbound 接口到达你:主机运行实际的监听器(webhook 服务器、供应商隧道、轮询客户端),将收到的每条消息入队到 InboundQueue,而你的 poll-message 导出通过调用 inbound-poll 来清空它。如果有用,可使用 inbound-pending 批量清空。

必需的导出

五个函数没有 Rust trait 默认实现,且必须真正工作(world channel-plugin 文档,channel.wit):

导出合同
name人类可读的频道名称。
configure完成加载时的初始化。它不接受任何参数;针对一个当前修订版本调用 config.getsecrets.get。错误字符串会导致加载失败。
send向平台发送一个 send-message(内容、收件人,以及可选的主题/线程/附件)。
poll-message非阻塞:立即返回下一条传入消息或 none。永不阻塞;主机的轮询桥负责节奏控制。
get-channel-capabilities返回你实际实现的可选方法位掩码。在加载时调用一次。

轮询桥值得一提:宿主运行一个轮询到推送的循环(wasm_channel.rs 中的 listen),在队列为空时以 50ms 到 500ms 的指数退避调用 poll-message,一旦有流量就重置。如果你的 poll-message trap,宿主会将该通道标记为轮询不健康,记录日志并退避;即使插件没有导出自己的 health-check,只要它的轮询持续 trap,通过 health_check 也会报告不健康。因此,poll-message 中的 trap 是可见的,而不是致命的,但它会使你的通道失去作用。保持简单:清空队列,翻译,返回。

能力标志:22 个可选方法

界面中的其他所有内容都由 channel-capabilities 标志控制。模式(与 memory world 完全相同):

  • 主机会在加载时读取你的标志一次。
  • 对于每个未设置的标志,主机都会使用 Rust trait 默认值,并且不会调用你的导出。
  • 你仍然必须导出每个函数;返回文档中默认值的存根可以编译通过,而且永远不会被调用。

各个标志的默认值已在 channel.wit 中紧邻标志声明的位置进行内联文档说明,这里才是事实来源。总而言之,这些组:

分组标志实现能为你带来什么
健康health-check报告平台可达性;并按主机适配器结合轮询健康状况。
身份self-handleself-addressed-mentiondrop-self-message自环保护(运行时会丢弃机器人自身的消息)以及每个频道系统提示中的正确 @ 提及形式。主机会在加载时缓存 self-handleself-addressed-mention;它们只会被读取一次。
输入start-typing, stop-typing在代理思考时显示指示器。
Draftssupports-draft-updates, send-draft, update-draft, update-draft-progress, finalize-draft, cancel-draft渐进式消息编辑:运行时会将响应流式传输到一个可编辑的平台消息中,而不是等待完成。必须一次性实现全部六项,或者一项也不实现。
多消息流式传输supports-multi-message-streaming, multi-message-delay-ms按段落逐段发送,消息之间的最小延迟(默认 800ms,在加载时缓存)。
审核add-reaction, remove-reaction, pin-message, unpin-message, redact-message表情反应、置顶、消息删除。
交互request-approval, request-choice, supports-free-form-ask在平台上原生呈现的工具调用审批提示和多项选择题。

从必需的 5 个开始,加上 health-check,并在平台支持时添加分组。宣传一个你尚未实现的标志比省略它更糟:宿主会调用你的导出并信任其返回结果。

审批界面

request-approval 是最深层的集成点。运行时会提供一个紧凑的 approval-request(工具名称、参数摘要、可选的原始 JSON 参数),而你的通道会按平台允许的方式将其渲染出来(按钮、表情反应、回复约定)。你返回的 approval-response 变体会驱动安全机制:

  • approve:执行这一次调用
  • deny:拒绝它
  • always-approve:执行并将该工具添加到会话范围的允许列表
  • deny-with-edit(string):拒绝,但提供编辑后的替换参数

当无法显示提示时返回 none;调用方将回退到自动拒绝。请采取失败即关闭。

入站消息形状

将平台事件如实转换为 inbound-message 记录。运行时的线程逻辑以平台载荷字段为依据,而路由标识仅来自主机发放的端点(channel.witwasm_channel.rs 中的 from_wit_inbound):

  • idsendercontent:基础字段。reply-target 是响应应发送到的位置(频道 ID、聊天 ID、电子邮件地址)。
  • channelchannel-alias 是保留在 v0 记录中的旧版提示。主机在路由时会忽略这两者,并标记已接纳的通道类型和已配置的绑定,因此插件无法选择其他所有者或会话。
  • thread-ts 承载平台的线程标识符,用于线程式回复;subject 用于电子邮件线程。
  • interruption-scope-id 将消息分组用于中断/取消。顶层消息请将其保持为 none
  • attachments 通过边界携带完整原始字节(media-attachment:文件名、字节、可选 MIME 类型)。语音笔记会以值传递跨越数兆字节;这就是 32 位边界的已记录成本,而资源句柄模型被明确推迟到未来的 WIT 修订版。

在出站侧,send-message 映射同样的字段;Rust 中 SendMessage 的取消令牌被刻意从 WIT 记录中省略,因为它是宿主端概念,在插件内部没有意义。

骨架

结构,省略按平台翻译部分,那才是你的实际工作:

#![allow(unused)]
fn main() {
#[cfg(target_family = "wasm")]
mod component {
    wit_bindgen::generate!({
        path: "wit/v0",
        world: "channel-plugin",
        features: ["plugins-wit-v0"],
    });

    use exports::zeroclaw::plugin::channel::{
        ApprovalRequest, ApprovalResponse, ChannelCapabilities,
        Guest as Channel, InboundMessage, SendMessage,
    };
    use exports::zeroclaw::plugin::plugin_info::Guest as PluginInfo;
    use zeroclaw::plugin::config::get as config_get;
    use zeroclaw::plugin::inbound::inbound_poll;
    use zeroclaw::plugin::secrets::get as secret_get;

    #[derive(serde::Deserialize)]
    #[serde(deny_unknown_fields)]
    struct ChannelConfig {
        api_base: String,
    }

    fn current_config() -> Result<ChannelConfig, String> {
        let json = config_get().map_err(|_| 公共配置不可用.to_string())?;
        serde_json::from_str(&json).map_err(|e| format!("无效的配置 JSON: {e}"))
    }

    fn current_api_token() -> Result<String, String> {
        secret_get("api_token").map_err(|_| "api_token 不可用".to_string())
    }

    fn current_inputs() -> Result<(ChannelConfig, String), String> {
        // 此导出中的两个导入共享同一个已解析的规范修订版本。
        Ok((current_config()?, current_api_token()?))
    }

    struct MyChannel;

    impl Channel for MyChannel {
        fn name() -> String {
            "my-platform".to_string()
        }

        fn configure() -> Result<(), String> {
            let (config, api_token) = current_inputs()?;
            validate_configuration(&config.api_base, &api_token)
        }

        fn send(message: SendMessage) -> Result<(), String> {
            let (config, api_token) = current_inputs()?;
            // 通过 wasi:http 进行平台出站传送
            // (需要在清单中声明 http_client 权限)。使用此调用的值构建
            // 请求;绝不要保留第二份副本。
            send_to_platform(&config.api_base, &api_token, message)
        }

        fn poll_message() -> Option<InboundMessage> {
            // 排空主机馈送的队列并进行翻译。
            inbound_poll().map(translate_inbound)
        }

        fn get_channel_capabilities() -> ChannelCapabilities {
            ChannelCapabilities::HEALTH_CHECK
        }

        fn health_check() -> bool {
            current_inputs().is_ok()
        }

        // 其他方法:返回 WIT 文档中默认值的存根。
        // 在其标志未设置时,宿主从不调用它们。
        // ...
    }

    export!(MyChannel);
}
}

current_inputs 特意在使用点调用。宿主将两个导入都绑定到此获准的包、channel 能力和别名;同一导出中的读取共享一个已解析的配置修订版本,而下一个导出可以观察到同一绑定的公共配置以及凭据轮换。ChannelConfig 是每次调用的类型化视图,会随令牌一并丢弃。不要添加 thread_local 配置或凭据缓存。

清单和权限

manifest 是插件目录中名为 manifest.toml 的文件。其字段是 crates/zeroclaw-plugins/src/lib.rsPluginManifest 的 serde 表面,这是唯一的事实来源:

字段必填含义
name唯一的规范包 slug,也是每个派生实例配置键中的包组件。它本身不是操作符配置键。使用 1–128 个小写 ASCII 字符;以 [a-z0-9] 开头和结尾,中间只能包含 [a-z0-9._-]。发现过程会拒绝无效或重复的名称。
version版本字符串,例如 0.1.0
descriptionzeroclaw plugin list 显示的人类可读描述。
author作者姓名或组织。
wasm_path用于 WASM 能力组件文件名,相对于插件目录。除非唯一的 capability 是 skill,否则为必填。如果指定的文件不存在,发现过程会跳过该插件。
capabilities是,非空插件类型:toolchannelmemoryobserverskill 中的任意一个(PluginCapability,序列化为 snake_case)。
permissions代码可能访问的主机服务:http_clientconfig_readfile_readfile_writememory_readmemory_writePluginPermission)。目前仅前两项会被强制执行;其余项虽会被接受,但不起作用。声明 config_read 需要 config_schema,目前只有工具/通道适配器会提供它。
config_schema恰好使用 config_read为此插件的私有配置起草 2020-12 JSON Schema;它会包含在规范清单字节中,因此在清单签名时也会受到保护。根必须是一个包含 properties 映射且 additionalProperties = false 的对象。每个顶层属性都必须具有一个明确的受支持类型,可直接指定,也可通过本地 JSON Pointer 指定:stringbooleanintegernumberarrayobject。工具和通道使用者可以直接在顶层字符串属性上设置 x-secret = true,以便将其从公共配置中移除,并通过带作用域的 secrets.get 主机导入来公开。工具会在 execute 期间通过 __config 接收公共配置,并可以读取机密。通道会在 configure 和操作调用期间通过 config.get 读取当前公共对象,并通过 secrets.get 读取机密;这两个导入在实例化和静态元数据发现期间均不可用。嵌套、值为 false 或非布尔值的机密标记,以及非字符串的机密属性都会被拒绝。没有 config_read 的 schema,或没有 schema 的 config_read,都会被拒绝。
signature对规范化清单字节进行的 Base64url Ed25519 签名。用于发布签名时设置。
publisher_key签名者的十六进制编码 Ed25519 公钥。

只声明代码实际使用的权限。未声明的权限是组件无法触达的宿主表面;不必要声明的权限则是你主动增加的攻击面,也是审查你的插件的人需要承担的审核负担。

操作方提供的值在 plugins.entries 中仍保持为字符串,并会在持久化时加密,以一个由宿主拥有的包、能力和绑定标识派生的版本化 zpi1_… 字符串作为键(安装过程会打印并初始化默认工具绑定的完整实例密钥):字符串按原样存储,布尔值和数字使用 JSON 标量文本,数组和对象使用 JSON 文本。在任何来宾代码运行之前,宿主会将这些字符串物化为包模式规定的类型,并针对工具和频道适配器验证完整对象。非机密工具属性构成 __config;频道通过 config.get 获取非机密对象。标记为 x-secret = true 的属性会从两个公共接口中省略,仅可在授权的服务帧中通过 secrets.get("property") 获取。频道在一次调用中的公共和机密读取共享同一个规范修订版本,调用结束时宿主会丢弃该物化视图。符合要求的频道插件 必须 在每个使用点都解析这两类值,并且不得在来宾的热状态中保留配置或凭据值;将明文返回给来宾意味着宿主无法针对恶意代码强制实施不保留策略。如果请求了 config_read 但未实际获授予,宿主会验证一个空对象;因此,包含必需属性的模式会拒绝启动,而不会在缺少必需配置的情况下启动。如果空对象有效,工具会省略空的 __config,而频道的配置/机密导入会返回 access-denied;在授权帧之外的调用、解析失败以及宿主调用预算耗尽会返回 unavailable

对于通道:包含 channelcapabilities,并且几乎可以肯定还需要同时包含 config_read(没有凭据,任何平台都无法运行)和 http_client。通道适配器实现了出站 wasi:http,但只有在验证该授权后才会将其链接;缺少这两项,send 就没有通往平台的网络路径。

config_readChannelConfig 使用的架构配对:

name = "my-platform"
version = "0.1.0"
wasm_path = "my_platform.wasm"
capabilities = ["channel"]
permissions = ["config_read", "http_client"]

[config_schema]
"$schema" = "https://json-schema.org/draft/2020-12/schema"
type = "object"
additionalProperties = false
required = ["api_base", "api_token"]

[config_schema.properties.api_base]
type = "string"
minLength = 1

[config_schema.properties.api_token]
type = "string"
minLength = 1
x-secret = true

主机会将这两个属性作为同一个对象进行验证。config.get 返回包含 api_base 的类型化 JSON,并省略 api_token;后者只能通过 secrets.get 获取。由于两者都是必需的,不提供 config_read 会在来宾代码运行前以安全失败方式终止,而不是在缺少必要配置的情况下启动通道。每个通道实例都会选择一个 plugins.entries 键,该键由其完整软件包、channel 能力和绑定标识派生,同时复用这一由软件包定义的架构。因此,不同软件包中的相同别名仍彼此隔离。install 和 info 命令无法创建此键,因为它们不拥有已配置的通道别名;它们的自动打印和初始化行为仅限于工具本身,因此通道实例的条目需要手动写入。

在每个使用它们的操作内部调用 config.getsecrets.get。宿主对该调用最多解析一个规范版本,并在调用结束后丢弃其视图。同一逻辑绑定中的公开配置和凭据轮换会在下一次操作中一并可见,无需重新加载守护进程或重建通道。更改机器人/账户身份、公布的能力、自身句柄、提及标识或其他加载时元数据,需要重建通道生命周期,因为这些导出项在静态发现期间只读取一次。

对于空对象有效的可选架构,即使实例未获授有效的 config_read 授权,也可以加载,但 config.getsecrets.get 会返回 access-denied。任一导入都会在实例化或静态发现期间、解析器/验证失败后,或共享主机调用预算耗尽时返回 unavailable。对于缺少某个名称或未将其标记为 x-secret = true 的情况,secrets.get 还会返回 not-found

激活频道插件

已安装的软件包在操作员将其绑定到逻辑通道实例之前不会执行任何操作。绑定仅命名软件包,不涉及其他内容;别名就是该实例的标识:

[plugins]
enabled = true

[channels.plugin.operations]
package = "acme.chat"
enabled = true

[agents.support]
channels = ["plugin.operations"]

别名会变成普通的频道引用,因此 plugin.operations 会像 telegram.main 一样被路由、监督、重启和寻址。两个别名可以指向同一个软件包;每个别名都有自己的实例、存储和 plugins.entries 键,因此它们不共享任何状态。

实例只有在以下所有条件均满足时才会获准。每一项都是有意设计的故障关闭门槛,缺少其中任何一项的声明都将保持不生效,而不会处于半启动状态:

  • plugins.enabled 为 true。
  • 声明中的 enabled 为 true。
  • 指定的软件包已安装,并且其清单声明了 channel 能力。
  • 某个已启用的代理在其 channels 中列出了 plugin.<alias>。未被引用的绑定会运行一个没有投递目标的监听器。

准入发生在运行任何来宾代码之前:它依据包宿主已验证的清单作出决定,因此,组件损坏的包与组件完好的包会以完全相同的方式被规划并拒绝。通过准入但随后构造失败的包会被记录并跳过,因此,一个损坏的插件不会阻止守护进程启动其他通道。

plugins.max_active_instances 限制所有能力中可接纳的逻辑实例数量。显式通道绑定的优先级高于自动发现的工具和技能,因此,已满的插件目录无法取代操作员手动配置的通道。

同一个获准集合驱动全部三个加载器:通道加载器、工具注册表和插件技能加载器。因此,上限是一个共享预算,而不是每种能力各自独立的预算。一个同时提供通道和工具的软件包确实会占用两个槽位,而超过上限的工具或技能根本不会被构造。准入是当前配置和已安装软件包的纯函数:它不保存任何计数器,因此,按智能体、按 CLI 运行、按委托以及按 SOP 执行分别重建的工具注册表,都会重新推导出同一个集合,而不会在长时间运行的守护进程生命周期内耗尽上限。

工具和技能实例会被_自动发现_,因此只有在 plugins.auto_discover 为 true 时才会被纳入。显式 [channels.plugin.<alias>] 声明无需启用此设置。在 plugins.enabled = trueauto_discover = false 的情况下,你得到的恰好是所声明的通道绑定,不会有其他内容。

plugins.max_plugins 迁移。 旧键从未生效,现已替换为 plugins.max_active_instances。这两个键统计的对象不同:旧键统计已安装的软件包数量,新键统计已纳入的逻辑实例数量,因此,同时提供通道和工具的软件包会占用两个实例。由于统计单位不同,现有的 max_plugins 值不会沿用:它会被忽略,新键使用其默认值。如果你依赖非默认上限,请显式设置 max_active_instances

还有哪些部分尚未接入

Plugin 通道是异步构建的,此时同步通道映射接口已经构建完成。因此,按通道寻址的 tools 暂时无法指向 Plugin 通道。通过受监管的监听器进行的入站轮询和出站传递不受影响;缺少的只有工具侧寻址。

构建并安装

先安装一次 WASI Preview 2 目标,然后构建组件:

rustup target add wasm32-wasip2
cargo build --release --target wasm32-wasip2

该组件位于 target/wasm32-wasip2/release/<crate_name>.wasm(crate 名称中的连字符会变成下划线)。在组装插件目录时,请将其重命名为你的清单中 wasm_path 声明的名称。

[!IMPORTANT] 编译后的 .wasm.cwasm 文件是二进制构件,通常每个都有数 MB。不要在没有 Git LFS 的情况下将它们提交到 git 源树中:每次重建都作为普通 blob 提交,会永久膨胀仓库历史,并且 git diff/审查工具会因此卡住。把它们当作其他构建输出一样处理:将 target/*.wasm/*.cwasm 添加到 .gitignore,并改为通过发布构件或插件注册表归档分发。如果某个构件确实必须留在树中,请在首次提交之前使用 LFS 跟踪该模式(git lfs track "*.wasm")。

如果目标主机是仅运行时构建(未编译 JIT 后端),它在加载时无法编译 .wasm;而是反序列化预编译的 .cwasm。请使用与主机版本匹配的 wasmtime CLI 进行预编译,并将 .cwasm 作为 wasm_path 构件分发。版本不匹配的构件会被 wasmtime 的反序列化检查拒绝,而不会被静默错误加载。

这些命令需要一个编译了插件宿主的二进制文件。 安装程序附带的预构建发布二进制文件在构建时未启用 plugins-wasm 特性,因此 zeroclaw plugin ... 在该版本中是无法识别的子命令,已安装的插件也永远不会被发现。请从源码构建并选择一个插件执行后端,例如 cargo build --release --features plugins-wasm-cranelift

每个插件都位于 plugins 目录的各自子目录中(默认 ~/.zeroclaw/plugins/,通过 plugins.plugins_dir 解析),其中包含清单以及与清单的 wasm_path 匹配的组件:

~/.zeroclaw/plugins/
└── my-plugin/
    ├── manifest.toml
    └── my-plugin.wasm

从本地目录安装(这会在复制任何内容之前验证清单形状并运行签名策略):

zeroclaw plugin install ./my-plugin/

启用插件系统并确认发现:

zeroclaw config set plugins.enabled true
zeroclaw plugin list
zeroclaw plugin info my-plugin

zeroclaw plugin listzeroclaw plugin info 可确认软件包已安装且可被发现,但发现并不等于激活。plugins.enabled = true 会开启插件主机;只有同时将 plugins.auto_discover = true 设为 true 时,自动发现的工具和技能功能才会在运行时加载,而该标志默认为 false(故障关闭):

zeroclaw config set plugins.auto_discover true

因此,仅设置 plugins.enabled = true 时,会启用你在 [channels.plugin.<alias>] 下声明的通道,而不会启用任何插件工具或技能:工具或技能包可能会出现在 zeroclaw plugin list 中,但在运行时不会产生任何作用。显式通道绑定由操作员命名,而不是自动发现,因此不需要 auto_discover;该标志仅控制自动发现的工具和技能。

zeroclaw plugin list 中缺失的插件在发现时已被跳过:请检查启动日志中的跳过警告(清单格式错误、缺少 wasm_path 文件,或签名策略拒绝)。

针对主机合约进行测试

主机适配器和配置解析器测试是可执行规范:它们涵盖类型化物化和架构验证、使用点的公开和机密作用域、一致的同修订版本轮换、被拒绝的授权、静态发现拒绝、入站队列移交、能力门控调度以及轮询健康度统计。

要在这些完全相同的语义下运行你自己的组件,请编写一个集成测试,通过真实的宿主适配器实例化它。zeroclaw-plugins 未发布到 crates.io,因此请将其作为 git dev-dependency 引入,并固定到与你的目标宿主匹配的标签:

cargo add --dev zeroclaw-plugins \
  --git https://github.com/zeroclaw-labs/zeroclaw --tag <host-version> \
  --no-default-features --features plugins-wasm-cranelift

随后,测试会在 PluginHostServices 中封装一个由清单和测试操作员值支持的 PluginConfigResolver::new,通过 WasmChannel::from_wasm 加载你的组件,将消息加入其公开的 InboundQueue 句柄,并断言你的 poll-message 会排空并转换该消息。生产守护进程将运行的代码路径与此完全相同;在没有实际主机的情况下,通过该测试是分发前能获得的最强信号。

下一个