Skip to main content

cortex_sdk/
lib.rs

1//! # Cortex SDK
2//!
3//! The official Rust SDK for Cortex's trusted native plugin boundary.
4//!
5//! This crate defines the public plugin surface with **zero dependency on
6//! Cortex internals**. The runtime loads trusted native plugins through a
7//! stable C-compatible ABI and bridges these traits to its own turn runtime,
8//! command surface, and transport layer.
9//!
10//! Process-isolated JSON plugins do **not** need this crate. They are defined
11//! through `manifest.toml` plus a child-process command. Use `cortex-sdk` when
12//! you are building a trusted in-process native plugin that exports
13//! `cortex_plugin_init`.
14//!
15//! ## Architecture
16//!
17//! ```text
18//!  ┌──────────────┐     dlopen      ┌──────────────────┐
19//!  │ cortex-runtime│ ──────────────▶ │  your plugin.so  │
20//!  │   (daemon)    │                 │  cortex-sdk only  │
21//!  └──────┬───────┘   FFI call      └────────┬─────────┘
22//!         │        cortex_plugin_init()         │
23//!         ▼                                    ▼
24//!    ToolRegistry  ◀─── register ───  MultiToolPlugin
25//!                                     ├─ plugin_info()
26//!                                     └─ create_tools()
27//!                                         ├─ Tool A
28//!                                         └─ Tool B
29//! ```
30//!
31//! Plugins are compiled as `cdylib` shared libraries. The runtime calls
32//! `cortex_plugin_init`, receives a C-compatible function table, then asks that
33//! table for plugin metadata, tool descriptors, and tool execution results.
34//! Rust trait objects stay inside the plugin; they never cross the
35//! dynamic-library boundary.
36//!
37//! The SDK now exposes a runtime-aware execution surface as well:
38//!
39//! - [`InvocationContext`] gives tools stable metadata such as session id,
40//!   canonical actor, transport/source, and foreground/background scope
41//! - [`ToolRuntime`] lets tools emit progress updates and observer text back
42//!   to the parent turn
43//! - [`ToolCapabilities`] lets tools declare whether they emit runtime signals
44//!   and whether they are background-safe
45//! - [`Attachment`] and [`ToolResult::with_media`] let tools return structured
46//!   image, audio, video, or file outputs without depending on Cortex internals
47//! ## Quick Start
48//!
49//! **Cargo.toml:**
50//!
51//! ```toml
52//! [lib]
53//! crate-type = ["cdylib"]
54//!
55//! [dependencies]
56//! cortex-sdk = "1.2"
57//! serde_json = "1"
58//! ```
59//!
60//! **src/lib.rs:**
61//!
62//! ```rust,no_run
63//! use cortex_sdk::prelude::*;
64//!
65//! // 1. Define the plugin entry point.
66//! #[derive(Default)]
67//! struct MyPlugin;
68//!
69//! impl MultiToolPlugin for MyPlugin {
70//!     fn plugin_info(&self) -> PluginInfo {
71//!         PluginInfo {
72//!             name: "my-plugin".into(),
73//!             version: env!("CARGO_PKG_VERSION").into(),
74//!             description: "My custom tools for Cortex".into(),
75//!         }
76//!     }
77//!
78//!     fn create_tools(&self) -> Vec<Box<dyn Tool>> {
79//!         vec![Box::new(WordCountTool)]
80//!     }
81//! }
82//!
83//! // 2. Implement one or more tools.
84//! struct WordCountTool;
85//!
86//! impl Tool for WordCountTool {
87//!     fn name(&self) -> &'static str { "word_count" }
88//!
89//!     fn description(&self) -> &'static str {
90//!         "Count words in a text string. Use when the user asks for word \
91//!          counts, statistics, or text length metrics."
92//!     }
93//!
94//!     fn input_schema(&self) -> serde_json::Value {
95//!         serde_json::json!({
96//!             "type": "object",
97//!             "properties": {
98//!                 "text": {
99//!                     "type": "string",
100//!                     "description": "The text to count words in"
101//!                 }
102//!             },
103//!             "required": ["text"]
104//!         })
105//!     }
106//!
107//!     fn execute(&self, input: serde_json::Value) -> Result<ToolResult, ToolError> {
108//!         let text = input["text"]
109//!             .as_str()
110//!             .ok_or_else(|| ToolError::InvalidInput("missing 'text' field".into()))?;
111//!         let count = text.split_whitespace().count();
112//!         Ok(ToolResult::success(format!("{count} words")))
113//!     }
114//! }
115//!
116//! // 3. Export the FFI entry point.
117//! cortex_sdk::export_plugin!(MyPlugin);
118//! ```
119//!
120//! Tools that need runtime context can override
121//! [`Tool::execute_with_runtime`] instead of only [`Tool::execute`].
122//!
123//! ## Build & Install
124//!
125//! ```bash
126//! cargo build --release
127//! cortex plugin install ./my-plugin/
128//! ```
129//!
130//! If `my-plugin/manifest.toml` declares `[native].library = "lib/libmy_plugin.so"`
131//! (or `.dylib` on macOS), Cortex copies the built library from
132//! `target/release/` into the installed plugin's `lib/` directory
133//! automatically when you install from a local folder.
134//!
135//! For versioned distribution:
136//!
137//! ```bash
138//! cargo build --release
139//! cortex plugin pack ./my-plugin
140//! cortex plugin install ./my-plugin-v0.1.0-linux-amd64.cpx
141//! ```
142//!
143//! Installing or replacing a trusted native shared library still requires a
144//! daemon restart so the new code is loaded. Process-isolated plugin manifest
145//! changes hot-apply without that restart.
146//!
147//! ## Plugin Lifecycle
148//!
149//! 1. **Load** — `dlopen` at daemon startup
150//! 2. **Create** — runtime calls [`export_plugin!`]-generated stable ABI init
151//! 3. **Register** — [`MultiToolPlugin::create_tools`] is called once; each
152//!    [`Tool`] is registered in the global tool registry
153//! 4. **Execute** — the LLM invokes tools by name during turns; the runtime
154//!    calls [`Tool::execute`] with JSON parameters
155//! 5. **Retain** — the library handle is held for the daemon's lifetime;
156//!    `Drop` runs only at shutdown
157//!
158//! ## Tool Design Guidelines
159//!
160//! - **`name`**: lowercase with underscores (`word_count`, not `WordCount`).
161//!   Must be unique across all tools in the registry.
162//! - **`description`**: written for the LLM — explain what the tool does,
163//!   when to use it, and when *not* to use it.  The LLM reads this to decide
164//!   whether to call the tool.
165//! - **`input_schema`**: a [JSON Schema](https://json-schema.org/) object
166//!   describing the parameters.  The LLM generates JSON matching this schema.
167//! - **`execute`**: receives the LLM-generated JSON.  Return
168//!   [`ToolResult::success`] for normal output or [`ToolResult::error`] for
169//!   recoverable errors the LLM should see.  Return [`ToolError`] only for
170//!   unrecoverable failures (invalid input, missing deps).
171//! - **Media output**: attach files with [`ToolResult::with_media`].  Cortex
172//!   delivers attachments through the active transport; plugins should not call
173//!   channel-specific APIs directly.
174//! - **`execute_with_runtime`**: use this when the tool needs invocation
175//!   metadata or wants to emit progress / observer updates during execution.
176//! - **`timeout_secs`**: optional per-tool timeout override.  If `None`, the
177//!   global `[turn].tool_timeout_secs` applies.
178
179use serde::{Deserialize, Serialize};
180pub use serde_json;
181use std::ffi::c_void;
182
183/// Version of the SDK crate used by native plugin builds.
184pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
185
186/// Stable native ABI version for trusted in-process plugins.
187///
188/// The runtime never exchanges Rust trait objects across the dynamic-library
189/// boundary. It loads a C-compatible function table through `cortex_plugin_init`
190/// and moves structured values as UTF-8 JSON buffers.
191pub const NATIVE_ABI_VERSION: u32 = 1;
192
193/// Stable multimedia attachment DTO exposed to plugins.
194///
195/// This type intentionally lives in `cortex-sdk` instead of depending on
196/// Cortex internal crates, so plugin authors only need the SDK.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct Attachment {
199    /// High-level type: `"image"`, `"audio"`, `"video"`, `"file"`.
200    pub media_type: String,
201    /// MIME type, for example `"image/png"` or `"audio/mpeg"`.
202    pub mime_type: String,
203    /// Local file path or remote URL readable by the runtime transport.
204    pub url: String,
205    /// Optional caption or description.
206    pub caption: Option<String>,
207    /// File size in bytes, if known.
208    pub size: Option<u64>,
209}
210
211/// Whether a tool invocation belongs to a user-visible foreground turn or a
212/// background maintenance execution.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum ExecutionScope {
216    #[default]
217    Foreground,
218    Background,
219}
220
221/// Stable runtime metadata exposed to plugin tools during execution.
222///
223/// This intentionally exposes the execution surface, not Cortex internals.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct InvocationContext {
226    /// Tool name being invoked.
227    pub tool_name: String,
228    /// Active session id when available.
229    pub session_id: Option<String>,
230    /// Canonical actor identity when available.
231    pub actor: Option<String>,
232    /// Transport or invocation source (`http`, `rpc`, `telegram`, `heartbeat`, ...).
233    pub source: Option<String>,
234    /// Whether this invocation belongs to a foreground or background execution.
235    pub execution_scope: ExecutionScope,
236}
237
238impl InvocationContext {
239    #[must_use]
240    pub fn is_background(&self) -> bool {
241        self.execution_scope == ExecutionScope::Background
242    }
243
244    #[must_use]
245    pub fn is_foreground(&self) -> bool {
246        self.execution_scope == ExecutionScope::Foreground
247    }
248}
249
250/// Declarative hints about how a tool participates in the runtime.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
252pub struct ToolCapabilities {
253    /// Tool emits intermediate progress updates.
254    pub emits_progress: bool,
255    /// Tool emits observer-lane notes for the parent turn.
256    pub emits_observer_text: bool,
257    /// Tool is safe to run in background maintenance contexts.
258    pub background_safe: bool,
259}
260
261/// Runtime bridge presented to tools during execution.
262///
263/// This allows plugins to consume stable runtime context and emit bounded
264/// execution signals without depending on Cortex internals.
265pub trait ToolRuntime: Send + Sync {
266    /// Stable invocation metadata.
267    fn invocation(&self) -> &InvocationContext;
268
269    /// Emit an intermediate progress update for the current tool.
270    fn emit_progress(&self, message: &str);
271
272    /// Emit observer text for the parent turn. This never speaks directly to
273    /// the user-facing channel.
274    fn emit_observer(&self, source: Option<&str>, content: &str);
275}
276
277// ── Tool Interface ──────────────────────────────────────────
278
279/// A tool that the LLM can invoke during conversation.
280///
281/// Tools are the primary extension point for Cortex plugins.  Each tool
282/// has a name, description, JSON Schema for input parameters, and an
283/// execute function.  The runtime presents the tool definition to the LLM
284/// and routes invocations to [`Tool::execute`].
285///
286/// # Thread Safety
287///
288/// Tools must be `Send + Sync` because a single tool instance is shared
289/// across all turns in the daemon process.  Use interior mutability
290/// (`Mutex`, `RwLock`, `AtomicXxx`) if you need mutable state.
291pub trait Tool: Send + Sync {
292    /// Unique tool name (lowercase, underscores, e.g. `"web_search"`).
293    ///
294    /// Must be unique across all registered tools.  If two tools share a
295    /// name, the later registration wins.
296    fn name(&self) -> &'static str;
297
298    /// Human-readable description shown to the LLM.
299    ///
300    /// Write this for the LLM, not for humans.  Include:
301    /// - What the tool does
302    /// - When to use it
303    /// - When *not* to use it
304    /// - Any constraints or limitations
305    fn description(&self) -> &'static str;
306
307    /// JSON Schema describing the tool's input parameters.
308    ///
309    /// The LLM generates a JSON object matching this schema.  Example:
310    ///
311    /// ```json
312    /// {
313    ///   "type": "object",
314    ///   "properties": {
315    ///     "query": { "type": "string", "description": "Search query" }
316    ///   },
317    ///   "required": ["query"]
318    /// }
319    /// ```
320    fn input_schema(&self) -> serde_json::Value;
321
322    /// Execute the tool with the given input.
323    ///
324    /// `input` is a JSON object matching [`Self::input_schema`].  The
325    /// runtime validates the schema before calling this method, but
326    /// individual field types should still be checked defensively.
327    ///
328    /// # Return Values
329    ///
330    /// - [`ToolResult::success`] — normal output returned to the LLM
331    /// - [`ToolResult::error`] — the tool ran but produced an error the
332    ///   LLM should see and potentially recover from
333    ///
334    /// # Errors
335    ///
336    /// Return [`ToolError::InvalidInput`] for malformed parameters or
337    /// [`ToolError::ExecutionFailed`] for unrecoverable failures.  These
338    /// are surfaced as error events in the turn journal.
339    fn execute(&self, input: serde_json::Value) -> Result<ToolResult, ToolError>;
340
341    /// Execute the tool with runtime context and host callbacks.
342    ///
343    /// Plugins can override this to read session/actor/source metadata and
344    /// emit progress or observer updates through the provided runtime bridge.
345    ///
346    /// The default implementation preserves the classic SDK contract and calls
347    /// [`Self::execute`].
348    ///
349    /// # Errors
350    ///
351    /// Returns the same `ToolError` variants that [`Self::execute`] would
352    /// return for invalid input or unrecoverable execution failure.
353    fn execute_with_runtime(
354        &self,
355        input: serde_json::Value,
356        runtime: &dyn ToolRuntime,
357    ) -> Result<ToolResult, ToolError> {
358        let _ = runtime;
359        self.execute(input)
360    }
361
362    /// Optional per-tool execution timeout in seconds.
363    ///
364    /// If `None` (the default), the global `[turn].tool_timeout_secs`
365    /// from the instance configuration applies.
366    fn timeout_secs(&self) -> Option<u64> {
367        None
368    }
369
370    /// Stable capability hints consumed by the runtime and observability
371    /// layers.
372    fn capabilities(&self) -> ToolCapabilities {
373        ToolCapabilities::default()
374    }
375}
376
377/// Result of a tool execution returned to the LLM.
378///
379/// Use [`ToolResult::success`] for normal output and [`ToolResult::error`]
380/// for recoverable errors the LLM should see.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct ToolResult {
383    /// Output text returned to the LLM.
384    pub output: String,
385    /// Structured media attachments produced by this tool.
386    ///
387    /// Attachments are delivered by Cortex transports independently from the
388    /// text the model sees, so tools do not need transport-specific protocols.
389    pub media: Vec<Attachment>,
390    /// Whether this result represents an error condition.
391    ///
392    /// When `true`, the LLM sees this as a failed tool call and may retry
393    /// with different parameters or switch strategy.
394    pub is_error: bool,
395}
396
397impl ToolResult {
398    /// Create a successful result.
399    #[must_use]
400    pub fn success(output: impl Into<String>) -> Self {
401        Self {
402            output: output.into(),
403            media: Vec::new(),
404            is_error: false,
405        }
406    }
407
408    /// Create an error result (tool ran but failed).
409    ///
410    /// Use this for recoverable errors — the LLM sees the output and can
411    /// decide how to proceed. For example: "file not found", "permission
412    /// denied", "rate limit exceeded".
413    #[must_use]
414    pub fn error(output: impl Into<String>) -> Self {
415        Self {
416            output: output.into(),
417            media: Vec::new(),
418            is_error: true,
419        }
420    }
421
422    /// Attach one media item to the result.
423    #[must_use]
424    pub fn with_media(mut self, attachment: Attachment) -> Self {
425        self.media.push(attachment);
426        self
427    }
428
429    /// Attach multiple media items to the result.
430    #[must_use]
431    pub fn with_media_many(mut self, media: impl IntoIterator<Item = Attachment>) -> Self {
432        self.media.extend(media);
433        self
434    }
435}
436
437/// Error from tool execution.
438///
439/// Unlike [`ToolResult::error`] (which is a "soft" error the LLM sees),
440/// `ToolError` represents a hard failure that is logged in the turn
441/// journal as a tool invocation error.
442#[derive(Debug)]
443pub enum ToolError {
444    /// Input parameters are invalid or missing required fields.
445    InvalidInput(String),
446    /// Execution failed due to an external or internal error.
447    ExecutionFailed(String),
448}
449
450impl std::fmt::Display for ToolError {
451    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452        match self {
453            Self::InvalidInput(e) => write!(f, "invalid input: {e}"),
454            Self::ExecutionFailed(e) => write!(f, "execution failed: {e}"),
455        }
456    }
457}
458
459impl std::error::Error for ToolError {}
460
461// ── Plugin Interface ────────────────────────────────────────
462
463/// Plugin metadata returned to the runtime at load time.
464///
465/// The `name` field must match the plugin's directory name and the
466/// `name` field in `manifest.toml`.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct PluginInfo {
469    /// Unique plugin identifier (e.g. `"my-plugin"`).
470    pub name: String,
471    /// Semantic version string (e.g. `"1.3.0"`).
472    pub version: String,
473    /// Human-readable one-line description.
474    pub description: String,
475}
476
477/// A plugin that provides multiple tools from a single shared library.
478///
479/// This is the primary interface between a plugin and the Cortex runtime.
480/// Implement this trait and use [`export_plugin!`] to generate the FFI
481/// entry point.
482///
483/// # Requirements
484///
485/// - The implementing type must also implement `Default` (required by
486///   [`export_plugin!`] for construction via FFI).
487/// - The type must be `Send + Sync` because the runtime may access it
488///   from multiple threads.
489///
490/// # Example
491///
492/// ```rust,no_run
493/// use cortex_sdk::prelude::*;
494///
495/// #[derive(Default)]
496/// struct MyPlugin;
497///
498/// impl MultiToolPlugin for MyPlugin {
499///     fn plugin_info(&self) -> PluginInfo {
500///         PluginInfo {
501///             name: "my-plugin".into(),
502///             version: "0.1.0".into(),
503///             description: "Example plugin".into(),
504///         }
505///     }
506///
507///     fn create_tools(&self) -> Vec<Box<dyn Tool>> {
508///         vec![]
509///     }
510/// }
511///
512/// cortex_sdk::export_plugin!(MyPlugin);
513/// ```
514pub trait MultiToolPlugin: Send + Sync {
515    /// Return plugin metadata.
516    fn plugin_info(&self) -> PluginInfo;
517
518    /// Create all tools this plugin provides.
519    ///
520    /// Called once at daemon startup.  Returned tools live for the
521    /// daemon's lifetime.  Each tool is registered by name into the
522    /// global tool registry.
523    fn create_tools(&self) -> Vec<Box<dyn Tool>>;
524}
525
526/// Native ABI-owned byte buffer.
527///
528/// All strings and JSON values that cross the stable native ABI boundary use
529/// this representation. Buffers returned by the plugin must be released by
530/// calling the table's `buffer_free` function.
531#[repr(C)]
532pub struct CortexBuffer {
533    /// Pointer to UTF-8 bytes.
534    pub ptr: *mut u8,
535    /// Number of initialized bytes at `ptr`.
536    pub len: usize,
537    /// Allocation capacity needed to reconstruct and free the buffer.
538    pub cap: usize,
539}
540
541impl CortexBuffer {
542    #[must_use]
543    pub const fn empty() -> Self {
544        Self {
545            ptr: std::ptr::null_mut(),
546            len: 0,
547            cap: 0,
548        }
549    }
550}
551
552impl From<String> for CortexBuffer {
553    fn from(value: String) -> Self {
554        let mut bytes = value.into_bytes();
555        let buffer = Self {
556            ptr: bytes.as_mut_ptr(),
557            len: bytes.len(),
558            cap: bytes.capacity(),
559        };
560        std::mem::forget(bytes);
561        buffer
562    }
563}
564
565impl CortexBuffer {
566    /// Read this buffer as UTF-8.
567    ///
568    /// # Errors
569    /// Returns a UTF-8 error when the buffer contains invalid UTF-8 bytes.
570    ///
571    /// # Safety
572    /// The caller must ensure `ptr` is valid for `len` bytes and remains alive
573    /// for the duration of this call.
574    pub const unsafe fn as_str(&self) -> Result<&str, std::str::Utf8Error> {
575        if self.ptr.is_null() || self.len == 0 {
576            return Ok("");
577        }
578        // SAFETY: upheld by the caller.
579        let bytes = unsafe { std::slice::from_raw_parts(self.ptr.cast_const(), self.len) };
580        std::str::from_utf8(bytes)
581    }
582}
583
584/// Free a buffer allocated by this SDK.
585///
586/// # Safety
587/// The buffer must have been returned by this SDK's ABI helpers and must not be
588/// freed more than once.
589pub unsafe extern "C" fn cortex_buffer_free(buffer: CortexBuffer) {
590    if buffer.ptr.is_null() {
591        return;
592    }
593    // SAFETY: the caller guarantees this buffer came from `CortexBuffer::from_string`.
594    unsafe {
595        drop(Vec::from_raw_parts(buffer.ptr, buffer.len, buffer.cap));
596    }
597}
598
599/// Host table supplied to a native plugin during initialization.
600#[repr(C)]
601pub struct CortexHostApi {
602    /// Runtime-supported native ABI version.
603    pub abi_version: u32,
604}
605
606/// Function table exported by a native plugin.
607#[repr(C)]
608pub struct CortexPluginApi {
609    /// Plugin-supported native ABI version.
610    pub abi_version: u32,
611    /// Opaque plugin state owned by the plugin.
612    pub plugin: *mut c_void,
613    /// Return [`PluginInfo`] encoded as JSON.
614    pub plugin_info: Option<unsafe extern "C" fn(*mut c_void) -> CortexBuffer>,
615    /// Return the number of tools exposed by the plugin.
616    pub tool_count: Option<unsafe extern "C" fn(*mut c_void) -> usize>,
617    /// Return one tool descriptor encoded as JSON.
618    pub tool_descriptor: Option<unsafe extern "C" fn(*mut c_void, usize) -> CortexBuffer>,
619    /// Execute a tool. The name, input, and invocation context are UTF-8 JSON
620    /// buffers except `tool_name`, which is a UTF-8 string.
621    pub tool_execute: Option<
622        unsafe extern "C" fn(*mut c_void, CortexBuffer, CortexBuffer, CortexBuffer) -> CortexBuffer,
623    >,
624    /// Drop plugin-owned state.
625    pub plugin_drop: Option<unsafe extern "C" fn(*mut c_void)>,
626    /// Free buffers returned by plugin functions.
627    pub buffer_free: Option<unsafe extern "C" fn(CortexBuffer)>,
628}
629
630impl CortexPluginApi {
631    #[must_use]
632    pub const fn empty() -> Self {
633        Self {
634            abi_version: 0,
635            plugin: std::ptr::null_mut(),
636            plugin_info: None,
637            tool_count: None,
638            tool_descriptor: None,
639            tool_execute: None,
640            plugin_drop: None,
641            buffer_free: None,
642        }
643    }
644}
645
646#[derive(Serialize)]
647struct ToolDescriptor<'a> {
648    name: &'a str,
649    description: &'a str,
650    input_schema: serde_json::Value,
651    timeout_secs: Option<u64>,
652    capabilities: ToolCapabilities,
653}
654
655struct NoopToolRuntime {
656    invocation: InvocationContext,
657}
658
659impl ToolRuntime for NoopToolRuntime {
660    fn invocation(&self) -> &InvocationContext {
661        &self.invocation
662    }
663
664    fn emit_progress(&self, _message: &str) {}
665
666    fn emit_observer(&self, _source: Option<&str>, _content: &str) {}
667}
668
669#[doc(hidden)]
670pub struct NativePluginState {
671    plugin: Box<dyn MultiToolPlugin>,
672    tools: Vec<Box<dyn Tool>>,
673}
674
675impl NativePluginState {
676    #[must_use]
677    pub fn new(plugin: Box<dyn MultiToolPlugin>) -> Self {
678        let tools = plugin.create_tools();
679        Self { plugin, tools }
680    }
681}
682
683fn json_buffer<T: Serialize>(value: &T) -> CortexBuffer {
684    match serde_json::to_string(value) {
685        Ok(json) => CortexBuffer::from(json),
686        Err(err) => CortexBuffer::from(
687            serde_json::json!({
688                "output": format!("native ABI serialization error: {err}"),
689                "media": [],
690                "is_error": true
691            })
692            .to_string(),
693        ),
694    }
695}
696
697#[doc(hidden)]
698pub unsafe extern "C" fn native_plugin_info(state: *mut c_void) -> CortexBuffer {
699    if state.is_null() {
700        return CortexBuffer::empty();
701    }
702    // SAFETY: the pointer is created by `export_plugin!` and remains owned by
703    // the plugin until `native_plugin_drop`.
704    let state = unsafe { &*state.cast::<NativePluginState>() };
705    json_buffer(&state.plugin.plugin_info())
706}
707
708#[doc(hidden)]
709pub unsafe extern "C" fn native_tool_count(state: *mut c_void) -> usize {
710    if state.is_null() {
711        return 0;
712    }
713    // SAFETY: see `native_plugin_info`.
714    let state = unsafe { &*state.cast::<NativePluginState>() };
715    state.tools.len()
716}
717
718#[doc(hidden)]
719pub unsafe extern "C" fn native_tool_descriptor(state: *mut c_void, index: usize) -> CortexBuffer {
720    if state.is_null() {
721        return CortexBuffer::empty();
722    }
723    // SAFETY: see `native_plugin_info`.
724    let state = unsafe { &*state.cast::<NativePluginState>() };
725    let Some(tool) = state.tools.get(index) else {
726        return CortexBuffer::empty();
727    };
728    let descriptor = ToolDescriptor {
729        name: tool.name(),
730        description: tool.description(),
731        input_schema: tool.input_schema(),
732        timeout_secs: tool.timeout_secs(),
733        capabilities: tool.capabilities(),
734    };
735    json_buffer(&descriptor)
736}
737
738#[doc(hidden)]
739pub unsafe extern "C" fn native_tool_execute(
740    state: *mut c_void,
741    tool_name: CortexBuffer,
742    input_json: CortexBuffer,
743    invocation_json: CortexBuffer,
744) -> CortexBuffer {
745    if state.is_null() {
746        return json_buffer(&ToolResult::error("native plugin state is null"));
747    }
748    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
749    let tool_name = match unsafe { tool_name.as_str() } {
750        Ok(value) => value,
751        Err(err) => return json_buffer(&ToolResult::error(format!("invalid tool name: {err}"))),
752    };
753    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
754    let input_json = match unsafe { input_json.as_str() } {
755        Ok(value) => value,
756        Err(err) => return json_buffer(&ToolResult::error(format!("invalid input JSON: {err}"))),
757    };
758    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
759    let invocation_json = match unsafe { invocation_json.as_str() } {
760        Ok(value) => value,
761        Err(err) => {
762            return json_buffer(&ToolResult::error(format!(
763                "invalid invocation JSON: {err}"
764            )));
765        }
766    };
767    let input = match serde_json::from_str(input_json) {
768        Ok(value) => value,
769        Err(err) => return json_buffer(&ToolResult::error(format!("invalid input JSON: {err}"))),
770    };
771    let invocation = match serde_json::from_str(invocation_json) {
772        Ok(value) => value,
773        Err(err) => {
774            return json_buffer(&ToolResult::error(format!(
775                "invalid invocation JSON: {err}"
776            )));
777        }
778    };
779    // SAFETY: see `native_plugin_info`.
780    let state = unsafe { &*state.cast::<NativePluginState>() };
781    let Some(tool) = state.tools.iter().find(|tool| tool.name() == tool_name) else {
782        return json_buffer(&ToolResult::error(format!(
783            "native plugin does not expose tool '{tool_name}'"
784        )));
785    };
786    let runtime = NoopToolRuntime { invocation };
787    match tool.execute_with_runtime(input, &runtime) {
788        Ok(result) => json_buffer(&result),
789        Err(err) => json_buffer(&ToolResult::error(format!("tool error: {err}"))),
790    }
791}
792
793#[doc(hidden)]
794pub unsafe extern "C" fn native_plugin_drop(state: *mut c_void) {
795    if state.is_null() {
796        return;
797    }
798    // SAFETY: pointer ownership is transferred from `export_plugin!` to this
799    // function exactly once by the runtime.
800    unsafe {
801        drop(Box::from_raw(state.cast::<NativePluginState>()));
802    }
803}
804
805// ── Export Macro ────────────────────────────────────────────
806
807/// Generate the stable native ABI entry point for a [`MultiToolPlugin`].
808///
809/// This macro expands to an `extern "C"` function named `cortex_plugin_init`
810/// that fills a C-compatible function table. The plugin type must implement
811/// [`Default`].
812///
813/// # Usage
814///
815/// `cortex_sdk::export_plugin!(MyPlugin);`
816///
817/// # Expansion
818///
819/// The macro constructs the Rust plugin internally and exposes it through the
820/// stable native ABI table. Rust trait objects never cross the dynamic-library
821/// boundary.
822#[macro_export]
823macro_rules! export_plugin {
824    ($plugin_type:ty) => {
825        #[unsafe(no_mangle)]
826        pub unsafe extern "C" fn cortex_plugin_init(
827            host: *const $crate::CortexHostApi,
828            out_plugin: *mut $crate::CortexPluginApi,
829        ) -> i32 {
830            if host.is_null() || out_plugin.is_null() {
831                return -1;
832            }
833            let host = unsafe { &*host };
834            if host.abi_version != $crate::NATIVE_ABI_VERSION {
835                return -2;
836            }
837            let plugin: Box<dyn $crate::MultiToolPlugin> = Box::new(<$plugin_type>::default());
838            let state = Box::new($crate::NativePluginState::new(plugin));
839            unsafe {
840                *out_plugin = $crate::CortexPluginApi {
841                    abi_version: $crate::NATIVE_ABI_VERSION,
842                    plugin: Box::into_raw(state).cast(),
843                    plugin_info: Some($crate::native_plugin_info),
844                    tool_count: Some($crate::native_tool_count),
845                    tool_descriptor: Some($crate::native_tool_descriptor),
846                    tool_execute: Some($crate::native_tool_execute),
847                    plugin_drop: Some($crate::native_plugin_drop),
848                    buffer_free: Some($crate::cortex_buffer_free),
849                };
850            }
851            0
852        }
853    };
854}
855
856// ── Prelude ─────────────────────────────────────────────────
857
858/// Convenience re-exports for plugin development.
859///
860/// ```rust,no_run
861/// use cortex_sdk::prelude::*;
862/// ```
863///
864/// This imports [`MultiToolPlugin`], [`PluginInfo`], [`Tool`],
865/// [`ToolError`], [`ToolResult`], and [`serde_json`].
866pub mod prelude {
867    pub use super::{
868        Attachment, CortexBuffer, CortexHostApi, CortexPluginApi, ExecutionScope,
869        InvocationContext, MultiToolPlugin, NATIVE_ABI_VERSION, PluginInfo, SDK_VERSION, Tool,
870        ToolCapabilities, ToolError, ToolResult, ToolRuntime,
871    };
872    pub use serde_json;
873}