Skip to main content

workforce_skill_sdk/
lib.rs

1//! # workforce-skill-sdk
2//!
3//! Write custom workforce skill tools in Rust that compile to WASM.
4//!
5//! This crate wraps the raw WASM host ABI so you write normal Rust code
6//! instead of pointer manipulation. Compile with `--target wasm32-unknown-unknown`
7//! and distribute via a GitHub repo.
8//!
9//! ## Quick Start
10//!
11//! ```rust,ignore
12//! use workforce_skill_sdk::prelude::*;
13//!
14//! workforce_skill_sdk::init!();
15//!
16//! tool!(get_weather, "Get current weather", {
17//!     "type": "object",
18//!     "properties": {
19//!         "city": { "type": "string", "description": "City name" }
20//!     },
21//!     "required": ["city"]
22//! }, |input: ToolInput| {
23//!     let city = input.get_str("city").unwrap_or("unknown");
24//!     let resp = http::get(
25//!         &format!("https://wttr.in/{}?format=j1", city),
26//!         &[],
27//!     );
28//!     match resp {
29//!         Some(r) if r.is_success() => ToolOutput::success(json!({"weather": r.body})),
30//!         _ => ToolOutput::error("Failed to fetch weather"),
31//!     }
32//! });
33//! ```
34//!
35//! ## Architecture
36//!
37//! Skill authors write tool handlers that receive a `ToolInput` (the agent's
38//! input parameters) and return a `ToolOutput` (success/failure with data).
39//! The SDK handles serialisation, memory management, and host function calls.
40
41use serde_json::Value;
42use std::collections::HashMap;
43
44// ─── Raw FFI ─────────────────────────────────────────────────────────────────
45// Host functions provided by the workforce WASM runtime.
46// Users never call these directly — use the `vault`, `http`, `log`, and
47// `config` modules instead.
48
49#[cfg(target_arch = "wasm32")]
50#[allow(dead_code)]
51extern "C" {
52    fn wf_log(level: i32, msg_ptr: i32, msg_len: i32);
53    fn wf_set_response(ptr: i32, len: i32);
54    fn wf_get_host_response_len() -> i32;
55    fn wf_get_host_response(buf_ptr: i32, buf_len: i32) -> i32;
56    fn wf_generate_uuid() -> i32;
57    fn wf_current_time() -> i32;
58    fn wf_vault_get(key_ptr: i32, key_len: i32) -> i32;
59    fn wf_vault_set(key_ptr: i32, key_len: i32, val_ptr: i32, val_len: i32) -> i32;
60    fn wf_config_get(key_ptr: i32, key_len: i32) -> i32;
61    fn wf_http_fetch(
62        method_ptr: i32,
63        method_len: i32,
64        url_ptr: i32,
65        url_len: i32,
66        headers_ptr: i32,
67        headers_len: i32,
68        body_ptr: i32,
69        body_len: i32,
70    ) -> i32;
71    fn wf_read_artifact(id_ptr: i32, id_len: i32) -> i32;
72    fn wf_stage_artifact(payload_ptr: i32, payload_len: i32) -> i32;
73    fn wf_tool_invoke(name_ptr: i32, name_len: i32, args_ptr: i32, args_len: i32) -> i32;
74}
75
76// ─── Common host response reader ────────────────────────────────────────────
77
78#[cfg(target_arch = "wasm32")]
79fn read_host_response_string() -> Option<String> {
80    let len = unsafe { wf_get_host_response_len() };
81    if len <= 0 {
82        return None;
83    }
84    let mut buf = vec![0u8; len as usize];
85    let read = unsafe { wf_get_host_response(buf.as_mut_ptr() as i32, len) };
86    if read <= 0 {
87        return None;
88    }
89    buf.truncate(read as usize);
90    String::from_utf8(buf).ok()
91}
92
93// ─── ToolInput ──────────────────────────────────────────────────────────────
94
95/// Input parameters received by a tool handler.
96///
97/// Wraps the JSON input from the agent with convenience accessors.
98#[derive(Debug, Clone)]
99pub struct ToolInput {
100    /// Raw JSON input from the agent.
101    pub data: Value,
102    /// The tool name being invoked.
103    pub tool_name: String,
104    /// The agent ID making the request.
105    pub agent_id: String,
106    /// The user ID making the request, if known. `None` for system-
107    /// initiated work that didn't opt into a synthetic identity. Skills
108    /// that read per-user credentials via `vault::get` don't need this —
109    /// the host applies user scoping automatically — but it's exposed
110    /// here for skills that want to surface "I am acting on behalf of X"
111    /// in their tool output.
112    pub user_id: Option<String>,
113}
114
115impl ToolInput {
116    /// Parse a `ToolInput` from the request JSON the runtime provides.
117    pub fn from_json(json: &str) -> Option<Self> {
118        let v: Value = serde_json::from_str(json).ok()?;
119        Some(Self {
120            data: v["input"].clone(),
121            tool_name: v["tool_name"].as_str().unwrap_or("").to_string(),
122            agent_id: v["agent_id"].as_str().unwrap_or("").to_string(),
123            user_id: v["user_id"].as_str().map(|s| s.to_string()),
124        })
125    }
126
127    /// Get a string parameter by key.
128    pub fn get_str(&self, key: &str) -> Option<&str> {
129        self.data.get(key).and_then(|v| v.as_str())
130    }
131
132    /// Get an integer parameter by key.
133    pub fn get_i64(&self, key: &str) -> Option<i64> {
134        self.data.get(key).and_then(|v| v.as_i64())
135    }
136
137    /// Get a float parameter by key.
138    pub fn get_f64(&self, key: &str) -> Option<f64> {
139        self.data.get(key).and_then(|v| v.as_f64())
140    }
141
142    /// Get a boolean parameter by key.
143    pub fn get_bool(&self, key: &str) -> Option<bool> {
144        self.data.get(key).and_then(|v| v.as_bool())
145    }
146
147    /// Get a nested JSON value by key.
148    pub fn get(&self, key: &str) -> Option<&Value> {
149        self.data.get(key)
150    }
151
152    /// Get the raw input as a reference.
153    pub fn raw(&self) -> &Value {
154        &self.data
155    }
156}
157
158// ─── ToolOutput ─────────────────────────────────────────────────────────────
159
160/// Output from a tool handler.
161///
162/// Serialised to JSON and returned to the workforce runtime, which passes
163/// it back to the agent as a `ToolResult`.
164#[derive(Debug, Clone)]
165pub struct ToolOutput {
166    success: bool,
167    result: Value,
168    error: Option<String>,
169}
170
171impl ToolOutput {
172    /// Create a successful output with the given result data.
173    pub fn success(result: Value) -> Self {
174        Self {
175            success: true,
176            result,
177            error: None,
178        }
179    }
180
181    /// Create a failure output with an error message.
182    pub fn error(message: &str) -> Self {
183        Self {
184            success: false,
185            result: Value::Null,
186            error: Some(message.to_string()),
187        }
188    }
189
190    /// Serialise to the JSON format the runtime expects.
191    pub fn into_json(self) -> String {
192        let obj = serde_json::json!({
193            "success": self.success,
194            "result": self.result,
195            "error": self.error,
196        });
197        serde_json::to_string(&obj)
198            .unwrap_or_else(|_| r#"{"success":false,"error":"serialisation failed"}"#.to_string())
199    }
200}
201
202// ─── vault module ───────────────────────────────────────────────────────────
203
204/// Read and write secrets in the workforce vault.
205///
206/// All paths are automatically scoped to `wasm-skills/{skill_name}/` — you
207/// only need to provide the key name relative to your skill.
208///
209/// Requires the `vault` capability in your `Skill.toml`.
210pub mod vault {
211    #[allow(unused_imports)]
212    use super::*;
213
214    /// Get a secret by key. Returns `None` if the key doesn't exist.
215    ///
216    /// ```rust,ignore
217    /// let api_key = vault::get("api_key");
218    /// ```
219    pub fn get(key: &str) -> Option<String> {
220        #[cfg(target_arch = "wasm32")]
221        {
222            let bytes = key.as_bytes();
223            let result = unsafe { wf_vault_get(bytes.as_ptr() as i32, bytes.len() as i32) };
224            if result < 0 {
225                return None;
226            }
227            read_host_response_string()
228        }
229        #[cfg(not(target_arch = "wasm32"))]
230        {
231            let _ = key;
232            None
233        }
234    }
235
236    /// Set a secret. Returns `true` on success.
237    ///
238    /// ```rust,ignore
239    /// vault::set("api_key", "sk-abc123");
240    /// ```
241    pub fn set(key: &str, value: &str) -> bool {
242        #[cfg(target_arch = "wasm32")]
243        {
244            let key_bytes = key.as_bytes();
245            let val_bytes = value.as_bytes();
246            let result = unsafe {
247                wf_vault_set(
248                    key_bytes.as_ptr() as i32,
249                    key_bytes.len() as i32,
250                    val_bytes.as_ptr() as i32,
251                    val_bytes.len() as i32,
252                )
253            };
254            result == 0
255        }
256        #[cfg(not(target_arch = "wasm32"))]
257        {
258            let _ = (key, value);
259            true
260        }
261    }
262}
263
264// ─── config module ──────────────────────────────────────────────────────────
265
266/// Read skill-scoped configuration values.
267///
268/// Config values are set when the skill is installed/configured. They are
269/// non-secret settings declared in the `[[config]]` section of `Skill.toml`.
270///
271/// Requires the `config` capability in your `Skill.toml`.
272pub mod config {
273    #[allow(unused_imports)]
274    use super::*;
275
276    /// Get a config value by key. Returns `None` if the key doesn't exist.
277    ///
278    /// ```rust,ignore
279    /// let units = config::get("units").unwrap_or_else(|| "metric".to_string());
280    /// ```
281    pub fn get(key: &str) -> Option<String> {
282        #[cfg(target_arch = "wasm32")]
283        {
284            let bytes = key.as_bytes();
285            let result = unsafe { wf_config_get(bytes.as_ptr() as i32, bytes.len() as i32) };
286            if result < 0 {
287                return None;
288            }
289            read_host_response_string()
290        }
291        #[cfg(not(target_arch = "wasm32"))]
292        {
293            let _ = key;
294            None
295        }
296    }
297}
298
299// ─── log module ─────────────────────────────────────────────────────────────
300
301/// Structured logging from inside your WASM skill.
302///
303/// Messages appear in the workforce hub's log output prefixed with `[wasm-skill]`.
304pub mod log {
305    #[allow(unused_imports)]
306    use super::*;
307
308    /// Log an error message (level 0).
309    pub fn error(msg: &str) {
310        write(0, msg);
311    }
312
313    /// Log a warning message (level 1).
314    pub fn warn(msg: &str) {
315        write(1, msg);
316    }
317
318    /// Log an info message (level 2).
319    pub fn info(msg: &str) {
320        write(2, msg);
321    }
322
323    /// Log a debug message (level 3).
324    pub fn debug(msg: &str) {
325        write(3, msg);
326    }
327
328    fn write(level: i32, msg: &str) {
329        #[cfg(target_arch = "wasm32")]
330        {
331            let bytes = msg.as_bytes();
332            unsafe {
333                super::wf_log(level, bytes.as_ptr() as i32, bytes.len() as i32);
334            }
335        }
336        #[cfg(not(target_arch = "wasm32"))]
337        {
338            let _ = (level, msg);
339        }
340    }
341}
342
343// ─── http module ────────────────────────────────────────────────────────────
344
345/// Make HTTP requests from inside your WASM skill.
346///
347/// Requires the `http` capability in your `Skill.toml`.
348pub mod tools {
349    #[allow(unused_imports)]
350    use super::*;
351
352    /// Invoke a platform tool declared in this skill's `[[permissions]]`.
353    ///
354    /// The call runs through the platform's governed dispatch — the user's
355    /// tools grant (`cmd: skill grant <skill> tools`), the capability gate
356    /// under the calling user, and the policy engine — so it can be refused
357    /// even when declared. On refusal the runtime attaches the full remedy to
358    /// this tool call's result for the agent; the `Err` here is a short
359    /// category for the guest's own control flow.
360    ///
361    /// `Ok` is the platform envelope: `{"success": bool, "output": …,
362    /// "error": …}` — check `success`, a tool-level failure is data, not a
363    /// policy denial.
364    ///
365    /// ```rust,ignore
366    /// let result = tools::invoke("slack.post_message", &json!({
367    ///     "channel": "C123", "text": "done"
368    /// }))?;
369    /// ```
370    pub fn invoke(name: &str, args: &Value) -> Result<Value, String> {
371        #[cfg(target_arch = "wasm32")]
372        {
373            let name_bytes = name.as_bytes();
374            let args_json = args.to_string();
375            let args_bytes = args_json.as_bytes();
376            let code = unsafe {
377                wf_tool_invoke(
378                    name_bytes.as_ptr() as i32,
379                    name_bytes.len() as i32,
380                    args_bytes.as_ptr() as i32,
381                    args_bytes.len() as i32,
382                )
383            };
384            match code {
385                0 => read_host_response_string()
386                    .and_then(|s| serde_json::from_str(&s).ok())
387                    .ok_or_else(|| "tool result unavailable".to_string()),
388                -2 => Err("the `tools` capability is not declared in Skill.toml".to_string()),
389                -4 => Err(
390                    "denied by platform policy (declaration, grant, capability, or rule) — \
391                     surface this failure; the runtime attaches the remedy for the agent"
392                        .to_string(),
393                ),
394                -5 => Err("tool-call budget exhausted for this invocation".to_string()),
395                _ => Err("tool invocation failed".to_string()),
396            }
397        }
398        #[cfg(not(target_arch = "wasm32"))]
399        {
400            let _ = (name, args);
401            Err("not running in the WASM runtime".to_string())
402        }
403    }
404}
405
406pub mod http {
407    #[allow(unused_imports)]
408    use super::*;
409
410    /// Response from an HTTP request.
411    #[derive(Debug, Clone)]
412    pub struct FetchResponse {
413        /// HTTP status code (e.g., 200, 404, 500).
414        pub status: i32,
415        /// Response body as a string.
416        pub body: String,
417        /// Body encoding: "utf8" for text, "base64" for binary content.
418        pub body_encoding: String,
419        /// Response headers.
420        pub headers: HashMap<String, String>,
421    }
422
423    impl FetchResponse {
424        /// Parse the response body as JSON.
425        pub fn json(&self) -> Option<Value> {
426            serde_json::from_str(&self.body).ok()
427        }
428
429        /// Check if the response status indicates success (2xx).
430        pub fn is_success(&self) -> bool {
431            (200..300).contains(&self.status)
432        }
433
434        /// Check if the body is base64-encoded (binary content).
435        pub fn is_base64(&self) -> bool {
436            self.body_encoding == "base64"
437        }
438    }
439
440    /// Make an HTTP request.
441    pub fn fetch(
442        method: &str,
443        url: &str,
444        headers: &[(&str, &str)],
445        body: Option<&str>,
446    ) -> Option<FetchResponse> {
447        #[cfg(target_arch = "wasm32")]
448        {
449            let method_bytes = method.as_bytes();
450            let url_bytes = url.as_bytes();
451            let headers_map: HashMap<&str, &str> = headers.iter().copied().collect();
452            let headers_json = serde_json::to_string(&headers_map).unwrap_or_default();
453            let headers_bytes = headers_json.as_bytes();
454            let (body_bytes, body_len) = match body {
455                Some(b) => (b.as_bytes(), b.len()),
456                None => (&[] as &[u8], 0),
457            };
458
459            let result = unsafe {
460                wf_http_fetch(
461                    method_bytes.as_ptr() as i32,
462                    method_bytes.len() as i32,
463                    url_bytes.as_ptr() as i32,
464                    url_bytes.len() as i32,
465                    headers_bytes.as_ptr() as i32,
466                    headers_bytes.len() as i32,
467                    body_bytes.as_ptr() as i32,
468                    body_len as i32,
469                )
470            };
471
472            if result < 0 {
473                return None;
474            }
475
476            read_fetch_response()
477        }
478        #[cfg(not(target_arch = "wasm32"))]
479        {
480            let _ = (method, url, headers, body);
481            None
482        }
483    }
484
485    /// Make a GET request.
486    pub fn get(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
487        fetch("GET", url, headers, None)
488    }
489
490    /// Make a POST request with a body.
491    pub fn post(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
492        fetch("POST", url, headers, Some(body))
493    }
494
495    /// Make a PUT request with a body.
496    pub fn put(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
497        fetch("PUT", url, headers, Some(body))
498    }
499
500    /// Make a DELETE request.
501    pub fn delete(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
502        fetch("DELETE", url, headers, None)
503    }
504
505    /// Make a PATCH request with a body.
506    pub fn patch(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
507        fetch("PATCH", url, headers, Some(body))
508    }
509
510    #[cfg(target_arch = "wasm32")]
511    fn read_fetch_response() -> Option<FetchResponse> {
512        let json_str = read_host_response_string()?;
513        let v: Value = serde_json::from_str(&json_str).ok()?;
514        Some(FetchResponse {
515            status: v["status"].as_i64().unwrap_or(0) as i32,
516            body: v["body"].as_str().unwrap_or("").to_string(),
517            body_encoding: v["body_encoding"].as_str().unwrap_or("utf8").to_string(),
518            headers: v["headers"]
519                .as_object()
520                .map(|m| {
521                    m.iter()
522                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
523                        .collect()
524                })
525                .unwrap_or_default(),
526        })
527    }
528}
529
530// ─── artifact module ────────────────────────────────────────────────────────
531
532/// Read and stage workforce artifacts (uploaded files, produced outputs).
533///
534/// A skill takes the bytes of a staged file by `artifact_id`, transforms them
535/// in-process, and stages the result for delivery — the bytes never pass
536/// through the agent's LLM context.
537///
538/// Requires the `artifacts` capability in your `Skill.toml`.
539pub mod artifact {
540    #[allow(unused_imports)]
541    use super::*;
542    #[cfg(target_arch = "wasm32")]
543    use base64::Engine;
544
545    /// The bytes of a staged artifact plus its stored mime and size.
546    #[derive(Debug, Clone)]
547    pub struct Artifact {
548        pub bytes: Vec<u8>,
549        pub mime: String,
550        pub size: usize,
551    }
552
553    /// A staged artifact ready for delivery. Pass it to [`attach`] so the
554    /// runtime delivers the file to the conversation.
555    #[derive(Debug, Clone)]
556    pub struct StagedArtifact {
557        pub artifact_id: String,
558        pub media_type: String,
559        pub filename: String,
560        pub bytes: u64,
561    }
562
563    impl StagedArtifact {
564        /// The delivery entry the runtime expects for one file.
565        pub fn entry(&self) -> Value {
566            serde_json::json!({
567                "artifact_id": self.artifact_id,
568                "media_type": self.media_type,
569                "filename": self.filename,
570            })
571        }
572    }
573
574    /// Read a staged artifact by id. Returns `None` if it doesn't exist, isn't
575    /// readable in this session, or exceeds the host read limit.
576    pub fn read(id: &str) -> Option<Artifact> {
577        #[cfg(target_arch = "wasm32")]
578        {
579            let bytes = id.as_bytes();
580            let result = unsafe { wf_read_artifact(bytes.as_ptr() as i32, bytes.len() as i32) };
581            if result < 0 {
582                return None;
583            }
584            let v: Value = serde_json::from_str(&read_host_response_string()?).ok()?;
585            let decoded = base64::engine::general_purpose::STANDARD
586                .decode(v["bytes_base64"].as_str()?)
587                .ok()?;
588            Some(Artifact {
589                bytes: decoded,
590                mime: v["mime"]
591                    .as_str()
592                    .unwrap_or("application/octet-stream")
593                    .to_string(),
594                size: v["size"].as_u64().unwrap_or(0) as usize,
595            })
596        }
597        #[cfg(not(target_arch = "wasm32"))]
598        {
599            let _ = id;
600            None
601        }
602    }
603
604    /// Stage `bytes` as a new artifact for delivery. `media_type` and
605    /// `filename` label the delivered download. Returns `None` on failure.
606    pub fn stage(bytes: &[u8], media_type: &str, filename: &str) -> Option<StagedArtifact> {
607        #[cfg(target_arch = "wasm32")]
608        {
609            let payload = serde_json::json!({
610                "bytes_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
611                "media_type": media_type,
612                "filename": filename,
613            })
614            .to_string();
615            let pb = payload.as_bytes();
616            let result = unsafe { wf_stage_artifact(pb.as_ptr() as i32, pb.len() as i32) };
617            if result < 0 {
618                return None;
619            }
620            let v: Value = serde_json::from_str(&read_host_response_string()?).ok()?;
621            Some(StagedArtifact {
622                artifact_id: v["artifact_id"].as_str()?.to_string(),
623                media_type: v["media_type"].as_str().unwrap_or(media_type).to_string(),
624                filename: v["filename"].as_str().unwrap_or(filename).to_string(),
625                bytes: v["bytes"].as_u64().unwrap_or(bytes.len() as u64),
626            })
627        }
628        #[cfg(not(target_arch = "wasm32"))]
629        {
630            let _ = (bytes, media_type, filename);
631            None
632        }
633    }
634
635    /// Key under which the runtime scans a tool result for files to deliver.
636    const DELIVERY_KEY: &str = "generated_images";
637
638    /// Attach staged files to a tool result so the runtime delivers them. Sets
639    /// the workforce file-delivery key on the result object; a non-object
640    /// result or an empty `files` slice is returned unchanged.
641    pub fn attach(mut result: Value, files: &[StagedArtifact]) -> Value {
642        if let (Value::Object(map), false) = (&mut result, files.is_empty()) {
643            map.insert(
644                DELIVERY_KEY.to_string(),
645                Value::Array(files.iter().map(StagedArtifact::entry).collect()),
646            );
647        }
648        result
649    }
650}
651
652// ─── util module ────────────────────────────────────────────────────────────
653
654/// Utility functions for common operations in WASM skill handlers.
655pub mod util {
656    #[allow(unused_imports)]
657    use super::*;
658
659    /// Generate a new random UUID v4 string.
660    pub fn generate_uuid() -> String {
661        #[cfg(target_arch = "wasm32")]
662        {
663            let result = unsafe { super::wf_generate_uuid() };
664            if result < 0 {
665                return String::new();
666            }
667            read_host_response_string().unwrap_or_default()
668        }
669
670        #[cfg(not(target_arch = "wasm32"))]
671        {
672            // Fallback for native testing — not a real UUID
673            format!("{:016x}", {
674                use std::time::SystemTime;
675                SystemTime::now()
676                    .duration_since(SystemTime::UNIX_EPOCH)
677                    .map(|d| d.as_nanos() as u64)
678                    .unwrap_or(0)
679            })
680        }
681    }
682
683    /// Get the current UTC time as an RFC3339 string.
684    pub fn current_time() -> String {
685        #[cfg(target_arch = "wasm32")]
686        {
687            let result = unsafe { super::wf_current_time() };
688            if result < 0 {
689                return String::new();
690            }
691            read_host_response_string().unwrap_or_default()
692        }
693
694        #[cfg(not(target_arch = "wasm32"))]
695        {
696            use std::time::SystemTime;
697            let secs = SystemTime::now()
698                .duration_since(SystemTime::UNIX_EPOCH)
699                .map(|d| d.as_secs())
700                .unwrap_or(0);
701            format!("1970-01-01T00:00:{:02}Z", secs % 60)
702        }
703    }
704}
705
706// ─── Handler runtime ────────────────────────────────────────────────────────
707
708/// Internal function used by the `tool!` macro. Do not call directly.
709#[doc(hidden)]
710pub fn __run_tool_handler<F>(ptr: i32, len: i32, f: F) -> i32
711where
712    F: FnOnce(ToolInput) -> ToolOutput,
713{
714    // Read the request JSON from guest memory.
715    let request_json = unsafe {
716        let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize);
717        String::from_utf8_lossy(slice).into_owned()
718    };
719
720    // Parse the input.
721    let input = ToolInput::from_json(&request_json).unwrap_or_else(|| ToolInput {
722        data: Value::Null,
723        tool_name: String::new(),
724        agent_id: String::new(),
725        user_id: None,
726    });
727
728    // Call the user's handler.
729    let output = f(input);
730    let response_bytes = output.into_json().into_bytes();
731
732    // Write response to guest memory: [4-byte LE length][data]
733    let total = 4 + response_bytes.len();
734    let layout = std::alloc::Layout::from_size_align(total, 1).expect("invalid layout");
735    let out_ptr = unsafe { std::alloc::alloc(layout) };
736
737    unsafe {
738        let len_bytes = (response_bytes.len() as u32).to_le_bytes();
739        std::ptr::copy_nonoverlapping(len_bytes.as_ptr(), out_ptr, 4);
740        std::ptr::copy_nonoverlapping(
741            response_bytes.as_ptr(),
742            out_ptr.add(4),
743            response_bytes.len(),
744        );
745    }
746
747    out_ptr as i32
748}
749
750// ─── Macros ─────────────────────────────────────────────────────────────────
751
752/// Initialize the workforce skill SDK runtime.
753///
754/// Call this once at the top of your `lib.rs`. Exports the `alloc` function
755/// that the workforce runtime needs to pass data into your WASM module.
756///
757/// ```rust,ignore
758/// workforce_skill_sdk::init!();
759/// ```
760#[macro_export]
761macro_rules! init {
762    () => {
763        #[no_mangle]
764        pub extern "C" fn alloc(size: i32) -> i32 {
765            let layout = std::alloc::Layout::from_size_align(size as usize, 1).unwrap();
766            unsafe { std::alloc::alloc(layout) as i32 }
767        }
768    };
769}
770
771/// Define a tool handler function.
772///
773/// This macro generates the `#[no_mangle] extern "C"` boilerplate so your
774/// tool handler is a plain Rust closure that receives a [`ToolInput`] and
775/// returns a [`ToolOutput`].
776///
777/// ```rust,ignore
778/// use workforce_skill_sdk::prelude::*;
779///
780/// workforce_skill_sdk::init!();
781///
782/// tool!(get_weather, |input: ToolInput| {
783///     let city = input.get_str("city").unwrap_or("unknown");
784///     ToolOutput::success(json!({"city": city, "temp": 22}))
785/// });
786/// ```
787#[macro_export]
788macro_rules! tool {
789    ($name:ident, |$input:ident : ToolInput| $body:expr) => {
790        #[no_mangle]
791        pub extern "C" fn $name(ptr: i32, len: i32) -> i32 {
792            $crate::__run_tool_handler(ptr, len, |$input: $crate::ToolInput| $body)
793        }
794    };
795}
796
797// ─── Prelude ────────────────────────────────────────────────────────────────
798
799/// Import everything you need to write skill tools.
800///
801/// ```rust,ignore
802/// use workforce_skill_sdk::prelude::*;
803/// ```
804pub mod prelude {
805    pub use crate::artifact;
806    pub use crate::config;
807    pub use crate::http;
808    pub use crate::log;
809    pub use crate::tools;
810    pub use crate::util;
811    pub use crate::vault;
812    pub use crate::ToolInput;
813    pub use crate::ToolOutput;
814    pub use serde_json::{json, Value};
815}
816
817// ─── Tests ──────────────────────────────────────────────────────────────────
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use serde_json::json;
823
824    #[test]
825    fn tool_input_parsing() {
826        let json = serde_json::to_string(&json!({
827            "tool_name": "weather.get_forecast",
828            "handler": "get_forecast",
829            "input": {"city": "London", "units": "metric"},
830            "agent_id": "agent-1",
831        }))
832        .unwrap();
833
834        let input = ToolInput::from_json(&json).unwrap();
835        assert_eq!(input.tool_name, "weather.get_forecast");
836        assert_eq!(input.agent_id, "agent-1");
837        assert_eq!(input.get_str("city"), Some("London"));
838        assert_eq!(input.get_str("units"), Some("metric"));
839        assert!(input.get_str("nonexistent").is_none());
840    }
841
842    #[test]
843    fn tool_input_accessors() {
844        let json = serde_json::to_string(&json!({
845            "input": {"count": 42, "ratio": 2.78, "active": true},
846        }))
847        .unwrap();
848
849        let input = ToolInput::from_json(&json).unwrap();
850        assert_eq!(input.get_i64("count"), Some(42));
851        assert_eq!(input.get_f64("ratio"), Some(2.78));
852        assert_eq!(input.get_bool("active"), Some(true));
853    }
854
855    #[test]
856    fn tool_input_missing_fields() {
857        let json = r#"{"input": {}}"#;
858        let input = ToolInput::from_json(json).unwrap();
859        assert_eq!(input.tool_name, "");
860        assert_eq!(input.agent_id, "");
861        assert_eq!(input.user_id, None);
862    }
863
864    #[test]
865    fn tool_input_parses_user_id() {
866        let json = serde_json::to_string(&json!({
867            "tool_name": "x",
868            "agent_id": "a",
869            "user_id": "alice",
870            "input": {},
871        }))
872        .unwrap();
873        let input = ToolInput::from_json(&json).unwrap();
874        assert_eq!(input.user_id.as_deref(), Some("alice"));
875    }
876
877    #[test]
878    fn tool_input_user_id_absent_when_unset() {
879        let json = r#"{"tool_name": "x", "agent_id": "a", "input": {}}"#;
880        let input = ToolInput::from_json(json).unwrap();
881        assert_eq!(input.user_id, None);
882    }
883
884    #[test]
885    fn tool_output_success() {
886        let output = ToolOutput::success(json!({"data": "test"}));
887        let json_str = output.into_json();
888        let parsed: Value = serde_json::from_str(&json_str).unwrap();
889        assert_eq!(parsed["success"], true);
890        assert_eq!(parsed["result"]["data"], "test");
891        assert!(parsed["error"].is_null());
892    }
893
894    #[test]
895    fn tool_output_error() {
896        let output = ToolOutput::error("something failed");
897        let json_str = output.into_json();
898        let parsed: Value = serde_json::from_str(&json_str).unwrap();
899        assert_eq!(parsed["success"], false);
900        assert!(parsed["result"].is_null());
901        assert_eq!(parsed["error"], "something failed");
902    }
903
904    #[test]
905    fn vault_get_noop_on_native() {
906        assert!(vault::get("any-key").is_none());
907    }
908
909    #[test]
910    fn vault_set_noop_on_native() {
911        assert!(vault::set("key", "value"));
912    }
913
914    #[test]
915    fn config_get_noop_on_native() {
916        assert!(config::get("any-key").is_none());
917    }
918
919    #[test]
920    fn http_get_noop_on_native() {
921        assert!(http::get("https://example.com", &[]).is_none());
922    }
923
924    #[test]
925    fn http_post_noop_on_native() {
926        assert!(http::post("https://example.com", &[], "{}").is_none());
927    }
928
929    #[test]
930    fn util_generate_uuid() {
931        let id = util::generate_uuid();
932        assert!(!id.is_empty());
933    }
934
935    #[test]
936    fn util_current_time() {
937        let time = util::current_time();
938        assert!(!time.is_empty());
939    }
940
941    #[test]
942    fn http_fetch_response_helpers() {
943        let resp = http::FetchResponse {
944            status: 200,
945            body: r#"{"key": "value"}"#.to_string(),
946            body_encoding: "utf8".to_string(),
947            headers: HashMap::new(),
948        };
949        assert!(resp.is_success());
950        assert!(!resp.is_base64());
951        let json = resp.json().unwrap();
952        assert_eq!(json["key"], "value");
953
954        let err_resp = http::FetchResponse {
955            status: 404,
956            body: "not found".to_string(),
957            body_encoding: "utf8".to_string(),
958            headers: HashMap::new(),
959        };
960        assert!(!err_resp.is_success());
961
962        let binary_resp = http::FetchResponse {
963            status: 200,
964            body: "aW1hZ2VkYXRh".to_string(),
965            body_encoding: "base64".to_string(),
966            headers: HashMap::new(),
967        };
968        assert!(binary_resp.is_base64());
969    }
970
971    #[test]
972    fn artifact_read_stage_noop_on_native() {
973        assert!(artifact::read("art_0").is_none());
974        assert!(artifact::stage(b"x", "text/plain", "x.txt").is_none());
975    }
976
977    #[test]
978    fn artifact_attach_sets_delivery_key() {
979        let staged = artifact::StagedArtifact {
980            artifact_id: "art_00000000000000000000000000000000".to_string(),
981            media_type: "application/pdf".to_string(),
982            filename: "out.pdf".to_string(),
983            bytes: 42,
984        };
985        let out = artifact::attach(json!({ "rows": 3 }), std::slice::from_ref(&staged));
986        assert_eq!(out["rows"], 3);
987        let entries = out["generated_images"].as_array().unwrap();
988        assert_eq!(entries.len(), 1);
989        assert_eq!(entries[0]["artifact_id"], staged.artifact_id);
990        assert_eq!(entries[0]["media_type"], "application/pdf");
991        assert_eq!(entries[0]["filename"], "out.pdf");
992    }
993
994    #[test]
995    fn artifact_attach_empty_is_unchanged() {
996        let out = artifact::attach(json!({ "rows": 3 }), &[]);
997        assert!(out.get("generated_images").is_none());
998    }
999}