编写内存插件
记忆插件是一种存储后端:它持久化代理记住的内容并回答回忆查询。它是数据模型负担最重的插件类型。工具有一个函数,channel 有一种消息形状,而记忆后端则有多代理归属、命名空间、会话、类别和重要性权重,并且运行时的记忆语义(作用域回忆、GDPR 导出、替代)依赖于你的实现正确处理行模型。
本指南假设你已了解 工具插件 的基础知识,以及来自 channel guide 的 warm-store 生命周期。它已针对 wit/v0/memory.wit 和 crates/zeroclaw-plugins/src/wasm_memory.rs 中的主机适配器进行校验。
接线状态。
WasmMemory基于memory-plugin世界实现了运行时完整的Memorytrait,受能力门控,并通过单元测试和真实组件的端到端测试覆盖。运行时尚未将其作为可配置后端进行构造;宿主缺少与channel_plugin_details()对应的内存侧接口。与通道相同,应面向约定进行构建:真正需要冻结的是 WIT 世界和适配器语义。内存世界目前还没有配置导出,因此不要请求config_read;只有在将类型化配置 ABI 和解析器接入WasmMemory之后,才能再添加该导出。
数据模型
一种记录类型会在两个方向上跨越边界,memory-entry (memory.wit)。在设计存储之前先将其字段内部化,因为这些可选方法都只是对它们的视图:
| 字段 | 含义 |
|---|---|
id | 行标识。 |
key | 查找键。不唯一:多行可能共享一个键,每个 agent 一行。你的存储必须以 (key, agent-id) 为键,而不是 key。 |
content | 记住的文本。 |
category | core(长期事实)、daily(会话日志)、conversation(上下文)或 custom(string)。 |
timestamp | RFC 3339 创建时间。时间范围回溯边界是包含性的。 |
session-id | 可选会话范围。 |
namespace | 代理或上下文之间的隔离边界。 |
score | 检索相关性 0.0-1.0;非向量召回时使用 none。 |
importance | 可选的优先级权重 0.0-1.0。 |
superseded-by | 替换此项的条目 ID(如果有)。 |
agent-alias / agent-id | 显示名称与原始存储标识符。使用 agent-id 进行作用域相等性检查,使用 agent-alias 进行显示。 |
(key, agent-id) 组合是最常见的错误。基础 get 契约明确说明:当多行共享同一个 key 时,返回的是任意一个匹配的行,而按 agent 范围的查找走的是 get-for-agent。同样,forget 会删除某个 key 的所有行,而不管归属如何;而 forget-for-agent 只会删除恰好对应 (key, agent-id) 的那一行,并保留其他同级行。
必需的导出
有十二个函数没有默认值,且必须实现(memory.wit,required-methods 部分):
| 导出 | 契约说明 |
|---|---|
name | 后端名称。 |
get-memory-capabilities | 可选方法的位掩码;在加载时读取一次。 |
store-entry | 存储 (key, content, category, session-id)。之所以命名为 store-entry,是因为 store 在 wit-bindgen 中是保留字。 |
recall | 查询 + 限制 + 可选会话和 RFC 3339 时间边界(含)。空查询或仅为 * 的查询表示仅按时间检索:返回最近的条目。 |
get | 按键;多代理键冲突时的任意行。 |
list-entries | 可选的类别和会话筛选器。命名为 wit 保留的 list。 |
forget | 删除键的所有行;如果删除了任何内容,则为 true。 |
forget-for-agent | 仅删除 (key, agent-id) 行。 |
count | 总条目。 |
health-check | 可达性。 |
store-with-agent / recall-for-agents | 感知归因的成对项;见下文。 |
recall-for-agents 接受一个 agent-filter 变体:all(不筛选 agent)或 some(list<string>)(限制为列出的 agent ID)。运行时将其 Rust &[&str] 切片映射为“空切片表示 all”,因此应将 some([]) 视为匹配不到任何内容,而不是匹配所有内容。
能力标志:11 个可选方法
与通道相同:宿主只读取一次 get-memory-capabilities,对于每个未设置的标志,它会使用 Rust trait 默认值,而不是调用你。默认值在 memory.wit 中紧挨着这些标志以内联注释的形式记录,宿主侧的回退逻辑可在 wasm_memory.rs 中看到(每个受保护的方法都会检查该标志,并在缺失时走回退路径):
| 标志 | 未设置时使用主机回退 |
|---|---|
get-for-agent | 主机组合 get + agent-id 相等过滤器 |
purge-namespace, purge-session, purge-session-for-agent, purge-agent | Host 返回“not supported” |
reindex | 主机返回 0 |
store-procedural | 主机无操作 |
ensure-agent-uuid | 主机会原样回显别名 |
recall-namespaced | Host 调用 recall 并按命名空间进行后过滤 |
export-entries | 主机调用 list-entries 并在之后进行过滤 |
store-with-metadata | Host 委托给 store-entry,丢弃 namespace 和 importance |
请把最后一行读两遍:如果你不实现 store-with-metadata,运行时请求的命名空间和重要性会被回退机制悄悄丢弃。存储命名空间数据的后端应当成套实现 store-with-metadata、recall-namespaced 和 purge-namespace,否则命名空间隔离会悄然退化为后过滤和有损写入。
purge 家族是你的数据删除入口。purge-agent 接受的是 agent-alias(不是 ID);export-entries 用于 GDPR 第 20 条的数据可携性,且必须返回按创建时间升序排列、排除 embeddings 的 entries。如果你的后端提供真实用户数据,请实现 purge 和 export 标志;只有对于一次性后端,“not supported” 才是可接受的回答。
Sketch:存储形状
组件模式采用的是 channel 模式(warm 实例、thread_local 状态),因此只有数据层不同。一个最小且诚实的后端实现是内存中的 map,并使用正确的键:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
struct Row {
id: String,
content: String,
category: Category,
timestamp: String,
session_id: Option<String>,
namespace: String,
importance: Option<f64>,
superseded_by: Option<String>,
agent_alias: Option<String>,
}
/// (key, agent_id) -> Row。agent_id None 表示未归因的行。
type Table = HashMap<(String, Option<String>), Row>;
}
然后每个必需的方法都是一次直接的遍历:
store-entry以命名空间"default"插入(key, None)。store-with-agent使用调用者的命名空间和重要性在(key, agent-id)处插入。recall按content进行子串/排序筛选,应用会话筛选器,对timestamp应用包含式 RFC 3339 边界,排序,并截断到limit。将空/*查询按最近优先处理。recall-for-agents在键的第二个组件上添加 agent-filter 遍历。
真实的后端可以在不改变契约结构的情况下,将 map 替换为嵌入式存储。内存适配器有意不链接 wasi:http,即使其作用域携带 http_client;远程后端需要经过独立组件测试的内存网络边界。
主机在你周围所做的事情
了解 wasm_memory.rs 中的适配器行为,有助于解释几个合约边缘情况:
- 温存储,每次调用重新加油。 与 channels 相同:插件生命周期内一个实例,每次调用补充新燃料,调用在互斥锁后串行化。你的后端永远不会看到并发调用。
- 能力在加载时缓存。 主机仅在
from_wasm期间读取一次你的标志,此后不再读取。没有动态能力发现;重启守护进程才会重新读取它们。 - 一次 trap 就会终止后端。 每个调用点都会用具名上下文包裹 trap(例如
memory.recall-namespaced trapped)。trap 或燃料耗尽会让 Wasmtime 拒绝再次进入该 store;而错过截止时间会在你的代码执行到一半时将其中断。因此在上述任一情况下,宿主都会丢弃你的实例,之后所有到达该实例的调用都会失败,并返回plugin instance is unavailable。宿主无法提升(lift)的结果也会如此,例如单次调用从 guest 内存中复制的数据超过 Wasmtime 默认的 128 MiB。宿主不会重建该实例:你的条目只存在于该实例中,新实例会表现得仿佛从未存储过任何内容。对于可预期的失败,请返回err(string),而不是 panic;你返回的错误会保留该实例。 - 后置过滤回退在主机端执行。 当你的
recall-namespaced标志未设置时,命名空间过滤会在你的recall返回后在主机端运行。你的limit处理会与此交互:主机把调用方的 limit 传给recall,因此后置过滤可能会使结果不足。这也是要原生实现 namespaced 变体的另一个原因。
清单、构建、安装
manifest 是插件目录中名为 manifest.toml 的文件。其字段是 crates/zeroclaw-plugins/src/lib.rs 中 PluginManifest 的 serde 表面,这是唯一的事实来源:
| 字段 | 必填 | 含义 |
|---|---|---|
name | 是 | 唯一的规范包 slug,也是每个派生实例配置键中的包组件。它本身不是操作符配置键。使用 1–128 个小写 ASCII 字符;以 [a-z0-9] 开头和结尾,中间只能包含 [a-z0-9._-]。发现过程会拒绝无效或重复的名称。 |
version | 是 | 版本字符串,例如 0.1.0。 |
description | 不 | 由 zeroclaw plugin list 显示的人类可读描述。 |
author | 不 | 作者姓名或组织。 |
wasm_path | 用于 WASM 能力 | 组件文件名,相对于插件目录。除非唯一的 capability 是 skill,否则为必填。如果指定的文件不存在,发现过程会跳过该插件。 |
wasm_sha256 | 在 WASM 能力的严格模式下 | 十六进制的 SHA-256 值,对应组件的精确字节内容。若存在该值,则始终强制校验;严格签名策略要求必须提供该值,以便已签名的清单能够绑定被准入的可执行文件。 |
capabilities | 是,非空 | 插件类型:tool、channel、memory、observer、skill 中的任意一个(PluginCapability,序列化为 snake_case)。 |
provides | 不 | 面向内置通道的 channel 插件(drop-in):它所镜像的蛇形命名配置 ID,例如 telegram、gmail_push。宿主会为每个已配置、已启用,且被已启用 agent 路由到的 [channels.<id>.<alias>] 准入一个逻辑实例,因此规范的通道配置仍是这些设置的唯一归属。需要 config_read。plugin 系列为保留项,会被拒绝;本构建未定义的 ID 会被拒绝;两个已安装包声明同一 ID 时,两者都会被拒绝。未设置(默认)时,表示从 [channels.plugin.<alias>] 配置的新通道。镜像已准入但尚未构建,参见 crates/zeroclaw-runtime/src/plugin_runtime.rs 中的激活计划。 |
permissions | 不 | 宿主服务(代码可能访问的服务):http_client、config_read、state_read、state_write、file_read、file_write、memory_read、memory_write(PluginPermission)。HTTP、配置和精确实例状态权限仅对工具/通道适配器强制执行;文件系统和内存权限目前不生效。声明 config_read 时必须同时声明 config_schema。 |
config_schema | 恰好使用 config_read | 为此插件的私有配置起草一份 Draft 2020-12 JSON Schema。它包含在规范清单字节中,因此清单签名时会一并覆盖。根对象必须是带有 properties 映射且 additionalProperties = false 的对象。每个顶层属性必须直接或通过本地 JSON Pointer 明确指定一种受支持的类型:string、boolean、integer、number、array 或 object。工具和通道的使用方可以直接在顶层 string 属性上设置 x-secret = true,使其从公共配置中移除,并通过限定作用域的 secrets.get 宿主导入提供。secret 属性名是 1–128 字节的可移植 ASCII 名称,只能包含字母、数字、_、- 或 .。工具通过 __config 接收公共配置,并可在 execute 期间读取 secret。通道在 configure 和运行时操作调用期间,通过 config.get 读取当前公共对象,并通过 secrets.get 读取 secret;这两个导入在实例化和静态元数据发现期间均不可用。嵌套的、值为 false 或非布尔值的 secret 标记,以及非字符串的 secret 属性都会被拒绝。没有 config_read 的 schema,或没有 schema 的 config_read,都会被拒绝。 |
signature | 不 | 对规范化清单字节进行的 Base64url Ed25519 签名。用于发布签名时设置。 |
publisher_key | 不 | 签名者的十六进制编码 Ed25519 公钥。 |
egress | 不 | 可选的 [egress] 表,包含一个 hosts 列表:声明该插件所需访问的目标地址,可以是精确主机名(api.example.com)或显式的后缀模式(*.example.com,匹配子域名但不匹配顶级域名本身)。该表属于规范化的清单字节内容,因此在清单签名时会被一并覆盖,修改它需要重新签名。无效语法会在发现和安装阶段导致整个清单被拒绝。缺失、为空以及“未声明任何内容“三种状态视为相同。 |
只声明代码实际使用的权限。未声明的权限是组件无法触达的宿主表面;不必要声明的权限则是你主动增加的攻击面,也是审查你的插件的人需要承担的审核负担。
[egress] 是一项声明,而非授权。清单文件本身不赋予任何网络访问权限:主机强制执行的白名单是运维人员在该实例自身的 zpi1_… 记录上设置的 plugins.entries[].egress_hosts,因此即使未签名的组件自行写入了 [egress] 表,也无法访问任何地址。声明的作用在于安装流程:zeroclaw plugin install 会将其写入它新建的记录中,并打印已授予的内容;对于已存在的记录,它从不会扩大其权限。参见 Declaring and granting egress。
操作方提供的值在 plugins.entries 中仍保持为字符串,并会在持久化时加密,以一个由宿主拥有的包、能力和绑定标识派生的版本化 zpi1_… 字符串作为键(安装过程会打印并初始化默认工具绑定的完整实例密钥):字符串按原样存储,布尔值和数字使用 JSON 标量文本,数组和对象使用 JSON 文本。在任何来宾代码运行之前,宿主会将这些字符串物化为包模式规定的类型,并针对工具和频道适配器验证完整对象。非机密工具属性构成 __config;频道通过 config.get 获取非机密对象。标记为 x-secret = true 的属性会从两个公共接口中省略,仅可在授权的服务帧中通过 secrets.get("property") 获取。频道在一次调用中的公共和机密读取共享同一个规范修订版本,调用结束时宿主会丢弃该物化视图。符合要求的频道插件 必须 在每个使用点都解析这两类值,并且不得在来宾的热状态中保留配置或凭据值;将明文返回给来宾意味着宿主无法针对恶意代码强制实施不保留策略。如果请求了 config_read 但未实际获授予,宿主会验证一个空对象;因此,包含必需属性的模式会拒绝启动,而不会在缺少必需配置的情况下启动。如果空对象有效,工具会省略空的 __config,而频道的配置/机密导入会返回 access-denied;在授权帧之外的调用、解析失败以及宿主调用预算耗尽会返回 unavailable。
对于内存后端:capabilities 包含 memory。暂时不要请求 config_read:准入需要一个模式,但当前内存世界没有可供主机交付生成对象的导出机制。也不要依赖 http_client:仅授予权限无法扩展内存适配器,而该适配器目前不提供任何网络接口。
先安装一次 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 的反序列化检查拒绝,而不会被静默错误加载。
这些命令需要一个编译了插件宿主的二进制文件。 64 位桌面和服务器目标的发布归档包(x86_64 和 aarch64 的 Linux,包括 glibc 和 musl 版本,Apple silicon 与 Intel 的 macOS,以及 Windows x86_64)都包含该宿主,并且每次发布前都会从这些归档中各执行一个插件,然后才会正式发布。两个 32 位 ARM 归档和实验性的 Android 归档不包含该宿主,使用 Cargo 默认配置的源码构建也不包含(
cargo install zeroclaw、install.sh --source或不带特性的cargo build):在这些情况下,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 list 和 zeroclaw 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 文件,或签名策略拒绝)。
下一个
- Skill bundles:通过相同的安装机制进行仅限 markdown 的技能分发。
- 分发插件。