Skip to main content

scv_core/
tool.rs

1//! The [`Tool`] trait, its inputs and outputs, and the [`ToolRegistry`].
2
3use std::{collections::HashMap, fmt, path::PathBuf, sync::Arc, time::Duration};
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use thiserror::Error;
9use tokio::time::{Instant, sleep_until};
10use tokio_util::sync::CancellationToken;
11
12use crate::{AgentError, ApprovalGate, ApprovalRequest, ProgressSink};
13
14/// What the model is told about a tool: its name, what it does, and the JSON
15/// Schema of its arguments.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct ToolSpec {
18    pub name: String,
19    pub description: String,
20    pub parameters: Value,
21}
22
23/// How much a tool call can affect; the approval policy decides per risk.
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "snake_case")]
26pub enum ToolRisk {
27    ReadOnly,
28    Filesystem,
29    Process,
30    Delegate,
31    /// Sends a request to a host outside the auto-approved set, whose URL can
32    /// carry data the model has read.
33    Network,
34}
35
36impl ToolRisk {
37    pub fn as_str(self) -> &'static str {
38        match self {
39            Self::ReadOnly => "read_only",
40            Self::Filesystem => "filesystem",
41            Self::Process => "process",
42            Self::Delegate => "delegate",
43            Self::Network => "network",
44        }
45    }
46
47    /// The risk named by [`ToolRisk::as_str`], such as one a nested SCV
48    /// reported for its own tool call.
49    pub fn parse(value: &str) -> Option<Self> {
50        [
51            Self::ReadOnly,
52            Self::Filesystem,
53            Self::Process,
54            Self::Delegate,
55            Self::Network,
56        ]
57        .into_iter()
58        .find(|risk| risk.as_str() == value)
59    }
60}
61
62/// What a running tool call gets besides its arguments.
63#[derive(Debug, Clone)]
64pub struct ToolContext {
65    /// The model's ID for this call, which its `ToolCompleted` event carries;
66    /// empty for a call the model did not make, such as a background job's.
67    pub call_id: String,
68    pub workspace: PathBuf,
69    pub cancellation: CancellationToken,
70    /// Whether this call runs as a detached background job, the only kind
71    /// of `agent` call that may run without a main deadline (`no_timeout`).
72    pub background_job: bool,
73    /// The background job this call runs as, such as `job-3`, so the runs
74    /// it starts can say which job they serve; `None` for any other call.
75    pub job: Option<String>,
76    /// Where the tool may report short status lines while it runs.
77    pub progress: ProgressSink,
78    /// The session's approval gate, for a tool relaying a nested agent's own
79    /// approval requests.
80    pub approvals: ToolApprovals,
81}
82
83impl ToolContext {
84    /// A context whose progress reports go nowhere and whose relayed
85    /// approval requests are denied, for a call without an ID.
86    pub fn new(workspace: PathBuf, cancellation: CancellationToken) -> Self {
87        Self {
88            call_id: String::new(),
89            workspace,
90            cancellation,
91            background_job: false,
92            job: None,
93            progress: ProgressSink::default(),
94            approvals: ToolApprovals::default(),
95        }
96    }
97}
98
99/// The session's approval gate as seen by one running tool call. A tool that
100/// drives a nested agent (such as another SCV) asks it on the nested agent's
101/// behalf, so the session's policy and its user decide every nested side
102/// effect too. Without a gate, every request is denied.
103#[derive(Clone, Default)]
104pub struct ToolApprovals {
105    gate: Option<Arc<dyn ApprovalGate>>,
106    call_id: String,
107}
108
109impl fmt::Debug for ToolApprovals {
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        formatter
112            .debug_struct("ToolApprovals")
113            .field("enabled", &self.gate.is_some())
114            .field("call_id", &self.call_id)
115            .finish()
116    }
117}
118
119impl ToolApprovals {
120    /// Requests for the tool call `call_id`, decided by `gate`.
121    pub fn new(gate: Arc<dyn ApprovalGate>, call_id: impl Into<String>) -> Self {
122        Self {
123            gate: Some(gate),
124            call_id: call_id.into(),
125        }
126    }
127
128    #[cfg(test)]
129    pub(crate) fn is_enabled(&self) -> bool {
130        self.gate.is_some()
131    }
132
133    /// Ask the session's gate to approve a nested agent's tool call. The
134    /// request carries this tool call's ID; `name`, `risk`, and `summary`
135    /// describe the nested call.
136    pub async fn request(
137        &self,
138        name: impl Into<String>,
139        risk: ToolRisk,
140        cwd: PathBuf,
141        summary: impl Into<String>,
142        cancellation: CancellationToken,
143    ) -> Result<bool, AgentError> {
144        let Some(gate) = &self.gate else {
145            return Ok(false);
146        };
147        gate.approve(
148            ApprovalRequest {
149                call_id: self.call_id.clone(),
150                name: name.into(),
151                risk,
152                cwd,
153                summary: summary.into(),
154            },
155            cancellation,
156        )
157        .await
158    }
159
160    /// [`request`](Self::request), giving up at `deadline` when there is
161    /// one. Then `cancellation` is cancelled, the gate gets up to a second
162    /// to withdraw the request it is showing, and the result is
163    /// [`AgentError::ToolLimit`]. Cancellation itself is the gate's to
164    /// observe, so it always cleans up after itself.
165    pub async fn request_until(
166        &self,
167        name: impl Into<String>,
168        risk: ToolRisk,
169        cwd: PathBuf,
170        summary: impl Into<String>,
171        cancellation: CancellationToken,
172        deadline: Option<Instant>,
173    ) -> Result<bool, AgentError> {
174        let request = self.request(name, risk, cwd, summary, cancellation.clone());
175        let Some(deadline) = deadline else {
176            return request.await;
177        };
178        tokio::pin!(request);
179        tokio::select! {
180            biased;
181            result = &mut request => result,
182            () = sleep_until(deadline) => {
183                // Dropping the request would skip the gate's own cleanup,
184                // such as forgetting the approval it sent a client.
185                cancellation.cancel();
186                let _ = tokio::time::timeout(WITHDRAW_GRACE, &mut request).await;
187                Err(AgentError::ToolLimit("the approval wait timed out".into()))
188            }
189        }
190    }
191}
192
193/// How long [`ToolApprovals::request_until`] waits for a gate to withdraw a
194/// request whose deadline passed.
195const WITHDRAW_GRACE: Duration = Duration::from_secs(1);
196
197/// Why a tool call failed. The model reads the call's output either way;
198/// this tells the session's clients what happened without reading the text.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
200pub enum ToolFailure {
201    /// The approval policy or the user refused the call.
202    Denied,
203    /// The call was cancelled while it ran.
204    Cancelled,
205    /// The call's arguments were refused, so nothing ran.
206    InvalidArguments,
207    /// The tool, or the agent it runs, could not be used: missing, signed
208    /// out, or its provider unreachable. Another agent might succeed.
209    Unavailable,
210    /// A configured size, count, depth, or time limit stopped the call.
211    Limit,
212    /// The call ran and failed.
213    Failed,
214    /// The model named a tool the session does not have.
215    UnknownTool,
216}
217
218/// A tool's result as the model sees it. A failure is still a result: the
219/// model reads it and can react.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct ToolOutput {
222    pub content: String,
223    /// Why the call failed; `None` when it succeeded.
224    pub failure: Option<ToolFailure>,
225    pub truncated: bool,
226}
227
228impl ToolOutput {
229    pub fn success(content: impl Into<String>) -> Self {
230        Self {
231            content: content.into(),
232            failure: None,
233            truncated: false,
234        }
235    }
236
237    /// A failed result whose `content` tells the model why.
238    pub fn failed(failure: ToolFailure, content: impl Into<String>) -> Self {
239        Self {
240            content: content.into(),
241            failure: Some(failure),
242            truncated: false,
243        }
244    }
245
246    pub fn is_error(&self) -> bool {
247        self.failure.is_some()
248    }
249}
250
251impl From<ToolError> for ToolOutput {
252    /// The failed result the model sees for a call that could not run.
253    fn from(error: ToolError) -> Self {
254        Self::failed(error.kind, error.message)
255    }
256}
257
258/// A tool call that could not run, such as invalid arguments. The runtime
259/// turns it into a failed [`ToolOutput`] whose content is `message`.
260#[derive(Debug, Clone, PartialEq, Eq, Error)]
261#[error("{message}")]
262pub struct ToolError {
263    pub kind: ToolFailure,
264    pub message: String,
265}
266
267impl ToolError {
268    pub(crate) fn new(kind: ToolFailure, message: impl Into<String>) -> Self {
269        Self {
270            kind,
271            message: message.into(),
272        }
273    }
274
275    /// The arguments were refused; the model can correct them.
276    pub fn invalid_arguments(message: impl Into<String>) -> Self {
277        Self::new(ToolFailure::InvalidArguments, message)
278    }
279
280    /// The tool or its agent could not be used.
281    pub fn unavailable(message: impl Into<String>) -> Self {
282        Self::new(ToolFailure::Unavailable, message)
283    }
284
285    /// A configured limit stopped the call.
286    pub fn limit(message: impl Into<String>) -> Self {
287        Self::new(ToolFailure::Limit, message)
288    }
289
290    /// The call was cancelled.
291    pub fn cancelled(message: impl Into<String>) -> Self {
292        Self::new(ToolFailure::Cancelled, message)
293    }
294
295    /// The call ran and failed.
296    pub fn failed(message: impl Into<String>) -> Self {
297        Self::new(ToolFailure::Failed, message)
298    }
299}
300
301impl From<String> for ToolError {
302    /// A [`ToolFailure::Failed`] error.
303    fn from(message: String) -> Self {
304        Self::failed(message)
305    }
306}
307
308/// Something the model can call. The runtime asks for the call's [`risk`] and
309/// [`approval_summary`] first, so both must validate the arguments without
310/// side effects; only an approved call reaches [`execute`].
311///
312/// [`risk`]: Tool::risk
313/// [`approval_summary`]: Tool::approval_summary
314/// [`execute`]: Tool::execute
315///
316/// # Example
317///
318/// ```
319/// use async_trait::async_trait;
320/// use serde_json::{Value, json};
321/// use scv_core::{Tool, ToolContext, ToolError, ToolOutput, ToolRisk, ToolSpec};
322///
323/// struct Echo;
324///
325/// #[async_trait]
326/// impl Tool for Echo {
327///     fn spec(&self) -> ToolSpec {
328///         ToolSpec {
329///             name: "echo".into(),
330///             description: "Repeat a value".into(),
331///             parameters: json!({
332///                 "type": "object",
333///                 "properties": {"value": {"type": "string"}},
334///                 "required": ["value"]
335///             }),
336///         }
337///     }
338///
339///     fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
340///         arguments["value"]
341///             .as_str()
342///             .ok_or_else(|| ToolError::invalid_arguments("value must be a string"))?;
343///         Ok(ToolRisk::ReadOnly)
344///     }
345///
346///     fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
347///         self.risk(arguments)?;
348///         Ok("Repeat a value".into())
349///     }
350///
351///     async fn execute(
352///         &self,
353///         arguments: Value,
354///         _context: ToolContext,
355///     ) -> Result<ToolOutput, ToolError> {
356///         Ok(ToolOutput::success(arguments["value"].as_str().unwrap_or_default()))
357///     }
358/// }
359///
360/// let mut registry = scv_core::ToolRegistry::default();
361/// registry.register(std::sync::Arc::new(Echo)).unwrap();
362/// assert_eq!(registry.specs()[0].name, "echo");
363/// ```
364#[async_trait]
365pub trait Tool: Send + Sync {
366    /// The name, description, and argument schema shown to the model.
367    fn spec(&self) -> ToolSpec;
368    /// The risk of this call, which selects the approval rule.
369    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError>;
370    /// One line describing this call for a person approving it.
371    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError>;
372    /// Run an approved call. Honour `context.cancellation`.
373    async fn execute(
374        &self,
375        arguments: Value,
376        context: ToolContext,
377    ) -> Result<ToolOutput, ToolError>;
378}
379
380/// The tools of one session, by unique name.
381#[derive(Default)]
382pub struct ToolRegistry {
383    tools: HashMap<String, Arc<dyn Tool>>,
384}
385
386impl ToolRegistry {
387    /// Add `tool`; a second tool with the same name is refused.
388    pub fn register(&mut self, tool: Arc<dyn Tool>) -> Result<(), ToolError> {
389        let name = tool.spec().name;
390        if self.tools.contains_key(&name) {
391            return Err(ToolError::failed(format!("duplicate tool name: {name}")));
392        }
393        self.tools.insert(name, tool);
394        Ok(())
395    }
396
397    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
398        self.tools.get(name).cloned()
399    }
400
401    /// Every tool's spec, sorted by name so requests are stable.
402    pub fn specs(&self) -> Vec<ToolSpec> {
403        let mut specs: Vec<_> = self.tools.values().map(|tool| tool.spec()).collect();
404        specs.sort_by(|a, b| a.name.cmp(&b.name));
405        specs
406    }
407}
408
409#[cfg(test)]
410mod tests;