Skip to main content

rpi_extensions/
registry.rs

1//! The extension registry — accumulates registrations during a plugin's
2//! `rpi_plugin_register` call, then snapshots for the host session.
3//!
4//! `HostApi` (in [`crate`]) holds an `ExtensionRegistry` behind a `Mutex` while
5//! the plugin's register trampolines run; after register completes the host
6//! [`take`]s it and builds a [`RegistrySnapshot`] the session keeps for its
7//! lifetime. Staleness across a session swap / `/reload` is guarded by a shared
8//! `Arc<AtomicBool>` "active" flag ([`assert_active`]).
9//!
10//! [`take`]: crate::HostApi::take_registry
11
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::Arc;
14
15use rpi_agent::error::AgentError;
16use rpi_ai::types::Tool;
17use rpi_plugin_sdk::{
18    CommandHandlerFn, EventHandlerFn, EventTag, FreeStringFn, ProviderRequestFn, RenderFn,
19    ResourcesDiscoverFn, EVENT_TAG_COUNT,
20};
21
22use crate::tool::PluginToolHandle;
23
24// ---------------------------------------------------------------------------
25// Registered-item records
26// ---------------------------------------------------------------------------
27
28/// A tool an extension registered: the provider-facing [`Tool`] schema + the
29/// plugin's 4-function handle ([`PluginToolHandle`]) the host drives via
30/// [`PluginToolAdapter`](crate::PluginToolAdapter).
31///
32/// `tool` is public so the host can merge schemas; `handle` is crate-private
33/// (only [`PluginToolAdapter`](crate::PluginToolAdapter) drives it). `Clone`
34/// derives because [`Tool`] is `Clone` and [`PluginToolHandle`] is `Copy`.
35#[derive(Clone)]
36pub struct ExtensionTool {
37    /// The provider-facing tool definition (name/description/parameters).
38    pub tool: Tool,
39    pub(crate) handle: PluginToolHandle,
40}
41
42impl ExtensionTool {
43    pub fn new(tool: Tool, handle: PluginToolHandle) -> Self {
44        Self { tool, handle }
45    }
46
47    /// The plugin-side function handles backing this tool. `Copy` (fn pointers
48    /// + a `FreeStringFn`), so handing it out is free. Public so the host
49    /// (e.g. rpi-cli's session builder) can wrap a registered tool in a
50    /// [`PluginToolAdapter`](crate::PluginToolAdapter) outside this crate.
51    pub fn handle(&self) -> PluginToolHandle {
52        self.handle
53    }
54}
55
56/// A registered slash command and its plugin callback.
57#[derive(Clone)]
58pub struct RegisteredCommand {
59    pub name: String,
60    pub description: String,
61    pub handler: CommandHandlerFn,
62    pub user_data: *mut std::ffi::c_void,
63}
64
65// SAFETY: the plugin owns the callback/context and promises they remain valid
66// for the loaded library lifetime, matching the other registered callbacks.
67unsafe impl Send for RegisteredCommand {}
68unsafe impl Sync for RegisteredCommand {}
69
70/// A CLI flag declared by a native extension. Values are supplied by the
71/// host's parsed `Args::unknown_flags` map and read by the plugin through the
72/// `RuntimeActionId::GetCliFlag` action.
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct RegisteredFlag {
75    pub name: String,
76    pub description: String,
77}
78
79/// A registered `on(tag)` event handler. `user_data` is the plugin's opaque
80/// context, passed back unchanged on every dispatch.
81///
82/// SAFETY contract: the plugin guarantees `handler` is safe to call from any
83/// thread (the host dispatches from the async emitter thread) and `user_data`
84/// is valid for the registry's lifetime. The host never frees `user_data`
85/// (plugin-owned).
86#[derive(Clone, Copy)]
87pub struct RegisteredHandler {
88    pub handler: EventHandlerFn,
89    pub user_data: *mut std::ffi::c_void,
90}
91// SAFETY: fn pointers are Send+Sync; `user_data` is an opaque plugin pointer the
92// plugin warrants is thread-safe to pass to `handler` from any thread. The host
93// only reads it through `handler`.
94unsafe impl Send for RegisteredHandler {}
95unsafe impl Sync for RegisteredHandler {}
96
97/// A registered `resources_discover` handler (B5b). The `out` [`StbString`] the
98/// handler produces is **plugin-owned**, so the host reclaims it via the
99/// plugin's own `plugin_free_string` traveled alongside. `user_data` is the
100/// plugin's opaque context. SAFETY: same as [`RegisteredHandler`] — the plugin
101/// warrants `handler` is callable from any thread and `user_data` is valid for
102/// the registry's lifetime; the host never frees `user_data`.
103#[derive(Clone, Copy)]
104pub struct ResourcesDiscoverHandler {
105    pub handler: ResourcesDiscoverFn,
106    pub plugin_free_string: FreeStringFn,
107    pub user_data: *mut std::ffi::c_void,
108}
109// SAFETY: fn pointers + an opaque plugin pointer the plugin warrants is
110// thread-safe; the host only reads `user_data` through `handler`.
111unsafe impl Send for ResourcesDiscoverHandler {}
112unsafe impl Sync for ResourcesDiscoverHandler {}
113
114/// A registered custom provider (B5c). The host wraps `request_fn` in a
115/// [`PluggableProvider`](crate::PluggableProvider) impl of `rpi_ai::Provider`;
116/// its `stream_simple` drives `request_fn` on `spawn_blocking` (the sync fn
117/// can't own a chunked stream), reads the **plugin-owned** `out` JSON (a full
118/// assistant message), reclaims it via `plugin_free_string`, parses it to an
119/// [`AssistantMessage`](rpi_ai::types::AssistantMessage), and emits it as one
120/// terminal `Done` chunk (v1 one-shot, documented divergence from pi's async
121/// streaming). `provider_id`/`base_url`/`api_style` carry the provider's
122/// identity (copied from the borrowed `StbStringRef`s at registration); the fn
123/// pointers + `user_data` live as long as the plugin (keepalive-mapped).
124/// `user_data` is the plugin's opaque context, passed back on every
125/// `request_fn` call.
126///
127/// SAFETY: the plugin warrants `request_fn` is callable from any thread (the
128/// host calls it from a `spawn_blocking` pool thread) and `user_data` is valid
129/// for the plugin's lifetime; the host never frees `user_data`.
130#[derive(Clone)]
131pub struct RegisteredProvider {
132    pub provider_id: String,
133    pub base_url: String,
134    pub api_style: String,
135    pub request_fn: ProviderRequestFn,
136    pub plugin_free_string: FreeStringFn,
137    pub user_data: *mut std::ffi::c_void,
138}
139// SAFETY: fn pointers + owned `String`s + an opaque plugin pointer the plugin
140// warrants is thread-safe; the host never frees `user_data`.
141unsafe impl Send for RegisteredProvider {}
142unsafe impl Sync for RegisteredProvider {}
143
144/// A registered message/markdown/entry renderer (B5c). The interactive TUI
145/// consumes all three kinds through the JSON component adapter. `render_fn` produces a
146/// **plugin-owned** `out` [`StbString`] the host reclaims via
147/// `plugin_free_string`; `user_data` is passed back on every render call. `name`
148/// is copied from the borrowed `StbStringRef` at registration.
149///
150/// SAFETY: same as [`RegisteredProvider`].
151#[derive(Clone)]
152pub struct RegisteredRenderer {
153    pub name: String,
154    pub kind: RegisteredRendererKind,
155    pub render_fn: RenderFn,
156    pub plugin_free_string: FreeStringFn,
157    pub user_data: *mut std::ffi::c_void,
158}
159
160/// Which render path this renderer targets — mirrors the three distinct
161/// `register_*` slots (`register_message_renderer` /
162/// `register_markdown_transformer` / `register_entry_renderer`).
163#[derive(Clone, Copy, PartialEq, Eq)]
164pub enum RegisteredRendererKind {
165    Message,
166    Markdown,
167    Entry,
168}
169// SAFETY: same as [`RegisteredProvider`] — fn pointers + owned `String` + an
170// opaque plugin pointer the plugin warrants is thread-safe.
171unsafe impl Send for RegisteredRenderer {}
172unsafe impl Sync for RegisteredRenderer {}
173
174/// One flat registration record, for iteration/diagnostics. Built on demand
175/// from the typed vecs in [`RegistrySnapshot`].
176#[derive(Clone)]
177pub enum RegistryEntry {
178    Tool(ExtensionTool),
179    Command(RegisteredCommand),
180    Flag(RegisteredFlag),
181}
182
183// ---------------------------------------------------------------------------
184// ExtensionRegistry — accumulated during register, then snapshotted
185// ---------------------------------------------------------------------------
186
187/// Accumulates registrations from one or more plugins' `rpi_plugin_register`
188/// calls. Held by [`HostApi`](crate::HostApi) during register; the host then
189/// [`take_registry`](crate::HostApi::take_registry) and builds a snapshot.
190///
191/// Merge semantics mirror pi: **first-registration-wins** on tool/command name
192/// collision (a later registration for an existing name is dropped, not an
193/// overwrite). Event handlers accumulate (multiple per tag — fan-out).
194pub struct ExtensionRegistry {
195    tools: Vec<ExtensionTool>,
196    commands: Vec<RegisteredCommand>,
197    flags: Vec<RegisteredFlag>,
198    /// `handlers[tag as usize]` — all handlers subscribed to that tag.
199    handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT],
200    /// `resources_discover` handlers (B5b). Fan-out on discovery, in registration
201    /// order. Unlike event handlers (which key off a tag in a fixed array), these
202    /// are a single flat list — `resources_discover` has its own out-param
203    /// signature and is never dispatched through the fire-and-forget event path.
204    resources_discover: Vec<ResourcesDiscoverHandler>,
205    /// Registered custom providers (B5c). The host wraps each in a
206    /// [`PluggableProvider`](crate::PluggableProvider). First-registration-wins on
207    /// `provider_id`.
208    providers: Vec<RegisteredProvider>,
209    /// Registered renderers (B5c), split by kind at registration. First-wins on
210    /// `(kind, name)`. The host records these now; TUI consumption is B5e.
211    renderers: Vec<RegisteredRenderer>,
212    /// Shared staleness flag. `true` while the session owning this registry is
213    /// active; set `false` on swap/`/reload`. Tool/event dispatch checks it.
214    active: Arc<AtomicBool>,
215}
216
217impl Default for ExtensionRegistry {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223impl ExtensionRegistry {
224    /// Build an empty, active registry.
225    pub fn new() -> Self {
226        let handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT] =
227            std::array::from_fn(|_| Vec::new());
228        Self {
229            tools: Vec::new(),
230            commands: Vec::new(),
231            flags: Vec::new(),
232            handlers,
233            resources_discover: Vec::new(),
234            providers: Vec::new(),
235            renderers: Vec::new(),
236            active: Arc::new(AtomicBool::new(true)),
237        }
238    }
239
240    /// Register a tool. Returns `true` if a **prior** tool of the same name was
241    /// kept (first-wins: the new one is dropped). Returns `false` on insert.
242    pub fn register_tool(&mut self, tool: Tool, handle: PluginToolHandle) -> bool {
243        if self.tools.iter().any(|t| t.tool.name == tool.name) {
244            // First-wins: keep the prior, drop the new handle. We must destroy
245            // the dropped handle's allocation? No — `handle` is just fn pointers
246            // + a FreeStringFn (all Copy, no allocation). Dropping it is a no-op.
247            // A real per-tool plugin allocation only exists after `execute`
248            // produces a StepHandle; we never call execute for a dropped
249            // registration, so nothing to free.
250            return true;
251        }
252        self.tools.push(ExtensionTool::new(tool, handle));
253        false
254    }
255
256    /// Register a slash command (first-wins by name). `true` if a prior was kept.
257    pub fn register_command(
258        &mut self,
259        name: String,
260        description: String,
261        handler: CommandHandlerFn,
262        user_data: *mut std::ffi::c_void,
263    ) -> bool {
264        if self.commands.iter().any(|c| c.name == name) {
265            return true;
266        }
267        self.commands.push(RegisteredCommand {
268            name,
269            description,
270            handler,
271            user_data,
272        });
273        false
274    }
275
276    /// Register a CLI flag (first-wins by name). Returns `true` when an
277    /// earlier extension already declared the same flag.
278    pub fn register_flag(&mut self, name: String, description: String) -> bool {
279        if self.flags.iter().any(|flag| flag.name == name) {
280            return true;
281        }
282        self.flags.push(RegisteredFlag { name, description });
283        false
284    }
285
286    /// Subscribe a handler to `tag`. Multiple handlers per tag are kept
287    /// (fan-out on dispatch). Always inserts; returns `false`.
288    pub fn register_event_handler(
289        &mut self,
290        tag: EventTag,
291        handler: EventHandlerFn,
292        user_data: *mut std::ffi::c_void,
293    ) -> bool {
294        let idx = tag as usize;
295        if idx < EVENT_TAG_COUNT {
296            self.handlers[idx].push(RegisteredHandler { handler, user_data });
297        }
298        false
299    }
300
301    /// Register a `resources_discover` handler (B5b). Multiple handlers are kept
302    /// (fan-out on discovery, registration order). Always inserts; returns `false`.
303    pub fn register_resources_discover(
304        &mut self,
305        handler: ResourcesDiscoverFn,
306        plugin_free_string: FreeStringFn,
307        user_data: *mut std::ffi::c_void,
308    ) -> bool {
309        self.resources_discover.push(ResourcesDiscoverHandler {
310            handler,
311            plugin_free_string,
312            user_data,
313        });
314        false
315    }
316
317    /// Register a custom provider (B5c). First-registration-wins on `provider_id`
318    /// (a later registration for an existing id is dropped, mirroring tool/command
319    /// first-wins). Returns `true` if a prior provider of the same id was kept.
320    pub fn register_provider(&mut self, provider: RegisteredProvider) -> bool {
321        if self
322            .providers
323            .iter()
324            .any(|p| p.provider_id == provider.provider_id)
325        {
326            return true;
327        }
328        self.providers.push(provider);
329        false
330    }
331
332    /// Register a renderer (B5c — message/markdown/entry). First-wins on
333    /// `(kind, name)`. Returns `true` if a prior renderer of the same kind+name
334    /// was kept.
335    pub fn register_renderer(&mut self, renderer: RegisteredRenderer) -> bool {
336        if self
337            .renderers
338            .iter()
339            .any(|r| r.kind == renderer.kind && r.name == renderer.name)
340        {
341            return true;
342        }
343        self.renderers.push(renderer);
344        false
345    }
346
347    /// Build a snapshot the host session keeps. The registry's `active` flag is
348    /// shared (Arc) so a later `invalidate` on the registry also invalidates the
349    /// snapshot — important for cross-session staleness.
350    pub fn snapshot(&self) -> RegistrySnapshot {
351        RegistrySnapshot {
352            tools: self
353                .tools
354                .iter()
355                .map(|t| ExtensionTool {
356                    tool: t.tool.clone(),
357                    handle: t.handle,
358                })
359                .collect(),
360            commands: self.commands.clone(),
361            flags: self.flags.clone(),
362            handlers: self.handlers.clone(),
363            resources_discover: self.resources_discover.clone(),
364            providers: self.providers.clone(),
365            renderers: self.renderers.clone(),
366            active: Arc::clone(&self.active),
367        }
368    }
369
370    /// Mark this registry (and any snapshot sharing its flag) as stale.
371    pub fn invalidate(&self) {
372        self.active.store(false, Ordering::SeqCst);
373    }
374
375    /// Whether this registry is still active (not invalidated).
376    pub fn is_active(&self) -> bool {
377        self.active.load(Ordering::SeqCst)
378    }
379
380    /// Absorb another registry's registrations into this one, first-wins on
381    /// name (tools/commands) and appending (event handlers). Used by
382    /// [`load_dir`](crate::loader::load_dir)/`merge_registries` to fold
383    /// per-plugin registries into one session registry in load order. Consumes
384    /// `other` (its `active` flag is discarded — the session registry's flag
385    /// wins, since it owns session lifetime).
386    pub(crate) fn absorb(&mut self, mut other: ExtensionRegistry) {
387        for t in other.tools.drain(..) {
388            if self.tools.iter().any(|x| x.tool.name == t.tool.name) {
389                // first-wins: keep the prior, discard the new. Nothing to free
390                // (handle is fn pointers). Emit no diagnostic here — the loader
391                // may surface collisions if desired; v1 silent first-wins.
392                continue;
393            }
394            self.tools.push(t);
395        }
396        for c in other.commands.drain(..) {
397            if self.commands.iter().any(|x| x.name == c.name) {
398                continue;
399            }
400            self.commands.push(c);
401        }
402        for flag in other.flags.drain(..) {
403            if self.flags.iter().any(|existing| existing.name == flag.name) {
404                continue;
405            }
406            self.flags.push(flag);
407        }
408        for (tag_idx, handlers) in other.handlers.iter_mut().enumerate() {
409            self.handlers[tag_idx].append(handlers);
410        }
411        self.resources_discover
412            .append(&mut other.resources_discover);
413        for p in other.providers.drain(..) {
414            if self
415                .providers
416                .iter()
417                .any(|x| x.provider_id == p.provider_id)
418            {
419                continue;
420            }
421            self.providers.push(p);
422        }
423        for r in other.renderers.drain(..) {
424            if self
425                .renderers
426                .iter()
427                .any(|x| x.kind == r.kind && x.name == r.name)
428            {
429                continue;
430            }
431            self.renderers.push(r);
432        }
433    }
434}
435
436// ---------------------------------------------------------------------------
437// RegistrySnapshot — the host session's immutable view
438// ---------------------------------------------------------------------------
439
440/// An immutable snapshot of an [`ExtensionRegistry`] the host session keeps for
441/// its lifetime. Tools are wrapped in [`ExtensionTool`] so the host can build
442/// [`PluginToolAdapter`](crate::PluginToolAdapter)s; event handlers are grouped
443/// by tag for the [`ExtensionEmitter`](crate::ExtensionEmitter) to fan out.
444///
445/// The `active` flag is shared with the source registry, so invalidating the
446/// registry (on session swap) invalidates this snapshot too.
447pub struct RegistrySnapshot {
448    tools: Vec<ExtensionTool>,
449    commands: Vec<RegisteredCommand>,
450    flags: Vec<RegisteredFlag>,
451    handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT],
452    resources_discover: Vec<ResourcesDiscoverHandler>,
453    providers: Vec<RegisteredProvider>,
454    renderers: Vec<RegisteredRenderer>,
455    active: Arc<AtomicBool>,
456}
457
458impl RegistrySnapshot {
459    /// The extension tools (schema + handle), insertion order.
460    pub fn tools(&self) -> &[ExtensionTool] {
461        &self.tools
462    }
463
464    /// Consume the snapshot into the owned tool list (for building adapters).
465    pub fn into_tools(self) -> Vec<ExtensionTool> {
466        self.tools
467    }
468
469    /// The registered slash commands.
470    pub fn commands(&self) -> &[RegisteredCommand] {
471        &self.commands
472    }
473
474    /// The registered CLI flags, insertion order.
475    pub fn flags(&self) -> &[RegisteredFlag] {
476        &self.flags
477    }
478
479    /// Handlers subscribed to `tag` (empty slice if none).
480    pub fn handlers_for(&self, tag: EventTag) -> &[RegisteredHandler] {
481        let idx = tag as usize;
482        if idx < EVENT_TAG_COUNT {
483            &self.handlers[idx]
484        } else {
485            &[]
486        }
487    }
488
489    /// The `resources_discover` handlers, registration order (B5b). Empty when
490    /// no plugin registered a discovery handler.
491    pub fn resources_discover(&self) -> &[ResourcesDiscoverHandler] {
492        &self.resources_discover
493    }
494
495    /// The registered custom providers (B5c), registration order. The host wraps
496    /// each in a [`PluggableProvider`](crate::PluggableProvider). Empty when no
497    /// plugin registered a provider.
498    pub fn providers(&self) -> &[RegisteredProvider] {
499        &self.providers
500    }
501
502    /// All registered renderers (B5c), registration order, across all three
503    /// kinds.
504    pub fn renderers(&self) -> &[RegisteredRenderer] {
505        &self.renderers
506    }
507
508    /// The registered renderers of a specific kind (B5c). The TUI dispatches
509    /// message and entry renderers through the same JSON adapter used by the
510    /// markdown transformer.
511    pub fn renderers_of(&self, kind: RegisteredRendererKind) -> Vec<RegisteredRenderer> {
512        self.renderers
513            .iter()
514            .filter(|r| r.kind == kind)
515            .cloned()
516            .collect()
517    }
518
519    /// A flat iterator of all registrations (tools + commands), for diagnostics.
520    pub fn entries(&self) -> Vec<RegistryEntry> {
521        let mut v: Vec<RegistryEntry> = Vec::new();
522        for t in &self.tools {
523            v.push(RegistryEntry::Tool(ExtensionTool {
524                tool: t.tool.clone(),
525                handle: t.handle,
526            }));
527        }
528        for c in &self.commands {
529            v.push(RegistryEntry::Command(c.clone()));
530        }
531        for flag in &self.flags {
532            v.push(RegistryEntry::Flag(flag.clone()));
533        }
534        v
535    }
536
537    /// Whether this snapshot's session is still active.
538    pub fn is_active(&self) -> bool {
539        self.active.load(Ordering::SeqCst)
540    }
541
542    /// The shared active flag (for [`assert_active`]).
543    pub fn active_flag(&self) -> &Arc<AtomicBool> {
544        &self.active
545    }
546}
547
548/// Staleness guard: `true` while the owning session is still active. Called
549/// before dispatching an extension event or driving an extension tool so a
550/// stale registry (from a swapped-out session) can't act. Mirrors pi's
551/// `ExtensionRuntimeState` active check.
552pub fn assert_active(active: &Arc<AtomicBool>) -> bool {
553    active.load(Ordering::SeqCst)
554}
555
556/// Build an `AgentError` for a stale-registry access.
557pub(crate) fn stale_error() -> AgentError {
558    AgentError::State("extensions registry is stale (session swapped/reloaded)".into())
559}
560
561// Suppress unused-fn warning until B3 wires dispatch through the snapshot.
562#[allow(dead_code)]
563fn _ensure_handler_accessor_used(snap: &RegistrySnapshot) {
564    let _ = snap.handlers_for(EventTag::MessageEnd);
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn cli_flags_snapshot_and_merge_first_wins() {
573        let mut first = ExtensionRegistry::new();
574        assert!(!first.register_flag("server".into(), "Start server".into()));
575        assert!(first.register_flag("server".into(), "Different description".into()));
576
577        let mut second = ExtensionRegistry::new();
578        assert!(!second.register_flag("server".into(), "Second server".into()));
579        assert!(!second.register_flag("port".into(), "Listen port".into()));
580
581        first.absorb(second);
582        let snapshot = first.snapshot();
583        let flags = snapshot.flags();
584        assert_eq!(flags.len(), 2);
585        assert_eq!(flags[0].name, "server");
586        assert_eq!(flags[0].description, "Start server");
587        assert_eq!(flags[1].name, "port");
588    }
589}