Expand description
Rust-native (cdylib) plugin loader + AgentTool adapter for rpi.
This is Part B1 of the extension-alignment plan. rpi loads extensions as
compiled Rust cdylibs (.dll/.so/.dylib) via libloading — not
TS/jiti — because we control both sides and the plugin is Rust
(动态加载可以加载rs 的代码 没必要是 ts). The ABI contract lives in
rpi_plugin_sdk; this crate is the host side that loads plugins and
bridges their tools/events into rpi’s native async types.
§Crate DAG position
Depends on rpi-plugin-sdk + rpi-ai + rpi-agent only (NOT
rpi-harness). The harness consumes this crate’s adapters as trait objects
via AgentHarnessOptions injection, so rpi-harness never imports
rpi-extensions — cycle-free (verified by adversarial review of the first
draft, which wrongly added a rpi-harness dep).
§The async-across-ABI bridge (load-bearing — corrected vs first draft)
A plugin tool drives an execution through four plugin-exported fns
(execute→handle, poll, cancel, destroy) — see
rpi_plugin_sdk::ToolExecuteFn etc. The host’s PluginToolAdapter
impls AgentTool::execute by:
- NOT owning a runtime. Acquire the ambient runtime
(
tokio::runtime::Handle::try_current()) — the adapter only ever runs inside the agent loop’s runtime. - Set up an unbounded mpsc for
ToolResultPartial(async side drains + forwards toon_update) and a oneshot for the terminalAgentToolResult. handle.spawn_blocking(move || drive(plugin, cancel_flag, partial_tx, done_tx))— the blocking driver loopspolluntilDone/Err, forwardingPendingpartials through the mpsc (via acatch_unwindtrampoline so a panicking partial callback can’t unwind across FFI), then sends the terminal result + callsdestroyexactly once on exit.- The async future
select!s between the oneshot (terminal) andsignal.cancelled()(the child token). On cancel: set anAtomicBoolcancel flag (SeqCst) so the blocking driver observes it; keep awaiting the oneshot (never drop the driver —spawn_blockingtasks run to completion regardless of outer-future drop, so dropping is a thread leak). Drop/drop-guard sets only the cancel flag — never callsdestroysynchronously (the driver may be mid-poll);destroyis called exactly once by the blocking driver.
cancel ≠ destroy: the first draft conflated them → UAF/double-free. Here
cancel is an idempotent thread-safe flag-set; destroy is the single
free, owned by the driver.
§Events
ExtensionEmitter impls AgentEmitter by subscribing to the host’s
broadcast::Sender<AgentEvent>, translating each AgentEvent → a
rpi_plugin_sdk::StablePluginEvent, and dispatching to every registered
handler for the event’s tag — all dispatch wrapped in catch_unwind. The
33-category on() surface (B3) is driven through this emitter; the
10 already-emitted AgentEvent variants fold into the matching tags now,
and the remaining tags light up as B3/B4/B5 add the emission points.
Structs§
- Action
Bridge - The host-side bridge carried in [
PluginApiVt::user_data] sotrampoline_runtime_actioncan recover the harness state from any thread. - Discovered
Resources - The merged
resources_discoverresult across all handlers: bare string arrays for skills, prompt-templates, and themes.theme_pathsis collected for parity but rpi has no theme system yet (accepted, ignored, documented). - Extension
Emitter - An
AgentEmitterthat fans eachAgentEventout to every plugin handler registered for the event’s tag. Built from aRegistrySnapshot(so it shares the registry’s staleness flag) and installed intoAgentHarnessOptions.agent_emitteralongside the host’s [BroadcastEmitter] — events flow to BOTH the TUI (which drains the broadcast receiver) and the plugin handlers (which receive translatedStablePluginEvents). The host composes the two viaTeeEmitter; this emitter alone only dispatches to plugins. - Extension
Provider Hooks - A
ProviderHooksthat dispatches to the registered extension handlers. Keeps the cdylib mappings alive via the keepalive (the handler fn pointers live inside the plugins). - Extension
Registry - Accumulates registrations from one or more plugins’
rpi_plugin_registercalls. Held byHostApiduring register; the host thentake_registryand builds a snapshot. - Extension
Session - The result of loading a session’s worth of extensions: a shared keepalive for
the cdylib handles + a snapshot of the merged registry. Built by
load_session; the host (pi-cli) stashes one per harness build and hands clones of the keepalive to each adapter it constructs from the snapshot. - Extension
Tool - A tool an extension registered: the provider-facing
Toolschema + the plugin’s 4-function handle (PluginToolHandle) the host drives viaPluginToolAdapter. - HostApi
- The host state a
PluginApiVtcloses over. Held behindArcso the fn pointers (which areextern "C",不好做闭包) can recover the host state via theuser_dataslot — but sinceextern "C" fncannot capture, the host stores per-registration receiver state in theHostApiitself keyed by nothing (single registry per host), and the fns are thin trampolines that read a process-global-attached registry. In v1 we keep it simple: the host builds oneHostApiper load session; theregister_*trampolines forward into it. - Loaded
Plugin - A successfully loaded + registered plugin. Holds the
Libraryso the cdylib stays mapped for the session. Dropping this unloads the plugin (do not drop while any of its tool drivers may still be running). - Null
Diagnostics - A no-op diagnostics sink (the default when the host does not supply one).
- Pluggable
Provider - A
Providerbacked by a plugin’s syncProviderRequestFn. - Plugin
Keepalive - Owns the loaded
Libraryhandles so the cdylibs stay mapped for as long as any registered tool/handler (whose fn pointers live inside the cdylib) may be called. Shared viaArc: everyPluginToolAdapter(and, in B3, theExtensionEmitter) holds a clone, so the libraries unload only when the last holder drops — which is never before the harness’s tool vec (and thus the last possible tool call) drops. - Plugin
Tool Adapter - An
AgentToolbacked by a plugin’s 4-function handle. One adapter is built per registered tool (schemacopied from the registration) and inserted into the session’s tool set in B2. - Plugin
Tool Handle - The plugin’s per-tool lifecycle bundle the host holds after a successful
register_tool. All fields are fn pointers (Copy), so the handle isCopy: cloning duplicates the pointers, not any allocation. A registered tool is driven by at most onePluginToolAdapterat a time, but the handle is copied through the registry snapshot path, henceCopy. - Registered
Flag - A CLI flag declared by a native extension. Values are supplied by the
host’s parsed
Args::unknown_flagsmap and read by the plugin through theRuntimeActionId::GetCliFlagaction. - Registered
Handler - A registered
on(tag)event handler.user_datais the plugin’s opaque context, passed back unchanged on every dispatch. - Registered
Provider - A registered custom provider (B5c). The host wraps
request_fnin aPluggableProviderimpl ofrpi_ai::Provider; itsstream_simpledrivesrequest_fnonspawn_blocking(the sync fn can’t own a chunked stream), reads the plugin-ownedoutJSON (a full assistant message), reclaims it viaplugin_free_string, parses it to anAssistantMessage, and emits it as one terminalDonechunk (v1 one-shot, documented divergence from pi’s async streaming).provider_id/base_url/api_stylecarry the provider’s identity (copied from the borrowedStbStringRefs at registration); the fn pointers +user_datalive as long as the plugin (keepalive-mapped).user_datais the plugin’s opaque context, passed back on everyrequest_fncall. - Registered
Renderer - A registered message/markdown/entry renderer (B5c). The interactive TUI
consumes all three kinds through the JSON component adapter.
render_fnproduces a plugin-ownedout[StbString] the host reclaims viaplugin_free_string;user_datais passed back on every render call.nameis copied from the borrowedStbStringRefat registration. - Registry
Snapshot - An immutable snapshot of an
ExtensionRegistrythe host session keeps for its lifetime. Tools are wrapped inExtensionToolso the host can buildPluginToolAdapters; event handlers are grouped by tag for theExtensionEmitterto fan out. - Reload
Mailbox - A reload-signal mail slot (B5d). The reload callback (built by
reload_callback_from_mailbox) captures a clone; the TUI installs atokiounbounded sender after it starts. When a plugin callsruntime_action(Reload), the callback signals()(if a TUI is installed) and the TUI performs the reload asynchronously — the plugin’s call returnsOk(null)immediately, so the calling plugin’s cdylib is NOT unmapped while itsruntime_actionframe is still on the stack (the reload, which drops the old keepalive, happens after the call returns). This breaks the self-unmapping race a synchronous plugin-initiated reload would have. - Resources
Discover Handler - A registered
resources_discoverhandler (B5b). Theout[StbString] the handler produces is plugin-owned, so the host reclaims it via the plugin’s ownplugin_free_stringtraveled alongside.user_datais the plugin’s opaque context. SAFETY: same asRegisteredHandler— the plugin warrantshandleris callable from any thread anduser_datais valid for the registry’s lifetime; the host never freesuser_data. - TeeEmitter
- An
AgentEmitterthat forwards every event to each of its children, in registration order. The host builds one around[BroadcastEmitter (→ TUI), ExtensionEmitter (→ plugin handlers)]so a singleAgentHarnessOptions .agent_emitterslot feeds both consumers: the TUI keeps rendering from its broadcast receiver, and pluginon()handlers receive translatedStablePluginEvents.
Enums§
- Plugin
Load Error - Error / skip reason from loading one plugin.
Skipvariants are non-fatal (logged via diagnostics);Fatalmeans the load itself failed. - Plugin
Tool Error - Error returned by [
PluginToolAdapter::execute] when the plugin side failed (terminalErrfrompoll, or the drive handle was never produced). - Registered
Renderer Kind - Which render path this renderer targets — mirrors the three distinct
register_*slots (register_message_renderer/register_markdown_transformer/register_entry_renderer). - Registry
Entry - One flat registration record, for iteration/diagnostics. Built on demand
from the typed vecs in
RegistrySnapshot.
Traits§
- Plugin
Diagnostics - A diagnostics/event sink the host wires so the loader + adapter can report
plugin-load skips, ABI mismatches, panics caught at the FFI boundary, etc.
Mirrors the
diagnosticchannel pi surfaces for extensions. - Runtime
Action Host - The host-side implementation the bridge delegates to. Defined in
rpi-extensions(NOTrpi-harness) so the crate DAG stays a leaf: this is a trait the host (rpi-cli) implements over the harness —rpi-extensionsonly names the async surface + carries JSON params/results. Norpi-harnesstypes appear in the trait.
Functions§
- assert_
active - Staleness guard:
truewhile the owning session is still active. Called before dispatching an extension event or driving an extension tool so a stale registry (from a swapped-out session) can’t act. Mirrors pi’sExtensionRuntimeStateactive check. - emit_
resources_ discover - Fan the
resources_discoverevent out to every registered handler in registration order and merge their returned paths. - host_
free_ string - The host’s
free_stringforStbStrings the host produces and hands to the plugin (event payloads, action outputs, execute params when the host owns them). The plugin frees what it receives via this; the host frees what it receives via the plugin’sfree_string(stored per-tool). - load_
dir - Load every cdylib in
dir(non-recursive). Each load failure is logged viadiagnosticsand skipped (one bad plugin doesn’t abort the rest). Returns the successfully loaded plugins in directory order. - load_
one - Load and register one cdylib plugin. Returns the live plugin + its
registry, or a
PluginLoadError(skip-fatality distinction is on the caller; both are logged viadiagnostics). - load_
session - Load + register every cdylib in the given dirs (in order, non-recursive),
merge their registries first-wins, and return a session with a shared
keepalive over the
Libraryhandles + the merged snapshot. Dirs that don’t exist are skipped silently; individual plugin load failures are logged viadiagnosticsand skipped (one bad plugin doesn’t abort the rest). - load_
session_ mixed - Load plugins from a mix of scanned dirs and explicit cdylib files
(the
--extension/-eCLI paths), assembled into one session. Mirrorsload_sessionbut additionallyload_ones each explicit file. - merge_
registries - Convenience: merge a slice of per-plugin
LoadedPluginregistries into one session registry, first-wins on name (mirrors pi’s cross-extension registration order). Consumes the registries (theLoadedPlugins themselves stay alive — callers keep theLibraryhandles). - reload_
callback_ from_ mailbox - Build the reload callback the bridge carries, backed by a
ReloadMailbox. When a plugin callsruntime_action(Reload), the bridge’s spawn site awaits this callback, which signals the TUI (if installed) and returns; the plugin receivesOk(null)and the TUI performs the reload asynchronously. If no TUI is installed, the callback returns without signalling and the host’sRuntimeActionHost::reloadfallback surfaces the “not configured” error. - trampoline_
runtime_ action - The real
runtime_actiontrampoline — replacesstub_runtime_actionwhen a bridge is present (seeHostApi::build_vtable). - trampoline_
runtime_ action_ v1 - ABI v1 runtime-action trampoline. The legacy vtable has the same physical
slot shape, but only the historical action ids
0..=15are valid.