Skip to main content

Crate rpi_extensions

Crate rpi_extensions 

Source
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 libloadingnot 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:

  1. NOT owning a runtime. Acquire the ambient runtime (tokio::runtime::Handle::try_current()) — the adapter only ever runs inside the agent loop’s runtime.
  2. Set up an unbounded mpsc for ToolResultPartial (async side drains + forwards to on_update) and a oneshot for the terminal AgentToolResult.
  3. handle.spawn_blocking(move || drive(plugin, cancel_flag, partial_tx, done_tx)) — the blocking driver loops poll until Done/Err, forwarding Pending partials through the mpsc (via a catch_unwind trampoline so a panicking partial callback can’t unwind across FFI), then sends the terminal result + calls destroy exactly once on exit.
  4. The async future select!s between the oneshot (terminal) and signal.cancelled() (the child token). On cancel: set an AtomicBool cancel flag (SeqCst) so the blocking driver observes it; keep awaiting the oneshot (never drop the driver — spawn_blocking tasks run to completion regardless of outer-future drop, so dropping is a thread leak).
  5. Drop/drop-guard sets only the cancel flag — never calls destroy synchronously (the driver may be mid-poll); destroy is called exactly once by the blocking driver.

canceldestroy: 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§

ActionBridge
The host-side bridge carried in [PluginApiVt::user_data] so trampoline_runtime_action can recover the harness state from any thread.
DiscoveredResources
The merged resources_discover result across all handlers: bare string arrays for skills, prompt-templates, and themes. theme_paths is collected for parity but rpi has no theme system yet (accepted, ignored, documented).
ExtensionEmitter
An AgentEmitter that fans each AgentEvent out to every plugin handler registered for the event’s tag. Built from a RegistrySnapshot (so it shares the registry’s staleness flag) and installed into AgentHarnessOptions.agent_emitter alongside the host’s [BroadcastEmitter] — events flow to BOTH the TUI (which drains the broadcast receiver) and the plugin handlers (which receive translated StablePluginEvents). The host composes the two via TeeEmitter; this emitter alone only dispatches to plugins.
ExtensionProviderHooks
A ProviderHooks that dispatches to the registered extension handlers. Keeps the cdylib mappings alive via the keepalive (the handler fn pointers live inside the plugins).
ExtensionRegistry
Accumulates registrations from one or more plugins’ rpi_plugin_register calls. Held by HostApi during register; the host then take_registry and builds a snapshot.
ExtensionSession
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.
ExtensionTool
A tool an extension registered: the provider-facing Tool schema + the plugin’s 4-function handle (PluginToolHandle) the host drives via PluginToolAdapter.
HostApi
The host state a PluginApiVt closes over. Held behind Arc so the fn pointers (which are extern "C",不好做闭包) can recover the host state via the user_data slot — but since extern "C" fn cannot capture, the host stores per-registration receiver state in the HostApi itself 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 one HostApi per load session; the register_* trampolines forward into it.
LoadedPlugin
A successfully loaded + registered plugin. Holds the Library so 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).
NullDiagnostics
A no-op diagnostics sink (the default when the host does not supply one).
PluggableProvider
A Provider backed by a plugin’s sync ProviderRequestFn.
PluginKeepalive
Owns the loaded Library handles so the cdylibs stay mapped for as long as any registered tool/handler (whose fn pointers live inside the cdylib) may be called. Shared via Arc: every PluginToolAdapter (and, in B3, the ExtensionEmitter) 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.
PluginToolAdapter
An AgentTool backed by a plugin’s 4-function handle. One adapter is built per registered tool (schema copied from the registration) and inserted into the session’s tool set in B2.
PluginToolHandle
The plugin’s per-tool lifecycle bundle the host holds after a successful register_tool. All fields are fn pointers (Copy), so the handle is Copy: cloning duplicates the pointers, not any allocation. A registered tool is driven by at most one PluginToolAdapter at a time, but the handle is copied through the registry snapshot path, hence Copy.
RegisteredFlag
A CLI flag declared by a native extension. Values are supplied by the host’s parsed Args::unknown_flags map and read by the plugin through the RuntimeActionId::GetCliFlag action.
RegisteredHandler
A registered on(tag) event handler. user_data is the plugin’s opaque context, passed back unchanged on every dispatch.
RegisteredProvider
A registered custom provider (B5c). The host wraps request_fn in a PluggableProvider impl of rpi_ai::Provider; its stream_simple drives request_fn on spawn_blocking (the sync fn can’t own a chunked stream), reads the plugin-owned out JSON (a full assistant message), reclaims it via plugin_free_string, parses it to an AssistantMessage, and emits it as one terminal Done chunk (v1 one-shot, documented divergence from pi’s async streaming). provider_id/base_url/api_style carry the provider’s identity (copied from the borrowed StbStringRefs at registration); the fn pointers + user_data live as long as the plugin (keepalive-mapped). user_data is the plugin’s opaque context, passed back on every request_fn call.
RegisteredRenderer
A registered message/markdown/entry renderer (B5c). The interactive TUI consumes all three kinds through the JSON component adapter. render_fn produces a plugin-owned out [StbString] the host reclaims via plugin_free_string; user_data is passed back on every render call. name is copied from the borrowed StbStringRef at registration.
RegistrySnapshot
An immutable snapshot of an ExtensionRegistry the host session keeps for its lifetime. Tools are wrapped in ExtensionTool so the host can build PluginToolAdapters; event handlers are grouped by tag for the ExtensionEmitter to fan out.
ReloadMailbox
A reload-signal mail slot (B5d). The reload callback (built by reload_callback_from_mailbox) captures a clone; the TUI installs a tokio unbounded sender after it starts. When a plugin calls runtime_action(Reload), the callback signals () (if a TUI is installed) and the TUI performs the reload asynchronously — the plugin’s call returns Ok(null) immediately, so the calling plugin’s cdylib is NOT unmapped while its runtime_action frame 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.
ResourcesDiscoverHandler
A registered resources_discover handler (B5b). The out [StbString] the handler produces is plugin-owned, so the host reclaims it via the plugin’s own plugin_free_string traveled alongside. user_data is the plugin’s opaque context. SAFETY: same as RegisteredHandler — the plugin warrants handler is callable from any thread and user_data is valid for the registry’s lifetime; the host never frees user_data.
TeeEmitter
An AgentEmitter that forwards every event to each of its children, in registration order. The host builds one around [BroadcastEmitter (→ TUI), ExtensionEmitter (→ plugin handlers)] so a single AgentHarnessOptions .agent_emitter slot feeds both consumers: the TUI keeps rendering from its broadcast receiver, and plugin on() handlers receive translated StablePluginEvents.

