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