Repository navigation
Plugin Development zh
English · 中文
对应 v0.12.0(
3a762b1)。本页的构建命令都在 Linux + wasmtime 19 上实跑过。 接口定义以仓库wit/nanopi-extension.wit为准;若与本页不符,以仓库为准。
插件是一个独立的 WASM component,不是链接 nanopi 的 crate。它不 use nanopi::...,唯一的依赖通常只有 serde_json。
宿主和插件的全部约定就是 wit/nanopi-extension.wit,它声明了三个 world:
world extension {
import host-log: func(level: u8, message: string);
import host-fs-read: func(path: string) -> string;
import host-http-get: func(url: string) -> string;
import host-store-get: func(key: string) -> string;
import host-store-set: func(key: string, value: string) -> string;
import host-notify: func(text: string) -> string;
import host-set-context: func(text: string) -> string;
import host-call-tool: func(name: string, args-json: string) -> string;
import host-send-user-message: func(text: string) -> string;
export list-tools: func() -> string;
export execute-tool: func(name: string, args-json: string) -> string;
}
world extension-commands {
include extension;
export list-commands: func() -> string;
export execute-command: func(name: string, args: string) -> string;
}
world extension-events {
include extension-commands;
export list-events: func() -> string;
export handle-event: func(event: string, payload-json: string) -> string;
}-
list-tools在加载时调用一次,返回工具的 JSON 数组(含 JSON Schema)。不导出它的 component 会被判定为"不是 nanopi extension"。 -
execute-tool每次工具调用一次,返回{"content": "...", "is_error": false}。 - 除
host-log和host-notify之外的每个导入,如果用户没在该插件的[[extensions]]条目里授权,调用一律被拒。拒绝是带内的error:前缀字符串,不是 trap。完整语义见 Plugin-Capabilities-zh。
三个 world 是一条线性阶梯,不是格:
extension ⊂ extension-commands ⊂ extension-events
WIT 表达不了"可选导出"。 只要 world 声明了某个导出而 module 没实现,wasm-tools component new 就失败——所以选了更高的 world,就必须把下面各层的导出全部实现,哪怕你根本不用。一个只想收事件的插件得把 list-commands 写成返回 "[]",execute-command 写成返回一个 error;两行 stub,这是不想"今天四个 world、下个特性八个"所付的代价。
宿主比工具链宽松:list-commands、execute-command 和 list-events 在宿主侧都是可选解析的,所以按旧 world 编译的插件照样能加载。逼你写 stub 的是 guest 侧的构建。(一个例外:如果 list-events 报了事件而没有 handle-event,那是硬性加载失败——既然申请了投递,就没有合理的兜底。)
导入是反方向的,根本不需要新 world。 九个导入全在 world extension 上、向上继承;按旧 WIT 编译的 guest 压根不引用新导入。这就是 v0.11 时期的插件二进制今天照样能加载的原因。
生命周期事件只能观察。 handle-event 的返回值被丢弃——插件拦不住工具调用,也改不了 prompt,而且这是写在类型里的,不是运行时检查。要否决什么,那是 生命周期 hooks:由用户自己配置的 shell 机制。插件侧细节见 插件事件订阅。
mkdir -p my-plugin/src && cd my-pluginCargo.toml——关键是 crate-type = ["cdylib"],这是 wasm32 目标产出 .wasm 的前提:
[package]
name = "my-plugin"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"下面是完整可编译的模板。BOILERPLATE 段原样抄,YOUR TOOLS 段是你的。
#![no_std]
extern crate alloc;
use alloc::format;
use alloc::string::{String, ToString};
use core::alloc::{GlobalAlloc, Layout};
// ═══ BOILERPLATE — 原样抄 ═══════════════════════════════════════════
// no_std 下 wasm32 不链接 libc,但 rustc 仍会 emit memcmp(字符串比较用)。
#[no_mangle]
pub unsafe extern "C" fn memcmp(a: *const u8, b: *const u8, n: usize) -> i32 {
for i in 0..n {
let (x, y) = (*a.add(i), *b.add(i));
if x != y {
return x as i32 - y as i32;
}
}
0
}
// no_std 仍需要堆给 String/Vec。wasm32 单线程,bump 分配器足够且模块小。
const ARENA_SIZE: usize = 1 << 20; // 1 MiB
static mut ARENA: [u8; ARENA_SIZE] = [0; ARENA_SIZE];
static mut OFFSET: usize = 0;
struct BumpAlloc;
unsafe impl GlobalAlloc for BumpAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let align = layout.align();
let start = (OFFSET + align - 1) & !(align - 1);
let end = start + layout.size();
if end > ARENA_SIZE {
return core::ptr::null_mut();
}
OFFSET = end;
core::ptr::addr_of_mut!(ARENA).cast::<u8>().add(start)
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {}
}
#[global_allocator]
static ALLOC: BumpAlloc = BumpAlloc;
// 宿主导入。注意 ret_area 是尾部出参 —— 见下方「ABI 陷阱」。
// 只声明你真的会调的;九个导入里剩下的见下方「其余宿主导入」。
#[link(wasm_import_module = "$root")]
extern "C" {
#[link_name = "host-log"]
fn host_log_raw(level: u32, ptr: *const u8, len: usize);
#[link_name = "host-fs-read"]
fn host_fs_read_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
#[link_name = "host-http-get"]
fn host_http_get_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
}
unsafe fn host_log(level: u32, msg: &str) {
host_log_raw(level, msg.as_ptr(), msg.len());
}
unsafe fn call_host_str(
f: unsafe extern "C" fn(*const u8, usize, *mut u8),
arg: &str,
) -> String {
let ret_area = ALLOC.alloc(Layout::from_size_align_unchecked(8, 4));
f(arg.as_ptr(), arg.len(), ret_area);
let ptr = ret_area.cast::<u32>().read() as *const u8;
let len = ret_area.cast::<u32>().add(1).read() as usize;
read_string(ptr, len)
}
#[allow(dead_code)]
unsafe fn host_fs_read(path: &str) -> String {
call_host_str(host_fs_read_raw, path)
}
unsafe fn host_http_get(url: &str) -> String {
call_host_str(host_http_get_raw, url)
}
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
// 宿主往我们内存里塞字符串时调用。canonical ABI 要求。
#[no_mangle]
pub unsafe extern "C" fn cabi_realloc(
_old_ptr: *mut u8,
_old_len: usize,
align: usize,
new_len: usize,
) -> *mut u8 {
if new_len == 0 {
return align as *mut u8;
}
ALLOC.alloc(Layout::from_size_align_unchecked(new_len, align))
}
// 导出返回字符串的约定:返回指向 (ptr,len) 对的指针。
unsafe fn string_result(s: String) -> *mut u8 {
let bytes = s.into_bytes();
let len = bytes.len();
let ptr = if len == 0 {
1 as *mut u8 // 0 长度用非空悬垂指针即可
} else {
let p = ALLOC.alloc(Layout::from_size_align_unchecked(len, 1));
core::ptr::copy_nonoverlapping(bytes.as_ptr(), p, len);
p
};
core::mem::forget(bytes);
let ret_area = ALLOC.alloc(Layout::from_size_align_unchecked(8, 4));
ret_area.cast::<u32>().write(ptr as u32);
ret_area.cast::<u32>().add(1).write(len as u32);
ret_area
}
unsafe fn read_string(ptr: *const u8, len: usize) -> String {
let slice = core::slice::from_raw_parts(ptr, len);
String::from_utf8_unchecked(slice.to_vec()) // WIT string 保证是合法 UTF-8
}
fn ok(content: String) -> String {
serde_json::json!({ "content": content, "is_error": false }).to_string()
}
fn err(content: String) -> String {
serde_json::json!({ "content": content, "is_error": true }).to_string()
}
// 导出名必须是 WIT 原名(带连字符),Rust 标识符写不出,故用 export_name。
#[export_name = "list-tools"]
pub unsafe extern "C" fn list_tools() -> *mut u8 {
string_result(TOOL_SPECS.to_string())
}
#[export_name = "execute-tool"]
pub unsafe extern "C" fn execute_tool(
name_ptr: *const u8,
name_len: usize,
args_ptr: *const u8,
args_len: usize,
) -> *mut u8 {
// 先读入参再分配别的:入参就住在 arena 里。
let name = read_string(name_ptr, name_len);
let args_json = read_string(args_ptr, args_len);
string_result(dispatch(&name, &args_json))
}
// ═══ YOUR TOOLS — 以下是你的部分 ═══════════════════════════════════
const TOOL_SPECS: &str = r#"[
{
"name": "greet",
"description": "Greet someone by name. Use when the user asks for a greeting.",
"parameters": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Who to greet." }
},
"required": ["name"]
}
},
{
"name": "fetch_head",
"description": "Fetch a URL and return the first 200 characters of the response body. Requires allow_network = true and the host in url_allowlist on this plugin's [[extensions]] entry.",
"parameters": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "The http/https URL to fetch." }
},
"required": ["url"]
}
}
]"#;
fn dispatch(name: &str, args_json: &str) -> String {
let args: serde_json::Value = match serde_json::from_str(args_json) {
Ok(v) => v,
Err(e) => return err(format!("bad arguments JSON: {e}")),
};
match name {
"greet" => {
let who = match args.get("name").and_then(|v| v.as_str()) {
Some(s) => s,
None => return err("missing required argument: name".to_string()),
};
ok(format!("Hello, {who}!"))
}
"fetch_head" => {
let url = match args.get("url").and_then(|v| v.as_str()) {
Some(s) => s,
None => return err("missing required argument: url".to_string()),
};
unsafe { host_log(1, "fetch_head: calling host-http-get") };
let body = unsafe { host_http_get(url) };
// 宿主用 `error: ` 前缀在带内返回拒绝/失败,透传给模型。
if body.starts_with("error: ") {
return err(body);
}
let head: String = body.chars().take(200).collect();
ok(format!("{} bytes; first 200 chars:\n{head}", body.len()))
}
other => err(format!("unknown tool: {other}")),
}
}import 和 export 返回字符串的方式不对称:
| 方向 | 约定 |
|---|---|
| import 返回 string | 尾部多一个 ret_area: *mut u8 出参 |
| export 返回 string | 返回一个指向 (ptr, len) 对的指针 |
搞反了不会在运行时报错,而是在 wasm-tools component new 阶段报类型不匹配。看到那个错先回来查这里。
第二个坑:导出名必须是 WIT 里的带连字符原名。wasm-tools component embed 找的是 list-tools,不是 list_tools,而 Rust 标识符不能带连字符——所以用 #[export_name = "list-tools"],不是 #[no_mangle]。
上面的模板只声明了九个里的三个。剩下六个就两种形状——一个字符串进、或两个字符串进,都是一个字符串出:
#[link(wasm_import_module = "$root")]
extern "C" {
#[link_name = "host-store-get"]
fn host_store_get_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
#[link_name = "host-notify"]
fn host_notify_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
#[link_name = "host-set-context"]
fn host_set_context_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
#[link_name = "host-send-user-message"]
fn host_send_user_message_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
// 两个 string 参数会摊平成两组 (ptr, len),再跟 ret_area。
#[link_name = "host-store-set"]
fn host_store_set_raw(
k_ptr: *const u8, k_len: usize,
v_ptr: *const u8, v_len: usize,
ret_area: *mut u8,
);
#[link_name = "host-call-tool"]
fn host_call_tool_raw(
n_ptr: *const u8, n_len: usize,
a_ptr: *const u8, a_len: usize,
ret_area: *mut u8,
);
}
unsafe fn call_host_str2(
f: unsafe extern "C" fn(*const u8, usize, *const u8, usize, *mut u8),
a: &str,
b: &str,
) -> String {
let ret_area = ALLOC.alloc(Layout::from_size_align_unchecked(8, 4));
f(a.as_ptr(), a.len(), b.as_ptr(), b.len(), ret_area);
let ptr = ret_area.cast::<u32>().read() as *const u8;
let len = ret_area.cast::<u32>().add(1).read() as usize;
read_string(ptr, len)
}这些函数返回的要么是有用的字符串,要么是 error: 前缀的字符串——没授权时的拒绝也走这条路。所以永远先看前缀,别默认成功。唯一值得记住的例外是 host-store-get:key 不存在时读到的是 "",不是 error。
签名写错的话,你会在 wasm-tools component new 这一步发现,而不是运行时——和上面的 ABI 陷阱一样。
选了 extension-commands 或 extension-events,下面各层的导出都必须存在。如果你不注册任何命令:
#[export_name = "list-commands"]
pub unsafe extern "C" fn list_commands() -> *mut u8 {
string_result("[]".to_string())
}
#[export_name = "execute-command"]
pub unsafe extern "C" fn execute_command(
_n_ptr: *const u8, _n_len: usize,
_a_ptr: *const u8, _a_len: usize,
) -> *mut u8 {
string_result(r#"{"error":"this plugin registers no commands"}"#.to_string())
}execute-command 的返回是单键 action 对象,三者之一:{"print": …}、{"send_user_message": …}、{"error": …}。
必须从 nanopi 仓库根目录跑,因为 wit/ 是仓库相对路径:
cd /path/to/nanopi
# 1. 编译成 wasm module
cargo build --manifest-path /path/to/my-plugin/Cargo.toml \
--target wasm32-wasip1 --release
# 2. 把 WIT world 嵌进去 —— 不能跳过
wasm-tools component embed wit/ \
/path/to/my-plugin/target/wasm32-wasip1/release/my_plugin.wasm \
-o /tmp/embedded.wasm --world extension
# ... 也可以是 --world extension-commands / --world extension-events,
# 但那样下面各层的导出必须全部存在(见上文)
# 3. module → component
wasm-tools component new /tmp/embedded.wasm \
-o my-plugin.component.wasm第 2 步跳过的后果:component new 会产出一个 world 为空的 component,宿主找不到 list-tools,报"不是 nanopi extension"。这是最常见的失败。
不要 cd 进插件目录再构建。 embed wit/ 解析的是仓库根的 wit/,混着来会静默嵌入一个过时的 module——能构建、能加载,但缺你刚加的 import,而且没有任何错误指向原因。
wasm-tools component wit my-plugin.component.wasmworld root {
import host-log: func(level: u8, message: string);
import host-http-get: func(url: string) -> string;
export list-tools: func() -> string;
export execute-tool: func(name: string, args-json: string) -> string;
}
注意上面没有 host-fs-read——模板里声明了但没实际调用,LTO 把它消掉了。只有真正用到的 import 才会进 world,这是预期行为,不是 bug。
在 GFW 后面且没配 cargo 镜像时,新项目解析 crates.io index 会超时。两个办法:
# A. 复用本地缓存(nanopi 已经依赖 serde_json)
cargo build ... --offline
# B. 配镜像,一劳永逸 —— ~/.cargo/config.toml
[source.crates-io]
replace-with = 'rsproxy-sparse'
[source.rsproxy-sparse]
registry = "sparse+https://rsproxy.cn/index/"wasm32-wasip1 目标若 rustup target add 失败(部分镜像不带这个组件),检查它是否已在 sysroot:
ls "$(rustc --print sysroot)/lib/rustlib/" | grep wasm存在就能直接 --target wasm32-wasip1 构建,即使 rustup target list --installed 不显示它。
# ~/.nanopi/config.toml 或 <项目>/.nanopi/config.toml
[[extensions]]
path = "/绝对路径/my-plugin.component.wasm"
allow_network = true
url_allowlist = ["127.0.0.1", "api.github.com"]用绝对路径。 path 里的 ~ 展开的是 $HOME,不认 NANOPI_HOME——设了 NANOPI_HOME 却写 ~/... 会解析到 $HOME 下,得到 skipping extension (not found)。
启动后看到这个就说明接上了:
nanopi: registered extension tool "greet"
nanopi: registered extension tool "fetch_head"
看不到?按顺序排查:宿主是否 --features wasm 构建 → 路径是否绝对且存在 → wasm-tools component wit 是否显示 list-tools。
配置的层级和合并规则见 Configuration-zh,能力门见 Plugin-Capabilities-zh。
description 就是 prompt。 模型靠这段文字决定要不要调你的工具,写含糊了工具就永远不被调用。值得像写 API 文档一样认真写,并在描述里点明前置条件(比如"需要 allow_network"),这样模型被拒绝时能理解原因而不是反复重试。
能力门的错误要透传,别吞掉。 宿主用 error: 前缀在带内返回拒绝:
if body.starts_with("error: ") {
return err(body); // 让模型知道是权限问题,而不是"失败了"
}别在 guest 里 panic。 panic 会 trap,宿主只能报给模型"工具调用失败",不带任何可操作信息。用 is_error: true + 一句人话。
arena 是 1 MiB 且不回收。 execute-tool 开头不能 reset arena(入参就住在里面),所以单次调用的总分配要留在 1 MiB 内。宿主侧 host-http-get 的响应体上限正是按这个尺寸设的。
- 不启动 LLM 就调试你的工具 → Debugging-Plugins-zh
- 能力门的完整语义,逐个门 → Plugin-Capabilities-zh
- 在插件里订阅生命周期事件 → 插件事件订阅
- 生命周期 hooks(另一套扩展机制,也是唯一能否决的那套)→ 生命周期 hooks
English
- Home
- Features
- Configuration
- Hooks
- Plugin Development
- Example Plugins
- Plugin Capabilities
- Plugin Events
- Debugging Plugins
中文
v0.12.0 · source of truth:
wit/nanopi-extension.wit