Skip to main content

rpi_extensions/
tool.rs

1//! [`PluginToolHandle`] — the plugin's 4-function lifecycle bundle held per
2//! registered tool — and [`PluginToolAdapter`], the `AgentTool` impl that drives
3//! it across the async/FFI boundary via the corrected spawn_blocking bridge.
4//!
5//! See the crate-level docs for the load-bearing soundness rationale. The short
6//! version: a plugin tool is driven by **four** plugin fns (`execute`→handle,
7//! `poll`, `cancel`, `destroy`). The adapter spawns a **blocking** driver that
8//! loops `poll`, forwards `Pending` partials through an mpsc, sends the terminal
9//! result through a oneshot, and calls `destroy` **exactly once**. Cancel sets
10//! an `AtomicBool` the driver observes; the async side keeps awaiting oneshot
11//! (never drops the driver). `cancel` ≠ `destroy`.
12
13use std::ffi::c_void;
14use std::panic::{catch_unwind, AssertUnwindSafe};
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use rpi_agent::agent_tool::AgentTool;
20use rpi_agent::error::AgentError;
21use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolExecutionMode, ToolResultPartial};
22use rpi_ai::types::Tool;
23use rpi_plugin_sdk::{
24    FreeStringFn, StbString, StbStringRef, StepHandle, StepResultTag, ToolCancelFn, ToolDestroyFn,
25    ToolExecuteFn, ToolPollFn,
26};
27use tokio::sync::{mpsc, oneshot};
28use tokio_util::sync::CancellationToken;
29
30use crate::host_free_string;
31use crate::loader::PluginKeepalive;
32
33// ---------------------------------------------------------------------------
34// PluginToolHandle — the 4-fn bundle + the plugin's free_string
35// ---------------------------------------------------------------------------
36
37/// The plugin's per-tool lifecycle bundle the host holds after a successful
38/// `register_tool`. All fields are fn pointers (Copy), so the handle is `Copy`:
39/// cloning duplicates the pointers, not any allocation. A registered tool is
40/// driven by at most one [`PluginToolAdapter`] at a time, but the handle is
41/// copied through the registry snapshot path, hence `Copy`.
42///
43/// `plugin_free_string` is the fn the **plugin** exports to free the
44/// [`StbString`]s it *produces* (schema strings, terminal result, pending
45/// progress). The host calls it for each such string it receives.
46#[derive(Clone, Copy)]
47pub struct PluginToolHandle {
48    pub(crate) execute_fn: ToolExecuteFn,
49    pub(crate) poll_fn: ToolPollFn,
50    pub(crate) cancel_fn: ToolCancelFn,
51    pub(crate) destroy_fn: ToolDestroyFn,
52    pub(crate) plugin_free_string: FreeStringFn,
53}
54
55// ---------------------------------------------------------------------------
56// JSON ⇄ AgentToolResult helpers (host side)
57// ---------------------------------------------------------------------------
58
59/// Serialize an `AgentToolResult` to a JSON string. Used for partials the host
60/// hands to the plugin's partial callback (host-produced → plugin frees via
61/// host `free_string`) and is also the shape the plugin returns for `Done`.
62fn result_to_json(result: &AgentToolResult) -> String {
63    let mut txt = String::new();
64    txt.push('{');
65    txt.push_str("\"content\":[");
66    for (i, c) in result.content.iter().enumerate() {
67        if i > 0 {
68            txt.push(',');
69        }
70        match c {
71            TextContentOrImage::Text(t) => {
72                txt.push_str(
73                    &serde_json::to_string(&serde_json::json!({ "type": "text", "text": t.text }))
74                        .unwrap_or_else(|_| "\"\"".into()),
75                );
76            }
77            TextContentOrImage::Image(img) => {
78                txt.push_str(
79                    &serde_json::to_string(&serde_json::json!({
80                        "type": "image",
81                        "data": img.data,
82                        "mimeType": img.mime_type,
83                    }))
84                    .unwrap_or_else(|_| "\"\"".into()),
85                );
86            }
87        }
88    }
89    txt.push(']');
90    txt.push_str(",\"details\":");
91    txt.push_str(&serde_json::to_string(&result.details).unwrap_or_else(|_| "null".into()));
92    txt.push_str(",\"terminate\":");
93    txt.push_str(if result.terminate { "true" } else { "false" });
94    txt.push_str(",\"addedToolNames\":");
95    txt.push_str(&serde_json::to_string(&result.added_tool_names).unwrap_or_else(|_| "[]".into()));
96    // usage omitted from the wire shape (Option<Usage> is not in every plugin's
97    // contract; the model-visible content/details are what matter). Documented
98    // v1 limit: usage does not cross to the plugin.
99    txt.push('}');
100    txt
101}
102
103/// Parse a plugin-produced JSON `AgentToolResult` back into the native struct.
104/// Lenient: missing fields default. The caller frees the input `StbString` via
105/// the plugin's `free_string` (plugin produced it).
106fn stb_to_result(s: &StbString) -> AgentToolResult {
107    let text = s.to_string_lossy();
108    let val: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
109    let mut result = AgentToolResult::default();
110    if let Some(obj) = val.as_object() {
111        if let Some(content) = obj.get("content").and_then(|v| v.as_array()) {
112            for block in content {
113                let kind = block.get("type").and_then(|v| v.as_str()).unwrap_or("text");
114                match kind {
115                    "image" => {
116                        let data = block
117                            .get("data")
118                            .and_then(|v| v.as_str())
119                            .unwrap_or("")
120                            .to_string();
121                        let mime = block
122                            .get("mimeType")
123                            .or_else(|| block.get("mime_type"))
124                            .and_then(|v| v.as_str())
125                            .unwrap_or("image/png")
126                            .to_string();
127                        result.content.push(TextContentOrImage::Image(
128                            rpi_ai::types::ImageContent {
129                                kind: rpi_ai::types::ImageContentType,
130                                data,
131                                mime_type: mime,
132                            },
133                        ));
134                    }
135                    _ => {
136                        let t = block
137                            .get("text")
138                            .and_then(|v| v.as_str())
139                            .unwrap_or("")
140                            .to_string();
141                        result.content.push(TextContentOrImage::text(t));
142                    }
143                }
144            }
145        }
146        if let Some(details) = obj.get("details") {
147            result.details = details.clone();
148        }
149        if let Some(terms) = obj.get("terminate").and_then(|v| v.as_bool()) {
150            result.terminate = terms;
151        }
152        if let Some(arr) = obj
153            .get("addedToolNames")
154            .or_else(|| obj.get("added_tool_names"))
155            .and_then(|v| v.as_array())
156        {
157            result.added_tool_names = arr
158                .iter()
159                .filter_map(|v| v.as_str().map(String::from))
160                .collect();
161        }
162        if let Some(usage) = obj.get("usage") {
163            if let Ok(u) = serde_json::from_value::<rpi_ai::types::Usage>(usage.clone()) {
164                result.usage = Some(u);
165            }
166        }
167    }
168    result
169}
170
171// ---------------------------------------------------------------------------
172// PluginToolAdapter — AgentTool impl driving the 4-fn handle
173// ---------------------------------------------------------------------------
174
175/// An [`AgentTool`] backed by a plugin's 4-function handle. One adapter is built
176/// per registered tool (`schema` copied from the registration) and inserted into
177/// the session's tool set in B2.
178///
179/// Holds a clone of the session's [`PluginKeepalive`] so the cdylib that owns
180/// `handle`'s fn pointers stays mapped for as long as the adapter (and thus any
181/// in-flight `execute`) may call them. Without this the `Library` could drop
182/// (unload the cdylib) while a fn pointer is still callable → UAF. The keepalive
183/// is `Arc`-shared with every other adapter built from the same session, so the
184/// last drop — which can only happen once the harness's tool vec drops — unloads.
185pub struct PluginToolAdapter {
186    schema: Tool,
187    label: String,
188    handle: PluginToolHandle,
189    // Drop order: `keepalive` is declared AFTER `handle` so the cdylib unloads
190    // only after the fn-pointer bundle is itself dropped — though since both are
191    // fine to drop in any order (fn pointers are Copy, the real call sites are
192    // all inside `execute` which holds `&self`, so the adapter is never dropped
193    // mid-call), this is belt-and-suspenders.
194    #[allow(dead_code)]
195    keepalive: Arc<PluginKeepalive>,
196}
197
198impl PluginToolAdapter {
199    /// Build an adapter from the registered schema + handle + the session's
200    /// keepalive. `label` defaults to the tool name. The keepalive clone keeps
201    /// the owning cdylib mapped for the adapter's lifetime.
202    pub fn new(schema: Tool, handle: PluginToolHandle, keepalive: Arc<PluginKeepalive>) -> Self {
203        let label = schema.name.clone();
204        Self {
205            schema,
206            label,
207            handle,
208            keepalive,
209        }
210    }
211}
212
213/// The partial-callback trampoline passed to `poll`. `user_data` is a
214/// `*const mpsc::UnboundedSender<AgentToolResult>` valid for the drive (the
215/// blocking driver owns the sender in its closure env). The plugin invokes this
216/// synchronously inside `poll()`; it parses the partial, frees the plugin-
217/// produced `StbString` via the host's `free_string`, and pushes the result
218/// through the mpsc. Wrapped in `catch_unwind` so a poisoned sender / panic
219/// cannot unwind across FFI (abort-on-unwind).
220extern "C" fn partial_cb_trampoline(partial: StbString, user_data: *mut c_void) {
221    let outcome = catch_unwind(AssertUnwindSafe(|| {
222        if user_data.is_null() {
223            // Still must free the partial (plugin produced it; host owns
224            // cleanup when no handler runs — here "no handler" means no sender).
225            host_free_string(partial);
226            return;
227        }
228        // SAFETY: the blocking driver guarantees `user_data` is a live
229        // `&mpsc::UnboundedSender<AgentToolResult>` for the duration of poll().
230        let sender = unsafe { &*(user_data as *const mpsc::UnboundedSender<AgentToolResult>) };
231        let result = stb_to_result(&partial);
232        // The partial StbString was plugin-produced inside poll(); the host is
233        // the receiver and owns the free (plugin allocated with the global
234        // allocator, which host_free_string reclaims — documented v1 contract).
235        host_free_string(partial);
236        let _ = sender.send(result);
237    }));
238    if outcome.is_err() {
239        tracing::error!("plugin partial callback panicked — aborting (cannot unwind across FFI)");
240        std::process::abort();
241    }
242}
243
244#[async_trait]
245impl AgentTool for PluginToolAdapter {
246    fn schema(&self) -> &Tool {
247        &self.schema
248    }
249
250    fn label(&self) -> &str {
251        &self.label
252    }
253
254    fn execution_mode(&self) -> ToolExecutionMode {
255        // Plugin tools default to Parallel (the AgentTool default). A plugin
256        // could declare Sequential via a future schema field; v1 keeps Parallel.
257        ToolExecutionMode::Parallel
258    }
259
260    async fn execute(
261        &self,
262        tool_call_id: &str,
263        params: serde_json::Value,
264        signal: CancellationToken,
265        on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
266    ) -> Result<AgentToolResult, AgentError> {
267        // 1. Acquire the ambient runtime (the adapter only runs inside the agent
268        //    loop's runtime). Do NOT own a runtime.
269        let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
270            AgentError::State(format!(
271                "plugin tool '{}' executed off-runtime: {e}",
272                self.schema.name
273            ))
274        })?;
275
276        // 2. Bridges: unbounded mpsc for partials, oneshot for terminal.
277        let (partial_tx, mut partial_rx) = mpsc::unbounded_channel::<AgentToolResult>();
278        let (done_tx, done_rx) = oneshot::channel::<Result<AgentToolResult, AgentError>>();
279
280        // The cancel flag the blocking driver observes. Set by `signal.cancelled()`
281        // (and by the drop guard, were one needed). SeqCst for cross-thread
282        // visibility with the blocking driver.
283        let cancel_flag = Arc::new(AtomicBool::new(false));
284
285        // 3. Prepare plugin execute() inputs. `params` is an owning JSON string
286        //    the host produced → the plugin frees it via the host's free_string.
287        //    `tool_call_id` is borrowed for the call.
288        let params_json = serde_json::to_string(&params).unwrap_or_else(|_| "null".to_string());
289        let params_stb = StbString::from_string(params_json);
290        let id_ref = StbStringRef::from_str(tool_call_id);
291        let plugin_free = self.handle.plugin_free_string;
292
293        let execute_fn = self.handle.execute_fn;
294        let poll_fn = self.handle.poll_fn;
295        let cancel_fn = self.handle.cancel_fn;
296        let destroy_fn = self.handle.destroy_fn;
297
298        let schema_name = self.schema.name.clone();
299        let schema_name_for_error = schema_name.clone();
300
301        // 4. spawn_blocking driver. It runs to completion regardless of
302        //    outer-future drop, so we NEVER drop it; on cancel we set the flag
303        //    and keep awaiting done_rx. The sender is captured (Send); the raw
304        //    pointer to it is computed INSIDE the closure (not moved across
305        //    threads — `*mut c_void` is not `Send`).
306        let cancel_flag_drive = Arc::clone(&cancel_flag);
307        let sender_for_cb = partial_tx.clone();
308        runtime.spawn_blocking(move || {
309            // Drive: execute → poll loop → destroy exactly once. Every plugin
310            // call is extern "C"; wrap in catch_unwind so a plugin panic cannot
311            // unwind across FFI (abort-on-unwind).
312            let step_handle: StepHandle = {
313                let outcome = catch_unwind(AssertUnwindSafe(|| {
314                    (execute_fn)(id_ref, params_stb, Some(host_free_string))
315                }));
316                match outcome {
317                    Ok(h) if !h.is_null() => h,
318                    Ok(_) => {
319                        // null handle — allocation failure / refused.
320                        let _ = done_tx.send(Err(AgentError::Tool(format!(
321                            "plugin execute returned null handle for '{schema_name}'"
322                        ))));
323                        return;
324                    }
325                    Err(_) => {
326                        tracing::error!("plugin execute panicked — aborting");
327                        std::process::abort();
328                    }
329                }
330            };
331
332            // The sender pointer, valid for the drive (sender_for_cb lives in
333            // this closure env). Computed here so no raw pointer crosses threads.
334            let sender_ptr = &sender_for_cb as *const _ as *mut c_void;
335
336            // poll loop
337            let terminal: Result<AgentToolResult, AgentError> = loop {
338                if cancel_flag_drive.load(Ordering::SeqCst) {
339                    // Observe cancel: tell the plugin, then break with an abort.
340                    let _ = catch_unwind(AssertUnwindSafe(|| (cancel_fn)(step_handle)));
341                    break Err(AgentError::Tool("plugin tool cancelled".into()));
342                }
343                let step_result = match catch_unwind(AssertUnwindSafe(|| {
344                    (poll_fn)(step_handle, Some(partial_cb_trampoline), sender_ptr)
345                })) {
346                    Ok(r) => r,
347                    Err(_) => {
348                        tracing::error!("plugin poll panicked — aborting");
349                        std::process::abort();
350                    }
351                };
352                match step_result.tag {
353                    StepResultTag::Pending => {
354                        // SAFETY: tag == Pending.
355                        let progress = unsafe { step_result.pending_payload().progress };
356                        if !progress.is_empty() {
357                            // The plugin may also have pushed via the partial cb;
358                            // both paths land in partial_rx. Forward this one too.
359                            let pr = stb_to_result(&progress);
360                            // progress was plugin-produced inside poll → free via
361                            // the plugin's free_string.
362                            (plugin_free)(progress);
363                            let _ = partial_tx.send(pr);
364                        }
365                        continue;
366                    }
367                    StepResultTag::Done => {
368                        // SAFETY: tag == Done.
369                        let done = unsafe { step_result.done_payload().result };
370                        let result = stb_to_result(&done);
371                        (plugin_free)(done);
372                        break Ok(result);
373                    }
374                    StepResultTag::Err => {
375                        // SAFETY: tag == Err.
376                        let msg = unsafe { step_result.err_payload().message };
377                        let message = msg.to_string_lossy();
378                        (plugin_free)(msg);
379                        break Err(AgentError::Tool(message));
380                    }
381                }
382            };
383
384            // destroy exactly once — idempotent, called by the driver only.
385            let _ = catch_unwind(AssertUnwindSafe(|| (destroy_fn)(step_handle)));
386            let _ = done_tx.send(terminal);
387        });
388
389        // 5. Async side: poll the terminal oneshot and the cancel signal in a
390        //    loop. On cancel: set the AtomicBool (driver observes it) and KEEP
391        //    polling the same oneshot (never drop the driver — spawn_blocking
392        //    runs to completion; dropping is a thread leak). Pinning both
393        //    futures lets us re-poll `done_rx` after `cancelled` fired without
394        //    moving it (a plain `select!` would consume it on the first fire).
395        let schema_name_err = schema_name_for_error.clone();
396        tokio::pin!(done_rx);
397        let mut cancelled = std::pin::pin!(signal.cancelled());
398        let result: Result<AgentToolResult, AgentError> = loop {
399            tokio::select! {
400                done = &mut done_rx => {
401                    while let Ok(p) = partial_rx.try_recv() { on_update(p); }
402                    break match done {
403                        Ok(Ok(r)) => Ok(r),
404                        Ok(Err(e)) => Err(e),
405                        Err(_) => Err(AgentError::State(format!(
406                            "plugin tool '{schema_name_err}' driver dropped done_tx"
407                        ))),
408                    };
409                }
410                _ = &mut cancelled => {
411                    // Set the flag once; the driver observes it and breaks. Keep
412                    // looping so we still poll done_rx to completion.
413                    let already = cancel_flag.swap(true, Ordering::SeqCst);
414                    if !already {
415                        while let Ok(p) = partial_rx.try_recv() { on_update(p); }
416                    }
417                    // Yield so we don't busy-spin against the driver.
418                    tokio::task::yield_now().await;
419                }
420            }
421        };
422
423        // Final drain of any partials that arrived between the select! branch and here.
424        while let Ok(p) = partial_rx.try_recv() {
425            on_update(p);
426        }
427
428        result
429    }
430}
431
432// ---------------------------------------------------------------------------
433// Tests — the ABI bridge against an in-process stub plugin
434// ---------------------------------------------------------------------------
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use rpi_plugin_sdk::{StepResult, ToolPartialCb};
440    use std::sync::atomic::{AtomicUsize, Ordering};
441    use std::sync::Mutex;
442
443    // These three tests share process-global stub counters (the stub fns are
444    // `extern "C"` and cannot capture per-test state). Parallel execution would
445    // have one test's `reset_counters` wipe another's in-flight increments. Hold
446    // this lock for the ENTIRE test body — the spawn_blocking driver finishes
447    // (destroy fires) before the awaited `execute` returns, so releasing the
448    // guard after the await means no driver outlives its test's reset window.
449    static TEST_LOCK: Mutex<()> = Mutex::new(());
450
451    static DESTROY_COUNT: AtomicUsize = AtomicUsize::new(0);
452    static CANCEL_COUNT: AtomicUsize = AtomicUsize::new(0);
453
454    struct DriveState {
455        cancelled: Arc<AtomicBool>,
456        polls: usize,
457        done_at: usize,
458    }
459
460    extern "C" fn stub_execute(
461        _id: StbStringRef,
462        _params: StbString,
463        _free: Option<FreeStringFn>,
464    ) -> StepHandle {
465        let state = Box::new(DriveState {
466            cancelled: Arc::new(AtomicBool::new(false)),
467            polls: 0,
468            done_at: 3,
469        });
470        Box::into_raw(state) as StepHandle
471    }
472
473    extern "C" fn stub_poll(
474        h: StepHandle,
475        _cb: Option<ToolPartialCb>,
476        _ud: *mut c_void,
477    ) -> StepResult {
478        let state = unsafe { &mut *(h as *mut DriveState) };
479        state.polls += 1;
480        if state.cancelled.load(Ordering::SeqCst) {
481            return StepResult::err(StbString::from_string("cancelled".into()));
482        }
483        if state.polls >= state.done_at {
484            let result_json = StbString::from_string(
485                r#"{"content":[{"type":"text","text":"echo: hello"}]}"#.to_string(),
486            );
487            StepResult::done(result_json)
488        } else {
489            StepResult::pending(StbString::from_string(
490                r#"{"content":[{"type":"text","text":"..."}]}"#.to_string(),
491            ))
492        }
493    }
494
495    extern "C" fn stub_cancel(h: StepHandle) {
496        CANCEL_COUNT.fetch_add(1, Ordering::SeqCst);
497        let state = unsafe { &*(h as *const DriveState) };
498        state.cancelled.store(true, Ordering::SeqCst);
499    }
500
501    extern "C" fn stub_destroy(h: StepHandle) {
502        DESTROY_COUNT.fetch_add(1, Ordering::SeqCst);
503        if h.is_null() {
504            return;
505        }
506        unsafe {
507            let _ = Box::from_raw(h as *mut DriveState);
508        }
509    }
510
511    extern "C" fn stub_free(s: StbString) {
512        if s.is_empty() || s.ptr.is_null() {
513            return;
514        }
515        unsafe {
516            let slice = std::slice::from_raw_parts(s.ptr as *const u8, s.len);
517            let _ = Box::from_raw(slice as *const [u8] as *mut [u8]);
518        }
519    }
520
521    fn stub_handle() -> PluginToolHandle {
522        PluginToolHandle {
523            execute_fn: stub_execute,
524            poll_fn: stub_poll,
525            cancel_fn: stub_cancel,
526            destroy_fn: stub_destroy,
527            plugin_free_string: stub_free,
528        }
529    }
530
531    fn echo_adapter() -> PluginToolAdapter {
532        let tool = Tool {
533            name: "echo".to_string(),
534            description: "echoes".to_string(),
535            parameters: rpi_ai::types::Schema::new(serde_json::json!({})),
536            constrained_sampling: None,
537        };
538        PluginToolAdapter::new(tool, stub_handle(), PluginKeepalive::empty())
539    }
540
541    fn reset_counters() {
542        DESTROY_COUNT.store(0, Ordering::SeqCst);
543        CANCEL_COUNT.store(0, Ordering::SeqCst);
544    }
545
546    #[tokio::test]
547    async fn adapter_drives_to_done_and_destroys_once() {
548        let _guard = TEST_LOCK.lock().unwrap();
549        reset_counters();
550        let adapter = echo_adapter();
551        let on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync> = Arc::new(|_| {});
552        let signal = CancellationToken::new();
553        let result = adapter
554            .execute("call_1", serde_json::json!({}), signal, on_update)
555            .await
556            .expect("drive should succeed");
557        assert_eq!(result.content.len(), 1);
558        assert_eq!(
559            DESTROY_COUNT.load(Ordering::SeqCst),
560            1,
561            "destroy exactly once"
562        );
563        assert_eq!(
564            CANCEL_COUNT.load(Ordering::SeqCst),
565            0,
566            "no cancel in happy path"
567        );
568    }
569
570    #[tokio::test]
571    async fn adapter_forwards_partials_to_on_update() {
572        let _guard = TEST_LOCK.lock().unwrap();
573        reset_counters();
574        let adapter = echo_adapter();
575        let seen = Arc::new(Mutex::new(Vec::<String>::new()));
576        let seen_clone = Arc::clone(&seen);
577        let on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync> = Arc::new(move |p| {
578            if let Some(t) = p.content.first().and_then(|c| match c {
579                TextContentOrImage::Text(t) => Some(t.text.clone()),
580                _ => None,
581            }) {
582                seen_clone.lock().unwrap().push(t);
583            }
584        });
585        let signal = CancellationToken::new();
586        let _ = adapter
587            .execute("call_2", serde_json::json!({}), signal, on_update)
588            .await
589            .expect("ok");
590        let partials = seen.lock().unwrap().clone();
591        assert!(partials.iter().any(|t| t == "..."), "got {:?}", partials);
592        assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 1);
593    }
594
595    #[tokio::test(flavor = "current_thread")]
596    async fn adapter_cancel_observed_no_uaf_no_leak() {
597        let _guard = TEST_LOCK.lock().unwrap();
598        reset_counters();
599        struct SlowState {
600            cancelled: Arc<AtomicBool>,
601            polls: usize,
602        }
603        extern "C" fn slow_execute(
604            _: StbStringRef,
605            _: StbString,
606            _: Option<FreeStringFn>,
607        ) -> StepHandle {
608            Box::into_raw(Box::new(SlowState {
609                cancelled: Arc::new(AtomicBool::new(false)),
610                polls: 0,
611            })) as StepHandle
612        }
613        extern "C" fn slow_poll(
614            h: StepHandle,
615            _: Option<ToolPartialCb>,
616            _: *mut c_void,
617        ) -> StepResult {
618            let s = unsafe { &mut *(h as *mut SlowState) };
619            s.polls += 1;
620            if s.cancelled.load(Ordering::SeqCst) {
621                return StepResult::err(StbString::from_string("cancelled".into()));
622            }
623            std::thread::sleep(std::time::Duration::from_millis(5));
624            StepResult::pending(StbString::empty())
625        }
626        extern "C" fn slow_cancel(h: StepHandle) {
627            CANCEL_COUNT.fetch_add(1, Ordering::SeqCst);
628            unsafe {
629                (*(h as *mut SlowState))
630                    .cancelled
631                    .store(true, Ordering::SeqCst);
632            }
633        }
634        extern "C" fn slow_destroy(h: StepHandle) {
635            DESTROY_COUNT.fetch_add(1, Ordering::SeqCst);
636            if !h.is_null() {
637                unsafe {
638                    let _ = Box::from_raw(h as *mut SlowState);
639                }
640            }
641        }
642        let tool = Tool {
643            name: "slow".to_string(),
644            description: "slow".to_string(),
645            parameters: rpi_ai::types::Schema::new(serde_json::json!({})),
646            constrained_sampling: None,
647        };
648        let handle = PluginToolHandle {
649            execute_fn: slow_execute,
650            poll_fn: slow_poll,
651            cancel_fn: slow_cancel,
652            destroy_fn: slow_destroy,
653            plugin_free_string: stub_free,
654        };
655        let adapter = PluginToolAdapter::new(tool, handle, PluginKeepalive::empty());
656        let on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync> = Arc::new(|_| {});
657        let signal = CancellationToken::new();
658        let signal_clone = signal.clone();
659        tokio::spawn(async move {
660            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
661            signal_clone.cancel();
662        });
663        let _ = adapter
664            .execute("call_3", serde_json::json!({}), signal, on_update)
665            .await;
666        // No hang + destroy exactly once (cancel observed via the AtomicBool).
667        assert_eq!(
668            DESTROY_COUNT.load(Ordering::SeqCst),
669            1,
670            "destroy once on cancel"
671        );
672    }
673}