Repository navigation
Debugging Plugins zh
English · 中文
对应 v0.12.0(
3a762b1)。PluginEngine::load在src/wasm/loader.rs——它现在有十二个参数, 每道门一个,下面的驱动器把它们全传了。
写插件时最耗时的循环是"改代码 → 重新构建 → 启动 agent → 想办法让模型调用你的工具 → 看结果"。中间那几步既慢又不确定——模型可能根本不调你的工具。
下面这个驱动器直接调用插件,不需要 API key、不需要网络、不需要模型配合。
把下面的文件放到 nanopi 仓库的 tests/manual_plugin_probe.rs。所有测试都标了 #[ignore],正常的 cargo test 会跳过,不影响套件计数。
//! Drive any nanopi plugin directly, without an LLM or an API key.
#![cfg(feature = "wasm")]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use nanopi::wasm::host::WasmExecuteBridge;
use nanopi::wasm::loader::PluginEngine;
fn plugin_path() -> PathBuf {
let p = std::env::var("PLUGIN")
.expect("set PLUGIN=/path/to/your.component.wasm");
let p = PathBuf::from(p);
assert!(p.exists(), "PLUGIN does not exist: {}", p.display());
p
}
/// 宿主用 `.wasm` 的文件名主干(stem)标识一个插件——它决定 store 目录名、
/// `host-notify` 的行前缀、context 块的署名。这里沿用同一规则,
/// 让探针看到的身份和 agent 给它的一致。
fn plugin_name(path: &Path) -> Arc<str> {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("plugin")
.into()
}
/// `PluginEngine::load` 除 path 之外的每个参数各一个字段。
/// 用 struct 而不是 tuple:现在有十个,其中四个是 `bool`——
/// 位置参数写错一个顺序,你测的就是另一道门了。
struct Gates {
url_allowlist: Vec<String>,
cwd: PathBuf,
allow_fs: bool,
allow_network: bool,
allow_store: bool,
allow_context: bool,
allow_tools: Vec<String>,
allow_send_message: bool,
store_root: PathBuf,
events: Vec<String>,
}
fn flag(key: &str) -> bool {
std::env::var(key).as_deref() == Ok("1")
}
fn list(key: &str) -> Vec<String> {
std::env::var(key)
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn gates() -> Gates {
Gates {
url_allowlist: list("ALLOWLIST"),
cwd: std::env::var("CWD")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir()),
allow_fs: flag("ALLOW_FS"),
allow_network: flag("ALLOW_NET"),
allow_store: flag("ALLOW_STORE"),
allow_context: flag("ALLOW_CONTEXT"),
allow_tools: list("ALLOW_TOOLS"),
allow_send_message: flag("ALLOW_SEND"),
// 刻意不用 ~/.nanopi/extensions:探针不该写进你真实会话读的那份 store。
store_root: std::env::var("STORE_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("nanopi-probe-store")),
events: list("EVENTS"),
}
}
fn load(
engine: &PluginEngine,
path: &Path,
g: Gates,
) -> (Arc<dyn WasmExecuteBridge>, Vec<nanopi::agent::context::ToolSpec>) {
engine
.load(
path,
g.url_allowlist,
g.cwd,
g.allow_fs,
g.allow_network,
g.allow_store,
g.allow_context,
g.allow_tools,
g.allow_send_message,
g.store_root,
plugin_name(path),
g.events,
)
.expect("load failed — is this a component built with a nanopi world?")
}
/// 列出插件导出的工具及完整 schema —— 也就是模型会看到的东西。
#[test]
#[ignore = "manual; run with --ignored"]
fn list_tools() {
let path = plugin_path();
let engine = PluginEngine::new().expect("engine");
let (bridge, tools) = load(&engine, &path, gates());
println!("\n{} exports {} tool(s):\n", path.display(), tools.len());
for t in &tools {
println!("{}", serde_json::to_string_pretty(t).unwrap_or_default());
println!();
}
// 另外两族导出,真实会话里由 `/tools` 展示。
println!("commands: {:?}", bridge.command_specs());
println!("events: {:?}", bridge.event_subscriptions());
println!("refused: {:?}", bridge.unsatisfied_event_requests());
}
/// 调用一个工具并打印原始结果。
#[test]
#[ignore = "manual; run with --ignored"]
fn call_tool() {
let path = plugin_path();
let tool = std::env::var("TOOL").expect("set TOOL=<tool name>");
let args = std::env::var("ARGS").unwrap_or_else(|_| "{}".to_string());
let g = gates();
println!("\nplugin {}", path.display());
println!("tool {tool}");
println!("args {args}");
println!("allow_network {}", g.allow_network);
println!("url_allowlist {:?}", g.url_allowlist);
println!("allow_fs {}", g.allow_fs);
println!("allow_store {}", g.allow_store);
println!("allow_context {}", g.allow_context);
println!("allow_tools {:?}", g.allow_tools);
println!("allow_send {}", g.allow_send_message);
println!("cwd {}", g.cwd.display());
println!("store_root {}", g.store_root.display());
println!("{}", "-".repeat(70));
let engine = PluginEngine::new().expect("engine");
let (bridge, _) = load(&engine, &path, g);
let out = bridge
.execute_tool(&tool, &args)
.expect("the plugin trapped — a panic in guest code shows up here");
println!("is_error {}", out.is_error);
println!("{}", "-".repeat(70));
println!("{}", out.content);
// 如果工具调了 host-set-context,这就是下一轮 system prompt 会带上的内容。
let ctx = nanopi::plugin_context::render_blocks();
if !ctx.is_empty() {
println!("{}", "-".repeat(70));
println!("{ctx}");
}
println!();
}
/// 像 agent 那样,给插件投一个生命周期事件。
/// 需要 `EVENTS` 授权它,**并且**插件自己的 `list-events` 也申请了它——
/// 交集才是宿主在加载时算出来的订阅。
#[test]
#[ignore = "manual; run with --ignored"]
fn deliver_event() {
let path = plugin_path();
let event = std::env::var("EVENT").expect("set EVENT=<pi event name>");
let payload = std::env::var("PAYLOAD").unwrap_or_else(|_| "{}".to_string());
let engine = PluginEngine::new().expect("engine");
let (bridge, _) = load(&engine, &path, gates());
let subs = bridge.event_subscriptions();
println!("\nsubscribed to {subs:?}");
assert!(
subs.iter().any(|e| e == &event),
"{event:?} is not in the effective subscription — grant it in \
EVENTS and make sure the plugin's list-events asks for it"
);
// 只观察:没有返回值可看。要看的是插件的副作用——
// stderr 上的 host-notify 行、它的 store、它的 context 贡献。
bridge.handle_event(&event, &payload);
println!("dropped so far: {}", bridge.dropped_events());
}handle_event 没有返回值——这是"只能观察"写进类型里的结果,不是探针偷了懒。见 插件事件订阅。
| 变量 | 含义 | 默认 |
|---|---|---|
PLUGIN |
.component.wasm 路径 |
必填 |
TOOL |
要调用的工具名 |
call_tool 必填 |
ARGS |
JSON 参数对象 | {} |
ALLOW_FS |
1 打开 allow_fs
|
关 |
CWD |
host-fs-read 被限制在的工作目录 |
临时目录 |
ALLOW_NET |
1 打开 allow_network
|
关 |
ALLOWLIST |
逗号分隔的 host,作为 url_allowlist
|
空(拒绝一切) |
ALLOW_STORE |
1 打开 allow_store
|
关 |
STORE_ROOT |
store.json 落在哪 |
$TMPDIR/nanopi-probe-store |
ALLOW_CONTEXT |
1 打开 allow_context
|
关 |
ALLOW_TOOLS |
逗号分隔的内置工具名,作为 allow_tools
|
空(拒绝每个工具) |
ALLOW_SEND |
1 打开 allow_send_message
|
关 |
EVENTS |
逗号分隔的 PI 事件名,即授权列表 | 空(什么都不授权) |
EVENT |
要投递的那一个事件名 |
deliver_event 必填 |
PAYLOAD |
事件的 payload-json
|
{} |
其中三点在探针里和真实会话里的行为不同,先知道比后面查半天好:
-
STORE_ROOT刻意不默认到~/.nanopi/extensions。 真实会话按.wasm主干给 store 分目录,探针若用默认根目录,就会写进你 agent 正在读的那份 store。 -
host-notify直接走 stderr,不进队列。宿主只在有渲染器接管 sink 之后才排队;测试里没有 drain 循环,排进队列的行等于消失。加--nocapture就能看到。 -
两个导入无论怎么授权都会被拒,因为探针不是 agent,而这两条路都由 agent 接起来。
host-send-user-message即使ALLOW_SEND=1也返回error: sending a message is not available right now——TUI 之外没有轮次循环可转向,宿主宁可带内拒绝也不假装成功。host-call-tool同理,没装 dispatch 时返回error: tool calls are not available right now,所以ALLOW_TOOLS在这里只能验证"授权解析通过",不能验证"调用能跑"。
也就是说:探针能端到端覆盖 host-fs-read、host-http-get、store、context 贡献、host-notify 和事件投递。需要活 agent 的那两个,恰好也是你本来就该在真实会话里试的——一个花钱,一个执行命令。
cd /path/to/nanopi
PLUGIN=/tmp/my-plugin/my-plugin.component.wasm \
cargo test --features wasm --test manual_plugin_probe -- --ignored --nocapture list_tools/tmp/my-plugin/my-plugin.component.wasm exports 2 tool(s):
{
"name": "greet",
"description": "Greet someone by name. Use when the user asks for a greeting.",
"parameters": {
"properties": {
"name": { "description": "Who to greet.", "type": "string" }
},
"required": ["name"],
"type": "object"
}
}
commands: []
events: []
refused: []
这一步比看起来重要。 description 就是模型选工具的依据,在这里通读一遍相当于替模型预演。工具"从来不被调用"的问题,多半在这页就能看出来。
PLUGIN=/tmp/my-plugin/my-plugin.component.wasm TOOL=greet ARGS='{"name":"世界"}' \
cargo test --features wasm --test manual_plugin_probe -- --ignored --nocapture call_tooltool greet
args {"name":"世界"}
allow_network false
----------------------------------------------------------------------
is_error false
----------------------------------------------------------------------
Hello, 世界!
起一个本地靶子,不要打外网——在受限网络里失败会表现为挂起而非快速报错,你会分不清是门拦的还是网断的:
python3 -m http.server 8899 --bind 127.0.0.1 --directory /tmp/my-plugin &
echo "PAYLOAD-42" > /tmp/my-plugin/probe.txt
P=/tmp/my-plugin/my-plugin.component.wasm
A='{"url":"http://127.0.0.1:8899/probe.txt"}'
# 门关着(默认)
PLUGIN=$P TOOL=fetch_head ARGS=$A cargo test ... call_tool
# 门开但白名单空
PLUGIN=$P TOOL=fetch_head ARGS=$A ALLOW_NET=1 cargo test ... call_tool
# 门开且 host 在白名单
PLUGIN=$P TOOL=fetch_head ARGS=$A ALLOW_NET=1 ALLOWLIST=127.0.0.1 cargo test ... call_tool三种状态的实际输出:
| 配置 | 结果 |
|---|---|
| 默认 | error: network access denied (set allow_network = true on this plugin's [[extensions]] entry) |
ALLOW_NET=1 |
error: url_allowlist does not permit http://127.0.0.1:8899/probe.txt (... an empty allowlist denies everything) |
ALLOW_NET=1 ALLOWLIST=127.0.0.1 |
29 bytes; first 200 chars: + 内容 |
PLUGIN=$P EVENTS=turn_start EVENT=turn_start PAYLOAD='{"session_id":"probe","turn_count":1}' \
cargo test --features wasm --test manual_plugin_probe -- --ignored --nocapture deliver_event探针在投递前先断言这个事件在生效的订阅里,所以插件的 list-events 和 EVENTS 不一致时会直接报错,而不是看起来像"handler 什么都没干"。这里没有返回值可查——去看插件的副作用:stderr 上的 host-notify 行、store 文件、context 贡献。
想要一份真实 payload,从 shell hook 那边取:把 [[hooks.turn_start]] 指向 cat > /tmp/payload.json,然后用这个文件。插件收到的字节和 hook 收到的字节是同一个 builder 产的,所以这不是近似。
| 现象 | 原因 |
|---|---|
load failed / "不是 nanopi extension" |
漏了 wasm-tools component embed 那一步,world 是空的 |
component new 报类型不匹配 |
import 的 ret_area 出参写反了,见 Plugin-Development-zh 的 ABI 陷阱 |
| 新加的 import 不出现在 world 里 | 没实际调用它,被 LTO 消掉了(预期行为);或者构建时 cd 进了插件目录导致嵌了旧 module |
the plugin trapped |
guest 里 panic 了。改成返回 is_error: true
|
| 工具注册了但模型从不调用 |
description 太含糊,用 list_tools 通读一遍 |
| 分配失败 / 结果被截断 | 单次调用超了 1 MiB arena |
load failed 里点名 allow_tools
|
allow_tools 里有不是内置工具的名字(内置只有 bash、edit、find、grep、ls、read、write)。授权拼错会被拒绝,而不是静默授权为空 |
load failed:exports events but not handle-event
|
list-events 报了事件但组件没有 handler。既然申请了投递,就没有兜底 |
deliver_event 断言该事件未被订阅 |
它在 EVENTS 里但不在插件的 list-events 里,或者反过来。投递要两边都有 |
明明授权了却拿到 error: 字符串 |
探针里 host-call-tool 和 host-send-user-message 一律被拒——见上文 |
验证时如果看到一批无关的 lib 测试红了,大概率不是你弄坏的。有若干测试会改进程级全局环境变量(HOME、NANOPI_HOME),在默认线程池上互相竞争,约 1/3 的运行会挂掉不同的子集。
cargo test --features wasm -- --test-threads=1加上 --test-threads=1 就是确定性的。
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