rpi_plugin_sdk/lib.rs
1//! Stable `#[repr(C)]` ABI contract for rpi **Rust-native (cdylib) plugins**.
2//!
3//! rpi loads extensions as compiled Rust cdylibs (`.dll`/`.so`/`.dylib`) via
4//! `libloading` — **not** TS/jiti. Because we control both sides, the plugin is
5//! Rust, but the *boundary* is still a hand-defined C ABI: the two sides may be
6//! compiled with different Rust versions / crate versions, so no Rust type with
7//! a non-`C` repr or a `Drop` impl may cross. This crate defines exactly those
8//! crossing types and the registration contract.
9//!
10//! ## Soundness rules (load-bearing — verified by an adversarial review)
11//!
12//! 1. **Every crossing type is `#[repr(C)]`.** Enums used in unions carry
13//! `#[repr(u32)]` so the discriminant width is pinned.
14//! 2. **No `Drop` type crosses.** `Vec`/`String`/`serde_json::Value`/`Option` of
15//! those / `Result` never appear in the ABI. Owned data crosses as
16//! [`StbString`] (ptr+len) with an **explicit `free_string`** the producer
17//! exports. [`StbString`] is `Copy` (raw pointers are `Copy`); copying
18//! duplicates the *pointer*, not the allocation, so each allocation is freed
19//! **exactly once** by the side that received it (see [`StbString`] docs).
20//! 3. **Owned → JSON round-trip.** Structured host data ([`StableJsonValue`],
21//! tool params, `AgentToolResult`, events) crosses as a JSON string in a
22//! [`StbString`]. `serde_json` with `preserve_order` + `arbitrary_precision`
23//! must be enabled **consistently on host AND plugin** or integers >
24//! `u64`/`i64` lose precision and object keys may reorder — documented as a
25//! an ABI-wide limit. Tool args from the model rarely carry overflow ints, but it is
26//! never silent.
27//! 4. **Unions are all-`Copy` payloads.** [`EventPayload`] / [`StepResultPayload`]
28//! variants are `#[repr(C)]` structs of primitives or [`StbString`] only, so
29//! the union is `Copy`-able and a wrong-variant read is `unsafe` (caller
30//! discriminates by `tag`).
31//! 5. **Unwinding never crosses the ABI.** Every host→plugin and plugin→host
32//! call is `extern "C"`; both sides wrap dispatch in `catch_unwind`
33//! (abort-on-unwind / log-and-drop). A poisoned mutex or panicking emitter
34//! cannot unwind into the other side.
35//!
36//! ## Lifetime: the 4-function handle
37//!
38//! A registered tool drives an execution through **four** plugin-exported
39//! functions (see [`ToolExecuteFn`] / [`ToolPollFn`] / [`ToolCancelFn`] /
40//! [`ToolDestroyFn`]) — `execute`→[`StepHandle`] (plugin-allocates), `poll`
41//! (non-blocking, **borrows** the handle, returns [`StepResult`]), `cancel`
42//! (sets an internal `AtomicBool` flag; **idempotent; does NOT free;
43//! thread-safe**), `destroy` (frees; **idempotent; called exactly once by the
44//! blocking driver**). `cancel` ≠ `destroy`: conflating them is a UAF /
45//! double-free. The adapter's blocking driver calls `poll` in a loop until
46//! `Done`/`Err`, forwards `Pending` partials, and calls `destroy` once on exit.
47//!
48//! `poll` is **non-blocking** and MUST observe the cancel flag and return
49//! `Done`/`Err` within a bounded number of polls; otherwise a cancelled call
50//! leaks a `spawn_blocking` thread forever (those tasks run to completion
51//! regardless of outer-future drop).
52//!
53//! The crate is `std` (not `no_std`): the ABI *types* are `#[repr(C)]` POD with
54//! no `Drop` — that is what makes the boundary sound — but plugins and the host
55//! are ordinary binaries with `std`, so the constructor/reader helpers and tests
56//! use `String`/`Vec`/`serde_json` directly. The `json` feature keeps
57//! `serde_json` optional for a plugin that wants to skip it.
58//!
59//! The optional `provider` feature adds the `provider` module and `export_provider!`
60//! for bridging Rust `rpi_ai::Provider` implementations to this ABI. It alone
61//! enables the AI, async and HTTP dependencies; the default ABI SDK stays light.
62
63#![deny(unsafe_op_in_unsafe_fn)]
64
65use core::ffi::c_char;
66use core::ffi::c_void;
67use core::ptr;
68
69#[cfg(feature = "binary")]
70pub mod binary;
71pub mod callbacks;
72#[cfg(feature = "json")]
73pub mod dialogs;
74#[cfg(feature = "json")]
75pub mod events;
76pub mod handles;
77#[cfg(feature = "process")]
78pub mod process;
79#[cfg(feature = "provider")]
80pub mod provider;
81#[cfg(feature = "stdio")]
82pub mod stdio;
83
84// ---------------------------------------------------------------------------
85// StbString — owned UTF-8 crossing as ptr+len with an explicit free
86// ---------------------------------------------------------------------------
87
88/// An owned UTF-8 string crossing the ABI as a `(ptr, len)` pair.
89///
90/// `Copy` (raw pointers are `Copy`): copying a `StbString` duplicates the
91/// **pointer**, not the allocation. The **receiver** of a `StbString` owns the
92/// allocation and MUST free it **exactly once** by calling the producer's
93/// [`FreeStringFn`] (or [`StbString::free_with`]).
94/// Never `free` a `StbString` you did not receive as an owner (e.g. one built
95/// from a borrow via [`StbString::from_ref`], which is non-owning — its `free`
96/// is a no-op only if the producer guarantees the buffer outlives the call; in
97/// practice inputs cross as [`StbStringRef`] instead).
98///
99/// **Construction ownership contract:**
100/// - [`StbString::from_owned`] — takes a `Box<[u8]>` the caller allocated; the
101/// `StbString` now owns it; `free` deallocates.
102/// - [`StbString::from_boxed_str`] / [`StbString::from_string`] — convenience
103/// over `from_owned` (host side, needs `alloc`).
104/// - [`StbString::empty`] — null/0; `free` is a no-op.
105///
106/// **Null `ptr` ⇒ empty** (`len` MUST be 0). A null pointer is never
107/// dereferenced.
108#[repr(C)]
109#[derive(Clone, Copy)]
110pub struct StbString {
111 /// UTF-8 bytes. Null when `len == 0` (empty string).
112 pub ptr: *mut c_char,
113 /// Byte length (NOT a NUL terminator — the buffer is NOT NUL-terminated).
114 pub len: usize,
115}
116
117// SAFETY: `StbString` is a plain `(ptr, len)` of raw pointers — no ownership
118// transferred across threads by the type itself, and lifetime/aliasing is the
119// caller's contract (documented above). It is `Send`+`Sync` so the host and the
120// blocking driver can pass it across threads; the *allocation* ownership rules
121// above still apply regardless of thread.
122unsafe impl Send for StbString {}
123unsafe impl Sync for StbString {}
124
125impl StbString {
126 /// An empty string: null pointer, zero length. `free` is a no-op.
127 pub const fn empty() -> Self {
128 Self {
129 ptr: ptr::null_mut(),
130 len: 0,
131 }
132 }
133
134 /// Whether this is the empty/null string.
135 pub const fn is_empty(&self) -> bool {
136 self.len == 0
137 }
138}
139
140// Constructors and the raw reader depend only on std, not serde_json. Keep
141// them available when the optional JSON feature is disabled: ABI trampolines
142// still allocate, copy and reclaim strings in that configuration.
143impl StbString {
144 /// Wrap an allocation the caller already boxed. The `StbString` takes
145 /// ownership: a later `free_with(free_fn)` will deallocate it via the
146 /// producer's `free_string`.
147 ///
148 /// The buffer MUST be UTF-8.
149 pub fn from_owned(buf: Box<[u8]>) -> Self {
150 let len = buf.len();
151 // Stabilize the pointer via `Box::into_raw`; the free fn reconstructs a
152 // slice from ptr+len and drops it. Store the element pointer as
153 // `*mut c_char` (u8 ↔ c_char on every platform rpi targets). Ownership
154 // moves into the `StbString` (we do NOT drop here).
155 let ptr = Box::into_raw(buf) as *mut [u8] as *mut u8 as *mut c_char;
156 let _ = len;
157 Self { ptr, len }
158 }
159
160 /// Convenience: from a `String`, transferring ownership. After this the
161 /// passed `String` is consumed and must not be reused.
162 pub fn from_string(s: String) -> Self {
163 Self::from_vec(s.into_bytes())
164 }
165
166 /// Convenience: from a `Vec<u8>` (UTF-8), transferring ownership.
167 pub fn from_vec(v: Vec<u8>) -> Self {
168 Self::from_owned(v.into_boxed_slice())
169 }
170
171 /// Convenience: from a boxed `str`.
172 pub fn from_boxed_str(s: Box<str>) -> Self {
173 let string: String = s.into();
174 Self::from_vec(string.into_bytes())
175 }
176
177 /// Copy this `StbString`'s bytes into an owned `String` (**without** freeing
178 /// the original — the caller still owns the original allocation). Use this
179 /// to *read* a received `StbString` into safe Rust; then `free` the original.
180 /// Invalid UTF-8 is replaced with the Unicode replacement character.
181 ///
182 /// # Safety
183 /// For a non-null pointer and nonzero length, `ptr` must be valid for
184 /// `len` initialized bytes in a single allocation, with `len <= isize::MAX`.
185 /// The bytes must remain live and unchanged during this call. A copied
186 /// `StbString` does not extend the allocation's lifetime.
187 ///
188 /// ```compile_fail
189 /// use rpi_plugin_sdk::StbString;
190 /// let raw = StbString::empty();
191 /// // Reading public ABI pointers requires an explicit validity contract.
192 /// let text = raw.to_string_lossy();
193 /// ```
194 pub unsafe fn to_string_lossy(&self) -> String {
195 if self.len == 0 || self.ptr.is_null() {
196 return String::new();
197 }
198 // SAFETY: the caller guarantees a live, readable, unchanged buffer.
199 // UTF-8 validity is not required by this lossy conversion.
200 let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
201 String::from_utf8_lossy(slice).into_owned()
202 }
203}
204
205/// A **borrowed**, non-owning view of a string passed as an *input* to an FFI
206/// call. The callee MUST NOT free it and MUST NOT retain it past the call.
207///
208/// Built from a `&str` on the calling side; the buffer is valid for the
209/// duration of the call (the caller's borrow).
210#[repr(C)]
211#[derive(Clone, Copy)]
212pub struct StbStringRef {
213 /// UTF-8 bytes (valid for the call; NUL-terminated not required).
214 pub ptr: *const c_char,
215 /// Byte length.
216 pub len: usize,
217}
218
219unsafe impl Send for StbStringRef {}
220unsafe impl Sync for StbStringRef {}
221
222impl StbStringRef {
223 /// Empty input.
224 pub const fn empty() -> Self {
225 Self {
226 ptr: ptr::null(),
227 len: 0,
228 }
229 }
230
231 /// Borrow a `&str` for the duration of a call. The caller must outlive the
232 /// call (normal borrow rules).
233 pub fn from_str(s: &str) -> Self {
234 Self {
235 ptr: s.as_ptr() as *const c_char,
236 len: s.len(),
237 }
238 }
239
240 /// Read into a safe `&str` for the callee's lifetime `'a`.
241 ///
242 /// Panics on invalid UTF-8; SDK callback trampolines contain that panic.
243 ///
244 /// # Safety
245 /// The caller guarantees `ptr` is valid for `len` initialized bytes in a
246 /// single allocation, with `len <= isize::MAX`, and those bytes remain live
247 /// and unchanged for the borrow's lifetime `'a`.
248 pub unsafe fn as_str<'a>(&self) -> &'a str {
249 if self.len == 0 || self.ptr.is_null() {
250 return "";
251 }
252 let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
253 core::str::from_utf8(slice).expect("ABI string input must be valid UTF-8")
254 }
255}
256
257/// Function pointer a plugin exports to free a [`StbString`] it produced.
258/// Empty strings require no deallocation. Transfer each non-empty allocation
259/// exactly once; freeing another copy of an already-freed string is invalid.
260///
261/// The host calls this for every [`StbString`] it receives from the plugin; the
262/// plugin calls the **host's** `free_string` (from [`PluginApi`]) for every
263/// [`StbString`] it receives from the host.
264///
265/// # Safety
266/// The caller must transfer a live allocation made by this producer exactly
267/// once. Calling with a forged, borrowed or already-freed buffer is invalid.
268pub type FreeStringFn = unsafe extern "C" fn(s: StbString);
269
270impl StbString {
271 /// Free this `StbString` via the given `free_string` fn, if non-null and
272 /// non-empty. Consumes ownership (the value is `Copy`, but semantically the
273 /// caller relinquishes the allocation).
274 ///
275 /// After this call the bytes are invalid; do not use the `StbString` again.
276 ///
277 /// # Safety
278 /// A non-empty string must be a live owning allocation created by the
279 /// supplied producer. This call transfers it exactly once; no other copy
280 /// may be read or freed afterward. Prefer [`callbacks::OwnedString`] for
281 /// Rust-side ownership and automatic cleanup.
282 ///
283 /// ```compile_fail
284 /// use rpi_plugin_sdk::{callbacks, StbString};
285 /// let raw = StbString::from_string("text".into());
286 /// raw.free_with(Some(callbacks::free_string));
287 /// ```
288 pub unsafe fn free_with(self, free_fn: Option<FreeStringFn>) {
289 if let Some(free_fn) = free_fn {
290 if !self.is_empty() {
291 // SAFETY: the caller supplies the producer and unique ownership.
292 unsafe { free_fn(self) };
293 }
294 }
295 }
296}
297
298// ---------------------------------------------------------------------------
299// StableJsonValue — JSON round-trip helpers (json feature)
300// ---------------------------------------------------------------------------
301
302/// Helpers to cross structured data as JSON-in-`StbString`. See the module docs
303/// for the precision/order limit.
304#[cfg(feature = "json")]
305pub mod json {
306 use super::StbString;
307 use serde_json::Value;
308
309 /// Serialize a `serde_json::Value` into an owning [`StbString`] the receiver
310 /// must `free` via the producer's `free_string`.
311 pub fn to_stable(value: &Value, free_fn: Option<super::FreeStringFn>) -> StbString {
312 let s = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
313 let stb = StbString::from_string(s);
314 // `free_fn` is advisory metadata the receiver needs; the StbString
315 // itself carries only ptr+len. Stash nothing — the receiver must know
316 // which free fn to use (host's vs plugin's) by direction.
317 let _ = free_fn;
318 stb
319 }
320
321 /// Parse a received [`StbString`] back into a `Value`. Does **not** free the
322 /// input — the caller still owns it.
323 ///
324 /// # Safety
325 /// The buffer must satisfy [`StbString::to_string_lossy`]'s validity and
326 /// lifetime requirements for the duration of this call.
327 ///
328 /// ```compile_fail
329 /// use rpi_plugin_sdk::{json, StbString};
330 /// let raw = StbString::empty();
331 /// let value = json::from_stable(&raw);
332 /// ```
333 pub unsafe fn from_stable(s: &StbString) -> Value {
334 // SAFETY: the caller supplies the raw string validity contract.
335 let text = unsafe { s.to_string_lossy() };
336 if text.is_empty() {
337 return Value::Null;
338 }
339 serde_json::from_str(&text).unwrap_or(Value::Null)
340 }
341}
342
343// ---------------------------------------------------------------------------
344// StableToolSchema — the provider-facing tool definition
345// ---------------------------------------------------------------------------
346
347/// A tool's provider-facing schema crossing the ABI. `name` / `description` are
348/// raw strings; `parameters` is a JSON Schema serialized to a JSON string (the
349/// host parses it into its native `schemars::Schema`).
350///
351/// All three are owning [`StbString`]s the **plugin** produced; the **host**
352/// frees them via the plugin's `free_string` (passed to [`ToolExecuteFn`] /
353/// `register_tool`).
354#[repr(C)]
355#[derive(Clone, Copy)]
356pub struct StableToolSchema {
357 pub name: StbString,
358 pub description: StbString,
359 /// JSON-encoded JSON Schema for the tool's `parameters`.
360 pub parameters: StbString,
361}
362
363// ---------------------------------------------------------------------------
364// Host context injected into plugin tool arguments
365// ---------------------------------------------------------------------------
366
367/// Reserved key the host adds to a **plugin** tool call's arguments.
368///
369/// The model never writes this field and never sees it: the host injects it
370/// after the model's arguments are parsed and before the plugin's
371/// [`ToolExecuteFn`] runs, so a plugin can tell *which* project and *which*
372/// session it is serving without the model having to supply either. (Built-in
373/// tools do not need it — they receive the host's `ExecutionToolContext`
374/// directly. A plugin tool has no such channel.)
375///
376/// The value is an object; today:
377///
378/// ```json
379/// { "cwd": "D:\\Projects\\pi-rust",
380/// "sessionId": "01adb026-7230-708b-855b-91eab6b056b8" }
381/// ```
382///
383/// Both fields are optional and either may be absent: `cwd` when the host
384/// cannot resolve one, `sessionId` for a session that has no id (ephemeral
385/// sessions) and on hosts that predate this field. The key itself may be absent
386/// entirely. Treat everything under it as best-effort, and prefer a
387/// project-scoped fallback when `sessionId` is missing rather than refusing to
388/// work.
389///
390/// Plugins using host-managed child actions additionally receive per-call
391/// `runtimeHandle` (opaque integer) and `runtimeKey` (capability string). Worker
392/// threads use that handle as runtime_action's opaque user_data and include
393/// `__rpiRuntimeKey` in ChildAgent arguments. Never log, persist, expose to the
394/// model, or reuse these values across tool calls. Missing values mean the
395/// host cannot support worker-originated ChildAgent safely; fail closed.
396///
397/// **Consequence for plugin parameter types:** a plugin must tolerate unknown
398/// top-level keys in its arguments. Reading them out of a `serde_json::Value`
399/// does that naturally; a `#[serde(deny_unknown_fields)]` parameter struct
400/// would reject this key, so do not use one for tool parameters.
401///
402/// The host's value wins if the model happens to send the same key: this is
403/// host truth, not a hint to be merged.
404pub const HOST_CONTEXT_KEY: &str = "__rpi";
405
406// ---------------------------------------------------------------------------
407// StepResult — the poll() return: Pending | Done | Err (explicit tag + union)
408// ---------------------------------------------------------------------------
409
410/// Opaque, plugin-allocated handle for one tool execution drive. Produced by
411/// [`ToolExecuteFn`], polled by [`ToolPollFn`], cancelled by [`ToolCancelFn`],
412/// freed by [`ToolDestroyFn`] (exactly once, idempotent).
413///
414/// The handle's interior layout is entirely plugin-private; the host treats it
415/// as an opaque pointer.
416pub type StepHandle = *mut c_void;
417
418/// Discriminant for [`StepResult`]. `#[repr(u32)]` pins the width so the union
419/// payload is sound across compilers.
420#[repr(u32)]
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422pub enum StepResultTag {
423 /// `poll` has no terminal result yet; the partial payload may carry progress.
424 Pending = 0,
425 /// Terminal success; `done.result` is the JSON `AgentToolResult`.
426 Done = 1,
427 /// Terminal failure; `err.message` is a UTF-8 error string.
428 Err = 2,
429}
430
431/// A partial/progress result emitted during `Pending`. `progress` is a JSON
432/// `AgentToolResult` (the same shape `on_update` carries) — the host forwards it
433/// to the adapter's `on_update` callback. May be empty.
434#[repr(C)]
435#[derive(Clone, Copy)]
436pub struct StbPending {
437 pub progress: StbString,
438}
439
440/// Terminal success payload. `result` is a JSON `AgentToolResult`.
441#[repr(C)]
442#[derive(Clone, Copy)]
443pub struct StbDone {
444 pub result: StbString,
445}
446
447/// Terminal failure payload. `message` is a UTF-8 error string.
448#[repr(C)]
449#[derive(Clone, Copy)]
450pub struct StbErr {
451 pub message: StbString,
452}
453
454/// The `poll()` return value. Read the `payload` variant matching `tag`.
455///
456/// All payload variants are `#[repr(C)]` structs of [`StbString`] (Copy), so the
457/// union is `Copy`. A wrong-variant read is `unsafe`; always match on `tag`.
458#[repr(C)]
459#[derive(Clone, Copy)]
460pub union StepResultPayload {
461 pub pending: StbPending,
462 pub done: StbDone,
463 pub err: StbErr,
464}
465
466/// Return value of [`ToolPollFn`]. The blocking driver matches on `tag`, reads
467/// the matching payload, and breaks the loop on `Done`/`Err`.
468#[repr(C)]
469#[derive(Clone, Copy)]
470pub struct StepResult {
471 pub tag: StepResultTag,
472 pub payload: StepResultPayload,
473}
474
475impl StepResult {
476 /// Build a `Pending` with a progress JSON string (may be empty).
477 pub fn pending(progress: StbString) -> Self {
478 Self {
479 tag: StepResultTag::Pending,
480 payload: StepResultPayload {
481 pending: StbPending { progress },
482 },
483 }
484 }
485
486 /// Build a `Done` with the terminal JSON `AgentToolResult`.
487 pub fn done(result: StbString) -> Self {
488 Self {
489 tag: StepResultTag::Done,
490 payload: StepResultPayload {
491 done: StbDone { result },
492 },
493 }
494 }
495
496 /// Build an `Err` with an error message.
497 pub fn err(message: StbString) -> Self {
498 Self {
499 tag: StepResultTag::Err,
500 payload: StepResultPayload {
501 err: StbErr { message },
502 },
503 }
504 }
505
506 /// Access the `pending` payload. Caller MUST guarantee `tag == Pending`.
507 ///
508 /// # Safety
509 /// Undefined behavior if `tag != StepResultTag::Pending`.
510 pub unsafe fn pending_payload(&self) -> &StbPending {
511 unsafe { &self.payload.pending }
512 }
513
514 /// Access the `done` payload. Caller MUST guarantee `tag == Done`.
515 ///
516 /// # Safety
517 /// Undefined behavior if `tag != StepResultTag::Done`.
518 pub unsafe fn done_payload(&self) -> &StbDone {
519 unsafe { &self.payload.done }
520 }
521
522 /// Access the `err` payload. Caller MUST guarantee `tag == Err`.
523 ///
524 /// # Safety
525 /// Undefined behavior if `tag != StepResultTag::Err`.
526 pub unsafe fn err_payload(&self) -> &StbErr {
527 unsafe { &self.payload.err }
528 }
529}
530
531// ---------------------------------------------------------------------------
532// The 4-function tool lifecycle fn-pointer types
533// ---------------------------------------------------------------------------
534
535/// Partial-result callback the **blocking driver** passes to `poll`, wrapped in
536/// `catch_unwind` on the host side. The plugin invokes it **synchronously
537/// inside `poll()`** when it has a `Pending` partial — never retained, never
538/// invoked after `Done`/`Err`.
539///
540/// `partial` is a JSON `AgentToolResult`; ownership passes to the callback (the
541/// host frees it through this tool's registered plugin `free_string`).
542pub type ToolPartialCb = extern "C" fn(partial: StbString, user_data: *mut c_void);
543
544/// `execute(tool_call_id, params) -> StepHandle`. Plugin-allocates a drive
545/// handle and begins the work (non-blocking — the real progress comes via
546/// `poll`). `tool_call_id` is a borrowed [`StbStringRef`] (valid for the call);
547/// `params` is an owning JSON string of the tool-call arguments (the plugin
548/// frees it via the host's `free_string`). Returns null on allocation failure.
549pub type ToolExecuteFn = extern "C" fn(
550 tool_call_id: StbStringRef,
551 params: StbString,
552 free_params: Option<FreeStringFn>,
553) -> StepHandle;
554
555/// `poll(handle, partial_cb, user_data) -> StepResult`. **Non-blocking.** Must
556/// observe the cancel flag (set by [`ToolCancelFn`]) and return `Done`/`Err`
557/// within a bounded number of polls. Borrows `handle` (does not free it).
558pub type ToolPollFn = extern "C" fn(
559 handle: StepHandle,
560 partial_cb: Option<ToolPartialCb>,
561 user_data: *mut c_void,
562) -> StepResult;
563
564/// `cancel(handle)`. Sets an internal `AtomicBool` (SeqCst) cancel flag.
565/// **Idempotent, thread-safe, does NOT free.** The poll loop observes it.
566pub type ToolCancelFn = extern "C" fn(handle: StepHandle);
567
568/// `destroy(handle)`. Frees the handle. **Idempotent; called exactly once by the
569/// blocking driver on exit** (after the loop sees `Done`/`Err`, or after cancel
570/// propagated). Null handle is a no-op.
571pub type ToolDestroyFn = extern "C" fn(handle: StepHandle);
572
573/// Host notification that a wake-capable tool may make progress. May be called
574/// from any thread, including after unregistration; the opaque token is never
575/// dereferenced. Notifications coalesce and are retained until the next poll.
576pub type ToolWakeCb = extern "C" fn(callback_data: *mut c_void);
577
578/// Install or unregister a tool wake callback. Installing must notify if work
579/// is already ready. `None` unregisters; callbacks racing with removal are safe.
580/// The host serializes execute/install/poll/cancel/unregister/destroy per handle.
581/// After Pending, the host waits for a wake; plugins must wake for every new
582/// opportunity to poll. Partial callbacks remain synchronous within poll only.
583pub type ToolSetWakerFn =
584 extern "C" fn(handle: StepHandle, callback: Option<ToolWakeCb>, callback_data: *mut c_void);
585
586/// Begin shutdown without blocking: cancel work and reject new work.
587/// Must be idempotent; do not join workers or call back into locked host state.
588pub type PluginBeginShutdownFn = extern "C" fn(user_data: *mut c_void);
589/// Join workers and release resources on a host blocking cleanup thread.
590/// Return zero only when no background thread, task, or callback can execute
591/// plugin code again. Nonzero retains the library and host context forever;
592/// the host never times out shutdown and then unloads live code.
593pub type PluginFinishShutdownFn = extern "C" fn(user_data: *mut c_void) -> i32;
594/// Register shutdown hooks and their plugin-owned resource context.
595/// The host calls begin outside its session locks, then calls finish after all
596/// active FFI calls and plugin-owned string frees release their keepalives.
597/// The plugin may free `user_data` only after a successful finish.
598pub type RegisterPluginLifecycleFn =
599 extern "C" fn(PluginBeginShutdownFn, PluginFinishShutdownFn, *mut c_void) -> i32;
600
601// ---------------------------------------------------------------------------
602// StablePluginEvent — 33 on() categories (explicit tag + union)
603// ---------------------------------------------------------------------------
604
605/// Discriminant for [`StablePluginEvent`], one variant per pi `on()` category
606/// (33 total) plus one rpi-specific category (34 total). `#[repr(u32)]` pins
607/// the discriminant width.
608///
609/// The 33 Pi categories (verified against `extensions/types.ts:1203-1244`):
610/// project_trust, resources_discover, session_start, session_info_changed,
611/// session_before_switch, session_before_fork, session_before_compact,
612/// session_compact, session_shutdown, session_before_tree, session_tree,
613/// context, before_provider_request, before_provider_headers,
614/// after_provider_response, before_agent_start, agent_start, agent_end,
615/// agent_settled, turn_start, turn_end, message_start, message_update,
616/// message_end, tool_execution_start, tool_execution_update,
617/// tool_execution_end, model_select, thinking_level_select, tool_call,
618/// tool_result, user_bash, input.
619///
620/// `BeforeTuiStart` is **rpi-specific** (not a Pi category): dispatched once
621/// before the interactive TUI is initialized, so extensions can prepare
622/// (connect a server, load a resource) before the UI starts. Appended last so
623/// existing discriminants keep their values (ABI-safe for already-compiled
624/// plugins).
625#[repr(u32)]
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub enum EventTag {
628 ProjectTrust = 0,
629 ResourcesDiscover = 1,
630 SessionStart = 2,
631 SessionInfoChanged = 3,
632 SessionBeforeSwitch = 4,
633 SessionBeforeFork = 5,
634 SessionBeforeCompact = 6,
635 SessionCompact = 7,
636 SessionShutdown = 8,
637 SessionBeforeTree = 9,
638 SessionTree = 10,
639 Context = 11,
640 BeforeProviderRequest = 12,
641 BeforeProviderHeaders = 13,
642 AfterProviderResponse = 14,
643 /// The start of a run, carrying the user prompt (`{"prompt", "imageCount",
644 /// "sessionId", "host"}`). Fire-and-forget like every other tag: subscribe to
645 /// *observe* the turn. To **change** the system prompt for the run, register a
646 /// handler with `register_before_agent_start` — that slot carries an `out`
647 /// pointer, which this fire-and-forget signature cannot.
648 BeforeAgentStart = 15,
649 AgentStart = 16,
650 AgentEnd = 17,
651 AgentSettled = 18,
652 TurnStart = 19,
653 TurnEnd = 20,
654 MessageStart = 21,
655 MessageUpdate = 22,
656 MessageEnd = 23,
657 ToolExecutionStart = 24,
658 ToolExecutionUpdate = 25,
659 ToolExecutionEnd = 26,
660 ModelSelect = 27,
661 ThinkingLevelSelect = 28,
662 ToolCall = 29,
663 ToolResult = 30,
664 UserBash = 31,
665 Input = 32,
666 /// rpi-specific: dispatched before the interactive TUI initializes (not a
667 /// Pi `on()` category). Appended last so existing discriminants are stable.
668 BeforeTuiStart = 33,
669 /// Pi `on()` category: dispatched when the TUI shows an interactive prompt
670 /// (selector, dialog, etc.) that blocks agent work.
671 UiPromptStart = 34,
672 /// Pi `on()` category: dispatched when the TUI prompt is dismissed.
673 UiPromptEnd = 35,
674 /// rpi-specific (not a Pi `on()` category): dispatched whenever the
675 /// interactive prompt editor's **text changes** — typing, pasting, deleting,
676 /// or a programmatic rewrite. Appended last so existing discriminants are
677 /// stable. The payload is `{"chars":N,"empty":bool}`; the draft text itself
678 /// is deliberately not shipped, because subscribers (a voice extension
679 /// barging in on playback) only need to know *that* the user is composing.
680 EditorChange = 36,
681}
682
683/// Number of event categories — `37` (33 Pi `on()` categories + 1 rpi-specific
684/// [`EventTag::BeforeTuiStart`] + 2 UI prompt events + 1 rpi-specific
685/// [`EventTag::EditorChange`]). A test asserts
686/// `EVENT_TAG_COUNT == 37` so a future edit that adds/removes a tag is caught.
687pub const EVENT_TAG_COUNT: usize = 37;
688
689/// The application name of the host that loads plugins: `"rpi"`.
690///
691/// This is the **contract's** copy of the host identity, and the host's
692/// `APP_NAME` is defined in terms of it, so the two cannot drift. An extension
693/// that labels its own output (a trace name, a tag, a `service.name`) should
694/// derive from this rather than hardcode a brand — it has no other way to learn
695/// what it is embedded in.
696///
697/// [`HOST_VERSION`] is the matching version. Both are also handed to plugins at
698/// runtime in the `BeforeAgentStart` payload's `host` object, which is what an
699/// extension should prefer: that value always describes the process actually
700/// running, whereas these constants describe the host the plugin was *built*
701/// against.
702pub const HOST_NAME: &str = "rpi";
703
704/// Version of the [`HOST_NAME`] host that this SDK ships with.
705///
706/// Because every workspace crate shares one version, this is the host's version
707/// as of this SDK release — good for a user agent or a fallback label, and
708/// superseded at runtime by the `host.version` field of the `BeforeAgentStart`
709/// payload.
710/// A preview build sets `RPI_BUILD_VERSION` so the executable and plugin
711/// bridge report the full prerelease version without changing the checked-in
712/// Cargo workspace version. Empty values are ignored for normal builds.
713pub const HOST_VERSION: &str = match option_env!("RPI_BUILD_VERSION") {
714 Some(version) if !version.is_empty() => version,
715 _ => env!("CARGO_PKG_VERSION"),
716};
717
718/// Environment variable carrying the id of the session this process serves.
719///
720/// The host **rewrites** it whenever a session becomes active — at startup and
721/// on every in-process swap (`/import`, fork, switch) — so the value always
722/// names the session currently being served. It is not an embedder override:
723/// a value inherited from a parent process would otherwise make a child `rpi`
724/// report its ancestor's session forever. Embedders that need to pin the id set
725/// it after the session is active rather than before.
726///
727/// The id is a plain UTF-8 string (no NUL), which is what makes writing it with
728/// `std::env::set_var` sound.
729pub const SESSION_ID_ENV: &str = "RPI_SESSION_ID";
730
731/// Environment variable a tracing extension publishes to name the trace a
732/// *nested* `rpi` should attach to, and reads back to decide it is a subagent.
733///
734/// The name is `rpi-langfuse`'s (it owns the `LANGFUSE_` prefix), but the
735/// variable is declared here because **three** parties have to agree on it and
736/// they live in different crates, two of them in different repositories:
737///
738/// - the extension writes it (so a launcher can attach a nested run),
739/// - the host's `bash`/`powershell` must *not* pass it on to a shell (or a plain
740/// `rpi` started from one mistakes itself for a subagent), and
741/// - a launcher may set it deliberately, which is the supported nesting path.
742///
743/// Declaring it once here is what keeps those three in step. The host builds its
744/// exclusion list from these declarations, so dropping or renaming the family
745/// ([`SUBAGENT_PARENT_ENV`]) is a compile error there rather than a silently
746/// re-opened leak, and a newly added member is excluded without the host having
747/// to be told. What the compiler still cannot check is the string itself, since
748/// `rpi-langfuse` lives in another repository and matches on the *value* — that
749/// is pinned by a test. See [`SUBAGENT_PARENT_ENV`] for the whole family.
750pub const SUBAGENT_PARENT_TRACE_ID_ENV: &str = "LANGFUSE_PI_PARENT_TRACE_ID";
751
752/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. The parent turn's root observation id.
753pub const SUBAGENT_PARENT_SPAN_ID_ENV: &str = "LANGFUSE_PI_PARENT_SPAN_ID";
754
755/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. The parent's session id.
756pub const SUBAGENT_PARENT_SESSION_ID_ENV: &str = "LANGFUSE_PI_PARENT_SESSION_ID";
757
758/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. Nesting depth, so a grandchild knows
759/// how far down it is.
760pub const SUBAGENT_PARENT_DEPTH_ENV: &str = "LANGFUSE_PI_PARENT_DEPTH";
761
762/// Every [`SUBAGENT_PARENT_TRACE_ID_ENV`] sibling, as one slice.
763///
764/// A host that excludes this channel from a spawned shell should cover the whole
765/// slice rather than restating the names, and assert it does — a new entry here
766/// that is not excluded is the failure mode this array exists to make testable.
767pub const SUBAGENT_PARENT_ENV: &[&str] = &[
768 SUBAGENT_PARENT_TRACE_ID_ENV,
769 SUBAGENT_PARENT_SPAN_ID_ENV,
770 SUBAGENT_PARENT_SESSION_ID_ENV,
771 SUBAGENT_PARENT_DEPTH_ENV,
772];
773
774/// No-payload marker for events that carry none (e.g. `session_shutdown`).
775/// Carries a dummy byte so the empty-struct isn't flagged FFI-unsafe by
776/// `improper_ctypes` (zero-sized C structs are rejected regardless of `repr(C)`).
777#[repr(C)]
778#[derive(Clone, Copy)]
779pub struct EventEmpty {
780 _opaque: u8,
781}
782
783impl EventEmpty {
784 /// The one no-payload instance.
785 pub const INSTANCE: EventEmpty = EventEmpty { _opaque: 0 };
786}
787
788impl Default for EventEmpty {
789 fn default() -> Self {
790 Self::INSTANCE
791 }
792}
793
794/// A serialized message lifecycle payload (`message_start`/`message_update`/
795/// `message_end`). `message` is JSON. For `message_update`, it follows native
796/// Pi's envelope and carries `assistantMessageEvent`; streaming consumers must
797/// read its real `text_delta.delta` instead of diffing a cumulative snapshot.
798#[repr(C)]
799#[derive(Clone, Copy)]
800pub struct EventMessage {
801 pub message: StbString,
802}
803
804/// A tool-call payload (`tool_call`, `tool_execution_start`/`update`).
805/// `tool_call_id` + `tool_name` are raw strings; `params` is the JSON args.
806#[repr(C)]
807#[derive(Clone, Copy)]
808pub struct EventToolCall {
809 pub tool_call_id: StbString,
810 pub tool_name: StbString,
811 pub params: StbString,
812}
813
814/// A tool-result payload (`tool_result`, `tool_execution_end`).
815#[repr(C)]
816#[derive(Clone, Copy)]
817pub struct EventToolResult {
818 pub tool_call_id: StbString,
819 pub tool_name: StbString,
820 pub result: StbString,
821 pub is_error: u8,
822}
823
824/// An error/failure payload.
825#[repr(C)]
826#[derive(Clone, Copy)]
827pub struct EventError {
828 pub message: StbString,
829}
830
831/// A generic JSON-data payload for the long-tail events whose structured shape
832/// the host serializes wholesale (`context`, `before_provider_request`, model
833/// select, resources_discover response, etc.). The plugin reads the fields it
834/// needs.
835#[repr(C)]
836#[derive(Clone, Copy)]
837pub struct EventData {
838 pub data: StbString,
839}
840
841/// Payload union for [`StablePluginEvent`]. All variants are `#[repr(C)]` structs
842/// of [`StbString`] / primitives (Copy), so the union is Copy. Discriminate by
843/// [`StablePluginEvent::tag`] before reading.
844#[repr(C)]
845#[derive(Clone, Copy)]
846pub union EventPayload {
847 pub empty: EventEmpty,
848 pub message: EventMessage,
849 pub tool_call: EventToolCall,
850 pub tool_result: EventToolResult,
851 pub error: EventError,
852 pub data: EventData,
853}
854
855/// One event dispatched to a plugin handler. The host translates its native
856/// `AgentEvent` / `HarnessEvent` into this and calls every registered handler
857/// for the `tag`. Event buffers are borrowed during fan-out: handlers must not
858/// free them or retain their pointers. The host frees them after all handlers
859/// return. Use `events::EventView` to retain owned payload copies.
860#[repr(C)]
861#[derive(Clone, Copy)]
862pub struct StablePluginEvent {
863 pub tag: EventTag,
864 pub payload: EventPayload,
865}
866
867impl StablePluginEvent {
868 /// Build a no-payload event.
869 pub fn empty(tag: EventTag) -> Self {
870 Self {
871 tag,
872 payload: EventPayload {
873 empty: EventEmpty::INSTANCE,
874 },
875 }
876 }
877
878 /// Build a message event.
879 pub fn message(tag: EventTag, message: StbString) -> Self {
880 debug_assert!(matches!(
881 tag,
882 EventTag::MessageStart | EventTag::MessageUpdate | EventTag::MessageEnd
883 ));
884 Self {
885 tag,
886 payload: EventPayload {
887 message: EventMessage { message },
888 },
889 }
890 }
891
892 /// Build a tool-call event.
893 pub fn tool_call(
894 tag: EventTag,
895 tool_call_id: StbString,
896 tool_name: StbString,
897 params: StbString,
898 ) -> Self {
899 debug_assert!(matches!(
900 tag,
901 EventTag::ToolCall | EventTag::ToolExecutionStart | EventTag::ToolExecutionUpdate
902 ));
903 Self {
904 tag,
905 payload: EventPayload {
906 tool_call: EventToolCall {
907 tool_call_id,
908 tool_name,
909 params,
910 },
911 },
912 }
913 }
914
915 /// Build a tool-result event.
916 pub fn tool_result(
917 tag: EventTag,
918 tool_call_id: StbString,
919 tool_name: StbString,
920 result: StbString,
921 is_error: bool,
922 ) -> Self {
923 debug_assert!(matches!(
924 tag,
925 EventTag::ToolResult | EventTag::ToolExecutionEnd
926 ));
927 Self {
928 tag,
929 payload: EventPayload {
930 tool_result: EventToolResult {
931 tool_call_id,
932 tool_name,
933 result,
934 is_error: is_error as u8,
935 },
936 },
937 }
938 }
939
940 /// Build an error event.
941 pub fn error(tag: EventTag, message: StbString) -> Self {
942 Self {
943 tag,
944 payload: EventPayload {
945 error: EventError { message },
946 },
947 }
948 }
949
950 /// Build a generic data event (JSON in `data`).
951 pub fn data(tag: EventTag, data: StbString) -> Self {
952 Self {
953 tag,
954 payload: EventPayload {
955 data: EventData { data },
956 },
957 }
958 }
959}
960
961/// Return codes an [`EventHandlerFn`] may return.
962///
963/// `0` means success; the host continues to the next handler. A **nonzero**
964/// return means the handler handled an error — the host logs it and (for the
965/// ordinary agent/observe fan-out) continues to other handlers (one handler's
966/// error does not abort the fan-out, mirroring pi's per-handler try/catch).
967///
968/// [`EVENT_HANDLER_ABORT`] is the **veto** code: the handler wants to stop the
969/// current lifecycle phase. It is honored only by the host's lifecycle
970/// dispatch (e.g. `before_tui_start` / `session_start` / `session_shutdown`);
971/// the ordinary observe fan-out treats it like any other nonzero (log +
972/// continue), because the agent loop must not be vetoed mid-run.
973pub const EVENT_HANDLER_CONTINUE: i32 = 0;
974/// The handler handled an error but the fan-out should continue (default
975/// nonzero semantics — equivalent to any other nonzero code).
976pub const EVENT_HANDLER_ERROR: i32 = 1;
977/// **Veto**: stop the current lifecycle phase. Honored only by the host's
978/// lifecycle dispatch; the observe fan-out logs + continues (see the module
979/// docs on [`EVENT_HANDLER_CONTINUE`]).
980pub const EVENT_HANDLER_ABORT: i32 = 2;
981/// **Claim**: the handler consumed this event and the host should not fall back
982/// to its own default handling. Honored only by dispatch paths that ask
983/// extensions to arbitrate (currently `EventTag::Input` key routing, via
984/// [`EVENT_HANDLER_CLAIMED`]); the ordinary observe fan-out treats it like any
985/// other nonzero (log + continue).
986///
987/// Lets a handler subscribe to a key yet decline it while its feature is off:
988/// return [`EVENT_HANDLER_CONTINUE`] to pass the key through to normal editor
989/// handling, or this to swallow it.
990pub const EVENT_HANDLER_CLAIMED: i32 = 3;
991
992/// Handler fn pointer registered via `register_event_handler(tag, handler)`.
993/// `user_data` is the plugin's opaque context. Return `0` on success; nonzero
994/// signals a handled error (the host logs it; dispatch continues to other
995/// handlers — one handler's error does not abort the fan-out). The one
996/// exception: when the host dispatches a **lifecycle** event through its
997/// veto-aware path, returning [`EVENT_HANDLER_ABORT`] stops the fan-out and
998/// aborts that phase (see the constants above).
999pub type EventHandlerFn = extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32;
1000
1001/// `resources_discover` handler signature (B5b). Unlike [`EventHandlerFn`]
1002/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin
1003/// can hand `{skillPaths, promptPaths, themePaths}` back to the host. `cwd` and
1004/// `reason` are borrowed inputs ([`StbStringRef`]); `out` is plugin-produced
1005/// and reclaimed via the `plugin_free_string` the host stored alongside the
1006/// handler at registration. `user_data` is the plugin's opaque context. Returns
1007/// `0` on success (host reads `out`); nonzero on a handled error (host logs +
1008/// skips this handler, fan-out continues — mirrors pi `runner.ts:1179-1188`).
1009pub type ResourcesDiscoverFn = extern "C" fn(
1010 cwd: StbStringRef,
1011 reason: StbStringRef,
1012 out: *mut StbString,
1013 user_data: *mut c_void,
1014) -> i32;
1015
1016/// `before_agent_start` handler signature. Unlike [`EventHandlerFn`]
1017/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin can
1018/// hand back a system prompt to install for the run — pi's
1019/// `BeforeAgentStartEventResult` (`extensions/types.ts:1394`), whose
1020/// `systemPrompt` becomes `forceSystemPrompt` (`runner.ts:1347`).
1021///
1022/// `event_json` is a borrowed envelope with the same fields the
1023/// [`EventTag::BeforeAgentStart`] broadcast carries (`prompt`, `imageCount`,
1024/// `sessionId`, `host`) plus the live `systemPrompt` the run would otherwise
1025/// use. `out` is plugin-produced JSON `{"systemPrompt": "..."}` (or an empty
1026/// object for "no change"), reclaimed via the `plugin_free_string` the host
1027/// stored at registration.
1028///
1029/// Returns `0` on success (host reads `out`); nonzero on a handled error (host
1030/// logs the plugin's message and leaves the prompt untouched, then continues the
1031/// fan-out — mirrors pi, which isolates a throwing handler).
1032///
1033/// **Replacement, not append**: the returned text becomes the system prompt. A
1034/// plugin that wants to append reads `event_json.systemPrompt` and returns the
1035/// two concatenated, which is what pi requires of its extensions too.
1036/// SAFETY: the plugin warrants `user_data` is valid for the registry's lifetime
1037/// and the handler is callable from any thread.
1038pub type BeforeAgentStartFn =
1039 extern "C" fn(event_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1040
1041// ---------------------------------------------------------------------------
1042// Runtime actions — uniform JSON-RPC dispatch by RuntimeActionId
1043// ---------------------------------------------------------------------------
1044
1045/// Identifier for a host runtime action the plugin may invoke via
1046/// `PluginApi::runtime_action`. One slot dispatches all actions; the numeric
1047/// id crosses the FFI boundary as a `u32` and the host validates it with
1048/// [`TryFrom<u32>`] before constructing this enum. Args/results cross as JSON
1049/// strings.
1050#[repr(u32)]
1051#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1052pub enum RuntimeActionId {
1053 /// Send `message` (stored AgentMessage or Pi customType/content/display/
1054 /// details) or `text`. Options `triggerTurn` and `deliverAs` can be flat or
1055 /// nested under `options`. Default: idle append, active steer. Use explicit
1056 /// `triggerTurn: true` to start an idle run. `nextTurn` always defers.
1057 SendMessage = 0,
1058 SendUserMessage = 1,
1059 /// Append without starting a run. `{"customType": ..., "data": ...}` stores
1060 /// extension data; `{"message": ...}` queues context until the active turn
1061 /// settles. The returned `entryId` can be reserved before its entry exists.
1062 AppendEntry = 2,
1063 SetSessionName = 3,
1064 GetActiveTools = 4,
1065 SetActiveTools = 5,
1066 SetModel = 6,
1067 GetThinkingLevel = 7,
1068 SetThinkingLevel = 8,
1069 Compact = 9,
1070 GetSystemPrompt = 10,
1071 NewSession = 11,
1072 Fork = 12,
1073 NavigateTree = 13,
1074 SwitchSession = 14,
1075 Reload = 15,
1076 /// Read a parsed CLI flag by name. Args: `{"name":"flag"}`; result:
1077 /// `{"value": <bool|string|null>}`.
1078 GetCliFlag = 16,
1079 /// Open/poll/cancel a host-provided interactive UI request. The payload is
1080 /// a JSON object with `op` (`open`, `poll`, or `cancel`) and a unique
1081 /// `requestId`; hosts without an attached TUI return an explicit error.
1082 UiDialog = 17,
1083 /// Publish a short status string for the host's UI (TUI footer).
1084 /// Args: `{"key":"langfuse","value":"langfuse ✓ (trace sent)"}`; an
1085 /// empty/absent `value` clears that key. Result: `{"ok":true,"changed":bool}`.
1086 /// Headless hosts accept the write but have nothing to render.
1087 /// Legacy footer text (`key`, `value`). Use UiComponent for registered UI.
1088 SetStatus = 18,
1089 /// Put text into the interactive editor (the prompt input box) instead of
1090 /// sending it. Args: `{"text":"...", "mode":"append"|"replace",
1091 /// "autoSendMs": 3000}` (omit/`0` for manual send only). Result:
1092 /// `{"ok":true,"chars":N}`. A headless host has no editor and accepts the
1093 /// write without rendering it, mirroring [`RuntimeActionId::SetStatus`].
1094 SetEditorText = 19,
1095 /// Return the current session branch entries oldest-first as JSON.
1096 /// Args: `{}`; result: `{"entries":[...]}`.
1097 GetSessionBranch = 20,
1098 /// Inspect or finish a plugin-owned stdio server lifecycle. Args:
1099 /// `{"op":"status"}` or `{"op":"finish","exitCode":0}`. Only the
1100 /// declaring owner may finish; the host performs normal session shutdown.
1101 StdioLifecycle = 21,
1102 /// Plugin-owned text component lifecycle and content updates.
1103 UiComponent = 22,
1104 /// Safe live execution metadata (no credentials, endpoints or headers).
1105 /// Args: `{}`; result: model identity, thinkingLevel, tools, cwd, sessionId.
1106 GetExecutionContext = 23,
1107 /// Host-owned independent child run. `op`: start/poll/cancel/dispose.
1108 /// Start: task, optional role/maxTurns/timeoutSeconds/tools/toolCallId.
1109 /// Others: childId. Poll drains text deltas and returns status/result.
1110 /// Handles are scoped to the calling plugin and must be disposed.
1111 ChildAgent = 24,
1112 /// Read-only, host-managed side question. Registration-level commands may
1113 /// use start(question), status, cancel, hide and show. Never changes the
1114 /// main transcript or its queues. Presentation and execution stay in host.
1115 SideQuestion = 25,
1116}
1117
1118/// Error returned when a plugin passes a numeric runtime-action id that this
1119/// ABI does not define.
1120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1121pub struct UnknownRuntimeActionId(pub u32);
1122
1123impl core::fmt::Display for UnknownRuntimeActionId {
1124 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1125 write!(f, "unknown runtime action id {}", self.0)
1126 }
1127}
1128
1129impl std::error::Error for UnknownRuntimeActionId {}
1130
1131impl TryFrom<u32> for RuntimeActionId {
1132 type Error = UnknownRuntimeActionId;
1133
1134 fn try_from(value: u32) -> Result<Self, Self::Error> {
1135 match value {
1136 0 => Ok(Self::SendMessage),
1137 1 => Ok(Self::SendUserMessage),
1138 2 => Ok(Self::AppendEntry),
1139 3 => Ok(Self::SetSessionName),
1140 4 => Ok(Self::GetActiveTools),
1141 5 => Ok(Self::SetActiveTools),
1142 6 => Ok(Self::SetModel),
1143 7 => Ok(Self::GetThinkingLevel),
1144 8 => Ok(Self::SetThinkingLevel),
1145 9 => Ok(Self::Compact),
1146 10 => Ok(Self::GetSystemPrompt),
1147 11 => Ok(Self::NewSession),
1148 12 => Ok(Self::Fork),
1149 13 => Ok(Self::NavigateTree),
1150 14 => Ok(Self::SwitchSession),
1151 15 => Ok(Self::Reload),
1152 16 => Ok(Self::GetCliFlag),
1153 17 => Ok(Self::UiDialog),
1154 18 => Ok(Self::SetStatus),
1155 19 => Ok(Self::SetEditorText),
1156 20 => Ok(Self::GetSessionBranch),
1157 21 => Ok(Self::StdioLifecycle),
1158 22 => Ok(Self::UiComponent),
1159 23 => Ok(Self::GetExecutionContext),
1160 24 => Ok(Self::ChildAgent),
1161 25 => Ok(Self::SideQuestion),
1162 other => Err(UnknownRuntimeActionId(other)),
1163 }
1164 }
1165}
1166
1167impl From<RuntimeActionId> for u32 {
1168 fn from(value: RuntimeActionId) -> Self {
1169 value as u32
1170 }
1171}
1172
1173/// Runtime-action signature: `runtime_action(action_id, args_json, out,
1174/// user_data) -> i32`. `args_json` is a borrowed input ([`StbStringRef`]); `out`
1175/// is an owning output ([`StbString`]) the host produces and the plugin frees
1176/// via the host's `free_string`. `action_id` is deliberately a raw `u32`, not a
1177/// Rust enum: an unknown value must be rejected as a normal protocol error
1178/// rather than materializing an invalid enum discriminant. Returns `0` on
1179/// success, nonzero on error.
1180pub type RuntimeActionFn = extern "C" fn(
1181 action_id: u32,
1182 args_json: StbStringRef,
1183 out: *mut StbString,
1184 user_data: *mut c_void,
1185) -> i32;
1186
1187// ---------------------------------------------------------------------------
1188// PluginApi — host-provided API struct the plugin calls
1189// ---------------------------------------------------------------------------
1190
1191/// A generic command-handler fn (for `register_command`). `args_json` is a
1192/// borrowed `{"args":"...","command":"/..."}` envelope; `out` is owning
1193/// JSON output reclaimed with the host `free_string`. The TUI understands
1194/// `{kind:"message",text}`, `{kind:"selector",items:[...]}`,
1195/// `{kind:"editor",initialText}`, and `{kind:"input",title,placeholder}`
1196/// responses; selector/editor/input submissions call the same handler with an
1197/// `action` field in `args`. `user_data` is the registering plugin's opaque
1198/// runtime handle (or null when no action bridge exists), scoped to that
1199/// registration. Pass it to runtime_action; never cache it across registrations.
1200pub type CommandHandlerFn =
1201 extern "C" fn(args_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1202
1203/// A render/transform fn (for the renderer registrars). `input_json` is borrowed;
1204/// `out` is owning output the plugin frees via host `free_string`. Markdown
1205/// handlers return `{markdown:"..."}`; message/entry handlers return
1206/// `{text:"...",markdown?:true}` or `{lines:["..."]}` for terminal UI.
1207pub type RenderFn =
1208 extern "C" fn(input_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1209
1210/// A provider-injection factory fn (for `register_provider`). `req_json` is a
1211/// borrowed request envelope; `out` is an owning response the plugin frees.
1212/// The host wraps this into a `Provider` impl (B4/B5).
1213///
1214/// **One-shot.** The plugin returns the complete assistant message and the host
1215/// emits it as a single terminal chunk (no progressive rendering). A provider
1216/// that can stream should use [`ProviderStreamRequestFn`] via
1217/// `register_streaming_provider` instead; both slots stay registered so an
1218/// existing plugin keeps its behavior unchanged.
1219pub type ProviderRequestFn =
1220 extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1221
1222/// Chunk kind for [`ProviderStreamControl::is_cancelled`]. **Appended last** so a
1223/// plugin compiled against an older, shorter struct reads only the fields it
1224/// knows about.
1225#[repr(u32)]
1226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1227pub enum ProviderChunkKind {
1228 /// A chunk that carries model output (`text_delta`, `thinking_delta`, …).
1229 /// A cancelled request has no one left to deliver it to.
1230 Content = 0,
1231 /// A structural or accounting chunk (`start`, `*_start`, `*_end`, `usage`).
1232 /// Still acknowledged while the request winds down. A host that already
1233 /// emitted its aborted terminal discards these cleanup chunks.
1234 Structural = 1,
1235}
1236
1237/// The plugin's synchronous control surface over the host, passed to a **v2**
1238/// streaming request fn.
1239///
1240/// The fields are plain fn pointers plus an opaque pointer, so the struct is
1241/// `Copy` and needs no deallocation. The host owns it for the duration of the
1242/// request and it must not be retained.
1243///
1244/// `is_cancelled` is the cheap check a plugin's request loop makes between
1245/// chunks (poll it, do not block on it). `wait_cancelled_ms` is the blocking
1246/// variant for a plugin whose loop is not able to poll — an SSE frame read, a
1247/// sleep between retries — and it must return promptly on cancellation rather
1248/// than waiting out the full timeout.
1249///
1250/// ```ignore
1251/// // Inside a v2 request fn, before handing a delta to `emit`:
1252/// if control.is_cancelled(control.user_data, ProviderChunkKind::Content as u32) != 0 {
1253/// return 1; // stop producing; the host is already gone
1254/// }
1255/// ```
1256#[repr(C)]
1257#[derive(Clone, Copy)]
1258pub struct ProviderStreamControl {
1259 /// `is_cancelled(control_user_data, chunk_kind: u32) -> i32`. Nonzero when
1260 /// the caller has gone away. `chunk_kind` lets the host still admit the
1261 /// structural chunks a partial message needs: pass
1262 /// [`ProviderChunkKind::Content`] (0) for model output and
1263 /// [`ProviderChunkKind::Structural`] (1) otherwise. Never blocking, never
1264 /// frees anything.
1265 pub is_cancelled: extern "C" fn(user_data: *mut c_void, chunk_kind: u32) -> i32,
1266 /// `wait_cancelled_ms(control_user_data, timeout_ms) -> i32`. Blocks up to
1267 /// `timeout_ms` waiting for cancellation, returning nonzero the moment it is
1268 /// observed. `timeout_ms == 0` is a non-blocking poll (same as
1269 /// `is_cancelled` with [`ProviderChunkKind::Content`]). Safe to call from the
1270 /// request thread: it only touches the host's cancellation latch, which is
1271 /// always present in the snapshot and never holds another lock.
1272 pub wait_cancelled_ms: extern "C" fn(user_data: *mut c_void, timeout_ms: u32) -> i32,
1273 /// Host-owned opaque handle, passed back unchanged to both fields above.
1274 pub user_data: *mut c_void,
1275}
1276
1277/// Completion signal a **v2** streaming provider calls **after** its request fn
1278/// has returned.
1279///
1280/// Retained in the ABI for a future host that can keep the plugin's own stream
1281/// alive past the request fn. This host does **not** use it: the plugin receives
1282/// `None` and must therefore finish inside its request fn (which it can do
1283/// promptly on cancellation by polling [`ProviderStreamControl`]). Keeping the
1284/// type documented — rather than deleting it — is what makes a future host able
1285/// to add the capability without another signature change.
1286///
1287/// `message_json` is a **plugin-owned** owning [`StbString`] holding the final
1288/// assistant message.
1289pub type ProviderStreamCompletionFn =
1290 extern "C" fn(message_json: StbString, user_data: *mut c_void) -> i32;
1291
1292/// Per-chunk callback a **streaming** provider invokes while the response is
1293/// still being produced (for `register_streaming_provider`).
1294///
1295/// `event_json` is an **owning** [`StbString`] the plugin produces and transfers
1296/// to the host: the host reclaims it with the **producer's** `plugin_free_string`
1297/// — the one the plugin passed to `register_streaming_provider` — exactly like a
1298/// tool's partial-progress chunk. The host must not use its own `free_string`
1299/// for it: the allocation belongs to the plugin's SDK instance.
1300///
1301/// The callback is invoked **synchronously inside the request fn**, on the same
1302/// thread, only for the duration of that call — a plugin must never retain
1303/// `emit`, `emit_user_data`, or the struct they live in past the request.
1304/// Returns `0` on success; nonzero means the host rejected the chunk (a malformed
1305/// envelope), which a plugin may ignore or treat as a request failure.
1306pub type ProviderStreamEmitFn = extern "C" fn(event_json: StbString, user_data: *mut c_void) -> i32;
1307
1308/// A **streaming** provider-injection fn (for `register_streaming_provider`).
1309///
1310/// Same request envelope and final-response contract as [`ProviderRequestFn`],
1311/// plus a per-chunk channel and the cancellation protocol:
1312///
1313/// - `req_json` — borrowed `{ model, context, options }` envelope (never freed by
1314/// the plugin).
1315/// - `emit` + `emit_user_data` — the host's per-chunk callback. `emit` is `None`
1316/// only if the host cannot accept chunks; a plugin then degrades to buffering
1317/// and returning the message through `out`. While the caller is cancelled the
1318/// host acknowledges structural cleanup chunks and rejects content chunks
1319/// (nonzero rc). Cleanup chunks are discarded after the aborted terminal.
1320/// - `control` — pointer to the host's [`ProviderStreamControl`], present on
1321/// **every** call. It is `null` only against a host that cannot describe its
1322/// control surface; a plugin then treats the request as not cancellable and
1323/// behaves as if cancellation never happens. The plugin must not retain it: it
1324/// is valid only for the duration of the request fn.
1325/// - `completion` — reserved; always `None` on this host (see
1326/// [`ProviderStreamCompletionFn`]). A plugin must return its final message
1327/// through `out` and may not expect to keep streaming after returning.
1328/// - `out` — owning, plugin-owned, plugin-freed response holding the **final**
1329/// `AssistantMessage` JSON (identical to the one-shot contract).
1330/// - `user_data` — the opaque value the plugin registered with.
1331///
1332/// Call order: zero or more `emit` chunks, then exactly one return. The host
1333/// derives the terminal `Done`/`Error` event from `out` (or from a nonzero rc),
1334/// so a plugin that emits deltas still must fill `out`.
1335///
1336/// **Cancellation is opt-in per chunk, not per registration.** There is one
1337/// streaming registration and one signature; whether a plugin checks `control` is
1338/// up to it. A plugin that ignores `control` behaves exactly like the simpler
1339/// fire-and-forget shape (the host still stops forwarding content and terminates
1340/// the run on `Aborted`) — it just keeps generating until it finishes on its own.
1341///
1342/// Chunk envelope (JSON object with a `type` discriminator). `contentIndex` is the
1343/// index of the block inside the final message; each index may be started once,
1344/// but blocks may be opened in any order:
1345///
1346/// ```json
1347/// {"type":"start"}
1348/// {"type":"text_start","contentIndex":0}
1349/// {"type":"text_delta","contentIndex":0,"delta":"hello "}
1350/// {"type":"text_end","contentIndex":0,"content":"hello world"}
1351/// {"type":"thinking_start","contentIndex":1}
1352/// {"type":"thinking_delta","contentIndex":1,"delta":"considering"}
1353/// {"type":"thinking_end","contentIndex":1,"content":"considering..."}
1354/// {"type":"toolcall_start","contentIndex":2,"id":"call_1","name":"echo"}
1355/// {"type":"toolcall_delta","contentIndex":2,"delta":"{\"text\""}
1356/// {"type":"toolcall_end","contentIndex":2,"id":"call_1","name":"echo","arguments":{}}
1357/// {"type":"usage","usage":{"input":1,"output":2}}
1358/// ```
1359///
1360/// Unknown `type` values are ignored (forward compatibility). A `*_delta` may
1361/// carry an optional `partial` object (a full assistant message) to override the
1362/// host-side accumulated snapshot; otherwise the host accumulates deltas itself.
1363pub type ProviderStreamRequestFn = extern "C" fn(
1364 req_json: StbStringRef,
1365 emit: Option<ProviderStreamEmitFn>,
1366 emit_user_data: *mut c_void,
1367 control: *const ProviderStreamControl,
1368 completion: Option<ProviderStreamCompletionFn>,
1369 completion_user_data: *mut c_void,
1370 out: *mut StbString,
1371 user_data: *mut c_void,
1372) -> i32;
1373
1374/// OAuth provider callback. The host sends a JSON action envelope and the
1375/// plugin returns a JSON response. Actions are `begin`, `exchange`,
1376/// `refresh`, and `revoke`; token material remains plugin-owned and is never
1377/// placed in normal agent events.
1378pub type OAuthRequestFn =
1379 extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1380
1381/// Register an OAuth provider using a JSON manifest plus one action callback.
1382/// The manifest is copied by the host and should include `id`, `displayName`,
1383/// `scopes`, `supportsPkce`, and optional `capabilities`.
1384pub type OAuthRegisterFn = OAuthRequestFn;
1385
1386// ---------------------------------------------------------------------------
1387// Unified ABI — single struct, version inside
1388// ---------------------------------------------------------------------------
1389
1390/// The unified ABI version.
1391pub const RPI_PLUGIN_ABI_VERSION_UNIFIED: u32 = 4;
1392
1393/// The unversioned symbol for the unified ABI.
1394pub const REGISTER_SYMBOL_UNIFIED: &[u8] = b"rpi_plugin_register\0";
1395
1396/// The host-provided API struct. The first two fields (`abi_version`
1397/// and `struct_size`) are contract-identity markers that survive future
1398/// signature changes: a plugin reads them from the pointer before anything
1399/// else, so even if the entrypoint arity changes, the version check remains
1400/// robust.
1401#[repr(C)]
1402#[derive(Clone, Copy)]
1403pub struct PluginApi {
1404 /// Contract identity. First field so a plugin can validate before reading
1405 /// anything else, independent of the entrypoint arity.
1406 pub abi_version: u32,
1407 /// `sizeof(PluginApi)` as the host built it. Lets a plugin detect a host
1408 /// that predates slots it wants (host_struct_size < plugin's expected
1409 /// offset). Future hosts may grow this struct; old plugins check
1410 /// `struct_size` before accessing new fields.
1411 pub struct_size: u32,
1412
1413 // --- Host API ---
1414 /// Host's `free_string` — the plugin calls this for every [`StbString`] it
1415 /// *receives* from the host (outputs of actions, event payloads, inputs to
1416 /// execute). Never null.
1417 pub free_string: FreeStringFn,
1418
1419 // --- registrars (plugin → host "register X into the host") ---
1420 pub register_tool: Option<
1421 extern "C" fn(
1422 schema: *const StableToolSchema,
1423 execute_fn: ToolExecuteFn,
1424 poll_fn: ToolPollFn,
1425 cancel_fn: ToolCancelFn,
1426 destroy_fn: ToolDestroyFn,
1427 plugin_free_string: FreeStringFn,
1428 ) -> i32,
1429 >,
1430 pub register_command: Option<
1431 extern "C" fn(
1432 name: StbStringRef,
1433 description: StbStringRef,
1434 handler: CommandHandlerFn,
1435 ) -> i32,
1436 >,
1437 pub register_shortcut:
1438 Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
1439 pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
1440 pub register_provider: Option<
1441 extern "C" fn(
1442 provider_id: StbStringRef,
1443 base_url: StbStringRef,
1444 api_style: StbStringRef,
1445 request_fn: ProviderRequestFn,
1446 plugin_free_string: FreeStringFn,
1447 user_data: *mut c_void,
1448 ) -> i32,
1449 >,
1450 pub register_message_renderer: Option<
1451 extern "C" fn(
1452 name: StbStringRef,
1453 render_fn: RenderFn,
1454 plugin_free_string: FreeStringFn,
1455 user_data: *mut c_void,
1456 ) -> i32,
1457 >,
1458 pub register_markdown_transformer: Option<
1459 extern "C" fn(
1460 name: StbStringRef,
1461 render_fn: RenderFn,
1462 plugin_free_string: FreeStringFn,
1463 user_data: *mut c_void,
1464 ) -> i32,
1465 >,
1466 pub register_entry_renderer: Option<
1467 extern "C" fn(
1468 name: StbStringRef,
1469 render_fn: RenderFn,
1470 plugin_free_string: FreeStringFn,
1471 user_data: *mut c_void,
1472 ) -> i32,
1473 >,
1474 pub register_event_handler: Option<
1475 extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
1476 >,
1477 pub register_resources_discover: Option<
1478 extern "C" fn(
1479 handler: ResourcesDiscoverFn,
1480 plugin_free_string: FreeStringFn,
1481 user_data: *mut c_void,
1482 ) -> i32,
1483 >,
1484 pub runtime_action: RuntimeActionFn,
1485 pub dispatch_event:
1486 Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
1487 pub user_data: *mut c_void,
1488
1489 // --- declarations ---
1490 /// Declare this plugin's host-side preferences as a JSON object, e.g.
1491 /// `{"priority":60,"platforms":["linux","macos"]}`. Nullable: host does
1492 /// not support declarations yet (plugin skips the call; host applies
1493 /// defaults: priority `100`, all platforms).
1494 pub declare: Option<extern "C" fn(json: StbStringRef) -> i32>,
1495
1496 /// Register a system-prompt transformer for the run (pi's
1497 /// `before_agent_start` returning `systemPrompt`).
1498 ///
1499 /// **Appended last on purpose.** A plugin compiled against a shorter
1500 /// `PluginApi` indexes every earlier field by offset, so a slot inserted in
1501 /// the middle would make an old plugin read the new host's fields at the
1502 /// wrong offsets. Appending keeps every existing offset valid, which is what
1503 /// lets this ship without an ABI version bump; a plugin checks
1504 /// `struct_size` (or the slot for `None`) before using it.
1505 ///
1506 /// Nullable: a host that predates this slot leaves it `None`, the plugin
1507 /// skips the call, and the prompt is left alone — exactly that host's
1508 /// behavior before this slot existed.
1509 pub register_before_agent_start: Option<
1510 extern "C" fn(
1511 handler: BeforeAgentStartFn,
1512 plugin_free_string: FreeStringFn,
1513 user_data: *mut c_void,
1514 ) -> i32,
1515 >,
1516
1517 /// Register a provider-owned OAuth flow. Appended for ABI compatibility;
1518 /// older plugins compiled against a shorter struct remain valid.
1519 pub register_oauth_provider: Option<
1520 extern "C" fn(
1521 manifest_json: StbStringRef,
1522 request_fn: OAuthRequestFn,
1523 plugin_free_string: FreeStringFn,
1524 user_data: *mut c_void,
1525 ) -> i32,
1526 >,
1527
1528 /// Register a **streaming** custom provider (see
1529 /// [`ProviderStreamRequestFn`]). Appended after `register_oauth_provider` so
1530 /// every earlier field keeps its offset — a plugin compiled against a shorter
1531 /// [`PluginApi`] stays valid without an ABI version bump.
1532 ///
1533 /// Nullable: `register_entrypoint` fills slots absent from an older host's
1534 /// vtable with `None`; a plugin that cannot find the slot should fall back to
1535 /// `register_provider` (one-shot) rather than refuse to load.
1536 ///
1537 /// `provider_id`/`base_url`/`api_style` mirror `register_provider` and are
1538 /// copied by the host at registration. A streaming registration for an id
1539 /// that already has a one-shot registration **replaces** it: streaming is a
1540 /// strict superset of the one-shot contract, so a plugin registering both
1541 /// ends up on the richer path.
1542 pub register_streaming_provider: Option<
1543 extern "C" fn(
1544 provider_id: StbStringRef,
1545 base_url: StbStringRef,
1546 api_style: StbStringRef,
1547 request_fn: ProviderStreamRequestFn,
1548 plugin_free_string: FreeStringFn,
1549 user_data: *mut c_void,
1550 ) -> i32,
1551 >,
1552 /// Optional wake-driven tool registration, appended for ABI compatibility.
1553 /// Fall back to `register_tool` when an older host supplies None.
1554 pub register_tool_with_wake: Option<
1555 extern "C" fn(
1556 schema: *const StableToolSchema,
1557 execute_fn: ToolExecuteFn,
1558 poll_fn: ToolPollFn,
1559 cancel_fn: ToolCancelFn,
1560 destroy_fn: ToolDestroyFn,
1561 plugin_free_string: FreeStringFn,
1562 set_waker_fn: ToolSetWakerFn,
1563 ) -> i32,
1564 >,
1565
1566 /// Optional plugin shutdown hooks, appended after wake-driven tools.
1567 pub register_plugin_lifecycle: Option<RegisterPluginLifecycleFn>,
1568}
1569unsafe impl Send for PluginApi {}
1570unsafe impl Sync for PluginApi {}
1571
1572/// Unified ABI plugin entrypoint signature: `(api)`. The version is read from
1573/// `api->abi_version`.
1574pub type RpiPluginRegisterUnified = extern "C" fn(api: *const PluginApi) -> i32;
1575
1576// ===========================================================================
1577// Panic containment
1578// ===========================================================================
1579
1580/// Status code a plugin's register entrypoint returns when its body panicked.
1581///
1582/// A panic must never unwind past the plugin's `extern "C"` entrypoint: Rust
1583/// turns such an unwind into a **process abort** (`panic in a function that
1584/// cannot unwind`), which would take down the whole host. The SDK catches the
1585/// panic and returns this code instead, so the host can skip the plugin with a
1586/// diagnostic.
1587pub const REGISTER_PANIC_STATUS: i32 = 70;
1588
1589/// Run `body`, converting a panic into [`Err`] instead of letting it unwind.
1590///
1591/// Plugin authors must wrap the body of **every** `extern "C"` entrypoint they
1592/// export (tool `execute`/`poll`/`cancel`, event handlers, provider/render
1593/// callbacks, runtime actions) with this (or an equivalent `catch_unwind`).
1594/// Unwinding out of an `extern "C"` function aborts the process.
1595pub fn guard<R>(body: impl FnOnce() -> R) -> Result<R, Box<dyn std::any::Any + Send>> {
1596 std::panic::catch_unwind(std::panic::AssertUnwindSafe(body))
1597}
1598
1599/// Like [`guard`], but returns `fallback` on panic. Handy for entrypoints that
1600/// must return a value rather than a `Result`.
1601pub fn guard_or<R>(fallback: R, body: impl FnOnce() -> R) -> R {
1602 guard(body).unwrap_or(fallback)
1603}
1604
1605/// Export a unified ABI plugin entrypoint under `rpi_plugin_register`.
1606///
1607/// The unified ABI has no version suffix in the symbol name; the version lives
1608/// inside the [`PluginApi`] struct (first field `abi_version`).
1609///
1610/// The expression receives `&PluginApi` and returns the plugin-defined
1611/// registration status code.
1612///
1613/// ```ignore
1614/// rpi_plugin_sdk::export_plugin!(|api| {
1615/// if let Some(declare) = api.declare {
1616/// declare(rpi_plugin_sdk::StbStringRef::from_str(
1617/// r#"{"priority":60,"platforms":["linux"]}"#,
1618/// ));
1619/// }
1620/// // ... register tools and handlers through `api` ...
1621/// 0
1622/// });
1623/// ```
1624#[macro_export]
1625macro_rules! export_plugin {
1626 ($body:expr) => {
1627 #[no_mangle]
1628 pub extern "C" fn rpi_plugin_register(api: *const $crate::PluginApi) -> i32 {
1629 // SAFETY: host guarantees `api` is valid for this call.
1630 unsafe { $crate::register_entrypoint(api, $body) }
1631 }
1632 };
1633}
1634
1635/// Unified ABI entrypoint helper: reads `abi_version` and `struct_size` from
1636/// the struct pointer before dereferencing anything else. This survives future
1637/// signature changes because the pointer is always the first argument.
1638///
1639/// # Safety
1640///
1641/// `api` must point to a properly aligned, readable ABI header followed by
1642/// `struct_size` readable bytes of the host's initialized vtable. It may be a
1643/// shorter, older layout of [`PluginApi`]. The mandatory prefix through
1644/// `user_data` must be present, and optional trailing slots must be complete.
1645pub unsafe fn register_entrypoint(
1646 api: *const PluginApi,
1647 body: impl FnOnce(&PluginApi) -> i32,
1648) -> i32 {
1649 if api.is_null() {
1650 return 2;
1651 }
1652 // SAFETY: caller guarantees `api` is valid; we check null above.
1653 // Read the version and size fields first — they're at known offsets
1654 // regardless of future struct growth.
1655 let (abi_version, struct_size) = unsafe { ((*api).abi_version, (*api).struct_size) };
1656 if abi_version != RPI_PLUGIN_ABI_VERSION_UNIFIED {
1657 // Mismatch: refuse to register. The host logs "ABI version mismatch"
1658 // and skips loading this plugin.
1659 return 1;
1660 }
1661 let host_size = struct_size as usize;
1662 let known_size = core::mem::size_of::<PluginApi>();
1663 let required_size = core::mem::offset_of!(PluginApi, declare);
1664 // All trailing fields are nullable function-pointer slots. Do not accept a
1665 // truncated mandatory prefix or a partial function pointer.
1666 if host_size < required_size
1667 || (host_size < known_size
1668 && (host_size - required_size) % core::mem::size_of::<*const ()>() != 0)
1669 {
1670 return 3;
1671 }
1672 let mut normalized = core::mem::MaybeUninit::<PluginApi>::zeroed();
1673 // SAFETY: copy only bytes the host advertises and this SDK knows. The
1674 // mandatory prefix contains every non-null function pointer; all missing
1675 // trailing fields are Option<extern "C" fn>, whose zero representation is
1676 // None. No reference to the larger PluginApi is formed over the old layout.
1677 let api = unsafe {
1678 core::ptr::copy_nonoverlapping(
1679 api.cast::<u8>(),
1680 normalized.as_mut_ptr().cast::<u8>(),
1681 host_size.min(known_size),
1682 );
1683 normalized.assume_init()
1684 };
1685 // Contain panics: unwinding out of the plugin's `extern "C"` shim aborts
1686 // the process, so convert a panic into a status code the host can report.
1687 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(&api))) {
1688 Ok(code) => code,
1689 Err(_) => REGISTER_PANIC_STATUS,
1690 }
1691}
1692
1693// ===========================================================================
1694// Tests (need std + serde_json)
1695// ===========================================================================
1696
1697#[cfg(test)]
1698mod tests {
1699 use super::*;
1700
1701 /// A panicking `register` body must NOT unwind out of the entrypoint shim;
1702 /// it must come back as `REGISTER_PANIC_STATUS` so the host can skip the
1703 /// plugin instead of the process aborting.
1704 #[test]
1705 fn register_entrypoint_contains_panics() {
1706 let mut api = std::mem::MaybeUninit::<PluginApi>::uninit();
1707 let api_ptr = api.as_mut_ptr();
1708 // Set the version/size fields so the entrypoint gets past the checks
1709 // and into the body (which panics).
1710 unsafe {
1711 (*api_ptr).abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED;
1712 (*api_ptr).struct_size = core::mem::size_of::<PluginApi>() as u32;
1713 }
1714 let rc = unsafe {
1715 register_entrypoint(api_ptr, |_api| {
1716 panic!("plugin register exploded");
1717 })
1718 };
1719 assert_eq!(rc, REGISTER_PANIC_STATUS);
1720 }
1721
1722 #[test]
1723 fn guard_returns_value_and_contains_panic() {
1724 assert_eq!(guard(|| 41 + 1).ok(), Some(42));
1725 assert!(guard(|| -> i32 { panic!("nope") }).is_err());
1726 assert_eq!(guard_or(-1, || -> i32 { panic!("nope") }), -1);
1727 }
1728
1729 // A test allocator + free fn so we can verify the own/free contract
1730 // without a real plugin's free_string.
1731 std::thread_local! {
1732 static FREED: std::cell::Cell<usize> = std::cell::Cell::new(0);
1733 }
1734
1735 unsafe extern "C" fn test_free(s: StbString) {
1736 if s.is_empty() || s.ptr.is_null() {
1737 return;
1738 }
1739 // SAFETY: the test transfers a live SDK allocation exactly once.
1740 unsafe { callbacks::free_string(s) };
1741 FREED.with(|freed| freed.set(freed.get() + 1));
1742 }
1743
1744 fn reset_freed() -> usize {
1745 FREED.with(|freed| freed.replace(0))
1746 }
1747
1748 fn freed_count() -> usize {
1749 FREED.with(std::cell::Cell::get)
1750 }
1751
1752 // A no-op `runtime_action` impl for the vtable-construction tests (closures
1753 // can't coerce to `extern "C" fn`, so we use a real fn).
1754 extern "C" fn noop_runtime_action(
1755 _action_id: u32,
1756 _args: StbStringRef,
1757 _out: *mut StbString,
1758 _user_data: *mut c_void,
1759 ) -> i32 {
1760 0
1761 }
1762
1763 #[test]
1764 fn stbstring_round_trip_and_free_once() {
1765 let prev = reset_freed();
1766 let _ = prev;
1767 let s = StbString::from_string("hello, pi".to_string());
1768 assert_eq!(s.len, 9);
1769 // SAFETY: s owns a live allocation, freed below.
1770 assert_eq!(unsafe { s.to_string_lossy() }, "hello, pi");
1771 // SAFETY: this test transfers its live SDK allocation exactly once.
1772 unsafe { s.free_with(Some(test_free)) };
1773 assert_eq!(freed_count(), 1);
1774 }
1775
1776 #[test]
1777 fn lossy_copy_accepts_non_utf8_and_does_not_free_or_modify_the_buffer() {
1778 let _ = reset_freed();
1779 let s = StbString::from_vec(vec![b'a', 0xff, b'b']);
1780 // SAFETY: s owns three initialized, unchanged bytes until free below.
1781 let first = unsafe { s.to_string_lossy() };
1782 // SAFETY: copying did not consume or free the original allocation.
1783 let second = unsafe { s.to_string_lossy() };
1784 assert_eq!(first, "a\u{fffd}b");
1785 assert_eq!(first, second);
1786 assert_eq!(freed_count(), 0);
1787 // SAFETY: this test transfers its live SDK allocation exactly once.
1788 unsafe { s.free_with(Some(test_free)) };
1789 assert_eq!(freed_count(), 1);
1790 assert_eq!(first, "a\u{fffd}b");
1791 }
1792
1793 #[test]
1794 fn empty_stbstring_free_is_noop() {
1795 let _ = reset_freed();
1796 // SAFETY: this test transfers its live SDK allocation exactly once.
1797 unsafe { StbString::empty().free_with(Some(test_free)) };
1798 assert_eq!(freed_count(), 0);
1799 }
1800
1801 #[cfg(feature = "json")]
1802 #[test]
1803 fn json_round_trip_preserves_structure() {
1804 let val = serde_json::json!({ "name": "echo", "args": [1, 2, 3], "ok": true });
1805 let stb = json::to_stable(&val, None);
1806 // SAFETY: stb owns a live allocation, freed below.
1807 let back = unsafe { json::from_stable(&stb) };
1808 assert_eq!(val, back);
1809 // SAFETY: this test transfers its live SDK allocation exactly once.
1810 unsafe { stb.free_with(Some(test_free)) };
1811 let _ = reset_freed();
1812 }
1813
1814 #[test]
1815 fn step_result_done_round_trip() {
1816 let result_json = StbString::from_string(r#"{"content":[{"text":"hi"}]}"#.to_string());
1817 let sr = StepResult::done(result_json);
1818 assert_eq!(sr.tag, StepResultTag::Done);
1819 // SAFETY: tag == Done.
1820 let done = unsafe { sr.done_payload() };
1821 assert_eq!(
1822 // SAFETY: the initialized Done result is live until test_free below.
1823 unsafe { done.result.to_string_lossy() },
1824 r#"{"content":[{"text":"hi"}]}"#
1825 );
1826 // SAFETY: this test transfers its live SDK allocation exactly once.
1827 unsafe { done.result.free_with(Some(test_free)) };
1828 let _ = reset_freed();
1829 }
1830
1831 #[test]
1832 fn step_result_pending_and_err() {
1833 let prog = StbString::from_string("...".to_string());
1834 let srp = StepResult::pending(prog);
1835 assert_eq!(srp.tag, StepResultTag::Pending);
1836 // SAFETY: tag == Pending.
1837 unsafe {
1838 assert_eq!(srp.pending_payload().progress.to_string_lossy(), "...");
1839 }
1840 unsafe { srp.pending_payload().progress.free_with(Some(test_free)) };
1841
1842 let msg = StbString::from_string("boom".to_string());
1843 let sre = StepResult::err(msg);
1844 assert_eq!(sre.tag, StepResultTag::Err);
1845 // SAFETY: tag == Err.
1846 unsafe {
1847 assert_eq!(sre.err_payload().message.to_string_lossy(), "boom");
1848 sre.err_payload().message.free_with(Some(test_free));
1849 }
1850 let _ = reset_freed();
1851 }
1852
1853 #[test]
1854 fn event_tag_count_is_37() {
1855 // Enumerate every tag; a compile-time + runtime guarantee that the
1856 // 37-category surface is intact (33 Pi on() categories + the
1857 // rpi-specific BeforeTuiStart + 2 UI prompt events + EditorChange).
1858 let tags = [
1859 EventTag::ProjectTrust,
1860 EventTag::ResourcesDiscover,
1861 EventTag::SessionStart,
1862 EventTag::SessionInfoChanged,
1863 EventTag::SessionBeforeSwitch,
1864 EventTag::SessionBeforeFork,
1865 EventTag::SessionBeforeCompact,
1866 EventTag::SessionCompact,
1867 EventTag::SessionShutdown,
1868 EventTag::SessionBeforeTree,
1869 EventTag::SessionTree,
1870 EventTag::Context,
1871 EventTag::BeforeProviderRequest,
1872 EventTag::BeforeProviderHeaders,
1873 EventTag::AfterProviderResponse,
1874 EventTag::BeforeAgentStart,
1875 EventTag::AgentStart,
1876 EventTag::AgentEnd,
1877 EventTag::AgentSettled,
1878 EventTag::TurnStart,
1879 EventTag::TurnEnd,
1880 EventTag::MessageStart,
1881 EventTag::MessageUpdate,
1882 EventTag::MessageEnd,
1883 EventTag::ToolExecutionStart,
1884 EventTag::ToolExecutionUpdate,
1885 EventTag::ToolExecutionEnd,
1886 EventTag::ModelSelect,
1887 EventTag::ThinkingLevelSelect,
1888 EventTag::ToolCall,
1889 EventTag::ToolResult,
1890 EventTag::UserBash,
1891 EventTag::Input,
1892 EventTag::BeforeTuiStart,
1893 EventTag::UiPromptStart,
1894 EventTag::UiPromptEnd,
1895 EventTag::EditorChange,
1896 ];
1897 assert_eq!(tags.len(), EVENT_TAG_COUNT);
1898 assert_eq!(EVENT_TAG_COUNT, 37);
1899 // Distinct discriminants 0..36.
1900 let mut discs: Vec<u32> = tags.iter().map(|t| *t as u32).collect();
1901 discs.sort();
1902 assert_eq!(discs, (0..37).collect::<Vec<u32>>());
1903 }
1904
1905 #[test]
1906 fn event_payloads_construct_and_free() {
1907 let m = StbString::from_string("msg".to_string());
1908 let ev = StablePluginEvent::message(EventTag::MessageEnd, m);
1909 assert_eq!(ev.tag, EventTag::MessageEnd);
1910 // SAFETY: tag == MessageEnd (message variant).
1911 unsafe {
1912 assert_eq!(ev.payload.message.message.to_string_lossy(), "msg");
1913 ev.payload.message.message.free_with(Some(test_free));
1914 }
1915
1916 let tc = StablePluginEvent::tool_call(
1917 EventTag::ToolCall,
1918 StbString::from_string("call_1".to_string()),
1919 StbString::from_string("echo".to_string()),
1920 StbString::from_string("{}".to_string()),
1921 );
1922 // SAFETY: tag == ToolCall (tool_call variant).
1923 unsafe {
1924 assert_eq!(tc.payload.tool_call.tool_name.to_string_lossy(), "echo");
1925 tc.payload.tool_call.tool_call_id.free_with(Some(test_free));
1926 tc.payload.tool_call.tool_name.free_with(Some(test_free));
1927 tc.payload.tool_call.params.free_with(Some(test_free));
1928 }
1929 let _ = reset_freed();
1930 }
1931
1932 #[test]
1933 fn plugin_api_is_pod_and_sized() {
1934 // The API struct is a plain old data struct: every fn pointer is
1935 // non-Drop, the struct has no Drop impl.
1936 let api = PluginApi {
1937 abi_version: RPI_PLUGIN_ABI_VERSION_UNIFIED,
1938 struct_size: core::mem::size_of::<PluginApi>() as u32,
1939 free_string: test_free,
1940 register_tool: None,
1941 register_command: None,
1942 register_shortcut: None,
1943 register_flag: None,
1944 register_provider: None,
1945 register_message_renderer: None,
1946 register_markdown_transformer: None,
1947 register_entry_renderer: None,
1948 register_event_handler: None,
1949 register_resources_discover: None,
1950 runtime_action: noop_runtime_action,
1951 dispatch_event: None,
1952 user_data: core::ptr::null_mut(),
1953 declare: None,
1954 register_before_agent_start: None,
1955 register_oauth_provider: None,
1956 register_streaming_provider: None,
1957 register_tool_with_wake: None,
1958 register_plugin_lifecycle: None,
1959 };
1960 // All optional slots are null → plugin must degrade.
1961 assert!(api.register_tool.is_none());
1962 assert!(api.register_event_handler.is_none());
1963 assert!(api.register_resources_discover.is_none());
1964 assert!(api.register_before_agent_start.is_none());
1965 // Copy (POD) — no UB from a plain copy.
1966 let _copy = api;
1967 assert!(!core::mem::needs_drop::<PluginApi>());
1968 assert!(!core::mem::needs_drop::<StbString>());
1969 assert!(!core::mem::needs_drop::<StepResult>());
1970 assert!(!core::mem::needs_drop::<StablePluginEvent>());
1971 assert!(!core::mem::needs_drop::<StableToolSchema>());
1972 assert!(
1973 !core::mem::needs_drop::<ProviderStreamControl>(),
1974 "the control struct is fn pointers + a u32: it must stay Copy/POD"
1975 );
1976 }
1977
1978 /// New vtable slots must be **appended**, never inserted.
1979 ///
1980 /// A plugin compiled against a shorter `PluginApi` reads every field by
1981 /// offset. Inserting a slot in the middle would make that plugin read the
1982 /// new host's fields at the wrong offsets — silently, since both sides just
1983 /// see pointers. Appending is what lets a slot ship without an ABI version
1984 /// bump. This pins the three fields at the end and the identity markers at
1985 /// the front, so a future edit that inserts in the middle fails here.
1986 #[test]
1987 fn vtable_grows_only_at_the_end() {
1988 use core::mem::{align_of, size_of};
1989
1990 // Identity markers stay first: a plugin reads these before anything
1991 // else, independent of the entrypoint arity.
1992 assert_eq!(core::mem::offset_of!(PluginApi, abi_version), 0);
1993 assert_eq!(
1994 core::mem::offset_of!(PluginApi, struct_size),
1995 size_of::<u32>()
1996 );
1997
1998 // The most recently added slot is last, and the ones before it are
1999 // adjacent — i.e. nothing was wedged in between them.
2000 let declare = core::mem::offset_of!(PluginApi, declare);
2001 let prompt_slot = core::mem::offset_of!(PluginApi, register_before_agent_start);
2002 assert_eq!(
2003 prompt_slot,
2004 declare + size_of::<Option<extern "C" fn(StbStringRef) -> i32>>(),
2005 "register_before_agent_start must be appended right after `declare`; \
2006 inserting a slot earlier shifts every later offset and breaks \
2007 plugins built against the previous layout"
2008 );
2009 let oauth_slot = core::mem::offset_of!(PluginApi, register_oauth_provider);
2010 assert_eq!(
2011 oauth_slot,
2012 prompt_slot
2013 + size_of::<
2014 Option<extern "C" fn(BeforeAgentStartFn, FreeStringFn, *mut c_void) -> i32>,
2015 >(),
2016 "register_oauth_provider must stay adjacent to register_before_agent_start"
2017 );
2018 let streaming_slot = core::mem::offset_of!(PluginApi, register_streaming_provider);
2019 assert_eq!(
2020 streaming_slot,
2021 oauth_slot
2022 + size_of::<
2023 Option<
2024 extern "C" fn(
2025 StbStringRef,
2026 OAuthRequestFn,
2027 FreeStringFn,
2028 *mut c_void,
2029 ) -> i32,
2030 >,
2031 >(),
2032 "register_streaming_provider must be appended after register_oauth_provider \
2033 so an older plugin's every offset stays valid"
2034 );
2035 assert_eq!(
2036 streaming_slot
2037 + size_of::<
2038 Option<
2039 extern "C" fn(
2040 StbStringRef,
2041 StbStringRef,
2042 StbStringRef,
2043 ProviderStreamRequestFn,
2044 FreeStringFn,
2045 *mut c_void,
2046 ) -> i32,
2047 >,
2048 >(),
2049 core::mem::offset_of!(PluginApi, register_tool_with_wake),
2050 "wake registration must follow streaming registration"
2051 );
2052 assert_eq!(
2053 core::mem::offset_of!(PluginApi, register_plugin_lifecycle),
2054 core::mem::offset_of!(PluginApi, register_tool_with_wake) + size_of::<usize>()
2055 );
2056 assert_eq!(
2057 size_of::<PluginApi>(),
2058 core::mem::offset_of!(PluginApi, register_plugin_lifecycle) + size_of::<usize>()
2059 );
2060 assert_eq!(align_of::<PluginApi>(), align_of::<*const ()>());
2061 }
2062
2063 #[test]
2064 fn runtime_action_ids_are_explicitly_validated() {
2065 let ids = [
2066 RuntimeActionId::SendMessage,
2067 RuntimeActionId::SendUserMessage,
2068 RuntimeActionId::AppendEntry,
2069 RuntimeActionId::SetSessionName,
2070 RuntimeActionId::GetActiveTools,
2071 RuntimeActionId::SetActiveTools,
2072 RuntimeActionId::SetModel,
2073 RuntimeActionId::GetThinkingLevel,
2074 RuntimeActionId::SetThinkingLevel,
2075 RuntimeActionId::Compact,
2076 RuntimeActionId::GetSystemPrompt,
2077 RuntimeActionId::NewSession,
2078 RuntimeActionId::Fork,
2079 RuntimeActionId::NavigateTree,
2080 RuntimeActionId::SwitchSession,
2081 RuntimeActionId::Reload,
2082 RuntimeActionId::GetCliFlag,
2083 RuntimeActionId::UiDialog,
2084 RuntimeActionId::SetStatus,
2085 RuntimeActionId::SetEditorText,
2086 RuntimeActionId::GetSessionBranch,
2087 RuntimeActionId::StdioLifecycle,
2088 RuntimeActionId::UiComponent,
2089 RuntimeActionId::GetExecutionContext,
2090 RuntimeActionId::ChildAgent,
2091 RuntimeActionId::SideQuestion,
2092 ];
2093
2094 for (raw, expected) in ids.into_iter().enumerate() {
2095 assert_eq!(RuntimeActionId::try_from(raw as u32), Ok(expected));
2096 assert_eq!(u32::from(expected), raw as u32);
2097 }
2098 // One past the highest defined id is rejected (nothing is silently
2099 // accepted), and so is the u32 ceiling.
2100 assert_eq!(
2101 RuntimeActionId::try_from(26),
2102 Err(UnknownRuntimeActionId(26))
2103 );
2104 assert_eq!(
2105 RuntimeActionId::try_from(u32::MAX),
2106 Err(UnknownRuntimeActionId(u32::MAX))
2107 );
2108 }
2109
2110 fn entrypoint_test_api() -> PluginApi {
2111 PluginApi {
2112 abi_version: RPI_PLUGIN_ABI_VERSION_UNIFIED,
2113 struct_size: core::mem::size_of::<PluginApi>() as u32,
2114 free_string: test_free,
2115 register_tool: None,
2116 register_command: None,
2117 register_shortcut: None,
2118 register_flag: None,
2119 register_provider: None,
2120 register_message_renderer: None,
2121 register_markdown_transformer: None,
2122 register_entry_renderer: None,
2123 register_event_handler: None,
2124 register_resources_discover: None,
2125 runtime_action: noop_runtime_action,
2126 dispatch_event: None,
2127 user_data: core::ptr::null_mut(),
2128 declare: None,
2129 register_before_agent_start: None,
2130 register_oauth_provider: None,
2131 register_streaming_provider: None,
2132 register_tool_with_wake: None,
2133 register_plugin_lifecycle: None,
2134 }
2135 }
2136
2137 #[test]
2138 fn register_entrypoint_version_mismatch_refuses() {
2139 let api = entrypoint_test_api();
2140 assert_eq!(RPI_PLUGIN_ABI_VERSION_UNIFIED, 4);
2141
2142 // A future version is rejected by the pre-dereference check.
2143 let mut future_api = api;
2144 future_api.abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED + 1;
2145 let rc = unsafe {
2146 register_entrypoint(&future_api, |_| {
2147 panic!("body must not run on version mismatch");
2148 })
2149 };
2150 assert_eq!(rc, 1);
2151
2152 // Right version → body runs, rc propagated.
2153 let rc = unsafe { register_entrypoint(&api, |_| 0) };
2154 assert_eq!(rc, 0);
2155 let rc = unsafe { register_entrypoint(&api, |_| 42) };
2156 assert_eq!(rc, 42);
2157
2158 // Null api → refuse.
2159 let rc = unsafe { register_entrypoint(core::ptr::null(), |_| 0) };
2160 assert_ne!(rc, 0);
2161 let _ = reset_freed();
2162 }
2163
2164 #[test]
2165 fn register_entrypoint_accepts_short_vtables_without_reading_missing_slots() {
2166 extern "C" fn declare(_: StbStringRef) -> i32 {
2167 7
2168 }
2169 extern "C" fn provider(
2170 _: StbStringRef,
2171 _: StbStringRef,
2172 _: StbStringRef,
2173 _: ProviderRequestFn,
2174 _: FreeStringFn,
2175 _: *mut c_void,
2176 ) -> i32 {
2177 0
2178 }
2179 for size in [
2180 core::mem::offset_of!(PluginApi, declare),
2181 core::mem::offset_of!(PluginApi, register_before_agent_start),
2182 core::mem::offset_of!(PluginApi, register_oauth_provider),
2183 core::mem::offset_of!(PluginApi, register_streaming_provider),
2184 core::mem::offset_of!(PluginApi, register_tool_with_wake),
2185 core::mem::offset_of!(PluginApi, register_plugin_lifecycle),
2186 ] {
2187 let mut api = entrypoint_test_api();
2188 api.struct_size = size as u32;
2189 api.register_provider = Some(provider);
2190 api.declare = Some(declare);
2191 // Allocate exactly the old layout's size, with pointer alignment.
2192 let mut old = vec![0usize; size / core::mem::size_of::<usize>()];
2193 // SAFETY: both buffers are valid for size bytes; the old layout is
2194 // a fully initialized prefix ending at a complete field boundary.
2195 unsafe {
2196 core::ptr::copy_nonoverlapping(
2197 (&api as *const PluginApi).cast::<u8>(),
2198 old.as_mut_ptr().cast::<u8>(),
2199 size,
2200 );
2201 }
2202 let mut called = false;
2203 let rc = unsafe {
2204 register_entrypoint(old.as_ptr().cast(), |api| {
2205 called = true;
2206 assert_eq!(api.struct_size, size as u32);
2207 assert!(api.register_streaming_provider.is_none());
2208 assert!(api.register_tool_with_wake.is_none());
2209 assert!(api.register_plugin_lifecycle.is_none());
2210 assert!(
2211 api.register_provider.is_some(),
2212 "one-shot fallback remains available"
2213 );
2214 if size > core::mem::offset_of!(PluginApi, declare) {
2215 assert_eq!((api.declare.unwrap())(StbStringRef::from_str("{}")), 7);
2216 } else {
2217 assert!(api.declare.is_none());
2218 }
2219 0
2220 })
2221 };
2222 assert_eq!(rc, 0);
2223 assert!(
2224 called,
2225 "the plugin registration body must run on an older host"
2226 );
2227 }
2228 }
2229
2230 #[test]
2231 fn register_entrypoint_rejects_incomplete_mandatory_fields_or_slots() {
2232 for size in [
2233 core::mem::offset_of!(PluginApi, declare) - 1,
2234 core::mem::offset_of!(PluginApi, register_streaming_provider) + 1,
2235 ] {
2236 let mut api = entrypoint_test_api();
2237 api.struct_size = size as u32;
2238 let rc = unsafe { register_entrypoint(&api, |_| panic!("incomplete ABI layout")) };
2239 assert_eq!(rc, 3);
2240 }
2241 }
2242}