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}