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}