1use std::sync::{Arc, Condvar, Mutex};
4
5use futures::future::BoxFuture;
6use pi_ai::{TextContent, Tool, ToolResultContent};
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9use tokio_util::sync::CancellationToken;
10
11use crate::error::ToolError;
12
13#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum ToolExecutionMode {
17 Sequential,
19 #[default]
21 Parallel,
22}
23
24fn empty_object() -> Value {
25 Value::Object(Map::new())
26}
27
28#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct AgentToolResult {
32 pub content: Vec<ToolResultContent>,
34 #[serde(default = "empty_object")]
36 pub details: Value,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub added_tool_names: Option<Vec<String>>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub terminate: Option<bool>,
43}
44
45impl Default for AgentToolResult {
46 fn default() -> Self {
47 Self {
48 content: Vec::new(),
49 details: empty_object(),
50 added_tool_names: None,
51 terminate: None,
52 }
53 }
54}
55
56#[must_use]
58pub fn error_tool_result(message: impl Into<String>) -> AgentToolResult {
59 AgentToolResult {
60 content: vec![ToolResultContent::Text(TextContent::new(message.into()))],
61 details: empty_object(),
62 added_tool_names: None,
63 terminate: None,
64 }
65}
66
67impl From<ToolError> for AgentToolResult {
68 fn from(error: ToolError) -> Self {
69 error_tool_result(error.message())
70 }
71}
72
73type UpdateSink = Arc<dyn Fn(AgentToolResult) + Send + Sync>;
74
75struct ToolUpdatesState {
76 accepting: bool,
77 in_flight: usize,
78 sink: Option<UpdateSink>,
79}
80
81struct InFlightUpdate<'a> {
82 state: &'a (Mutex<ToolUpdatesState>, Condvar),
83}
84
85impl Drop for InFlightUpdate<'_> {
86 fn drop(&mut self) {
87 let (lock, cvar) = self.state;
88 let mut guard = lock
89 .lock()
90 .unwrap_or_else(std::sync::PoisonError::into_inner);
91 guard.in_flight = guard.in_flight.saturating_sub(1);
92 if guard.in_flight == 0 {
93 cvar.notify_all();
94 }
95 }
96}
97
98#[derive(Clone)]
104pub struct ToolUpdates {
105 state: Arc<(Mutex<ToolUpdatesState>, Condvar)>,
106}
107
108impl Default for ToolUpdates {
109 fn default() -> Self {
110 Self::noop()
111 }
112}
113
114impl ToolUpdates {
115 #[must_use]
117 pub fn new(sink: impl Fn(AgentToolResult) + Send + Sync + 'static) -> Self {
118 Self {
119 state: Arc::new((
120 Mutex::new(ToolUpdatesState {
121 accepting: true,
122 in_flight: 0,
123 sink: Some(Arc::new(sink)),
124 }),
125 Condvar::new(),
126 )),
127 }
128 }
129
130 #[must_use]
132 pub fn noop() -> Self {
133 Self {
134 state: Arc::new((
135 Mutex::new(ToolUpdatesState {
136 accepting: true,
137 in_flight: 0,
138 sink: None,
139 }),
140 Condvar::new(),
141 )),
142 }
143 }
144
145 pub fn send(&self, partial_result: AgentToolResult) {
147 let (lock, _cvar) = &*self.state;
148 let sink = {
149 let mut guard = lock
150 .lock()
151 .unwrap_or_else(std::sync::PoisonError::into_inner);
152 if !guard.accepting {
153 return;
154 }
155 let Some(sink) = guard.sink.clone() else {
156 return;
157 };
158 guard.in_flight = guard.in_flight.saturating_add(1);
159 sink
160 };
161
162 let in_flight = InFlightUpdate {
163 state: self.state.as_ref(),
164 };
165 sink(partial_result);
166 drop(in_flight);
167 }
168
169 pub fn stop_accepting(&self) {
173 let (lock, cvar) = &*self.state;
174 let mut guard = lock
175 .lock()
176 .unwrap_or_else(std::sync::PoisonError::into_inner);
177 guard.accepting = false;
178 guard.sink = None;
179 while guard.in_flight > 0 {
180 guard = cvar
181 .wait(guard)
182 .unwrap_or_else(std::sync::PoisonError::into_inner);
183 }
184 }
185
186 #[must_use]
188 pub fn is_accepting(&self) -> bool {
189 let (lock, _) = &*self.state;
190 let guard = lock
191 .lock()
192 .unwrap_or_else(std::sync::PoisonError::into_inner);
193 guard.accepting
194 }
195}
196
197pub trait AgentTool: Send + Sync {
202 fn name(&self) -> &str;
204
205 fn label(&self) -> &str;
207
208 fn description(&self) -> &str;
210
211 fn parameters(&self) -> &Value;
213
214 fn execution_mode(&self) -> Option<ToolExecutionMode> {
219 None
220 }
221
222 fn prepare_arguments(&self, raw: &Map<String, Value>) -> Result<Map<String, Value>, ToolError> {
228 Ok(raw.clone())
229 }
230
231 fn validate_arguments(
239 &self,
240 args: &Map<String, Value>,
241 ) -> Result<Map<String, Value>, ToolError>;
242
243 fn prepare_and_validate_arguments(
248 &self,
249 raw: Map<String, Value>,
250 ) -> BoxFuture<'_, Result<Map<String, Value>, ToolError>> {
251 Box::pin(async move {
252 let prepared = self.prepare_arguments(&raw)?;
253 self.validate_arguments(&prepared)
254 })
255 }
256
257 fn execute(
267 &self,
268 tool_call_id: &str,
269 args: Map<String, Value>,
270 cancel: CancellationToken,
271 updates: ToolUpdates,
272 ) -> BoxFuture<'static, Result<AgentToolResult, ToolError>>;
273}
274
275#[must_use]
277pub fn to_pi_tool(tool: &dyn AgentTool) -> Tool {
278 Tool {
279 name: tool.name().to_owned(),
280 description: tool.description().to_owned(),
281 parameters: tool.parameters().clone(),
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use serde_json::json;
289 use std::sync::mpsc;
290 use std::time::Duration;
291
292 struct StrictTool {
293 name: String,
294 label: String,
295 description: String,
296 parameters: Value,
297 mode: Option<ToolExecutionMode>,
298 }
299
300 impl AgentTool for StrictTool {
301 fn name(&self) -> &str {
302 &self.name
303 }
304
305 fn label(&self) -> &str {
306 &self.label
307 }
308
309 fn description(&self) -> &str {
310 &self.description
311 }
312
313 fn parameters(&self) -> &Value {
314 &self.parameters
315 }
316
317 fn execution_mode(&self) -> Option<ToolExecutionMode> {
318 self.mode
319 }
320
321 fn prepare_arguments(
322 &self,
323 raw: &Map<String, Value>,
324 ) -> Result<Map<String, Value>, ToolError> {
325 let mut prepared = raw.clone();
326 if let Some(Value::String(path)) = prepared.get("path").cloned() {
327 prepared.insert("path".to_owned(), Value::String(path.trim().to_owned()));
328 }
329 Ok(prepared)
330 }
331
332 fn validate_arguments(
333 &self,
334 args: &Map<String, Value>,
335 ) -> Result<Map<String, Value>, ToolError> {
336 match args.get("path") {
337 Some(Value::String(path)) if !path.is_empty() => Ok(args.clone()),
338 _ => Err(ToolError::new("path is required")),
339 }
340 }
341
342 fn execute(
343 &self,
344 _tool_call_id: &str,
345 args: Map<String, Value>,
346 _cancel: CancellationToken,
347 updates: ToolUpdates,
348 ) -> BoxFuture<'static, Result<AgentToolResult, ToolError>> {
349 Box::pin(async move {
350 updates.send(AgentToolResult {
351 content: vec![ToolResultContent::Text(TextContent::new("partial"))],
352 details: json!({ "stage": "partial" }),
353 added_tool_names: None,
354 terminate: None,
355 });
356 updates.stop_accepting();
357 updates.send(AgentToolResult {
358 content: vec![ToolResultContent::Text(TextContent::new("late"))],
359 details: json!({ "stage": "late" }),
360 added_tool_names: None,
361 terminate: None,
362 });
363 Ok(AgentToolResult {
364 content: vec![ToolResultContent::Text(TextContent::new("ok"))],
365 details: Value::Object(args),
366 added_tool_names: None,
367 terminate: None,
368 })
369 })
370 }
371 }
372
373 #[test]
374 fn tool_execution_mode_serde_is_lowercase() -> Result<(), serde_json::Error> {
375 assert_eq!(
376 serde_json::to_value(ToolExecutionMode::Sequential)?,
377 json!("sequential")
378 );
379 assert_eq!(
380 serde_json::to_value(ToolExecutionMode::Parallel)?,
381 json!("parallel")
382 );
383 let sequential: ToolExecutionMode = serde_json::from_value(json!("sequential"))?;
384 assert_eq!(sequential, ToolExecutionMode::Sequential);
385 Ok(())
386 }
387
388 #[test]
389 fn sequential_mode_and_validation_contracts_are_observable() -> Result<(), ToolError> {
390 let tool = StrictTool {
391 name: "strict".to_owned(),
392 label: "Strict".to_owned(),
393 description: "requires path".to_owned(),
394 parameters: json!({
395 "type": "object",
396 "properties": { "path": { "type": "string" } },
397 "required": ["path"]
398 }),
399 mode: Some(ToolExecutionMode::Sequential),
400 };
401
402 assert_eq!(tool.execution_mode(), Some(ToolExecutionMode::Sequential));
403
404 let prepared =
405 tool.prepare_arguments(&Map::from_iter([("path".to_owned(), json!(" a.rs "))]))?;
406 assert_eq!(prepared.get("path"), Some(&json!("a.rs")));
407
408 let validated = tool.validate_arguments(&prepared)?;
409 assert_eq!(validated.get("path"), Some(&json!("a.rs")));
410
411 let missing = tool.validate_arguments(&Map::new());
412 assert!(matches!(&missing, Err(error) if error.message() == "path is required"));
413
414 let pi_tool = to_pi_tool(&tool);
415 assert_eq!(pi_tool.name, "strict");
416 assert_eq!(pi_tool.description, "requires path");
417 assert_eq!(pi_tool.parameters, tool.parameters);
418 Ok(())
419 }
420
421 #[test]
422 fn tool_result_and_error_conversion_round_trip() -> Result<(), serde_json::Error> {
423 let result = AgentToolResult {
424 content: vec![ToolResultContent::Text(TextContent::new("hello"))],
425 details: json!({ "n": 1 }),
426 added_tool_names: Some(vec!["extra".to_owned()]),
427 terminate: Some(true),
428 };
429 let encoded = serde_json::to_value(&result)?;
430 assert_eq!(
431 encoded,
432 json!({
433 "content": [{ "type": "text", "text": "hello" }],
434 "details": { "n": 1 },
435 "addedToolNames": ["extra"],
436 "terminate": true
437 })
438 );
439
440 let error_result = AgentToolResult::from(ToolError::new("nope"));
441 assert_eq!(
442 serde_json::to_value(&error_result)?,
443 json!({
444 "content": [{ "type": "text", "text": "nope" }],
445 "details": {}
446 })
447 );
448 Ok(())
449 }
450
451 #[test]
452 fn tool_updates_ignore_sends_after_stop() {
453 let seen = Arc::new(Mutex::new(Vec::new()));
454 let seen_cb = Arc::clone(&seen);
455 let updates = ToolUpdates::new(move |partial| {
456 let mut values = seen_cb
457 .lock()
458 .unwrap_or_else(std::sync::PoisonError::into_inner);
459 values.push(partial.content.first().map(|content| match content {
460 ToolResultContent::Text(text) => text.text.to_string(),
461 ToolResultContent::Image(_) => "image".to_owned(),
462 }));
463 });
464
465 updates.send(error_tool_result("one"));
466 updates.stop_accepting();
467 updates.send(error_tool_result("two"));
468
469 let values = seen
470 .lock()
471 .unwrap_or_else(std::sync::PoisonError::into_inner);
472 assert_eq!(values.as_slice(), &[Some("one".to_owned())]);
473 assert!(!updates.is_accepting());
474 assert!(ToolUpdates::default().is_accepting());
475 }
476
477 #[test]
478 fn sink_panic_releases_in_flight_update() {
479 let updates = ToolUpdates::new(|_| std::panic::resume_unwind(Box::new("sink panic")));
480 let sender = updates.clone();
481 let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
482 sender.send(error_tool_result("partial"));
483 }));
484 assert!(panic.is_err());
485
486 let (stopped_tx, stopped_rx) = mpsc::channel();
487 std::thread::spawn(move || {
488 updates.stop_accepting();
489 let _ = stopped_tx.send(());
490 });
491 assert!(
492 stopped_rx.recv_timeout(Duration::from_secs(1)).is_ok(),
493 "stop_accepting hung after the sink panicked"
494 );
495 }
496}