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}