Repository navigation
Debugging Plugins
English · 中文
Tracks v0.12.0 (
3a762b1).PluginEngine::loadis insrc/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.
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.
| 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_ROOTdefaults away from~/.nanopi/extensionson purpose. A real session keys the store on the.wasmstem, so a probe pointed at the default root would write into the store your agent reads. -
host-notifygoes 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--nocaptureand 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-messagereturnserror: sending a message is not available right noweven withALLOW_SEND=1— there is no turn loop outside the TUI to steer, and the host refuses in band rather than pretending.host-call-toollikewise returnserror: tool calls are not available right nowwith no dispatch installed, soALLOW_TOOLShere 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.
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.
PLUGIN=/tmp/my-plugin/my-plugin.component.wasm TOOL=greet ARGS='{"name":"world"}' \
cargo test --features wasm --test manual_plugin_probe -- --ignored --nocapture call_tooltool greet
args {"name":"world"}
allow_network false
----------------------------------------------------------------------
is_error false
----------------------------------------------------------------------
Hello, world!
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_toolActual 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 |
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_eventThe 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.
| 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 |
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=1With --test-threads=1 it's deterministic.
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