Skip to main content

pi_agent/
tool.rs

1//! Tool contract used by the agent loop.
2
3use 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/// How tool calls from a single assistant message are scheduled.
14#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum ToolExecutionMode {
17    /// Execute tool calls one by one in assistant source order.
18    Sequential,
19    /// Preflight sequentially, then execute allowed tools concurrently.
20    #[default]
21    Parallel,
22}
23
24fn empty_object() -> Value {
25    Value::Object(Map::new())
26}
27
28/// Final or partial result produced by a tool.
29#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct AgentToolResult {
32    /// Text or image content returned to the model.
33    pub content: Vec<ToolResultContent>,
34    /// Arbitrary structured details for logs or UI rendering.
35    #[serde(default = "empty_object")]
36    pub details: Value,
37    /// Tool names introduced by this result and available afterward.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub added_tool_names: Option<Vec<String>>,
40    /// Hint that the agent should stop after the current tool batch.
41    #[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/// Builds an error tool result whose content is the provided message.
57#[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/// Streaming update handle scoped to one `execute` invocation.
99///
100/// Calls made after [`ToolUpdates::stop_accepting`] returns are ignored.
101/// `stop_accepting` waits for any already-accepted callback to finish so a late
102/// update cannot publish after the execution lifecycle has settled.
103#[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    /// Creates an update handle that invokes `sink` while accepting updates.
116    #[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    /// Creates a no-op update handle.
131    #[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    /// Emits a partial tool result while this handle still accepts updates.
146    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    /// Stops accepting further updates after execute settles.
170    ///
171    /// Returns only after any already-accepted callback has finished.
172    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    /// Returns whether this handle still accepts updates.
187    #[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
197/// Tool definition used by the agent runtime.
198///
199/// Object-safe and independent of `async-trait`/`jsonschema`. Concrete tools
200/// own their own argument validation (for example via schemars in Phase 3).
201pub trait AgentTool: Send + Sync {
202    /// Unique tool name.
203    fn name(&self) -> &str;
204
205    /// Human-readable label for UI display.
206    fn label(&self) -> &str;
207
208    /// Human-readable tool description.
209    fn description(&self) -> &str;
210
211    /// JSON Schema for tool arguments.
212    fn parameters(&self) -> &Value;
213
214    /// Optional per-tool execution mode override.
215    ///
216    /// When any tool in a batch returns [`ToolExecutionMode::Sequential`], the
217    /// whole batch executes sequentially.
218    fn execution_mode(&self) -> Option<ToolExecutionMode> {
219        None
220    }
221
222    /// Optional compatibility shim for raw tool-call arguments before validation.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`ToolError`] when raw arguments cannot be prepared for validation.
227    fn prepare_arguments(&self, raw: &Map<String, Value>) -> Result<Map<String, Value>, ToolError> {
228        Ok(raw.clone())
229    }
230
231    /// Validates prepared arguments for this tool.
232    ///
233    /// On success returns the arguments that will be passed to [`AgentTool::execute`].
234    ///
235    /// # Errors
236    ///
237    /// Returns [`ToolError`] when arguments fail tool-specific validation.
238    fn validate_arguments(
239        &self,
240        args: &Map<String, Value>,
241    ) -> Result<Map<String, Value>, ToolError>;
242
243    /// Prepares and validates raw arguments before lifecycle hooks and execution.
244    ///
245    /// The default preserves the synchronous compatibility shims. Host-backed
246    /// tools may override this method to perform an asynchronous preflight.
247    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    /// Executes the tool call.
258    ///
259    /// Failures must be returned as [`ToolError`]; the loop converts them into
260    /// error tool results. The returned future is `'static` so implementations
261    /// must clone any needed state instead of borrowing `self`.
262    ///
263    /// # Errors
264    ///
265    /// Returns [`ToolError`] when tool execution fails.
266    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/// Converts an [`AgentTool`] into a provider-facing [`Tool`] definition.
276#[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}