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