Skip to content

Debugging Plugins

Dev edited this page Sep 7, 2026 · 2 revisions

English · 中文

Debugging plugins without an LLM

Tracks v0.12.0 (3a762b1). PluginEngine::load is in src/wasm/loader.rs — it takes twelve arguments now, one per gate, and the driver below passes all of them.

The slowest loop when writing a plugin is: edit → rebuild → start the agent → coax the model into calling your tool → read the result. The middle steps are slow and nondeterministic — the model may simply not call your tool.

The driver below invokes a plugin directly. No API key, no network, no cooperation from a model.


Install the driver

Drop this into tests/manual_plugin_probe.rs in the nanopi repo. Every test is #[ignore]d, so a normal cargo test skips them and suite counts are unaffected.

//! 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
}

/// The host identifies a plugin by its `.wasm` file stem — that is what
/// names its store directory, prefixes its `host-notify` lines, and
/// heads its context block. Same rule here, so the probe sees the same
/// identity the agent would give it.
fn plugin_name(path: &Path) -> Arc<str> {
    path.file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("plugin")
        .into()
}

/// One field per argument `PluginEngine::load` takes after the path.
/// Kept as a struct rather than a tuple because there are now ten of
/// them and four are `bool` — a positional tuple is one transposition
/// away from testing a gate you did not mean to open.
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"),
        // Deliberately NOT ~/.nanopi/extensions: a probe must not write
        // into the store your real session reads.
        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?")
}

/// List the tools a plugin exports, with full schema — i.e. exactly
/// what the model will be shown.
#[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!();
    }
    // The other two export families, which `/tools` would show you in a
    // real session.
    println!("commands: {:?}", bridge.command_specs());
    println!("events:   {:?}", bridge.event_subscriptions());
    println!("refused:  {:?}", bridge.unsatisfied_event_requests());
}

/// Invoke one tool and print the raw result.
#[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);
    // If the tool called host-set-context, this is what the next turn's
    // system prompt would have carried.
    let ctx = nanopi::plugin_context::render_blocks();
    if !ctx.is_empty() {
        println!("{}", "-".repeat(70));
        println!("{ctx}");
    }
    println!();
}

/// Hand the plugin one lifecycle event, exactly as the agent would.
/// Needs `EVENTS` to name it *and* the plugin's own `list-events` to
/// ask for it — the intersection is what the host computed at load.
#[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"
    );

    // Observe-only: there is no return value to inspect. What you look
    // for is the plugin's side effects — host-notify lines on stderr,
    // its store, its context contribution.
    bridge.handle_event(&event, &payload);
    println!("dropped so far: {}", bridge.dropped_events());
}

handle_event returns nothing — that is the observe-only contract in the type, not a simplification of the probe. See Plugin-Events.


Environment knobs

Variable Meaning Default
PLUGIN path to the .component.wasm required
TOOL tool name to invoke required for call_tool
ARGS JSON arguments object {}
ALLOW_FS 1 to set allow_fs off
CWD working dir host-fs-read is confined to temp dir
ALLOW_NET 1 to set allow_network off
ALLOWLIST comma-separated hosts for url_allowlist empty (denies all)
ALLOW_STORE 1 to set allow_store off
STORE_ROOT where store.json goes $TMPDIR/nanopi-probe-store
ALLOW_CONTEXT 1 to set allow_context off
ALLOW_TOOLS comma-separated built-in tool names for allow_tools empty (denies every tool)
ALLOW_SEND 1 to set allow_send_message off
EVENTS comma-separated PI event names to grant empty (grants nothing)
EVENT one event name to deliver required for deliver_event
PAYLOAD the event's payload-json {}

