Expand description
Stable #[repr(C)] ABI contract for rpi Rust-native (cdylib) plugins.
rpi loads extensions as compiled Rust cdylibs (.dll/.so/.dylib) via
libloading — not TS/jiti. Because we control both sides, the plugin is
Rust, but the boundary is still a hand-defined C ABI: the two sides may be
compiled with different Rust versions / crate versions, so no Rust type with
a non-C repr or a Drop impl may cross. This crate defines exactly those
crossing types and the registration contract.
§Soundness rules (load-bearing — verified by an adversarial review)
- Every crossing type is
#[repr(C)]. Enums used in unions carry#[repr(u32)]so the discriminant width is pinned. - No
Droptype crosses.Vec/String/serde_json::Value/Optionof those /Resultnever appear in the ABI. Owned data crosses asStbString(ptr+len) with an explicitfree_stringthe producer exports.StbStringisCopy(raw pointers areCopy); copying duplicates the pointer, not the allocation, so each allocation is freed exactly once by the side that received it (seeStbStringdocs). - Owned → JSON round-trip. Structured host data ([
StableJsonValue], tool params,AgentToolResult, events) crosses as a JSON string in aStbString.serde_jsonwithpreserve_order+arbitrary_precisionmust be enabled consistently on host AND plugin or integers >u64/i64lose precision and object keys may reorder — documented as a an ABI-wide limit. Tool args from the model rarely carry overflow ints, but it is never silent. - Unions are all-
Copypayloads.EventPayload/StepResultPayloadvariants are#[repr(C)]structs of primitives orStbStringonly, so the union isCopy-able and a wrong-variant read isunsafe(caller discriminates bytag). - Unwinding never crosses the ABI. Every host→plugin and plugin→host
call is
extern "C"; both sides wrap dispatch incatch_unwind(abort-on-unwind / log-and-drop). A poisoned mutex or panicking emitter cannot unwind into the other side.
§Lifetime: the 4-function handle
A registered tool drives an execution through four plugin-exported
functions (see ToolExecuteFn / ToolPollFn / ToolCancelFn /
ToolDestroyFn) — execute→StepHandle (plugin-allocates), poll
(non-blocking, borrows the handle, returns StepResult), cancel
(sets an internal AtomicBool flag; idempotent; does NOT free;
thread-safe), destroy (frees; idempotent; called exactly once by the
blocking driver). cancel ≠ destroy: conflating them is a UAF /
double-free. The adapter’s blocking driver calls poll in a loop until
Done/Err, forwards Pending partials, and calls destroy once on exit.
poll is non-blocking and MUST observe the cancel flag and return
Done/Err within a bounded number of polls; otherwise a cancelled call
leaks a spawn_blocking thread forever (those tasks run to completion
regardless of outer-future drop).
The crate is std (not no_std): the ABI types are #[repr(C)] POD with
no Drop — that is what makes the boundary sound — but plugins and the host
are ordinary binaries with std, so the constructor/reader helpers and tests
use String/Vec/serde_json directly. The json feature keeps
serde_json optional for a plugin that wants to skip it.
Modules§
- json
- Helpers to cross structured data as JSON-in-
StbString. See the module docs for the precision/order limit.
Macros§
- export_
plugin_ v2 - Export an ABI v2 plugin entrypoint under
rpi_plugin_register_v2.
Structs§
- Event
Data - A generic JSON-data payload for the long-tail events whose structured shape
the host serializes wholesale (
context,before_provider_request, model select, resources_discover response, etc.). The plugin reads the fields it needs. - Event
Empty - No-payload marker for events that carry none (e.g.
session_shutdown). Carries a dummy byte so the empty-struct isn’t flagged FFI-unsafe byimproper_ctypes(zero-sized C structs are rejected regardless ofrepr(C)). - Event
Error - An error/failure payload.
- Event
Message - A serialized message payload (
message_start/update/end, tool-result messages).messageis a JSONAgentMessage. - Event
Tool Call - A tool-call payload (
tool_call,tool_execution_start/update).tool_call_id+tool_nameare raw strings;paramsis the JSON args. - Event
Tool Result - A tool-result payload (
tool_result,tool_execution_end). - Legacy
Plugin ApiV1 - Frozen host vtable layout used by plugins exporting
LEGACY_REGISTER_SYMBOL. Do not add, remove, reorder, or retype fields in this struct. New plugins usePluginApiVtandREGISTER_SYMBOL_V2. - Plugin
ApiVt - The ABI v2 host-provided vtable, passed to
rpi_plugin_register_v2as a*const. - Stable
Plugin Event - One event dispatched to a plugin handler. The host translates its native
AgentEvent/HarnessEventinto this and calls every registered handler for thetag(dispatch wrapped incatch_unwind). Ownership of theStbStrings passes to the handler; the handler frees them via the host’sfree_string. - Stable
Tool Schema - A tool’s provider-facing schema crossing the ABI.
name/descriptionare raw strings;parametersis a JSON Schema serialized to a JSON string (the host parses it into its nativeschemars::Schema). - StbDone
- Terminal success payload.
resultis a JSONAgentToolResult. - StbErr
- Terminal failure payload.
messageis a UTF-8 error string. - StbPending
- A partial/progress result emitted during
Pending.progressis a JSONAgentToolResult(the same shapeon_updatecarries) — the host forwards it to the adapter’son_updatecallback. May be empty. - StbString
- An owned UTF-8 string crossing the ABI as a
(ptr, len)pair. - StbString
Ref - A borrowed, non-owning view of a string passed as an input to an FFI call. The callee MUST NOT free it and MUST NOT retain it past the call.
- Step
Result - Return value of
ToolPollFn. The blocking driver matches ontag, reads the matching payload, and breaks the loop onDone/Err. - Unknown
Runtime Action Id - Error returned when a plugin passes a numeric runtime-action id that this ABI does not define.
Enums§
- Event
Tag - Discriminant for
StablePluginEvent, one variant per pion()category (33 total).#[repr(u32)]pins the discriminant width. - Runtime
Action Id - Identifier for a host runtime action the plugin may invoke via
PluginApiVt::runtime_action. One slot dispatches all actions; the numeric id crosses the FFI boundary as au32and the host validates it withTryFrom<u32>before constructing this enum. Args/results cross as JSON strings. - Step
Result Tag - Discriminant for
StepResult.#[repr(u32)]pins the width so the union payload is sound across compilers.
Constants§
- EVENT_
TAG_ COUNT - Number of
on()event categories —33. A test assertsEVENT_TAG_COUNT == 33so a future edit that adds/removes a tag is caught. - LEGACY_
PLUGIN_ ABI_ VERSION - ABI version passed to the legacy
rpi_plugin_registerentrypoint. - LEGACY_
REGISTER_ SYMBOL - The ABI v1 symbol used only when
REGISTER_SYMBOL_V2is absent. - REGISTER_
SYMBOL - Alias for the current SDK’s register symbol.
- REGISTER_
SYMBOL_ V2 - The ABI v2 symbol the host looks up first in each cdylib.
Must be an
extern "C" fn(*const PluginApiVt, u32) -> i32. - RPI_
PLUGIN_ ABI_ VERSION - The ABI version this SDK publishes. ABI v2 adds the
GetCliFlagruntime action and makesRuntimeActionFn’s action parameter an explicitly validatedu32.
Functions§
- register_
entrypoint - Convenience for host + plugin: declare the register entrypoint.
Type Aliases§
- Command
Handler Fn - A generic command-handler fn (for
register_command).args_jsonis a borrowed{"args":"...","command":"/..."}envelope;outis owning JSON output reclaimed with the hostfree_string. The TUI understands{kind:"message",text},{kind:"selector",items:[...]},{kind:"editor",initialText}, and{kind:"input",title,placeholder}responses; selector/editor/input submissions call the same handler with anactionfield inargs. - Event
Handler Fn - Handler fn pointer registered via
register_event_handler(tag, handler).user_datais the plugin’s opaque context. Return0on success; nonzero signals a handled error (the host logs it; dispatch continues to other handlers — one handler’s error does not abort the fan-out). - Free
String Fn - Function pointer a plugin exports to free a
StbStringit produced. Idempotent: freeing an already-freed or emptyStbStringis a no-op. - Legacy
RpiPlugin Register - ABI v1 plugin entrypoint signature.
- Legacy
Runtime Action Fn - ABI v1 runtime-action signature. The original SDK exposed a
#[repr(u32)]enum at this position; the legacy host view uses the ABI-equivalent raw integer so it can reject unknown values before constructing an enum. - Provider
Request Fn - A provider-injection factory fn (for
register_provider).req_jsonis a borrowed request envelope;outis an owning response the plugin frees. The host wraps this into aProviderimpl (B4/B5). - Render
Fn - A render/transform fn (for the renderer registrars).
input_jsonis borrowed;outis owning output the plugin frees via hostfree_string. Markdown handlers return{markdown:"..."}; message/entry handlers return{text:"...",markdown?:true}or{lines:["..."]}for terminal UI. - Resources
Discover Fn resources_discoverhandler signature (B5b). UnlikeEventHandlerFn(fire-and-forget,i32only), this carries an owningoutso the plugin can hand{skillPaths, promptPaths, themePaths}back to the host.cwdandreasonare borrowed inputs (StbStringRef);outis plugin-produced and reclaimed via theplugin_free_stringthe host stored alongside the handler at registration.user_datais the plugin’s opaque context. Returns0on success (host readsout); nonzero on a handled error (host logs + skips this handler, fan-out continues — mirrors pirunner.ts:1179-1188).- RpiPlugin
Register - Plugin entrypoint signature. The host loads the cdylib, looks up
rpi_plugin_register_v2, and calls it with the hostPluginApiVtand the host’s currentRPI_PLUGIN_ABI_VERSION. - Runtime
Action Fn - Runtime-action signature:
runtime_action(action_id, args_json, out, user_data) -> i32.args_jsonis a borrowed input (StbStringRef);outis an owning output (StbString) the host produces and the plugin frees via the host’sfree_string.action_idis deliberately a rawu32, not a Rust enum: an unknown value must be rejected as a normal protocol error rather than materializing an invalid enum discriminant. Returns0on success, nonzero on error. - Step
Handle - Opaque, plugin-allocated handle for one tool execution drive. Produced by
ToolExecuteFn, polled byToolPollFn, cancelled byToolCancelFn, freed byToolDestroyFn(exactly once, idempotent). - Tool
Cancel Fn cancel(handle). Sets an internalAtomicBool(SeqCst) cancel flag. Idempotent, thread-safe, does NOT free. The poll loop observes it.- Tool
Destroy Fn destroy(handle). Frees the handle. Idempotent; called exactly once by the blocking driver on exit (after the loop seesDone/Err, or after cancel propagated). Null handle is a no-op.- Tool
Execute Fn execute(tool_call_id, params) -> StepHandle. Plugin-allocates a drive handle and begins the work (non-blocking — the real progress comes viapoll).tool_call_idis a borrowedStbStringRef(valid for the call);paramsis an owning JSON string of the tool-call arguments (the plugin frees it via the host’sfree_string). Returns null on allocation failure.- Tool
Partial Cb - Partial-result callback the blocking driver passes to
poll, wrapped incatch_unwindon the host side. The plugin invokes it synchronously insidepoll()when it has aPendingpartial — never retained, never invoked afterDone/Err. - Tool
Poll Fn poll(handle, partial_cb, user_data) -> StepResult. Non-blocking. Must observe the cancel flag (set byToolCancelFn) and returnDone/Errwithin a bounded number of polls. Borrowshandle(does not free it).
Unions§
- Event
Payload - Payload union for
StablePluginEvent. All variants are#[repr(C)]structs ofStbString/ primitives (Copy), so the union is Copy. Discriminate byStablePluginEvent::tagbefore reading. - Step
Result Payload - The
poll()return value. Read thepayloadvariant matchingtag.