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