Three of these behave differently in a probe than in a session, and it is better to know why than to debug it:

  • STORE_ROOT defaults away from ~/.nanopi/extensions on purpose. A real session keys the store on the .wasm stem, so a probe pointed at the default root would write into the store your agent reads.
  • host-notify goes to stderr, not to a queue. The host only queues lines once a renderer has claimed the sink; a test has no drain loop, so a line that merely queued would vanish. Run with --nocapture and you will see it.
  • Two imports refuse no matter what you grant, because the probe is not an agent and both are wired up by one. host-send-user-message returns error: sending a message is not available right now even with ALLOW_SEND=1 — there is no turn loop outside the TUI to steer, and the host refuses in band rather than pretending. host-call-tool likewise returns error: tool calls are not available right now with no dispatch installed, so ALLOW_TOOLS here proves the grant parses, not that the call works.

So the probe covers host-fs-read, host-http-get, the store, the context contribution, host-notify and event delivery end to end. The two that need a live agent are the two you should be testing in a real session anyway — they are the ones that spend money and run commands.


Usage

See what the model sees

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:  []

This step matters more than it looks. The description is what the model uses to pick tools, so reading this output is rehearsing the decision on the model's behalf. A tool that "never gets called" usually explains itself here.

Call a tool

PLUGIN=/tmp/my-plugin/my-plugin.component.wasm TOOL=greet ARGS='{"name":"world"}' \
  cargo test --features wasm --test manual_plugin_probe -- --ignored --nocapture call_tool
tool          greet
args          {"name":"world"}
allow_network false
----------------------------------------------------------------------
is_error      false
----------------------------------------------------------------------
Hello, world!

Exercise the capability gates

Stand up a local target rather than hitting a real host — a test that reaches the open internet isn't hermetic, will fail in CI or on an airgapped box, and when it does fail it tends to hang until the 10s timeout instead of failing fast:

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"}'

# gate closed (default)
PLUGIN=$P TOOL=fetch_head ARGS=$A cargo test ... call_tool
# gate open, allowlist empty
PLUGIN=$P TOOL=fetch_head ARGS=$A ALLOW_NET=1 cargo test ... call_tool
# gate open, host allowlisted
PLUGIN=$P TOOL=fetch_head ARGS=$A ALLOW_NET=1 ALLOWLIST=127.0.0.1 cargo test ... call_tool

Actual output for the three states:

Config Result
default 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: + the body

Deliver an event

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

The probe asserts the event is in the effective subscription before delivering, so a plugin whose list-events disagrees with EVENTS fails loudly instead of looking like a handler that did nothing. There is no return value to check — look at the plugin's side effects: its host-notify lines on stderr, its store file, its context contribution.

For a realistic payload, take one from a shell hook: point [[hooks.turn_start]] at cat > /tmp/payload.json and use that file. The bytes a plugin receives are the bytes a hook receives — same builder — so this is not an approximation.


Failure lookup

Symptom Cause
load failed / "not a nanopi extension" skipped wasm-tools component embed; the world is empty
component new reports a type mismatch the import's trailing ret_area out-param is backwards — see the ABI trap in Plugin-Development
a new import doesn't appear in the world you never call it, so LTO dropped it (expected); or you cd'd into the plugin dir and embedded a stale module
the plugin trapped guest code panicked. Return is_error: true instead
tool registers but the model never calls it the description is too vague — read it back with list_tools
allocation failure / truncated result one call exceeded the 1 MiB arena
load failed naming allow_tools a name in allow_tools is not a built-in (bash, edit, find, grep, ls, read, write). A misspelled grant is refused, not silently granted
load failed: "exports events but not handle-event" list-events named events and the component has no handler. Asking to be delivered to has no fallback
deliver_event asserts the event is not subscribed it is in EVENTS but not in the plugin's list-events, or the reverse. Delivery needs both
an error: string where you granted the capability in a probe, host-call-tool and host-send-user-message refuse regardless — see above

Aside: the suite is flaky in parallel

While verifying, if you see a batch of unrelated lib tests go red, it's probably not you. Several tests mutate process-global environment variables (HOME, NANOPI_HOME) and race on the default thread pool; roughly 1 run in 3 fails a different subset.

cargo test --features wasm -- --test-threads=1

With --test-threads=1 it's deterministic.

Clone this wiki locally