Enums§

PluginLoadError
Error / skip reason from loading one plugin. Skip variants are non-fatal (logged via diagnostics); Fatal means the load itself failed.
PluginToolError
Error returned by [PluginToolAdapter::execute] when the plugin side failed (terminal Err from poll, or the drive handle was never produced).
RegisteredRendererKind
Which render path this renderer targets — mirrors the three distinct register_* slots (register_message_renderer / register_markdown_transformer / register_entry_renderer).
RegistryEntry
One flat registration record, for iteration/diagnostics. Built on demand from the typed vecs in RegistrySnapshot.

Traits§

PluginDiagnostics
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 diagnostic channel pi surfaces for extensions.
RuntimeActionHost
The host-side implementation the bridge delegates to. Defined in rpi-extensions (NOT rpi-harness) so the crate DAG stays a leaf: this is a trait the host (rpi-cli) implements over the harness — rpi-extensions only names the async surface + carries JSON params/results. No rpi-harness types appear in the trait.

Functions§

assert_active
Staleness guard: true while 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’s ExtensionRuntimeState active check.
emit_resources_discover
Fan the resources_discover event out to every registered handler in registration order and merge their returned paths.
host_free_string
The host’s free_string for StbStrings 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’s free_string (stored per-tool).
load_dir
Load every cdylib in dir (non-recursive). Each load failure is logged via diagnostics and 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 via diagnostics).
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 Library handles + the merged snapshot. Dirs that don’t exist are skipped silently; individual plugin load failures are logged via diagnostics and 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/-e CLI paths), assembled into one session. Mirrors load_session but additionally load_ones each explicit file.
merge_registries
Convenience: merge a slice of per-plugin LoadedPlugin registries into one session registry, first-wins on name (mirrors pi’s cross-extension registration order). Consumes the registries (the LoadedPlugins themselves stay alive — callers keep the Library handles).
reload_callback_from_mailbox
Build the reload callback the bridge carries, backed by a ReloadMailbox. When a plugin calls runtime_action(Reload), the bridge’s spawn site awaits this callback, which signals the TUI (if installed) and returns; the plugin receives Ok(null) and the TUI performs the reload asynchronously. If no TUI is installed, the callback returns without signalling and the host’s RuntimeActionHost::reload fallback surfaces the “not configured” error.
trampoline_runtime_action
The real runtime_action trampoline — replaces stub_runtime_action when a bridge is present (see HostApi::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..=15 are valid.