Skip to main content

agentzero_plugin_sdk/
lib.rs

1//! AgentZero Plugin SDK
2//!
3//! Minimal SDK for building WASM plugins that integrate with AgentZero's
4//! tool system. Plugins compile to `wasm32-wasip1` and are loaded by the
5//! AgentZero runtime via the ABI v2 protocol.
6//!
7//! # Quick Start
8//!
9//! ```rust,ignore
10//! use agentzero_plugin_sdk::prelude::*;
11//!
12//! declare_tool!("my_tool", execute);
13//!
14//! fn execute(input: ToolInput) -> ToolOutput {
15//!     ToolOutput::success(format!("got: {}", input.input))
16//! }
17//! ```
18//!
19//! Build with: `cargo build --target wasm32-wasip1 --release`
20
21pub mod prelude;
22
23use serde::{Deserialize, Serialize};
24
25/// Input provided to a plugin tool by the AgentZero runtime.
26///
27/// The runtime serializes this as JSON, writes it into WASM linear memory,
28/// and passes the pointer/length to `az_tool_execute`.
29#[derive(Debug, Clone, Deserialize)]
30pub struct ToolInput {
31    /// The raw input string from the LLM tool call.
32    pub input: String,
33    /// Absolute path to the workspace root directory.
34    pub workspace_root: String,
35}
36
37/// Output returned by a plugin tool to the AgentZero runtime.
38///
39/// Serialized as JSON and returned via a packed ptr|len i64.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ToolOutput {
42    /// The tool's output text (shown to the LLM).
43    pub output: String,
44    /// Optional error message. If set with empty output, treated as a tool error.
45    /// If set with non-empty output, appended as a warning.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub error: Option<String>,
48}
49
50impl ToolOutput {
51    /// Create a successful output.
52    pub fn success(output: impl Into<String>) -> Self {
53        Self {
54            output: output.into(),
55            error: None,
56        }
57    }
58
59    /// Create an error output with no result text.
60    pub fn error(msg: impl Into<String>) -> Self {
61        Self {
62            output: String::new(),
63            error: Some(msg.into()),
64        }
65    }
66
67    /// Create an output with both result text and a warning.
68    pub fn with_warning(output: impl Into<String>, warning: impl Into<String>) -> Self {
69        Self {
70            output: output.into(),
71            error: Some(warning.into()),
72        }
73    }
74}
75
76/// Pack a (ptr, len) pair into a single i64 for the ABI v2 return convention.
77///
78/// Layout: bits 0-31 = ptr, bits 32-63 = len.
79#[inline]
80pub fn pack_ptr_len(ptr: u32, len: u32) -> i64 {
81    (ptr as i64) | ((len as i64) << 32)
82}
83
84/// Allocate `size` bytes via the Rust allocator and leak the allocation.
85///
86/// Returns a raw pointer suitable for sharing with the host via linear memory.
87/// These allocations are intentionally leaked — WASM plugin instances are
88/// short-lived and all memory is reclaimed when the instance is dropped.
89#[inline]
90pub fn sdk_alloc(size: usize) -> *mut u8 {
91    let mut buf = vec![0u8; size];
92    let ptr = buf.as_mut_ptr();
93    std::mem::forget(buf);
94    ptr
95}
96
97/// Write bytes into WASM linear memory at the given pointer.
98///
99/// # Safety
100///
101/// `dst` must point to at least `src.len()` bytes of valid, writable memory.
102#[inline]
103pub unsafe fn write_to_memory(dst: *mut u8, src: &[u8]) {
104    core::ptr::copy_nonoverlapping(src.as_ptr(), dst, src.len());
105}
106
107/// Declare a WASM plugin tool with ABI v2 exports.
108///
109/// Generates three required exports:
110/// - `az_alloc(size: i32) -> i32` — allocator for host↔plugin memory sharing
111/// - `az_tool_name() -> i64` — packed ptr|len of the tool name string
112/// - `az_tool_execute(input_ptr: i32, input_len: i32) -> i64` — main entry point
113///
114/// # Usage
115///
116/// ```rust,ignore
117/// use agentzero_plugin_sdk::prelude::*;
118///
119/// declare_tool!("my_tool", handler);
120///
121/// fn handler(input: ToolInput) -> ToolOutput {
122///     ToolOutput::success("hello from plugin")
123/// }
124/// ```
125///
126/// The handler function must have signature `fn(ToolInput) -> ToolOutput`.
127#[macro_export]
128macro_rules! declare_tool {
129    ($name:expr, $handler:ident) => {
130        /// ABI v2 allocator export. Called by the host to allocate space in
131        /// plugin linear memory for writing input data.
132        #[no_mangle]
133        pub extern "C" fn az_alloc(size: i32) -> i32 {
134            $crate::sdk_alloc(size as usize) as i32
135        }
136
137        /// ABI v2 tool name export. Returns a packed ptr|len pointing to the
138        /// tool name string in linear memory.
139        #[no_mangle]
140        pub extern "C" fn az_tool_name() -> i64 {
141            let name: &[u8] = $name.as_bytes();
142            let ptr = $crate::sdk_alloc(name.len());
143            unsafe {
144                $crate::write_to_memory(ptr, name);
145            }
146            $crate::pack_ptr_len(ptr as u32, name.len() as u32)
147        }
148
149        /// ABI v2 main entry point. Receives JSON input, calls the handler,
150        /// and returns JSON output as a packed ptr|len.
151        #[no_mangle]
152        pub extern "C" fn az_tool_execute(input_ptr: i32, input_len: i32) -> i64 {
153            // Read input bytes from linear memory
154            let input_bytes =
155                unsafe { core::slice::from_raw_parts(input_ptr as *const u8, input_len as usize) };
156
157            // Deserialize input JSON
158            let tool_input: $crate::ToolInput = match serde_json::from_slice(input_bytes) {
159                Ok(v) => v,
160                Err(e) => {
161                    // Return a structured error on parse failure
162                    let err_output =
163                        $crate::ToolOutput::error(format!("failed to parse input: {}", e));
164                    let json = serde_json::to_vec(&err_output).unwrap_or_default();
165                    let ptr = $crate::sdk_alloc(json.len());
166                    unsafe {
167                        $crate::write_to_memory(ptr, &json);
168                    }
169                    return $crate::pack_ptr_len(ptr as u32, json.len() as u32);
170                }
171            };
172
173            // Call the user's handler
174            let output = $handler(tool_input);
175
176            // Serialize output JSON
177            let json = serde_json::to_vec(&output).unwrap_or_default();
178            let ptr = $crate::sdk_alloc(json.len());
179            unsafe {
180                $crate::write_to_memory(ptr, &json);
181            }
182            $crate::pack_ptr_len(ptr as u32, json.len() as u32)
183        }
184    };
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn tool_output_success() {
193        let out = ToolOutput::success("hello");
194        assert_eq!(out.output, "hello");
195        assert!(out.error.is_none());
196
197        let json = serde_json::to_string(&out).unwrap();
198        assert!(json.contains("\"output\":\"hello\""));
199        assert!(!json.contains("error"));
200    }
201
202    #[test]
203    fn tool_output_error() {
204        let out = ToolOutput::error("something broke");
205        assert!(out.output.is_empty());
206        assert_eq!(out.error.as_deref(), Some("something broke"));
207
208        let json = serde_json::to_string(&out).unwrap();
209        assert!(json.contains("\"error\":\"something broke\""));
210    }
211
212    #[test]
213    fn tool_output_with_warning() {
214        let out = ToolOutput::with_warning("result", "heads up");
215        assert_eq!(out.output, "result");
216        assert_eq!(out.error.as_deref(), Some("heads up"));
217    }
218
219    #[test]
220    fn tool_input_deserialize() {
221        let json = r#"{"input":"test data","workspace_root":"/tmp/ws"}"#;
222        let input: ToolInput = serde_json::from_str(json).unwrap();
223        assert_eq!(input.input, "test data");
224        assert_eq!(input.workspace_root, "/tmp/ws");
225    }
226
227    #[test]
228    fn pack_ptr_len_roundtrip() {
229        // Test the same encoding the runtime uses
230        let ptr: u32 = 0x1000;
231        let len: u32 = 42;
232        let packed = pack_ptr_len(ptr, len);
233
234        let recovered_ptr = (packed & 0xFFFF_FFFF) as u32;
235        let recovered_len = ((packed >> 32) & 0xFFFF_FFFF) as u32;
236        assert_eq!(recovered_ptr, ptr);
237        assert_eq!(recovered_len, len);
238    }
239
240    #[test]
241    fn pack_ptr_len_zero() {
242        let packed = pack_ptr_len(0, 0);
243        assert_eq!(packed, 0);
244    }
245
246    #[test]
247    fn pack_ptr_len_max_values() {
248        let packed = pack_ptr_len(u32::MAX, u32::MAX);
249        let recovered_ptr = (packed & 0xFFFF_FFFF) as u32;
250        let recovered_len = ((packed >> 32) & 0xFFFF_FFFF) as u32;
251        assert_eq!(recovered_ptr, u32::MAX);
252        assert_eq!(recovered_len, u32::MAX);
253    }
254
255    #[test]
256    fn sdk_alloc_returns_valid_pointer() {
257        let ptr = sdk_alloc(64);
258        assert!(!ptr.is_null());
259        // Write to it to verify it's valid memory
260        unsafe {
261            write_to_memory(ptr, &[0xAB; 64]);
262            assert_eq!(*ptr, 0xAB);
263            assert_eq!(*ptr.add(63), 0xAB);
264        }
265    }
266
267    #[test]
268    fn sdk_alloc_zero_size() {
269        // Zero-size allocation should not panic
270        let ptr = sdk_alloc(0);
271        // Pointer validity for zero-size is implementation-defined, just ensure no panic
272        let _ = ptr;
273    }
274
275    // Verify that the declare_tool! macro expands without errors.
276    fn test_handler(input: ToolInput) -> ToolOutput {
277        ToolOutput::success(format!("echo: {}", input.input))
278    }
279
280    declare_tool!("test_plugin", test_handler);
281
282    #[test]
283    fn macro_generates_az_alloc() {
284        // az_alloc returns a valid (non-null) pointer cast to i32.
285        // On native 64-bit this truncates, but the allocation itself succeeds.
286        let ptr = az_alloc(32);
287        // Just verify no panic — pointer validity can only be tested on wasm32
288        let _ = ptr;
289    }
290
291    // The remaining macro tests involve unpacking pointers from the packed i64
292    // ABI format. This only works correctly on wasm32 where pointers are 32-bit.
293    // On native 64-bit, the u32 truncation makes dereferencing unsafe.
294    // These are covered by the integration test (build to wasm32 + execute via wasmtime).
295    #[cfg(target_pointer_width = "32")]
296    mod wasm_abi_tests {
297        use super::*;
298
299        #[test]
300        fn macro_generates_az_tool_name() {
301            let packed = az_tool_name();
302            let ptr = (packed & 0xFFFF_FFFF) as u32;
303            let len = ((packed >> 32) & 0xFFFF_FFFF) as u32;
304            assert_eq!(len, 11); // "test_plugin".len()
305            let name = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) };
306            assert_eq!(name, b"test_plugin");
307        }
308
309        #[test]
310        fn macro_generates_az_tool_execute() {
311            let input_json = r#"{"input":"hello world","workspace_root":"/tmp"}"#;
312            let input_bytes = input_json.as_bytes();
313
314            let input_ptr = az_alloc(input_bytes.len() as i32);
315            unsafe {
316                core::ptr::copy_nonoverlapping(
317                    input_bytes.as_ptr(),
318                    input_ptr as *mut u8,
319                    input_bytes.len(),
320                );
321            }
322
323            let packed = az_tool_execute(input_ptr, input_bytes.len() as i32);
324            let out_ptr = (packed & 0xFFFF_FFFF) as u32;
325            let out_len = ((packed >> 32) & 0xFFFF_FFFF) as u32;
326
327            let output_bytes =
328                unsafe { core::slice::from_raw_parts(out_ptr as *const u8, out_len as usize) };
329            let output: ToolOutput = serde_json::from_slice(output_bytes).unwrap();
330            assert_eq!(output.output, "echo: hello world");
331            assert!(output.error.is_none());
332        }
333
334        #[test]
335        fn macro_handles_invalid_input() {
336            let bad_json = b"not valid json";
337            let input_ptr = az_alloc(bad_json.len() as i32);
338            unsafe {
339                core::ptr::copy_nonoverlapping(
340                    bad_json.as_ptr(),
341                    input_ptr as *mut u8,
342                    bad_json.len(),
343                );
344            }
345
346            let packed = az_tool_execute(input_ptr, bad_json.len() as i32);
347            let out_ptr = (packed & 0xFFFF_FFFF) as u32;
348            let out_len = ((packed >> 32) & 0xFFFF_FFFF) as u32;
349
350            let output_bytes =
351                unsafe { core::slice::from_raw_parts(out_ptr as *const u8, out_len as usize) };
352            let output: ToolOutput = serde_json::from_slice(output_bytes).unwrap();
353            assert!(output.output.is_empty());
354            assert!(output.error.as_deref().unwrap().contains("failed to parse"));
355        }
356    }
357}