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/// Stable categories for side effects a tool may perform.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
252#[serde(rename_all = "snake_case")]
253pub enum ToolEffectKind {
254    ReadFile,
255    ReadSecret,
256    WriteFile,
257    DeleteFile,
258    RunProcess,
259    NetworkRequest,
260    SendMessage,
261    SpendMoney,
262    Deploy,
263    ModifyCredential,
264    PersistMemory,
265    PublishContent,
266    ScheduleTask,
267    GenerateMedia,
268    IntrospectRuntime,
269    DelegateWork,
270}
271
272/// Whether a declared effect can be undone after execution.
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
274#[serde(rename_all = "snake_case")]
275pub enum EffectReversibility {
276    Reversible,
277    PartiallyReversible,
278    Irreversible,
279}
280
281/// When the runtime should ask for confirmation before executing an effect.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
283#[serde(rename_all = "snake_case")]
284pub enum EffectConfirmation {
285    Never,
286    OnRisk,
287    Always,
288}
289
290/// Whether a tool can preview its effect before committing it.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum DryRunSupport {
294    NotSupported,
295    Supported,
296    RequiredBeforeExecute,
297}
298
299/// Declarative hints about how a tool participates in the runtime.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301pub struct ToolEffect {
302    /// The stable effect category.
303    pub kind: ToolEffectKind,
304    /// Optional target, such as a path, host, channel, or resource name.
305    #[serde(default, skip_serializing_if = "String::is_empty")]
306    pub target: String,
307    /// Whether this effect can be undone after execution.
308    pub reversibility: EffectReversibility,
309    /// Confirmation preference declared by the tool author.
310    pub confirmation: EffectConfirmation,
311    /// Dry-run support declared by the tool author.
312    pub dry_run: DryRunSupport,
313}
314
315impl ToolEffect {
316    #[must_use]
317    pub const fn new(kind: ToolEffectKind) -> Self {
318        Self {
319            kind,
320            target: String::new(),
321            reversibility: kind.default_reversibility(),
322            confirmation: kind.default_confirmation(),
323            dry_run: DryRunSupport::NotSupported,
324        }
325    }
326
327    #[must_use]
328    pub fn with_target(mut self, target: impl Into<String>) -> Self {
329        self.target = target.into();
330        self
331    }
332
333    #[must_use]
334    pub const fn with_reversibility(mut self, reversibility: EffectReversibility) -> Self {
335        self.reversibility = reversibility;
336        self
337    }
338
339    #[must_use]
340    pub const fn with_confirmation(mut self, confirmation: EffectConfirmation) -> Self {
341        self.confirmation = confirmation;
342        self
343    }
344
345    #[must_use]
346    pub const fn with_dry_run(mut self, dry_run: DryRunSupport) -> Self {
347        self.dry_run = dry_run;
348        self
349    }
350
351    #[must_use]
352    pub const fn is_mutating(&self) -> bool {
353        self.kind.is_mutating()
354    }
355
356    #[must_use]
357    pub fn label(&self) -> String {
358        if self.target.is_empty() {
359            format!("{:?}", self.kind)
360        } else {
361            format!("{:?}:{}", self.kind, self.target)
362        }
363    }
364}
365
366impl ToolEffectKind {
367    #[must_use]
368    pub const fn is_mutating(self) -> bool {
369        !matches!(
370            self,
371            Self::ReadFile | Self::ReadSecret | Self::NetworkRequest | Self::IntrospectRuntime
372        )
373    }
374
375    const fn default_reversibility(self) -> EffectReversibility {
376        match self {
377            Self::ReadFile | Self::NetworkRequest | Self::IntrospectRuntime => {
378                EffectReversibility::Reversible
379            }
380            Self::WriteFile
381            | Self::RunProcess
382            | Self::PersistMemory
383            | Self::ScheduleTask
384            | Self::GenerateMedia
385            | Self::DelegateWork => EffectReversibility::PartiallyReversible,
386            Self::ReadSecret
387            | Self::DeleteFile
388            | Self::SendMessage
389            | Self::SpendMoney
390            | Self::Deploy
391            | Self::ModifyCredential
392            | Self::PublishContent => EffectReversibility::Irreversible,
393        }
394    }
395
396    const fn default_confirmation(self) -> EffectConfirmation {
397        match self {
398            Self::ReadFile | Self::NetworkRequest | Self::IntrospectRuntime => {
399                EffectConfirmation::OnRisk
400            }
401            _ => EffectConfirmation::Always,
402        }
403    }
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
407pub struct ToolCapabilities {
408    /// Tool emits intermediate progress updates.
409    pub emits_progress: bool,
410    /// Tool emits observer-lane notes for the parent turn.
411    pub emits_observer_text: bool,
412    /// Tool is safe to run in background maintenance contexts.
413    pub background_safe: bool,
414    /// Declarative effect surface used by risk policy and transaction tracing.
415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
416    pub effects: Vec<ToolEffect>,
417}
418
419impl ToolCapabilities {
420    #[must_use]
421    pub fn with_effect(mut self, effect: ToolEffect) -> Self {
422        self.effects.push(effect);
423        self
424    }
425
426    #[must_use]
427    pub fn with_effects(mut self, effects: impl IntoIterator<Item = ToolEffect>) -> Self {
428        self.effects.extend(effects);
429        self
430    }
431}
432
433/// Runtime bridge presented to tools during execution.
434///
435/// This allows plugins to consume stable runtime context and emit bounded
436/// execution signals without depending on Cortex internals.
437pub trait ToolRuntime: Send + Sync {
438    /// Stable invocation metadata.
439    fn invocation(&self) -> &InvocationContext;
440
441    /// Emit an intermediate progress update for the current tool.
442    fn emit_progress(&self, message: &str);
443
444    /// Emit observer text for the parent turn. This never speaks directly to
445    /// the user-facing channel.
446    fn emit_observer(&self, source: Option<&str>, content: &str);
447}
448
449// ── Tool Interface ──────────────────────────────────────────
450
451/// A tool that the LLM can invoke during conversation.
452///
453/// Tools are the primary extension point for Cortex plugins.  Each tool
454/// has a name, description, JSON Schema for input parameters, and an
455/// execute function.  The runtime presents the tool definition to the LLM
456/// and routes invocations to [`Tool::execute`].
457///
458/// # Thread Safety
459///
460/// Tools must be `Send + Sync` because a single tool instance is shared
461/// across all turns in the daemon process.  Use interior mutability
462/// (`Mutex`, `RwLock`, `AtomicXxx`) if you need mutable state.
463pub trait Tool: Send + Sync {
464    /// Unique tool name (lowercase, underscores, e.g. `"web_search"`).
465    ///
466    /// Must be unique across all registered tools.  If two tools share a
467    /// name, the later registration wins.
468    fn name(&self) -> &'static str;
469
470    /// Human-readable description shown to the LLM.
471    ///
472    /// Write this for the LLM, not for humans.  Include:
473    /// - What the tool does
474    /// - When to use it
475    /// - When *not* to use it
476    /// - Any constraints or limitations
477    fn description(&self) -> &'static str;
478
479    /// JSON Schema describing the tool's input parameters.
480    ///
481    /// The LLM generates a JSON object matching this schema.  Example:
482    ///
483    /// ```json
484    /// {
485    ///   "type": "object",
486    ///   "properties": {
487    ///     "query": { "type": "string", "description": "Search query" }
488    ///   },
489    ///   "required": ["query"]
490    /// }
491    /// ```
492    fn input_schema(&self) -> serde_json::Value;
493
494    /// Execute the tool with the given input.
495    ///
496    /// `input` is a JSON object matching [`Self::input_schema`].  The
497    /// runtime validates the schema before calling this method, but
498    /// individual field types should still be checked defensively.
499    ///
500    /// # Return Values
501    ///
502    /// - [`ToolResult::success`] — normal output returned to the LLM
503    /// - [`ToolResult::error`] — the tool ran but produced an error the
504    ///   LLM should see and potentially recover from
505    ///
506    /// # Errors
507    ///
508    /// Return [`ToolError::InvalidInput`] for malformed parameters or
509    /// [`ToolError::ExecutionFailed`] for unrecoverable failures.  These
510    /// are surfaced as error events in the turn journal.
511    fn execute(&self, input: serde_json::Value) -> Result<ToolResult, ToolError>;
512
513    /// Execute the tool with runtime context and host callbacks.
514    ///
515    /// Plugins can override this to read session/actor/source metadata and
516    /// emit progress or observer updates through the provided runtime bridge.
517    ///
518    /// The default implementation preserves the classic SDK contract and calls
519    /// [`Self::execute`].
520    ///
521    /// # Errors
522    ///
523    /// Returns the same `ToolError` variants that [`Self::execute`] would
524    /// return for invalid input or unrecoverable execution failure.
525    fn execute_with_runtime(
526        &self,
527        input: serde_json::Value,
528        runtime: &dyn ToolRuntime,
529    ) -> Result<ToolResult, ToolError> {
530        let _ = runtime;
531        self.execute(input)
532    }
533
534    /// Optional per-tool execution timeout in seconds.
535    ///
536    /// If `None` (the default), the global `[turn].tool_timeout_secs`
537    /// from the instance configuration applies.
538    fn timeout_secs(&self) -> Option<u64> {
539        None
540    }
541
542    /// Stable capability hints consumed by the runtime and observability
543    /// layers.
544    fn capabilities(&self) -> ToolCapabilities {
545        ToolCapabilities::default()
546    }
547}
548
549/// Result of a tool execution returned to the LLM.
550///
551/// Use [`ToolResult::success`] for normal output and [`ToolResult::error`]
552/// for recoverable errors the LLM should see.
553#[derive(Debug, Clone, Serialize, Deserialize)]
554pub struct ToolResult {
555    /// Output text returned to the LLM.
556    pub output: String,
557    /// Structured media attachments produced by this tool.
558    ///
559    /// Attachments are delivered by Cortex transports independently from the
560    /// text the model sees, so tools do not need transport-specific protocols.
561    pub media: Vec<Attachment>,
562    /// Whether this result represents an error condition.
563    ///
564    /// When `true`, the LLM sees this as a failed tool call and may retry
565    /// with different parameters or switch strategy.
566    pub is_error: bool,
567}
568
569impl ToolResult {
570    /// Create a successful result.
571    #[must_use]
572    pub fn success(output: impl Into<String>) -> Self {
573        Self {
574            output: output.into(),
575            media: Vec::new(),
576            is_error: false,
577        }
578    }
579
580    /// Create an error result (tool ran but failed).
581    ///
582    /// Use this for recoverable errors — the LLM sees the output and can
583    /// decide how to proceed. For example: "file not found", "permission
584    /// denied", "rate limit exceeded".
585    #[must_use]
586    pub fn error(output: impl Into<String>) -> Self {
587        Self {
588            output: output.into(),
589            media: Vec::new(),
590            is_error: true,
591        }
592    }
593
594    /// Attach one media item to the result.
595    #[must_use]
596    pub fn with_media(mut self, attachment: Attachment) -> Self {
597        self.media.push(attachment);
598        self
599    }
600
601    /// Attach multiple media items to the result.
602    #[must_use]
603    pub fn with_media_many(mut self, media: impl IntoIterator<Item = Attachment>) -> Self {
604        self.media.extend(media);
605        self
606    }
607}
608
609/// Error from tool execution.
610///
611/// Unlike [`ToolResult::error`] (which is a "soft" error the LLM sees),
612/// `ToolError` represents a hard failure that is logged in the turn
613/// journal as a tool invocation error.
614#[derive(Debug)]
615pub enum ToolError {
616    /// Input parameters are invalid or missing required fields.
617    InvalidInput(String),
618    /// Execution failed due to an external or internal error.
619    ExecutionFailed(String),
620}
621
622impl std::fmt::Display for ToolError {
623    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
624        match self {
625            Self::InvalidInput(e) => write!(f, "invalid input: {e}"),
626            Self::ExecutionFailed(e) => write!(f, "execution failed: {e}"),
627        }
628    }
629}
630
631impl std::error::Error for ToolError {}
632
633// ── Plugin Interface ────────────────────────────────────────
634
635/// Plugin metadata returned to the runtime at load time.
636///
637/// The `name` field must match the plugin's directory name and the
638/// `name` field in `manifest.toml`.
639#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct PluginInfo {
641    /// Unique plugin identifier (e.g. `"my-plugin"`).
642    pub name: String,
643    /// Semantic version string (e.g. `"1.4.0"`).
644    pub version: String,
645    /// Human-readable one-line description.
646    pub description: String,
647}
648
649/// A plugin that provides multiple tools from a single shared library.
650///
651/// This is the primary interface between a plugin and the Cortex runtime.
652/// Implement this trait and use [`export_plugin!`] to generate the FFI
653/// entry point.
654///
655/// # Requirements
656///
657/// - The implementing type must also implement `Default` (required by
658///   [`export_plugin!`] for construction via FFI).
659/// - The type must be `Send + Sync` because the runtime may access it
660///   from multiple threads.
661///
662/// # Example
663///
664/// ```rust,no_run
665/// use cortex_sdk::prelude::*;
666///
667/// #[derive(Default)]
668/// struct MyPlugin;
669///
670/// impl MultiToolPlugin for MyPlugin {
671///     fn plugin_info(&self) -> PluginInfo {
672///         PluginInfo {
673///             name: "my-plugin".into(),
674///             version: "0.1.0".into(),
675///             description: "Example plugin".into(),
676///         }
677///     }
678///
679///     fn create_tools(&self) -> Vec<Box<dyn Tool>> {
680///         vec![]
681///     }
682/// }
683///
684/// cortex_sdk::export_plugin!(MyPlugin);
685/// ```
686pub trait MultiToolPlugin: Send + Sync {
687    /// Return plugin metadata.
688    fn plugin_info(&self) -> PluginInfo;
689
690    /// Create all tools this plugin provides.
691    ///
692    /// Called once at daemon startup.  Returned tools live for the
693    /// daemon's lifetime.  Each tool is registered by name into the
694    /// global tool registry.
695    fn create_tools(&self) -> Vec<Box<dyn Tool>>;
696}
697
698/// Native ABI-owned byte buffer.
699///
700/// All strings and JSON values that cross the stable native ABI boundary use
701/// this representation. Buffers returned by the plugin must be released by
702/// calling the table's `buffer_free` function.
703#[repr(C)]
704pub struct CortexBuffer {
705    /// Pointer to UTF-8 bytes.
706    pub ptr: *mut u8,
707    /// Number of initialized bytes at `ptr`.
708    pub len: usize,
709    /// Allocation capacity needed to reconstruct and free the buffer.
710    pub cap: usize,
711}
712
713impl CortexBuffer {
714    #[must_use]
715    pub const fn empty() -> Self {
716        Self {
717            ptr: std::ptr::null_mut(),
718            len: 0,
719            cap: 0,
720        }
721    }
722}
723
724impl From<String> for CortexBuffer {
725    fn from(value: String) -> Self {
726        let mut bytes = value.into_bytes();
727        let buffer = Self {
728            ptr: bytes.as_mut_ptr(),
729            len: bytes.len(),
730            cap: bytes.capacity(),
731        };
732        std::mem::forget(bytes);
733        buffer
734    }
735}
736
737impl CortexBuffer {
738    /// Read this buffer as UTF-8.
739    ///
740    /// # Errors
741    /// Returns a UTF-8 error when the buffer contains invalid UTF-8 bytes.
742    ///
743    /// # Safety
744    /// The caller must ensure `ptr` is valid for `len` bytes and remains alive
745    /// for the duration of this call.
746    pub const unsafe fn as_str(&self) -> Result<&str, std::str::Utf8Error> {
747        if self.ptr.is_null() || self.len == 0 {
748            return Ok("");
749        }
750        // SAFETY: upheld by the caller.
751        let bytes = unsafe { std::slice::from_raw_parts(self.ptr.cast_const(), self.len) };
752        std::str::from_utf8(bytes)
753    }
754}
755
756/// Free a buffer allocated by this SDK.
757///
758/// # Safety
759/// The buffer must have been returned by this SDK's ABI helpers and must not be
760/// freed more than once.
761pub unsafe extern "C" fn cortex_buffer_free(buffer: CortexBuffer) {
762    if buffer.ptr.is_null() {
763        return;
764    }
765    // SAFETY: the caller guarantees this buffer came from `CortexBuffer::from_string`.
766    unsafe {
767        drop(Vec::from_raw_parts(buffer.ptr, buffer.len, buffer.cap));
768    }
769}
770
771/// Host table supplied to a native plugin during initialization.
772#[repr(C)]
773pub struct CortexHostApi {
774    /// Runtime-supported native ABI version.
775    pub abi_version: u32,
776}
777
778/// Function table exported by a native plugin.
779#[repr(C)]
780pub struct CortexPluginApi {
781    /// Plugin-supported native ABI version.
782    pub abi_version: u32,
783    /// Opaque plugin state owned by the plugin.
784    pub plugin: *mut c_void,
785    /// Return [`PluginInfo`] encoded as JSON.
786    pub plugin_info: Option<unsafe extern "C" fn(*mut c_void) -> CortexBuffer>,
787    /// Return the number of tools exposed by the plugin.
788    pub tool_count: Option<unsafe extern "C" fn(*mut c_void) -> usize>,
789    /// Return one tool descriptor encoded as JSON.
790    pub tool_descriptor: Option<unsafe extern "C" fn(*mut c_void, usize) -> CortexBuffer>,
791    /// Execute a tool. The name, input, and invocation context are UTF-8 JSON
792    /// buffers except `tool_name`, which is a UTF-8 string.
793    pub tool_execute: Option<
794        unsafe extern "C" fn(*mut c_void, CortexBuffer, CortexBuffer, CortexBuffer) -> CortexBuffer,
795    >,
796    /// Drop plugin-owned state.
797    pub plugin_drop: Option<unsafe extern "C" fn(*mut c_void)>,
798    /// Free buffers returned by plugin functions.
799    pub buffer_free: Option<unsafe extern "C" fn(CortexBuffer)>,
800}
801
802impl CortexPluginApi {
803    #[must_use]
804    pub const fn empty() -> Self {
805        Self {
806            abi_version: 0,
807            plugin: std::ptr::null_mut(),
808            plugin_info: None,
809            tool_count: None,
810            tool_descriptor: None,
811            tool_execute: None,
812            plugin_drop: None,
813            buffer_free: None,
814        }
815    }
816}
817
818#[derive(Serialize)]
819struct ToolDescriptor<'a> {
820    name: &'a str,
821    description: &'a str,
822    input_schema: serde_json::Value,
823    timeout_secs: Option<u64>,
824    capabilities: ToolCapabilities,
825}
826
827struct NoopToolRuntime {
828    invocation: InvocationContext,
829}
830
831impl ToolRuntime for NoopToolRuntime {
832    fn invocation(&self) -> &InvocationContext {
833        &self.invocation
834    }
835
836    fn emit_progress(&self, _message: &str) {}
837
838    fn emit_observer(&self, _source: Option<&str>, _content: &str) {}
839}
840
841#[doc(hidden)]
842pub struct NativePluginState {
843    plugin: Box<dyn MultiToolPlugin>,
844    tools: Vec<Box<dyn Tool>>,
845}
846
847impl NativePluginState {
848    #[must_use]
849    pub fn new(plugin: Box<dyn MultiToolPlugin>) -> Self {
850        let tools = plugin.create_tools();
851        Self { plugin, tools }
852    }
853}
854
855fn json_buffer<T: Serialize>(value: &T) -> CortexBuffer {
856    match serde_json::to_string(value) {
857        Ok(json) => CortexBuffer::from(json),
858        Err(err) => CortexBuffer::from(
859            serde_json::json!({
860                "output": format!("native ABI serialization error: {err}"),
861                "media": [],
862                "is_error": true
863            })
864            .to_string(),
865        ),
866    }
867}
868
869#[doc(hidden)]
870pub unsafe extern "C" fn native_plugin_info(state: *mut c_void) -> CortexBuffer {
871    if state.is_null() {
872        return CortexBuffer::empty();
873    }
874    // SAFETY: the pointer is created by `export_plugin!` and remains owned by
875    // the plugin until `native_plugin_drop`.
876    let state = unsafe { &*state.cast::<NativePluginState>() };
877    json_buffer(&state.plugin.plugin_info())
878}
879
880#[doc(hidden)]
881pub unsafe extern "C" fn native_tool_count(state: *mut c_void) -> usize {
882    if state.is_null() {
883        return 0;
884    }
885    // SAFETY: see `native_plugin_info`.
886    let state = unsafe { &*state.cast::<NativePluginState>() };
887    state.tools.len()
888}
889
890#[doc(hidden)]
891pub unsafe extern "C" fn native_tool_descriptor(state: *mut c_void, index: usize) -> CortexBuffer {
892    if state.is_null() {
893        return CortexBuffer::empty();
894    }
895    // SAFETY: see `native_plugin_info`.
896    let state = unsafe { &*state.cast::<NativePluginState>() };
897    let Some(tool) = state.tools.get(index) else {
898        return CortexBuffer::empty();
899    };
900    let descriptor = ToolDescriptor {
901        name: tool.name(),
902        description: tool.description(),
903        input_schema: tool.input_schema(),
904        timeout_secs: tool.timeout_secs(),
905        capabilities: tool.capabilities(),
906    };
907    json_buffer(&descriptor)
908}
909
910#[doc(hidden)]
911pub unsafe extern "C" fn native_tool_execute(
912    state: *mut c_void,
913    tool_name: CortexBuffer,
914    input_json: CortexBuffer,
915    invocation_json: CortexBuffer,
916) -> CortexBuffer {
917    if state.is_null() {
918        return json_buffer(&ToolResult::error("native plugin state is null"));
919    }
920    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
921    let tool_name = match unsafe { tool_name.as_str() } {
922        Ok(value) => value,
923        Err(err) => return json_buffer(&ToolResult::error(format!("invalid tool name: {err}"))),
924    };
925    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
926    let input_json = match unsafe { input_json.as_str() } {
927        Ok(value) => value,
928        Err(err) => return json_buffer(&ToolResult::error(format!("invalid input JSON: {err}"))),
929    };
930    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
931    let invocation_json = match unsafe { invocation_json.as_str() } {
932        Ok(value) => value,
933        Err(err) => {
934            return json_buffer(&ToolResult::error(format!(
935                "invalid invocation JSON: {err}"
936            )));
937        }
938    };
939    let input = match serde_json::from_str(input_json) {
940        Ok(value) => value,
941        Err(err) => return json_buffer(&ToolResult::error(format!("invalid input JSON: {err}"))),
942    };
943    let invocation = match serde_json::from_str(invocation_json) {
944        Ok(value) => value,
945        Err(err) => {
946            return json_buffer(&ToolResult::error(format!(
947                "invalid invocation JSON: {err}"
948            )));
949        }
950    };
951    // SAFETY: see `native_plugin_info`.
952    let state = unsafe { &*state.cast::<NativePluginState>() };
953    let Some(tool) = state.tools.iter().find(|tool| tool.name() == tool_name) else {
954        return json_buffer(&ToolResult::error(format!(
955            "native plugin does not expose tool '{tool_name}'"
956        )));
957    };
958    let runtime = NoopToolRuntime { invocation };
959    match tool.execute_with_runtime(input, &runtime) {
960        Ok(result) => json_buffer(&result),
961        Err(err) => json_buffer(&ToolResult::error(format!("tool error: {err}"))),
962    }
963}
964
965#[doc(hidden)]
966pub unsafe extern "C" fn native_plugin_drop(state: *mut c_void) {
967    if state.is_null() {
968        return;
969    }
970    // SAFETY: pointer ownership is transferred from `export_plugin!` to this
971    // function exactly once by the runtime.
972    unsafe {
973        drop(Box::from_raw(state.cast::<NativePluginState>()));
974    }
975}
976
977// ── Export Macro ────────────────────────────────────────────
978
979/// Generate the stable native ABI entry point for a [`MultiToolPlugin`].
980///
981/// This macro expands to an `extern "C"` function named `cortex_plugin_init`
982/// that fills a C-compatible function table. The plugin type must implement
983/// [`Default`].
984///
985/// # Usage
986///
987/// `cortex_sdk::export_plugin!(MyPlugin);`
988///
989/// # Expansion
990///
991/// The macro constructs the Rust plugin internally and exposes it through the
992/// stable native ABI table. Rust trait objects never cross the dynamic-library
993/// boundary.
994#[macro_export]
995macro_rules! export_plugin {
996    ($plugin_type:ty) => {
997        #[unsafe(no_mangle)]
998        pub unsafe extern "C" fn cortex_plugin_init(
999            host: *const $crate::CortexHostApi,
1000            out_plugin: *mut $crate::CortexPluginApi,
1001        ) -> i32 {
1002            if host.is_null() || out_plugin.is_null() {
1003                return -1;
1004            }
1005            let host = unsafe { &*host };
1006            if host.abi_version != $crate::NATIVE_ABI_VERSION {
1007                return -2;
1008            }
1009            let plugin: Box<dyn $crate::MultiToolPlugin> = Box::new(<$plugin_type>::default());
1010            let state = Box::new($crate::NativePluginState::new(plugin));
1011            unsafe {
1012                *out_plugin = $crate::CortexPluginApi {
1013                    abi_version: $crate::NATIVE_ABI_VERSION,
1014                    plugin: Box::into_raw(state).cast(),
1015                    plugin_info: Some($crate::native_plugin_info),
1016                    tool_count: Some($crate::native_tool_count),
1017                    tool_descriptor: Some($crate::native_tool_descriptor),
1018                    tool_execute: Some($crate::native_tool_execute),
1019                    plugin_drop: Some($crate::native_plugin_drop),
1020                    buffer_free: Some($crate::cortex_buffer_free),
1021                };
1022            }
1023            0
1024        }
1025    };
1026}
1027
1028// ── Prelude ─────────────────────────────────────────────────
1029
1030/// Convenience re-exports for plugin development.
1031///
1032/// ```rust,no_run
1033/// use cortex_sdk::prelude::*;
1034/// ```
1035///
1036/// This imports [`MultiToolPlugin`], [`PluginInfo`], [`Tool`],
1037/// [`ToolError`], [`ToolResult`], and [`serde_json`].
1038pub mod prelude {
1039    pub use super::{
1040        Attachment, CortexBuffer, CortexHostApi, CortexPluginApi, DryRunSupport,
1041        EffectConfirmation, EffectReversibility, ExecutionScope, InvocationContext,
1042        MultiToolPlugin, NATIVE_ABI_VERSION, PluginInfo, SDK_VERSION, Tool, ToolCapabilities,
1043        ToolEffect, ToolEffectKind, ToolError, ToolResult, ToolRuntime,
1044    };
1045    pub use serde_json;
1046}