Skip to content

Debugging Plugins zh

Dev edited this page Sep 7, 2026 · 2 revisions

English · 中文

调试插件(不启动 LLM)

对应 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_tool
tool          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 就是确定性的。

Clone this wiki locally