Skip to main content

cortex_sdk/
native.rs

1use crate::{InvocationContext, MultiToolPlugin, Tool, ToolCapabilities, ToolResult, ToolRuntime};
2use serde::Serialize;
3use std::ffi::c_void;
4
5/// Native ABI-owned byte buffer.
6///
7/// All strings and JSON values that cross the stable native ABI boundary use
8/// this representation. Buffers returned by the plugin must be released by
9/// calling the table's `buffer_free` function.
10#[repr(C)]
11pub struct CortexBuffer {
12    /// Pointer to UTF-8 bytes.
13    pub ptr: *mut u8,
14    /// Number of initialized bytes at `ptr`.
15    pub len: usize,
16    /// Allocation capacity needed to reconstruct and free the buffer.
17    pub cap: usize,
18}
19
20impl CortexBuffer {
21    #[must_use]
22    pub const fn empty() -> Self {
23        Self {
24            ptr: std::ptr::null_mut(),
25            len: 0,
26            cap: 0,
27        }
28    }
29}
30
31impl From<String> for CortexBuffer {
32    fn from(value: String) -> Self {
33        let mut bytes = value.into_bytes();
34        let buffer = Self {
35            ptr: bytes.as_mut_ptr(),
36            len: bytes.len(),
37            cap: bytes.capacity(),
38        };
39        std::mem::forget(bytes);
40        buffer
41    }
42}
43
44impl CortexBuffer {
45    /// Read this buffer as UTF-8.
46    ///
47    /// # Errors
48    /// Returns a UTF-8 error when the buffer contains invalid UTF-8 bytes.
49    ///
50    /// # Safety
51    /// The caller must ensure `ptr` is valid for `len` bytes and remains alive
52    /// for the duration of this call.
53    pub const unsafe fn as_str(&self) -> Result<&str, std::str::Utf8Error> {
54        if self.ptr.is_null() || self.len == 0 {
55            return Ok("");
56        }
57        // SAFETY: upheld by the caller.
58        let bytes = unsafe { std::slice::from_raw_parts(self.ptr.cast_const(), self.len) };
59        std::str::from_utf8(bytes)
60    }
61}
62
63/// Free a buffer allocated by this SDK.
64///
65/// # Safety
66/// The buffer must have been returned by this SDK's ABI helpers and must not be
67/// freed more than once.
68pub unsafe extern "C" fn cortex_buffer_free(buffer: CortexBuffer) {
69    if buffer.ptr.is_null() {
70        return;
71    }
72    // SAFETY: the caller guarantees this buffer came from `CortexBuffer::from_string`.
73    unsafe {
74        drop(Vec::from_raw_parts(buffer.ptr, buffer.len, buffer.cap));
75    }
76}
77
78/// Host table supplied to a native plugin during initialization.
79#[repr(C)]
80pub struct CortexHostApi {
81    /// Runtime-supported native ABI version.
82    pub abi_version: u32,
83}
84
85/// Function table exported by a native plugin.
86#[repr(C)]
87pub struct CortexPluginApi {
88    /// Plugin-supported native ABI version.
89    pub abi_version: u32,
90    /// Opaque plugin state owned by the plugin.
91    pub plugin: *mut c_void,
92    /// Return [`crate::PluginInfo`] encoded as JSON.
93    pub plugin_info: Option<unsafe extern "C" fn(*mut c_void) -> CortexBuffer>,
94    /// Return the number of tools exposed by the plugin.
95    pub tool_count: Option<unsafe extern "C" fn(*mut c_void) -> usize>,
96    /// Return one tool descriptor encoded as JSON.
97    pub tool_descriptor: Option<unsafe extern "C" fn(*mut c_void, usize) -> CortexBuffer>,
98    /// Execute a tool. The name, input, and invocation context are UTF-8 JSON
99    /// buffers except `tool_name`, which is a UTF-8 string.
100    pub tool_execute: Option<
101        unsafe extern "C" fn(*mut c_void, CortexBuffer, CortexBuffer, CortexBuffer) -> CortexBuffer,
102    >,
103    /// Drop plugin-owned state.
104    pub plugin_drop: Option<unsafe extern "C" fn(*mut c_void)>,
105    /// Free buffers returned by plugin functions.
106    pub buffer_free: Option<unsafe extern "C" fn(CortexBuffer)>,
107}
108
109impl CortexPluginApi {
110    #[must_use]
111    pub const fn empty() -> Self {
112        Self {
113            abi_version: 0,
114            plugin: std::ptr::null_mut(),
115            plugin_info: None,
116            tool_count: None,
117            tool_descriptor: None,
118            tool_execute: None,
119            plugin_drop: None,
120            buffer_free: None,
121        }
122    }
123}
124
125#[derive(Serialize)]
126struct ToolDescriptor<'a> {
127    name: &'a str,
128    description: &'a str,
129    input_schema: serde_json::Value,
130    timeout_secs: Option<u64>,
131    capabilities: ToolCapabilities,
132}
133
134struct NoopToolRuntime {
135    invocation: InvocationContext,
136}
137
138impl ToolRuntime for NoopToolRuntime {
139    fn invocation(&self) -> &InvocationContext {
140        &self.invocation
141    }
142
143    fn emit_progress(&self, _message: &str) {}
144
145    fn emit_observer(&self, _source: Option<&str>, _content: &str) {}
146}
147
148#[doc(hidden)]
149pub struct NativePluginState {
150    plugin: Box<dyn MultiToolPlugin>,
151    tools: Vec<Box<dyn Tool>>,
152}
153
154impl NativePluginState {
155    #[must_use]
156    pub fn new(plugin: Box<dyn MultiToolPlugin>) -> Self {
157        let tools = plugin.create_tools();
158        Self { plugin, tools }
159    }
160}
161
162fn json_buffer<T: Serialize>(value: &T) -> CortexBuffer {
163    match serde_json::to_string(value) {
164        Ok(json) => CortexBuffer::from(json),
165        Err(err) => CortexBuffer::from(
166            serde_json::json!({
167                "output": format!("native ABI serialization error: {err}"),
168                "media": [],
169                "is_error": true
170            })
171            .to_string(),
172        ),
173    }
174}
175
176#[doc(hidden)]
177pub unsafe extern "C" fn native_plugin_info(state: *mut c_void) -> CortexBuffer {
178    if state.is_null() {
179        return CortexBuffer::empty();
180    }
181    // SAFETY: the pointer is created by `export_plugin!` and remains owned by
182    // the plugin until `native_plugin_drop`.
183    let state = unsafe { &*state.cast::<NativePluginState>() };
184    json_buffer(&state.plugin.plugin_info())
185}
186
187#[doc(hidden)]
188pub unsafe extern "C" fn native_tool_count(state: *mut c_void) -> usize {
189    if state.is_null() {
190        return 0;
191    }
192    // SAFETY: see `native_plugin_info`.
193    let state = unsafe { &*state.cast::<NativePluginState>() };
194    state.tools.len()
195}
196
197#[doc(hidden)]
198pub unsafe extern "C" fn native_tool_descriptor(state: *mut c_void, index: usize) -> CortexBuffer {
199    if state.is_null() {
200        return CortexBuffer::empty();
201    }
202    // SAFETY: see `native_plugin_info`.
203    let state = unsafe { &*state.cast::<NativePluginState>() };
204    let Some(tool) = state.tools.get(index) else {
205        return CortexBuffer::empty();
206    };
207    let descriptor = ToolDescriptor {
208        name: tool.name(),
209        description: tool.description(),
210        input_schema: tool.input_schema(),
211        timeout_secs: tool.timeout_secs(),
212        capabilities: tool.capabilities(),
213    };
214    json_buffer(&descriptor)
215}
216
217#[doc(hidden)]
218pub unsafe extern "C" fn native_tool_execute(
219    state: *mut c_void,
220    tool_name: CortexBuffer,
221    input_json: CortexBuffer,
222    invocation_json: CortexBuffer,
223) -> CortexBuffer {
224    if state.is_null() {
225        return json_buffer(&ToolResult::error("native plugin state is null"));
226    }
227    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
228    let tool_name = match unsafe { tool_name.as_str() } {
229        Ok(value) => value,
230        Err(err) => return json_buffer(&ToolResult::error(format!("invalid tool name: {err}"))),
231    };
232    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
233    let input_json = match unsafe { input_json.as_str() } {
234        Ok(value) => value,
235        Err(err) => return json_buffer(&ToolResult::error(format!("invalid input JSON: {err}"))),
236    };
237    // SAFETY: inbound buffers are supplied by the runtime and valid for this call.
238    let invocation_json = match unsafe { invocation_json.as_str() } {
239        Ok(value) => value,
240        Err(err) => {
241            return json_buffer(&ToolResult::error(format!(
242                "invalid invocation JSON: {err}"
243            )));
244        }
245    };
246    let input = match serde_json::from_str(input_json) {
247        Ok(value) => value,
248        Err(err) => return json_buffer(&ToolResult::error(format!("invalid input JSON: {err}"))),
249    };
250    let invocation = match serde_json::from_str(invocation_json) {
251        Ok(value) => value,
252        Err(err) => {
253            return json_buffer(&ToolResult::error(format!(
254                "invalid invocation JSON: {err}"
255            )));
256        }
257    };
258    // SAFETY: see `native_plugin_info`.
259    let state = unsafe { &*state.cast::<NativePluginState>() };
260    let Some(tool) = state.tools.iter().find(|tool| tool.name() == tool_name) else {
261        return json_buffer(&ToolResult::error(format!(
262            "native plugin does not expose tool '{tool_name}'"
263        )));
264    };
265    let runtime = NoopToolRuntime { invocation };
266    match tool.execute_with_runtime(input, &runtime) {
267        Ok(result) => json_buffer(&result),
268        Err(err) => json_buffer(&ToolResult::error(format!("tool error: {err}"))),
269    }
270}
271
272#[doc(hidden)]
273pub unsafe extern "C" fn native_plugin_drop(state: *mut c_void) {
274    if state.is_null() {
275        return;
276    }
277    // SAFETY: pointer ownership is transferred from `export_plugin!` to this
278    // function exactly once by the runtime.
279    unsafe {
280        drop(Box::from_raw(state.cast::<NativePluginState>()));
281    }
282}