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`, `summary`, and
135 /// the exact `command`, when the nested call runs one, describe it.
136 pub async fn request(
137 &self,
138 name: impl Into<String>,
139 risk: ToolRisk,
140 cwd: PathBuf,
141 summary: impl Into<String>,
142 command: Option<String>,
143 cancellation: CancellationToken,
144 ) -> Result<bool, AgentError> {
145 let Some(gate) = &self.gate else {
146 return Ok(false);
147 };
148 gate.approve(
149 ApprovalRequest {
150 call_id: self.call_id.clone(),
151 name: name.into(),
152 risk,
153 cwd,
154 summary: summary.into(),
155 command,
156 },
157 cancellation,
158 )
159 .await
160 }
161
162 /// [`request`](Self::request), giving up at `deadline` when there is
163 /// one. Then `cancellation` is cancelled, the gate gets up to a second
164 /// to withdraw the request it is showing, and the result is
165 /// [`AgentError::ToolLimit`]. Cancellation itself is the gate's to
166 /// observe, so it always cleans up after itself.
167 #[allow(
168 clippy::too_many_arguments,
169 reason = "the request's fields and its wait"
170 )]
171 pub async fn request_until(
172 &self,
173 name: impl Into<String>,
174 risk: ToolRisk,
175 cwd: PathBuf,
176 summary: impl Into<String>,
177 command: Option<String>,
178 cancellation: CancellationToken,
179 deadline: Option<Instant>,
180 ) -> Result<bool, AgentError> {
181 let request = self.request(name, risk, cwd, summary, command, cancellation.clone());
182 let Some(deadline) = deadline else {
183 return request.await;
184 };
185 tokio::pin!(request);
186 tokio::select! {
187 biased;
188 result = &mut request => result,
189 () = sleep_until(deadline) => {
190 // Dropping the request would skip the gate's own cleanup,
191 // such as forgetting the approval it sent a client.
192 cancellation.cancel();
193 let _ = tokio::time::timeout(WITHDRAW_GRACE, &mut request).await;
194 Err(AgentError::ToolLimit("the approval wait timed out".into()))
195 }
196 }
197 }
198}
199
200/// How long [`ToolApprovals::request_until`] waits for a gate to withdraw a
201/// request whose deadline passed.
202const WITHDRAW_GRACE: Duration = Duration::from_secs(1);
203
204/// Why a tool call failed. The model reads the call's output either way;
205/// this tells the session's clients what happened without reading the text.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
207pub enum ToolFailure {
208 /// The approval policy or the user refused the call.
209 Denied,
210 /// The call was cancelled while it ran.
211 Cancelled,
212 /// The call's arguments were refused, so nothing ran.
213 InvalidArguments,
214 /// The tool, or the agent it runs, could not be used: missing, signed
215 /// out, or its provider unreachable. Another agent might succeed.
216 Unavailable,
217 /// A configured size, count, depth, or time limit stopped the call.
218 Limit,
219 /// The call ran and failed.
220 Failed,
221 /// The model named a tool the session does not have.
222 UnknownTool,
223}
224
225/// A tool's result as the model sees it. A failure is still a result: the
226/// model reads it and can react.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct ToolOutput {
229 pub content: String,
230 /// Why the call failed; `None` when it succeeded.
231 pub failure: Option<ToolFailure>,
232 pub truncated: bool,
233}
234
235impl ToolOutput {
236 pub fn success(content: impl Into<String>) -> Self {
237 Self {
238 content: content.into(),
239 failure: None,
240 truncated: false,
241 }
242 }
243
244 /// A failed result whose `content` tells the model why.
245 pub fn failed(failure: ToolFailure, content: impl Into<String>) -> Self {
246 Self {
247 content: content.into(),
248 failure: Some(failure),
249 truncated: false,
250 }
251 }
252
253 pub fn is_error(&self) -> bool {
254 self.failure.is_some()
255 }
256}
257
258impl From<ToolError> for ToolOutput {
259 /// The failed result the model sees for a call that could not run.
260 fn from(error: ToolError) -> Self {
261 Self::failed(error.kind, error.message)
262 }
263}
264
265/// A tool call that could not run, such as invalid arguments. The runtime
266/// turns it into a failed [`ToolOutput`] whose content is `message`.
267#[derive(Debug, Clone, PartialEq, Eq, Error)]
268#[error("{message}")]
269pub struct ToolError {
270 pub kind: ToolFailure,
271 pub message: String,
272}
273
274impl ToolError {
275 pub(crate) fn new(kind: ToolFailure, message: impl Into<String>) -> Self {
276 Self {
277 kind,
278 message: message.into(),
279 }
280 }
281
282 /// The arguments were refused; the model can correct them.
283 pub fn invalid_arguments(message: impl Into<String>) -> Self {
284 Self::new(ToolFailure::InvalidArguments, message)
285 }
286
287 /// The tool or its agent could not be used.
288 pub fn unavailable(message: impl Into<String>) -> Self {
289 Self::new(ToolFailure::Unavailable, message)
290 }
291
292 /// A configured limit stopped the call.
293 pub fn limit(message: impl Into<String>) -> Self {
294 Self::new(ToolFailure::Limit, message)
295 }
296
297 /// The call was cancelled.
298 pub fn cancelled(message: impl Into<String>) -> Self {
299 Self::new(ToolFailure::Cancelled, message)
300 }
301
302 /// The call ran and failed.
303 pub fn failed(message: impl Into<String>) -> Self {
304 Self::new(ToolFailure::Failed, message)
305 }
306}
307
308impl From<String> for ToolError {
309 /// A [`ToolFailure::Failed`] error.
310 fn from(message: String) -> Self {
311 Self::failed(message)
312 }
313}
314
315/// Something the model can call. The runtime asks for the call's [`risk`] and
316/// [`approval_summary`] first, so both must validate the arguments without
317/// side effects; only an approved call reaches [`execute`].
318///
319/// [`risk`]: Tool::risk
320/// [`approval_summary`]: Tool::approval_summary
321/// [`execute`]: Tool::execute
322///
323/// # Example
324///
325/// ```
326/// use async_trait::async_trait;
327/// use serde_json::{Value, json};
328/// use scv_core::{Tool, ToolContext, ToolError, ToolOutput, ToolRisk, ToolSpec};
329///
330/// struct Echo;
331///
332/// #[async_trait]
333/// impl Tool for Echo {
334/// fn spec(&self) -> ToolSpec {
335/// ToolSpec {
336/// name: "echo".into(),
337/// description: "Repeat a value".into(),
338/// parameters: json!({
339/// "type": "object",
340/// "properties": {"value": {"type": "string"}},
341/// "required": ["value"]
342/// }),
343/// }
344/// }
345///
346/// fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
347/// arguments["value"]
348/// .as_str()
349/// .ok_or_else(|| ToolError::invalid_arguments("value must be a string"))?;
350/// Ok(ToolRisk::ReadOnly)
351/// }
352///
353/// fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
354/// self.risk(arguments)?;
355/// Ok("Repeat a value".into())
356/// }
357///
358/// async fn execute(
359/// &self,
360/// arguments: Value,
361/// _context: ToolContext,
362/// ) -> Result<ToolOutput, ToolError> {
363/// Ok(ToolOutput::success(arguments["value"].as_str().unwrap_or_default()))
364/// }
365/// }
366///
367/// let mut registry = scv_core::ToolRegistry::default();
368/// registry.register(std::sync::Arc::new(Echo)).unwrap();
369/// assert_eq!(registry.specs()[0].name, "echo");
370/// ```
371#[async_trait]
372pub trait Tool: Send + Sync {
373 /// The name, description, and argument schema shown to the model.
374 fn spec(&self) -> ToolSpec;
375 /// The risk of this call, which selects the approval rule.
376 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError>;
377 /// One line describing this call for a person approving it.
378 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError>;
379 /// The exact command this call runs, for the approval policy's command
380 /// gates; `None` (the default) for a tool that runs no command.
381 fn approval_command(&self, _arguments: &Value) -> Option<String> {
382 None
383 }
384 /// Run an approved call. Honour `context.cancellation`.
385 async fn execute(
386 &self,
387 arguments: Value,
388 context: ToolContext,
389 ) -> Result<ToolOutput, ToolError>;
390}
391
392/// The tools of one session, by unique name.
393#[derive(Default)]
394pub struct ToolRegistry {
395 tools: HashMap<String, Arc<dyn Tool>>,
396}
397
398impl ToolRegistry {
399 /// Add `tool`; a second tool with the same name is refused.
400 pub fn register(&mut self, tool: Arc<dyn Tool>) -> Result<(), ToolError> {
401 let name = tool.spec().name;
402 if self.tools.contains_key(&name) {
403 return Err(ToolError::failed(format!("duplicate tool name: {name}")));
404 }
405 self.tools.insert(name, tool);
406 Ok(())
407 }
408
409 pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
410 self.tools.get(name).cloned()
411 }
412
413 /// Every tool's spec, sorted by name so requests are stable.
414 pub fn specs(&self) -> Vec<ToolSpec> {
415 let mut specs: Vec<_> = self.tools.values().map(|tool| tool.spec()).collect();
416 specs.sort_by(|a, b| a.name.cmp(&b.name));
417 specs
418 }
419}
420
421#[cfg(test)]
422mod tests;