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