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 exposes a runtime-aware execution surface:
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//! ## Native Plugin Quick Start
48//!
49//! **Cargo.toml:**
50//!
51//! ```toml
52//! [package]
53//! name = "cortex-plugin-native-hello"
54//! version = "0.1.0"
55//! edition = "2024"
56//! publish = false
57//!
58//! [lib]
59//! crate-type = ["cdylib", "rlib"]
60//!
61//! [dependencies]
62//! cortex-sdk = "1.6.4"
63//! serde_json = "1"
64//! ```
65//!
66//! **src/lib.rs:**
67//!
68//! ```rust,no_run
69//! use cortex_sdk::prelude::*;
70//!
71//! #[derive(Default)]
72//! struct MyPlugin;
73//!
74//! impl MultiToolPlugin for MyPlugin {
75//!     fn plugin_info(&self) -> PluginInfo {
76//!         PluginInfo {
77//!             name: "my-plugin".into(),
78//!             version: env!("CARGO_PKG_VERSION").into(),
79//!             description: "My custom tools for Cortex".into(),
80//!         }
81//!     }
82//!
83//!     fn create_tools(&self) -> Vec<Box<dyn Tool>> {
84//!         vec![Box::new(WordCountTool)]
85//!     }
86//! }
87//!
88//! struct WordCountTool;
89//!
90//! impl Tool for WordCountTool {
91//!     fn name(&self) -> &'static str { "word_count" }
92//!
93//!     fn description(&self) -> &'static str {
94//!         "Count words in a text string. Use when the user asks for word \
95//!          counts, statistics, or text length metrics."
96//!     }
97//!
98//!     fn input_schema(&self) -> serde_json::Value {
99//!         serde_json::json!({
100//!             "type": "object",
101//!             "properties": {
102//!                 "text": {
103//!                     "type": "string",
104//!                     "description": "The text to count words in"
105//!                 }
106//!             },
107//!             "required": ["text"]
108//!         })
109//!     }
110//!
111//!     fn execute(&self, input: serde_json::Value) -> Result<ToolResult, ToolError> {
112//!         let text = input["text"]
113//!             .as_str()
114//!             .ok_or_else(|| ToolError::InvalidInput("missing 'text' field".into()))?;
115//!         let count = text.split_whitespace().count();
116//!         Ok(ToolResult::success(format!("{count} words")))
117//!     }
118//! }
119//!
120//! cortex_sdk::export_plugin!(MyPlugin);
121//! ```
122//!
123//! Tools that need runtime context can override
124//! [`Tool::execute_with_runtime`] instead of only [`Tool::execute`].
125//!
126//! **manifest.toml:**
127//!
128//! ```toml
129//! name = "native-hello"
130//! version = "0.1.0"
131//! description = "Example trusted native Cortex plugin"
132//! cortex_version = "1.6.4"
133//! trust = "trusted_native"
134//!
135//! [capabilities]
136//! provides = ["tools"]
137//! secrets = false
138//!
139//! [sandbox]
140//! level = "trusted_in_process"
141//!
142//! [native]
143//! library = "lib/libcortex_plugin_native_hello.so"
144//! isolation = "trusted_in_process"
145//! abi_version = 1
146//! ```
147//!
148//! ## Build, Sign, Pack, Publish
149//!
150//! ```bash
151//! cargo build --release
152//! cortex plugin review .
153//! cortex plugin test .
154//! cortex plugin keygen ~/.config/cortex/plugin-signing/example-dev.ed25519
155//! cortex plugin sign . --key ~/.config/cortex/plugin-signing/example-dev.ed25519 --publisher example.dev
156//! cortex plugin pack .
157//! sha256sum cortex-plugin-native-hello-v0.1.0-linux-amd64.cpx > cortex-plugin-native-hello-v0.1.0-linux-amd64.cpx.sha256
158//! ```
159//!
160//! Upload the `.cpx` and `.sha256` files to a GitHub Release. Users install the
161//! package by repository name and restart the daemon so the native library is
162//! loaded:
163//!
164//! ```bash
165//! cortex plugin install owner/cortex-plugin-native-hello@0.1.0
166//! cortex restart
167//! ```
168//!
169//! Installing or replacing a trusted native shared library still requires a
170//! daemon restart so the new code is loaded. Process-isolated plugin manifest
171//! changes hot-apply without that restart.
172//!
173//! ## Plugin Lifecycle
174//!
175//! 1. **Load** — `dlopen` at daemon startup
176//! 2. **Create** — runtime calls [`export_plugin!`]-generated stable ABI init
177//! 3. **Register** — [`MultiToolPlugin::create_tools`] is called once; each
178//!    [`Tool`] is registered in the global tool registry
179//! 4. **Execute** — the LLM invokes tools by name during turns; the runtime
180//!    calls [`Tool::execute`] with JSON parameters
181//! 5. **Retain** — the library handle is held for the daemon's lifetime;
182//!    `Drop` runs only at shutdown
183//!
184//! ## Tool Design Guidelines
185//!
186//! - **`name`**: lowercase with underscores (`word_count`, not `WordCount`).
187//!   Must be unique across all tools in the registry.
188//! - **`description`**: written for the LLM — explain what the tool does,
189//!   when to use it, and when *not* to use it.  The LLM reads this to decide
190//!   whether to call the tool.
191//! - **`input_schema`**: a [JSON Schema](https://json-schema.org/) object
192//!   describing the parameters.  The LLM generates JSON matching this schema.
193//! - **`execute`**: receives the LLM-generated JSON.  Return
194//!   [`ToolResult::success`] for normal output or [`ToolResult::error`] for
195//!   recoverable errors the LLM should see.  Return [`ToolError`] only for
196//!   unrecoverable failures (invalid input, missing deps).
197//! - **Media output**: attach files with [`ToolResult::with_media`].  Cortex
198//!   delivers attachments through the active transport; plugins should not call
199//!   channel-specific APIs directly.
200//! - **`execute_with_runtime`**: use this when the tool needs invocation
201//!   metadata or wants to emit progress / observer updates during execution.
202//! - **`timeout_secs`**: optional per-tool timeout override.  If `None`, the
203//!   global `[turn].tool_timeout_secs` applies.
204
205use serde::{Deserialize, Serialize};
206pub use serde_json;
207use std::ffi::c_void;
208
209/// Version of the SDK crate used by native plugin builds.
210pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
211
212/// Stable native ABI version for trusted in-process plugins.
213///
214/// The runtime never exchanges Rust trait objects across the dynamic-library
215/// boundary. It loads a C-compatible function table through `cortex_plugin_init`
216/// and moves structured values as UTF-8 JSON buffers.
217pub const NATIVE_ABI_VERSION: u32 = 1;
218
219/// Stable multimedia attachment DTO exposed to plugins.
220///
221/// This type intentionally lives in `cortex-sdk` instead of depending on
222/// Cortex internal crates, so plugin authors only need the SDK.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct Attachment {
225    /// High-level type: `"image"`, `"audio"`, `"video"`, `"file"`.
226    pub media_type: String,
227    /// MIME type, for example `"image/png"` or `"audio/mpeg"`.
228    pub mime_type: String,
229    /// Local file path or remote URL readable by the runtime transport.
230    pub url: String,
231    /// Optional caption or description.
232    pub caption: Option<String>,
233    /// File size in bytes, if known.
234    pub size: Option<u64>,
235}
236
237/// Whether a tool invocation belongs to a user-visible foreground turn or a
238/// background maintenance execution.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum ExecutionScope {
242    #[default]
243    Foreground,
244    Background,
245}
246
247/// Stable runtime metadata exposed to plugin tools during execution.
248///
249/// This intentionally exposes the execution surface, not Cortex internals.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct InvocationContext {
252    /// Tool name being invoked.
253    pub tool_name: String,
254    /// Active session id when available.
255    pub session_id: Option<String>,
256    /// Canonical actor identity when available.
257    pub actor: Option<String>,
258    /// Transport or invocation source (`http`, `rpc`, `telegram`, `heartbeat`, ...).
259    pub source: Option<String>,
260    /// Whether this invocation belongs to a foreground or background execution.
261    pub execution_scope: ExecutionScope,
262}
263
264impl InvocationContext {
265    #[must_use]
266    pub fn is_background(&self) -> bool {
267        self.execution_scope == ExecutionScope::Background
268    }
269
270    #[must_use]
271    pub fn is_foreground(&self) -> bool {
272        self.execution_scope == ExecutionScope::Foreground
273    }
274}
275
276/// Stable categories for side effects a tool may perform.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum ToolEffectKind {
280    ReadFile,
281    ReadSecret,
282    WriteFile,
283    DeleteFile,
284    RunProcess,
285    NetworkRequest,
286    SendMessage,
287    SpendMoney,
288    Deploy,
289    ModifyCredential,
290    PersistMemory,
291    PublishContent,
292    ScheduleTask,
293    GenerateMedia,
294    IntrospectRuntime,
295    DelegateWork,
296}
297
298/// Whether a declared effect can be undone after execution.
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
300#[serde(rename_all = "snake_case")]
301pub enum EffectReversibility {
302    Reversible,
303    PartiallyReversible,
304    Irreversible,
305}
306
307/// When the runtime should ask for confirmation before executing an effect.
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
309#[serde(rename_all = "snake_case")]
310pub enum EffectConfirmation {
311    Never,
312    OnRisk,
313    Always,
314}
315
316/// Whether a tool can preview its effect before committing it.
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
318#[serde(rename_all = "snake_case")]
319pub enum DryRunSupport {
320    NotSupported,
321    Supported,
322    RequiredBeforeExecute,
323}
324
325/// Declarative hints about how a tool participates in the runtime.
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327pub struct ToolEffect {
328    /// The stable effect category.
329    pub kind: ToolEffectKind,
330    /// Optional target, such as a path, host, channel, or resource name.
331    #[serde(default, skip_serializing_if = "String::is_empty")]
332    pub target: String,
333    /// Whether this effect can be undone after execution.
334    pub reversibility: EffectReversibility,
335    /// Confirmation preference declared by the tool author.
336    pub confirmation: EffectConfirmation,
337    /// Dry-run support declared by the tool author.
338    pub dry_run: DryRunSupport,
339}
340
341impl ToolEffect {
342    #[must_use]
343    pub const fn new(kind: ToolEffectKind) -> Self {
344        Self {
345            kind,
346            target: String::new(),
347            reversibility: kind.default_reversibility(),
348            confirmation: kind.default_confirmation(),
349            dry_run: DryRunSupport::NotSupported,
350        }
351    }
352
353    #[must_use]
354    pub fn with_target(mut self, target: impl Into<String>) -> Self {
355        self.target = target.into();
356        self
357    }
358
359    #[must_use]
360    pub const fn with_reversibility(mut self, reversibility: EffectReversibility) -> Self {
361        self.reversibility = reversibility;
362        self
363    }
364
365    #[must_use]
366    pub const fn with_confirmation(mut self, confirmation: EffectConfirmation) -> Self {
367        self.confirmation = confirmation;
368        self
369    }
370
371    #[must_use]
372    pub const fn with_dry_run(mut self, dry_run: DryRunSupport) -> Self {
373        self.dry_run = dry_run;
374        self
375    }
376
377    #[must_use]
378    pub const fn is_mutating(&self) -> bool {
379        self.kind.is_mutating()
380    }
381
382    #[must_use]
383    pub fn label(&self) -> String {
384        if self.target.is_empty() {
385            format!("{:?}", self.kind)
386        } else {
387            format!("{:?}:{}", self.kind, self.target)
388        }
389    }
390}
391
392impl ToolEffectKind {
393    #[must_use]
394    pub const fn is_mutating(self) -> bool {
395        !matches!(
396            self,
397            Self::ReadFile | Self::ReadSecret | Self::NetworkRequest | Self::IntrospectRuntime
398        )
399    }
400
401    const fn default_reversibility(self) -> EffectReversibility {
402        match self {
403            Self::ReadFile | Self::NetworkRequest | Self::IntrospectRuntime => {
404                EffectReversibility::Reversible
405            }
406            Self::WriteFile
407            | Self::RunProcess
408            | Self::PersistMemory
409            | Self::ScheduleTask
410            | Self::GenerateMedia
411            | Self::DelegateWork => EffectReversibility::PartiallyReversible,
412            Self::ReadSecret
413            | Self::DeleteFile
414            | Self::SendMessage
415            | Self::SpendMoney
416            | Self::Deploy
417            | Self::ModifyCredential
418            | Self::PublishContent => EffectReversibility::Irreversible,
419        }
420    }
421
422    const fn default_confirmation(self) -> EffectConfirmation {
423        match self {
424            Self::ReadFile | Self::NetworkRequest | Self::IntrospectRuntime => {
425                EffectConfirmation::OnRisk
426            }
427            _ => EffectConfirmation::Always,
428        }
429    }
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
433pub struct ToolCapabilities {
434    /// Tool emits intermediate progress updates.
435    pub emits_progress: bool,
436    /// Tool emits observer-lane notes for the parent turn.
437    pub emits_observer_text: bool,
438    /// Tool is safe to run in background maintenance contexts.
439    pub background_safe: bool,
440    /// Declarative effect surface used by risk policy and transaction tracing.
441    #[serde(default, skip_serializing_if = "Vec::is_empty")]
442    pub effects: Vec<ToolEffect>,
443}
444
445impl ToolCapabilities {
446    #[must_use]
447    pub fn with_effect(mut self, effect: ToolEffect) -> Self {
448        self.effects.push(effect);
449        self
450    }
451
452    #[must_use]
453    pub fn with_effects(mut self, effects: impl IntoIterator<Item = ToolEffect>) -> Self {
454        self.effects.extend(effects);
455        self
456    }
457}
458
459/// Runtime bridge presented to tools during execution.
460///
461/// This allows plugins to consume stable runtime context and emit bounded
462/// execution signals without depending on Cortex internals.
463pub trait ToolRuntime: Send + Sync {
464    /// Stable invocation metadata.
465    fn invocation(&self) -> &InvocationContext;
466
467    /// Emit an intermediate progress update for the current tool.
468    fn emit_progress(&self, message: &str);
469
470    /// Emit observer text for the parent turn. This never speaks directly to
471    /// the user-facing channel.
472    fn emit_observer(&self, source: Option<&str>, content: &str);
473}
474
475// ── Tool Interface ──────────────────────────────────────────
476
477/// A tool that the LLM can invoke during conversation.
478///
479/// Tools are the primary extension point for Cortex plugins.  Each tool
480/// has a name, description, JSON Schema for input parameters, and an
481/// execute function.  The runtime presents the tool definition to the LLM
482/// and routes invocations to [`Tool::execute`].
483///
484/// # Thread Safety
485///
486/// Tools must be `Send + Sync` because a single tool instance is shared
487/// across all turns in the daemon process.  Use interior mutability
488/// (`Mutex`, `RwLock`, `AtomicXxx`) if you need mutable state.
489pub trait Tool: Send + Sync {
490    /// Unique tool name (lowercase, underscores, e.g. `"web_search"`).
491    ///
492    /// Must be unique across all registered tools.  If two tools share a
493    /// name, the later registration wins.
494    fn name(&self) -> &'static str;
495
496    /// Human-readable description shown to the LLM.
497    ///
498    /// Write this for the LLM, not for humans.  Include:
499    /// - What the tool does
500    /// - When to use it
501    /// - When *not* to use it
502    /// - Any constraints or limitations
503    fn description(&self) -> &'static str;
504
505    /// JSON Schema describing the tool's input parameters.
506    ///
507    /// The LLM generates a JSON object matching this schema.  Example:
508    ///
509    /// ```json
510    /// {
511    ///   "type": "object",
512    ///   "properties": {
513    ///     "query": { "type": "string", "description": "Search query" }
514    ///   },
515    ///   "required": ["query"]
516    /// }
517    /// ```
518    fn input_schema(&self) -> serde_json::Value;
519
520    /// Execute the tool with the given input.
521    ///
522    /// `input` is a JSON object matching [`Self::input_schema`].  The
523    /// runtime validates the schema before calling this method, but
524    /// individual field types should still be checked defensively.
525    ///
526    /// # Return Values
527    ///
528    /// - [`ToolResult::success`] — normal output returned to the LLM
529    /// - [`ToolResult::error`] — the tool ran but produced an error the
530    ///   LLM should see and potentially recover from
531    ///
532    /// # Errors
533    ///
534    /// Return [`ToolError::InvalidInput`] for malformed parameters or
535    /// [`ToolError::ExecutionFailed`] for unrecoverable failures.  These
536    /// are surfaced as error events in the turn journal.
537    fn execute(&self, input: serde_json::Value) -> Result<ToolResult, ToolError>;
538
539    /// Execute the tool with runtime context and host callbacks.
540    ///
541    /// Plugins can override this to read session/actor/source metadata and
542    /// emit progress or observer updates through the provided runtime bridge.
543    ///
544    /// The default implementation preserves the classic SDK contract and calls
545    /// [`Self::execute`].
546    ///
547    /// # Errors
548    ///
549    /// Returns the same `ToolError` variants that [`Self::execute`] would
550    /// return for invalid input or unrecoverable execution failure.
551    fn execute_with_runtime(
552        &self,
553        input: serde_json::Value,
554        runtime: &dyn ToolRuntime,
555    ) -> Result<ToolResult, ToolError> {
556        let _ = runtime;
557        self.execute(input)
558    }
559
560    /// Optional per-tool execution timeout in seconds.
561    ///
562    /// If `None` (the default), the global `[turn].tool_timeout_secs`
563    /// from the instance configuration applies.
564    fn timeout_secs(&self) -> Option<u64> {
565        None
566    }
567
568    /// Stable capability hints consumed by the runtime and observability
569    /// layers.
570    fn capabilities(&self) -> ToolCapabilities {
571        ToolCapabilities::default()
572    }
573}
574
575/// Result of a tool execution returned to the LLM.
576///
577/// Use [`ToolResult::success`] for normal output and [`ToolResult::error`]
578/// for recoverable errors the LLM should see.
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct ToolResult {
581    /// Output text returned to the LLM.
582    pub output: String,
583    /// Structured media attachments produced by this tool.
584    ///
585    /// Attachments are delivered by Cortex transports independently from the
586    /// text the model sees, so tools do not need transport-specific protocols.
587    pub media: Vec<Attachment>,
588    /// Whether this result represents an error condition.
589    ///
590    /// When `true`, the LLM sees this as a failed tool call and may retry
591    /// with different parameters or switch strategy.
592    pub is_error: bool,
593}
594
595impl ToolResult {
596    /// Create a successful result.
597    #[must_use]
598    pub fn success(output: impl Into<String>) -> Self {
599        Self {
600            output: output.into(),
601            media: Vec::new(),
602            is_error: false,
603        }
604    }
605
606    /// Create an error result (tool ran but failed).
607    ///
608    /// Use this for recoverable errors — the LLM sees the output and can
609    /// decide how to proceed. For example: "file not found", "permission
610    /// denied", "rate limit exceeded".
611    #[must_use]
612    pub fn error(output: impl Into<String>) -> Self {
613        Self {
614            output: output.into(),
615            media: Vec::new(),
616            is_error: true,
617        }
618    }
619
620    /// Attach one media item to the result.
621    #[must_use]
622    pub fn with_media(mut self, attachment: Attachment) -> Self {
623        self.media.push(attachment);
624        self
625    }
626
627    /// Attach multiple media items to the result.
628    #[must_use]
629    pub fn with_media_many(mut self, media: impl IntoIterator<Item = Attachment>) -> Self {
630        self.media.extend(media);
631        self
632    }
633}
634
635/// Error from tool execution.
636///
637/// Unlike [`ToolResult::error`] (which is a "soft" error the LLM sees),
638/// `ToolError` represents a hard failure that is logged in the turn
639/// journal as a tool invocation error.
640#[derive(Debug)]
641pub enum ToolError {
642    /// Input parameters are invalid or missing required fields.
643    InvalidInput(String),
644    /// Execution failed due to an external or internal error.
645    ExecutionFailed(String),
646}
647
648impl std::fmt::Display for ToolError {
649    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650        match self {
651            Self::InvalidInput(e) => write!(f, "invalid input: {e}"),
652            Self::ExecutionFailed(e) => write!(f, "execution failed: {e}"),
653        }
654    }
655}
656
657impl std::error::Error for ToolError {}
658
659// ── Plugin Interface ────────────────────────────────────────
660
661/// Plugin metadata returned to the runtime at load time.
662///
663/// The `name` field must match the plugin's directory name and the
664/// `name` field in `manifest.toml`.
665#[derive(Debug, Clone, Serialize, Deserialize)]
666pub struct PluginInfo {
667    /// Unique plugin identifier (e.g. `"my-plugin"`).
668    pub name: String,
669    /// Semantic version string (e.g. `"1.6.4"`).
670    pub version: String,
671    /// Human-readable one-line description.
672    pub description: String,
673}
674
675/// A plugin that provides multiple tools from a single shared library.
676///
677/// This is the primary interface between a plugin and the Cortex runtime.
678/// Implement this trait and use [`export_plugin!`] to generate the FFI
679/// entry point.
680///
681/// # Requirements
682///
683/// - The implementing type must also implement `Default` (required by
684///   [`export_plugin!`] for construction via FFI).
685/// - The type must be `Send + Sync` because the runtime may access it
686///   from multiple threads.
687///
688/// # Example
689///
690/// ```rust,no_run
691/// use cortex_sdk::prelude::*;
692///
693/// #[derive(Default)]
694/// struct MyPlugin;
695///
696/// impl MultiToolPlugin for MyPlugin {
697///     fn plugin_info(&self) -> PluginInfo {
698///         PluginInfo {
699///             name: "my-plugin".into(),
700///             version: "0.1.0".into(),
701///             description: "Example plugin".into(),
702///         }
703///     }
704///
705///     fn create_tools(&self) -> Vec<Box<dyn Tool>> {
706///         vec![]
707///     }
708/// }
709///
710/// cortex_sdk::export_plugin!(MyPlugin);
711/// ```
712pub trait MultiToolPlugin: Send + Sync {
713    /// Return plugin metadata.
714    fn plugin_info(&self) -> PluginInfo;
715
716    /// Create all tools this plugin provides.
717    ///
718    /// Called once at daemon startup.  Returned tools live for the
719    /// daemon's lifetime.  Each tool is registered by name into the
720    /// global tool registry.
721    fn create_tools(&self) -> Vec<Box<dyn Tool>>;
722}
723
724/// Native ABI-owned byte buffer.
725///
726/// All strings and JSON values that cross the stable native ABI boundary use
727/// this representation. Buffers returned by the plugin must be released by
728/// calling the table's `buffer_free` function.
729#[repr(C)]
730pub struct CortexBuffer {
731    /// Pointer to UTF-8 bytes.
732    pub ptr: *mut u8,
733    /// Number of initialized bytes at `ptr`.
734    pub len: usize,
735    /// Allocation capacity needed to reconstruct and free the buffer.
736    pub cap: usize,
737}
738
739impl CortexBuffer {
740    #[must_use]
741    pub const fn empty() -> Self {
742        Self {
743            ptr: std::ptr::null_mut(),
744            len: 0,
745            cap: 0,
746        }
747    }
748}
749
750impl From<String> for CortexBuffer {
751    fn from(value: String) -> Self {
752        let mut bytes = value.into_bytes();
753        let buffer = Self {
754            ptr: bytes.as_mut_ptr(),
755            len: bytes.len(),
756            cap: bytes.capacity(),
757        };
758        std::mem::forget(bytes);
759        buffer
760    }
761}
762
763impl CortexBuffer {
764    /// Read this buffer as UTF-8.
765    ///
766    /// # Errors
767    /// Returns a UTF-8 error when the buffer contains invalid UTF-8 bytes.
768    ///
769    /// # Safety
770    /// The caller must ensure `ptr` is valid for `len` bytes and remains alive
771    /// for the duration of this call.
772    pub const unsafe fn as_str(&self) -> Result<&str, std::str::Utf8Error> {
773        if self.ptr.is_null() || self.len == 0 {
774            return Ok("");
775        }
776        // SAFETY: upheld by the caller.
777        let bytes = unsafe { std::slice::from_raw_parts(self.ptr.cast_const(), self.len) };
778        std::str::from_utf8(bytes)
779    }
780}
781
782/// Free a buffer allocated by this SDK.
783///
784/// # Safety
785/// The buffer must have been returned by this SDK's ABI helpers and must not be
786/// freed more than once.
787pub unsafe extern "C" fn cortex_buffer_free(buffer: CortexBuffer) {
788    if buffer.ptr.is_null() {
789        return;
790    }
791    // SAFETY: the caller guarantees this buffer came from `CortexBuffer::from_string`.
792    unsafe {
793        drop(Vec::from_raw_parts(buffer.ptr, buffer.len, buffer.cap));
794    }
795}
796
797/// Host table supplied to a native plugin during initialization.
798#[repr(C)]
799pub struct CortexHostApi {
800    /// Runtime-supported native ABI version.
801    pub abi_version: u32,
802}
803
804/// Function table exported by a native plugin.
805#[repr(C)]
806pub struct CortexPluginApi {
807    /// Plugin-supported native ABI version.
808    pub abi_version: u32,
809    /// Opaque plugin state owned by the plugin.
810    pub plugin: *mut c_void,
811    /// Return [`PluginInfo`] encoded as JSON.
812    pub plugin_info: Option<unsafe extern "C" fn(*mut c_void) -> CortexBuffer>,
813    /// Return the number of tools exposed by the plugin.
814    pub tool_count: Option<unsafe extern "C" fn(*mut c_void) -> usize>,
815    /// Return one tool descriptor encoded as JSON.
816    pub tool_descriptor: Option<unsafe extern "C" fn(*mut c_void, usize) -> CortexBuffer>,
817    /// Execute a tool. The name, input, and invocation context are UTF-8 JSON
818    /// buffers except `tool_name`, which is a UTF-8 string.
819    pub tool_execute: Option<
820        unsafe extern "C" fn(*mut c_void, CortexBuffer, CortexBuffer, CortexBuffer) -> CortexBuffer,
821    >,
822    /// Drop plugin-owned state.
823    pub plugin_drop: Option<unsafe extern "C" fn(*mut c_void)>,
824    /// Free buffers returned by plugin functions.
825    pub buffer_free: Option<unsafe extern "C" fn(CortexBuffer)>,
826}
827
828impl CortexPluginApi {
829    #[must_use]
830    pub const fn empty() -> Self {
831        Self {
832            abi_version: 0,
833            plugin: std::ptr::null_mut(),
834            plugin_info: None,
835            tool_count: None,
836            tool_descriptor: None,
837            tool_execute: None,
838            plugin_drop: None,
839            buffer_free: None,
840        }
841    }
842}
843
844#[derive(Serialize)]
845struct ToolDescriptor<'a> {
846    name: &'a str,
847    description: &'a str,
848    input_schema: serde_json::Value,
849    timeout_secs: Option<u64>,
850    capabilities: ToolCapabilities,
851}
852
853struct NoopToolRuntime {
854    invocation: InvocationContext,
855}
856
857impl ToolRuntime for NoopToolRuntime {
858    fn invocation(&self) -> &InvocationContext {
859        &self.invocation
860    }
861
862    fn emit_progress(&self, _message: &str) {}
863
864    fn emit_observer(&self, _source: Option<&str>, _content: &str) {}
865}
866
867#[doc(hidden)]
868pub struct NativePluginState {
869    plugin: Box<dyn MultiToolPlugin>,
870    tools: Vec<Box<dyn Tool>>,
871}
872
873impl NativePluginState {
874    #[must_use]
875    pub fn new(plugin: Box<dyn MultiToolPlugin>) -> Self {
876        let tools = plugin.create_tools();
877        Self { plugin, tools }
878    }
879}
880
881fn json_buffer<T: Serialize>(value: &T) -> CortexBuffer {
882    match serde_json::to_string(value) {
883        Ok(json) => CortexBuffer::from(json),
884        Err(err) => CortexBuffer::from(
885            serde_json::json!({
886                "output": format!("native ABI serialization error: {err}"),
887                "media": [],
888                "is_error": true
889            })
890            .to_string(),
891        ),
892    }
893}
894
895#[doc(hidden)]
896pub unsafe extern "C" fn native_plugin_info(state: *mut c_void) -> CortexBuffer {
897    if state.is_null() {
898        return CortexBuffer::empty();
899    }
900    // SAFETY: the pointer is created by `export_plugin!` and remains owned by
901    // the plugin until `native_plugin_drop`.
902    let state = unsafe { &*state.cast::<NativePluginState>() };
903    json_buffer(&state.plugin.plugin_info())
904}
905
906#[doc(hidden)]
907pub unsafe extern "C" fn native_tool_count(state: *mut c_void) -> usize {
908    if state.is_null() {
909        return 0;
910    }
911    // SAFETY: see `native_plugin_info`.
912    let state = unsafe { &*state.cast::<NativePluginState>() };
913    state.tools.len()
914}
915
916#[doc(hidden)]
917pub unsafe extern "C" fn native_tool_descriptor(state: *mut c_void, index: usize) -> CortexBuffer {
918    if state.is_null() {
919        return CortexBuffer::empty();
920    }
921    // SAFETY: see `native_plugin_info`.
922    let state = unsafe { &*state.cast::<NativePluginState>() };
923    let Some(tool) = state.tools.get(index) else {
924        return CortexBuffer::empty();
925    };
926    let descriptor = ToolDescriptor {
927        name: tool.name(),
928        description: tool.description(),
929        input_schema: tool.input_schema(),
930        timeout_secs: tool.timeout_secs(),
931        capabilities: tool.capabilities(),
932    };
933    json_buffer(&descriptor)
934}
935
936#[doc(hidden)]
937pub unsafe extern "C" fn native_tool_execute(
938    state: *mut c_void,
939    tool_name: CortexBuffer,
940    input_json: CortexBuffer,
941    invocation_json: CortexBuffer,
942) -> CortexBuffer {
943    if state.is_null() {
944        return json_buffer(&ToolResult::error("native plugin state is null"));
945    }
946    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
947    let tool_name = match unsafe { tool_name.as_str() } {
948        Ok(value) => value,
949        Err(err) => return json_buffer(&ToolResult::error(format!("invalid tool name: {err}"))),
950    };
951    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
952    let input_json = match unsafe { input_json.as_str() } {
953        Ok(value) => value,
954        Err(err) => return json_buffer(&ToolResult::error(format!("invalid input JSON: {err}"))),
955    };
956    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
957    let invocation_json = match unsafe { invocation_json.as_str() } {
958        Ok(value) => value,
959        Err(err) => {
960            return json_buffer(&ToolResult::error(format!(
961                "invalid invocation JSON: {err}"
962            )));
963        }
964    };
965    let input = match serde_json::from_str(input_json) {
966        Ok(value) => value,
967        Err(err) => return json_buffer(&ToolResult::error(format!("invalid input JSON: {err}"))),
968    };
969    let invocation = match serde_json::from_str(invocation_json) {
970        Ok(value) => value,
971        Err(err) => {
972            return json_buffer(&ToolResult::error(format!(
973                "invalid invocation JSON: {err}"
974            )));
975        }
976    };
977    // SAFETY: see `native_plugin_info`.
978    let state = unsafe { &*state.cast::<NativePluginState>() };
979    let Some(tool) = state.tools.iter().find(|tool| tool.name() == tool_name) else {
980        return json_buffer(&ToolResult::error(format!(
981            "native plugin does not expose tool '{tool_name}'"
982        )));
983    };
984    let runtime = NoopToolRuntime { invocation };
985    match tool.execute_with_runtime(input, &runtime) {
986        Ok(result) => json_buffer(&result),
987        Err(err) => json_buffer(&ToolResult::error(format!("tool error: {err}"))),
988    }
989}
990
991#[doc(hidden)]
992pub unsafe extern "C" fn native_plugin_drop(state: *mut c_void) {
993    if state.is_null() {
994        return;
995    }
996    // SAFETY: pointer ownership is transferred from `export_plugin!` to this
997    // function exactly once by the runtime.
998    unsafe {
999        drop(Box::from_raw(state.cast::<NativePluginState>()));
1000    }
1001}
1002
1003// ── Export Macro ────────────────────────────────────────────
1004
1005/// Generate the stable native ABI entry point for a [`MultiToolPlugin`].
1006///
1007/// This macro expands to an `extern "C"` function named `cortex_plugin_init`
1008/// that fills a C-compatible function table. The plugin type must implement
1009/// [`Default`].
1010///
1011/// # Usage
1012///
1013/// `cortex_sdk::export_plugin!(MyPlugin);`
1014///
1015/// # Expansion
1016///
1017/// The macro constructs the Rust plugin internally and exposes it through the
1018/// stable native ABI table. Rust trait objects never cross the dynamic-library
1019/// boundary.
1020#[macro_export]
1021macro_rules! export_plugin {
1022    ($plugin_type:ty) => {
1023        #[unsafe(no_mangle)]
1024        pub unsafe extern "C" fn cortex_plugin_init(
1025            host: *const $crate::CortexHostApi,
1026            out_plugin: *mut $crate::CortexPluginApi,
1027        ) -> i32 {
1028            if host.is_null() || out_plugin.is_null() {
1029                return -1;
1030            }
1031            let host = unsafe { &*host };
1032            if host.abi_version != $crate::NATIVE_ABI_VERSION {
1033                return -2;
1034            }
1035            let plugin: Box<dyn $crate::MultiToolPlugin> = Box::new(<$plugin_type>::default());
1036            let state = Box::new($crate::NativePluginState::new(plugin));
1037            unsafe {
1038                *out_plugin = $crate::CortexPluginApi {
1039                    abi_version: $crate::NATIVE_ABI_VERSION,
1040                    plugin: Box::into_raw(state).cast(),
1041                    plugin_info: Some($crate::native_plugin_info),
1042                    tool_count: Some($crate::native_tool_count),
1043                    tool_descriptor: Some($crate::native_tool_descriptor),
1044                    tool_execute: Some($crate::native_tool_execute),
1045                    plugin_drop: Some($crate::native_plugin_drop),
1046                    buffer_free: Some($crate::cortex_buffer_free),
1047                };
1048            }
1049            0
1050        }
1051    };
1052}
1053
1054// ── Prelude ─────────────────────────────────────────────────
1055
1056/// Convenience re-exports for plugin development.
1057///
1058/// ```rust,no_run
1059/// use cortex_sdk::prelude::*;
1060/// ```
1061///
1062/// This imports [`MultiToolPlugin`], [`PluginInfo`], [`Tool`],
1063/// [`ToolError`], [`ToolResult`], and [`serde_json`].
1064pub mod prelude {
1065    pub use super::{
1066        Attachment, CortexBuffer, CortexHostApi, CortexPluginApi, DryRunSupport,
1067        EffectConfirmation, EffectReversibility, ExecutionScope, InvocationContext,
1068        MultiToolPlugin, NATIVE_ABI_VERSION, PluginInfo, SDK_VERSION, Tool, ToolCapabilities,
1069        ToolEffect, ToolEffectKind, ToolError, ToolResult, ToolRuntime,
1070    };
1071    pub use serde_json;
1072}