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;