Skip to main content

cortex_sdk/
lib.rs

1//! # Cortex SDK
2//!
3//! The official SDK for building [Cortex](https://github.com/by-scott/cortex)
4//! plugins.
5//!
6//! This crate defines the public plugin surface with **zero dependency on
7//! Cortex internals**. The runtime loads plugins via FFI and bridges these
8//! traits to its own turn runtime, command surface, and transport layer.
9//!
10//! ## Architecture
11//!
12//! ```text
13//!  ┌──────────────┐     dlopen      ┌──────────────────┐
14//!  │ cortex-runtime│ ──────────────▶ │  your plugin.so  │
15//!  │   (daemon)    │                 │  cortex-sdk only  │
16//!  └──────┬───────┘   FFI call      └────────┬─────────┘
17//!         │        cortex_plugin_              │
18//!         │        create_multi()              │
19//!         ▼                                    ▼
20//!    ToolRegistry  ◀─── register ───  MultiToolPlugin
21//!                                     ├─ plugin_info()
22//!                                     └─ create_tools()
23//!                                         ├─ Tool A
24//!                                         └─ Tool B
25//! ```
26//!
27//! Plugins are compiled as `cdylib` shared libraries. The runtime calls a
28//! single FFI entry point (`cortex_plugin_create_multi`) that returns a
29//! [`MultiToolPlugin`] trait object. Each tool returned by
30//! [`MultiToolPlugin::create_tools`] is registered into the global tool
31//! registry and becomes available to the LLM during turns.
32//!
33//! The SDK now exposes a runtime-aware execution surface as well:
34//!
35//! - [`InvocationContext`] gives tools stable metadata such as session id,
36//!   canonical actor, transport/source, and foreground/background scope
37//! - [`ToolRuntime`] lets tools emit progress updates and observer text back
38//!   to the parent turn
39//! - [`ToolCapabilities`] lets tools declare whether they emit runtime signals
40//!   and whether they are background-safe
41//! - [`Attachment`] and [`ToolResult::with_media`] let tools return structured
42//!   image, audio, video, or file outputs without depending on Cortex internals
43//! ## Quick Start
44//!
45//! **Cargo.toml:**
46//!
47//! ```toml
48//! [lib]
49//! crate-type = ["cdylib"]
50//!
51//! [dependencies]
52//! cortex-sdk = "1.0"
53//! serde_json = "1"
54//! ```
55//!
56//! **src/lib.rs:**
57//!
58//! ```rust,no_run
59//! use cortex_sdk::prelude::*;
60//!
61//! // 1. Define the plugin entry point.
62//! #[derive(Default)]
63//! struct MyPlugin;
64//!
65//! impl MultiToolPlugin for MyPlugin {
66//!     fn plugin_info(&self) -> PluginInfo {
67//!         PluginInfo {
68//!             name: "my-plugin".into(),
69//!             version: env!("CARGO_PKG_VERSION").into(),
70//!             description: "My custom tools for Cortex".into(),
71//!         }
72//!     }
73//!
74//!     fn create_tools(&self) -> Vec<Box<dyn Tool>> {
75//!         vec![Box::new(WordCountTool)]
76//!     }
77//! }
78//!
79//! // 2. Implement one or more tools.
80//! struct WordCountTool;
81//!
82//! impl Tool for WordCountTool {
83//!     fn name(&self) -> &'static str { "word_count" }
84//!
85//!     fn description(&self) -> &'static str {
86//!         "Count words in a text string. Use when the user asks for word \
87//!          counts, statistics, or text length metrics."
88//!     }
89//!
90//!     fn input_schema(&self) -> serde_json::Value {
91//!         serde_json::json!({
92//!             "type": "object",
93//!             "properties": {
94//!                 "text": {
95//!                     "type": "string",
96//!                     "description": "The text to count words in"
97//!                 }
98//!             },
99//!             "required": ["text"]
100//!         })
101//!     }
102//!
103//!     fn execute(&self, input: serde_json::Value) -> Result<ToolResult, ToolError> {
104//!         let text = input["text"]
105//!             .as_str()
106//!             .ok_or_else(|| ToolError::InvalidInput("missing 'text' field".into()))?;
107//!         let count = text.split_whitespace().count();
108//!         Ok(ToolResult::success(format!("{count} words")))
109//!     }
110//! }
111//!
112//! // 3. Export the FFI entry point.
113//! cortex_sdk::export_plugin!(MyPlugin);
114//! ```
115//!
116//! Tools that need runtime context can override
117//! [`Tool::execute_with_runtime`] instead of only [`Tool::execute`].
118//!
119//! ## Build & Install
120//!
121//! ```bash
122//! cargo build --release
123//! mkdir -p my-plugin/lib
124//! cp target/release/libmy_plugin.so my-plugin/lib/     # .dylib on macOS
125//! cortex plugin install ./my-plugin/
126//! ```
127//!
128//! ## Plugin Lifecycle
129//!
130//! 1. **Load** — `dlopen` at daemon startup
131//! 2. **Create** — runtime calls [`export_plugin!`]-generated FFI function
132//! 3. **Register** — [`MultiToolPlugin::create_tools`] is called once; each
133//!    [`Tool`] is registered in the global tool registry
134//! 4. **Execute** — the LLM invokes tools by name during turns; the runtime
135//!    calls [`Tool::execute`] with JSON parameters
136//! 5. **Retain** — the library handle is held for the daemon's lifetime;
137//!    `Drop` runs only at shutdown
138//!
139//! ## Tool Design Guidelines
140//!
141//! - **`name`**: lowercase with underscores (`word_count`, not `WordCount`).
142//!   Must be unique across all tools in the registry.
143//! - **`description`**: written for the LLM — explain what the tool does,
144//!   when to use it, and when *not* to use it.  The LLM reads this to decide
145//!   whether to call the tool.
146//! - **`input_schema`**: a [JSON Schema](https://json-schema.org/) object
147//!   describing the parameters.  The LLM generates JSON matching this schema.
148//! - **`execute`**: receives the LLM-generated JSON.  Return
149//!   [`ToolResult::success`] for normal output or [`ToolResult::error`] for
150//!   recoverable errors the LLM should see.  Return [`ToolError`] only for
151//!   unrecoverable failures (invalid input, missing deps).
152//! - **Media output**: attach files with [`ToolResult::with_media`].  Cortex
153//!   delivers attachments through the active transport; plugins should not call
154//!   channel-specific APIs directly.
155//! - **`execute_with_runtime`**: use this when the tool needs invocation
156//!   metadata or wants to emit progress / observer updates during execution.
157//! - **`timeout_secs`**: optional per-tool timeout override.  If `None`, the
158//!   global `[turn].tool_timeout_secs` applies.
159
160pub use serde_json;
161
162/// Stable multimedia attachment DTO exposed to plugins.
163///
164/// This type intentionally lives in `cortex-sdk` instead of depending on
165/// Cortex internal crates, so plugin authors only need the SDK.
166#[derive(Debug, Clone)]
167pub struct Attachment {
168    /// High-level type: `"image"`, `"audio"`, `"video"`, `"file"`.
169    pub media_type: String,
170    /// MIME type, for example `"image/png"` or `"audio/mpeg"`.
171    pub mime_type: String,
172    /// Local file path or remote URL readable by the runtime transport.
173    pub url: String,
174    /// Optional caption or description.
175    pub caption: Option<String>,
176    /// File size in bytes, if known.
177    pub size: Option<u64>,
178}
179
180/// Whether a tool invocation belongs to a user-visible foreground turn or a
181/// background maintenance execution.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
183pub enum ExecutionScope {
184    #[default]
185    Foreground,
186    Background,
187}
188
189/// Stable runtime metadata exposed to plugin tools during execution.
190///
191/// This intentionally exposes the execution surface, not Cortex internals.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct InvocationContext {
194    /// Tool name being invoked.
195    pub tool_name: String,
196    /// Active session id when available.
197    pub session_id: Option<String>,
198    /// Canonical actor identity when available.
199    pub actor: Option<String>,
200    /// Transport or invocation source (`http`, `rpc`, `telegram`, `heartbeat`, ...).
201    pub source: Option<String>,
202    /// Whether this invocation belongs to a foreground or background execution.
203    pub execution_scope: ExecutionScope,
204}
205
206impl InvocationContext {
207    #[must_use]
208    pub fn is_background(&self) -> bool {
209        self.execution_scope == ExecutionScope::Background
210    }
211
212    #[must_use]
213    pub fn is_foreground(&self) -> bool {
214        self.execution_scope == ExecutionScope::Foreground
215    }
216}
217
218/// Declarative hints about how a tool participates in the runtime.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
220pub struct ToolCapabilities {
221    /// Tool emits intermediate progress updates.
222    pub emits_progress: bool,
223    /// Tool emits observer-lane notes for the parent turn.
224    pub emits_observer_text: bool,
225    /// Tool is safe to run in background maintenance contexts.
226    pub background_safe: bool,
227}
228
229/// Runtime bridge presented to tools during execution.
230///
231/// This allows plugins to consume stable runtime context and emit bounded
232/// execution signals without depending on Cortex internals.
233pub trait ToolRuntime: Send + Sync {
234    /// Stable invocation metadata.
235    fn invocation(&self) -> &InvocationContext;
236
237    /// Emit an intermediate progress update for the current tool.
238    fn emit_progress(&self, message: &str);
239
240    /// Emit observer text for the parent turn. This never speaks directly to
241    /// the user-facing channel.
242    fn emit_observer(&self, source: Option<&str>, content: &str);
243}
244
245// ── Tool Interface ──────────────────────────────────────────
246
247/// A tool that the LLM can invoke during conversation.
248///
249/// Tools are the primary extension point for Cortex plugins.  Each tool
250/// has a name, description, JSON Schema for input parameters, and an
251/// execute function.  The runtime presents the tool definition to the LLM
252/// and routes invocations to [`Tool::execute`].
253///
254/// # Thread Safety
255///
256/// Tools must be `Send + Sync` because a single tool instance is shared
257/// across all turns in the daemon process.  Use interior mutability
258/// (`Mutex`, `RwLock`, `AtomicXxx`) if you need mutable state.
259pub trait Tool: Send + Sync {
260    /// Unique tool name (lowercase, underscores, e.g. `"web_search"`).
261    ///
262    /// Must be unique across all registered tools.  If two tools share a
263    /// name, the later registration wins.
264    fn name(&self) -> &'static str;
265
266    /// Human-readable description shown to the LLM.
267    ///
268    /// Write this for the LLM, not for humans.  Include:
269    /// - What the tool does
270    /// - When to use it
271    /// - When *not* to use it
272    /// - Any constraints or limitations
273    fn description(&self) -> &'static str;
274
275    /// JSON Schema describing the tool's input parameters.
276    ///
277    /// The LLM generates a JSON object matching this schema.  Example:
278    ///
279    /// ```json
280    /// {
281    ///   "type": "object",
282    ///   "properties": {
283    ///     "query": { "type": "string", "description": "Search query" }
284    ///   },
285    ///   "required": ["query"]
286    /// }
287    /// ```
288    fn input_schema(&self) -> serde_json::Value;
289
290    /// Execute the tool with the given input.
291    ///
292    /// `input` is a JSON object matching [`Self::input_schema`].  The
293    /// runtime validates the schema before calling this method, but
294    /// individual field types should still be checked defensively.
295    ///
296    /// # Return Values
297    ///
298    /// - [`ToolResult::success`] — normal output returned to the LLM
299    /// - [`ToolResult::error`] — the tool ran but produced an error the
300    ///   LLM should see and potentially recover from
301    ///
302    /// # Errors
303    ///
304    /// Return [`ToolError::InvalidInput`] for malformed parameters or
305    /// [`ToolError::ExecutionFailed`] for unrecoverable failures.  These
306    /// are surfaced as error events in the turn journal.
307    fn execute(&self, input: serde_json::Value) -> Result<ToolResult, ToolError>;
308
309    /// Execute the tool with runtime context and host callbacks.
310    ///
311    /// Plugins can override this to read session/actor/source metadata and
312    /// emit progress or observer updates through the provided runtime bridge.
313    ///
314    /// The default implementation preserves the classic SDK contract and calls
315    /// [`Self::execute`].
316    ///
317    /// # Errors
318    ///
319    /// Returns the same `ToolError` variants that [`Self::execute`] would
320    /// return for invalid input or unrecoverable execution failure.
321    fn execute_with_runtime(
322        &self,
323        input: serde_json::Value,
324        runtime: &dyn ToolRuntime,
325    ) -> Result<ToolResult, ToolError> {
326        let _ = runtime;
327        self.execute(input)
328    }
329
330    /// Optional per-tool execution timeout in seconds.
331    ///
332    /// If `None` (the default), the global `[turn].tool_timeout_secs`
333    /// from the instance configuration applies.
334    fn timeout_secs(&self) -> Option<u64> {
335        None
336    }
337
338    /// Stable capability hints consumed by the runtime and observability
339    /// layers.
340    fn capabilities(&self) -> ToolCapabilities {
341        ToolCapabilities::default()
342    }
343}
344
345/// Result of a tool execution returned to the LLM.
346///
347/// Use [`ToolResult::success`] for normal output and [`ToolResult::error`]
348/// for recoverable errors the LLM should see.
349#[derive(Debug, Clone)]
350pub struct ToolResult {
351    /// Output text returned to the LLM.
352    pub output: String,
353    /// Structured media attachments produced by this tool.
354    ///
355    /// Attachments are delivered by Cortex transports independently from the
356    /// text the model sees, so tools do not need transport-specific protocols.
357    pub media: Vec<Attachment>,
358    /// Whether this result represents an error condition.
359    ///
360    /// When `true`, the LLM sees this as a failed tool call and may retry
361    /// with different parameters or switch strategy.
362    pub is_error: bool,
363}
364
365impl ToolResult {
366    /// Create a successful result.
367    #[must_use]
368    pub fn success(output: impl Into<String>) -> Self {
369        Self {
370            output: output.into(),
371            media: Vec::new(),
372            is_error: false,
373        }
374    }
375
376    /// Create an error result (tool ran but failed).
377    ///
378    /// Use this for recoverable errors — the LLM sees the output and can
379    /// decide how to proceed. For example: "file not found", "permission
380    /// denied", "rate limit exceeded".
381    #[must_use]
382    pub fn error(output: impl Into<String>) -> Self {
383        Self {
384            output: output.into(),
385            media: Vec::new(),
386            is_error: true,
387        }
388    }
389
390    /// Attach one media item to the result.
391    #[must_use]
392    pub fn with_media(mut self, attachment: Attachment) -> Self {
393        self.media.push(attachment);
394        self
395    }
396
397    /// Attach multiple media items to the result.
398    #[must_use]
399    pub fn with_media_many(mut self, media: impl IntoIterator<Item = Attachment>) -> Self {
400        self.media.extend(media);
401        self
402    }
403}
404
405/// Error from tool execution.
406///
407/// Unlike [`ToolResult::error`] (which is a "soft" error the LLM sees),
408/// `ToolError` represents a hard failure that is logged in the turn
409/// journal as a tool invocation error.
410#[derive(Debug)]
411pub enum ToolError {
412    /// Input parameters are invalid or missing required fields.
413    InvalidInput(String),
414    /// Execution failed due to an external or internal error.
415    ExecutionFailed(String),
416}
417
418impl std::fmt::Display for ToolError {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        match self {
421            Self::InvalidInput(e) => write!(f, "invalid input: {e}"),
422            Self::ExecutionFailed(e) => write!(f, "execution failed: {e}"),
423        }
424    }
425}
426
427impl std::error::Error for ToolError {}
428
429// ── Plugin Interface ────────────────────────────────────────
430
431/// Plugin metadata returned to the runtime at load time.
432///
433/// The `name` field must match the plugin's directory name and the
434/// `name` field in `manifest.toml`.
435#[derive(Debug, Clone)]
436pub struct PluginInfo {
437    /// Unique plugin identifier (e.g. `"my-plugin"`).
438    pub name: String,
439    /// Semantic version string (e.g. `"1.0.0"`).
440    pub version: String,
441    /// Human-readable one-line description.
442    pub description: String,
443}
444
445/// A plugin that provides multiple tools from a single shared library.
446///
447/// This is the primary interface between a plugin and the Cortex runtime.
448/// Implement this trait and use [`export_plugin!`] to generate the FFI
449/// entry point.
450///
451/// # Requirements
452///
453/// - The implementing type must also implement `Default` (required by
454///   [`export_plugin!`] for construction via FFI).
455/// - The type must be `Send + Sync` because the runtime may access it
456///   from multiple threads.
457///
458/// # Example
459///
460/// ```rust,no_run
461/// use cortex_sdk::prelude::*;
462///
463/// #[derive(Default)]
464/// struct MyPlugin;
465///
466/// impl MultiToolPlugin for MyPlugin {
467///     fn plugin_info(&self) -> PluginInfo {
468///         PluginInfo {
469///             name: "my-plugin".into(),
470///             version: "0.1.0".into(),
471///             description: "Example plugin".into(),
472///         }
473///     }
474///
475///     fn create_tools(&self) -> Vec<Box<dyn Tool>> {
476///         vec![]
477///     }
478/// }
479///
480/// cortex_sdk::export_plugin!(MyPlugin);
481/// ```
482pub trait MultiToolPlugin: Send + Sync {
483    /// Return plugin metadata.
484    fn plugin_info(&self) -> PluginInfo;
485
486    /// Create all tools this plugin provides.
487    ///
488    /// Called once at daemon startup.  Returned tools live for the
489    /// daemon's lifetime.  Each tool is registered by name into the
490    /// global tool registry.
491    fn create_tools(&self) -> Vec<Box<dyn Tool>>;
492}
493
494// ── Export Macro ────────────────────────────────────────────
495
496/// Generate the FFI entry point for a [`MultiToolPlugin`].
497///
498/// This macro expands to an `extern "C"` function named
499/// `cortex_plugin_create_multi` that the runtime calls via `dlopen` /
500/// `dlsym`.  The plugin type must implement [`Default`].
501///
502/// # Usage
503///
504/// `cortex_sdk::export_plugin!(MyPlugin);`
505///
506/// # Expansion
507///
508/// The macro expands to an `extern "C" fn cortex_plugin_create_multi()`
509/// that constructs the plugin via `Default::default()` and returns a raw
510/// pointer to the `MultiToolPlugin` trait object.
511#[macro_export]
512macro_rules! export_plugin {
513    ($plugin_type:ty) => {
514        #[unsafe(no_mangle)]
515        pub extern "C" fn cortex_plugin_create_multi() -> *mut dyn $crate::MultiToolPlugin {
516            Box::into_raw(Box::new(<$plugin_type>::default()))
517        }
518    };
519}
520
521// ── Prelude ─────────────────────────────────────────────────
522
523/// Convenience re-exports for plugin development.
524///
525/// ```rust,no_run
526/// use cortex_sdk::prelude::*;
527/// ```
528///
529/// This imports [`MultiToolPlugin`], [`PluginInfo`], [`Tool`],
530/// [`ToolError`], [`ToolResult`], and [`serde_json`].
531pub mod prelude {
532    pub use super::{
533        Attachment, ExecutionScope, InvocationContext, MultiToolPlugin, PluginInfo, Tool,
534        ToolCapabilities, ToolError, ToolResult, ToolRuntime,
535    };
536    pub use serde_json;
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    struct DummyRuntime {
544        invocation: InvocationContext,
545    }
546
547    impl ToolRuntime for DummyRuntime {
548        fn invocation(&self) -> &InvocationContext {
549            &self.invocation
550        }
551
552        fn emit_progress(&self, _message: &str) {}
553
554        fn emit_observer(&self, _source: Option<&str>, _content: &str) {}
555    }
556
557    struct EchoTool;
558
559    impl Tool for EchoTool {
560        fn name(&self) -> &'static str {
561            "echo"
562        }
563
564        fn description(&self) -> &'static str {
565            "echo tool"
566        }
567
568        fn input_schema(&self) -> serde_json::Value {
569            serde_json::json!({"type": "object"})
570        }
571
572        fn execute(&self, input: serde_json::Value) -> Result<ToolResult, ToolError> {
573            Ok(ToolResult::success(input.to_string()))
574        }
575    }
576
577    #[test]
578    fn execute_with_runtime_defaults_to_execute() {
579        let tool = EchoTool;
580        let runtime = DummyRuntime {
581            invocation: InvocationContext {
582                tool_name: "echo".into(),
583                session_id: Some("s1".into()),
584                actor: Some("user:test".into()),
585                source: Some("rpc".into()),
586                execution_scope: ExecutionScope::Foreground,
587            },
588        };
589        let result = tool
590            .execute_with_runtime(serde_json::json!({"hello": "world"}), &runtime)
591            .unwrap();
592        assert_eq!(result.output, r#"{"hello":"world"}"#);
593        assert!(!result.is_error);
594    }
595
596    #[test]
597    fn default_tool_capabilities_are_empty() {
598        let tool = EchoTool;
599        assert_eq!(tool.capabilities(), ToolCapabilities::default());
600    }
601
602    #[test]
603    fn tool_result_can_carry_structured_media() {
604        let attachment = Attachment {
605            media_type: "image".into(),
606            mime_type: "image/png".into(),
607            url: "/tmp/example.png".into(),
608            caption: None,
609            size: Some(42),
610        };
611
612        let result = ToolResult::success("generated").with_media(attachment.clone());
613
614        assert_eq!(result.output, "generated");
615        assert!(!result.is_error);
616        assert_eq!(result.media.len(), 1);
617        assert_eq!(result.media[0].url, attachment.url);
618    }
619}