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}