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