1use 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#[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
55fn 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 txt.push('}');
100 txt
101}
102
103fn 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
171pub struct PluginToolAdapter {
186 schema: Tool,
187 label: String,
188 handle: PluginToolHandle,
189 #[allow(dead_code)]
195 keepalive: Arc<PluginKeepalive>,
196}
197
198impl PluginToolAdapter {
199 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
213extern "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 host_free_string(partial);
226 return;
227 }
228 let sender = unsafe { &*(user_data as *const mpsc::UnboundedSender<AgentToolResult>) };
231 let result = stb_to_result(&partial);
232 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 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 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 let (partial_tx, mut partial_rx) = mpsc::unbounded_channel::<AgentToolResult>();
278 let (done_tx, done_rx) = oneshot::channel::<Result<AgentToolResult, AgentError>>();
279
280 let cancel_flag = Arc::new(AtomicBool::new(false));
284
285 let params_json = serde_json::to_string(¶ms).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 let cancel_flag_drive = Arc::clone(&cancel_flag);
307 let sender_for_cb = partial_tx.clone();
308 runtime.spawn_blocking(move || {
309 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 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 let sender_ptr = &sender_for_cb as *const _ as *mut c_void;
335
336 let terminal: Result<AgentToolResult, AgentError> = loop {
338 if cancel_flag_drive.load(Ordering::SeqCst) {
339 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 let progress = unsafe { step_result.pending_payload().progress };
356 if !progress.is_empty() {
357 let pr = stb_to_result(&progress);
360 (plugin_free)(progress);
363 let _ = partial_tx.send(pr);
364 }
365 continue;
366 }
367 StepResultTag::Done => {
368 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 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 let _ = catch_unwind(AssertUnwindSafe(|| (destroy_fn)(step_handle)));
386 let _ = done_tx.send(terminal);
387 });
388
389 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 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 tokio::task::yield_now().await;
419 }
420 }
421 };
422
423 while let Ok(p) = partial_rx.try_recv() {
425 on_update(p);
426 }
427
428 result
429 }
430}
431
432#[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 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 assert_eq!(
668 DESTROY_COUNT.load(Ordering::SeqCst),
669 1,
670 "destroy once on cancel"
671 );
672 }
673}