Skip to main content

scv_tools/
registry.rs

1//! [`builtin_registry`]: the tools one session offers its model.
2
3use std::{
4    path::{Path, PathBuf},
5    sync::Arc,
6    time::Duration,
7};
8
9use scv_core::{ToolContext, ToolError, ToolRegistry};
10use serde_json::Value;
11use tokio_util::sync::CancellationToken;
12
13use crate::{
14    AgentAdapterConfig, DelegationContext, SkillMap, ToolsConfig,
15    args::Timeouts,
16    builtin::{
17        chat_attach,
18        chat_history::{ChatHistoryTool, ChatKeepTool},
19        fs::{ReadTool, WriteTool},
20        shell::BashTool,
21        skill::ReadSkillTool,
22    },
23    delegate::{
24        acp::AcpAgentTool,
25        adapters::{self, Transport},
26        agent::{AGENT_TOOL, AgentTool, Backend, Offered},
27        background,
28        conversation::ConversationStore,
29        native::NativeAgentTool,
30        records,
31        scv::ScvAgentTool,
32    },
33};
34
35/// The tools a session offers: the built-in ones, then, when an installed
36/// agent is offered below the delegation depth limit, the `agent` tool,
37/// able to run in the background, and the job tools (`agent_wait`,
38/// `agent_status`, `agent_cancel`).
39pub fn builtin_registry(
40    config: ToolsConfig,
41    skills: SkillMap,
42    skill_roots: Vec<PathBuf>,
43    max_skill_bytes: usize,
44    adapters: impl IntoIterator<Item = (String, AgentAdapterConfig)>,
45) -> Result<ToolRegistry, ToolError> {
46    let mut registry = ToolRegistry::default();
47    registry.register(Arc::new(ReadTool {
48        max_bytes: config.max_read_bytes,
49    }))?;
50    registry.register(Arc::new(ReadSkillTool {
51        skills,
52        roots: skill_roots,
53        max_bytes: max_skill_bytes,
54    }))?;
55    registry.register(Arc::new(WriteTool {
56        max_bytes: config.max_write_bytes,
57    }))?;
58    registry.register(Arc::new(BashTool {
59        timeout: config.command_timeout,
60        max_timeout: config.max_timeout,
61        output_limit: config.output_limit_bytes,
62    }))?;
63    if let Some(chat) = config.chat_attach.clone() {
64        registry.register(Arc::new(chat_attach::ChatAttachTool { config: chat }))?;
65    }
66    if let Some(history) = config.chat_history.clone() {
67        registry.register(Arc::new(ChatHistoryTool {
68            config: history.clone(),
69        }))?;
70        registry.register(Arc::new(ChatKeepTool { config: history }))?;
71    }
72    // A delegated SCV at the depth limit may not delegate further.
73    let depth = config
74        .delegation
75        .as_ref()
76        .map_or_else(records::current_depth, DelegationContext::owner_depth);
77    if depth >= config.max_delegation_depth {
78        return Ok(registry);
79    }
80    // One store per session, shared by its agents and dropped with it.
81    let conversations = Arc::new(ConversationStore::new(
82        config.conversations,
83        config
84            .delegation
85            .as_ref()
86            .map(|context| context.registry.conversation_dir().to_owned()),
87    ));
88    let offered: Vec<Offered> = adapters
89        .into_iter()
90        .filter_map(|(name, adapter)| offer(name, adapter, &config, &conversations))
91        .collect();
92    if offered.is_empty() {
93        return Ok(registry);
94    }
95    let timeouts = Timeouts {
96        default: config.agent_timeout,
97        max: config.max_timeout,
98    };
99    let agent = Arc::new(AgentTool::new(offered, &config.prefer, timeouts));
100    if config.max_background == 0 {
101        registry.register(agent)?;
102        return Ok(registry);
103    }
104    // One job store per session, dropped with it, which cancels the jobs
105    // still running.
106    let jobs = config
107        .background
108        .clone()
109        .unwrap_or_else(|| Arc::new(background::BackgroundJobs::new(config.max_background, None)));
110    registry.register(Arc::new(background::BackgroundCapable {
111        inner: agent,
112        jobs: Arc::clone(&jobs),
113    }))?;
114    registry.register(Arc::new(background::WaitTool {
115        jobs: Arc::clone(&jobs),
116        timeouts,
117    }))?;
118    registry.register(Arc::new(background::StatusTool {
119        jobs: Arc::clone(&jobs),
120    }))?;
121    registry.register(Arc::new(background::CancelTool { jobs }))?;
122    Ok(registry)
123}
124
125/// How long `call_agent` waits for its agent to shut down after the call
126/// before stopping what is left of it.
127const CHECK_STOP_WAIT: Duration = Duration::from_secs(10);
128
129/// Make one `agent` call to `name` from `cwd`, as a session's `agent` tool
130/// makes it, for `scv agents check`. The call runs in the foreground and
131/// may take `timeout`; when `interrupted` completes first, it is cancelled.
132/// The agent's model list decides, not a saved one. The run is recorded
133/// under `delegation`, like a session's, and this returns only once the
134/// agent's process group is gone. The result is the tool's JSON result, or
135/// SCV's own error when the call was refused or nothing could run.
136pub async fn call_agent(
137    name: &str,
138    adapter: AgentAdapterConfig,
139    cwd: &Path,
140    arguments: Value,
141    timeout: Duration,
142    delegation: DelegationContext,
143    interrupted: impl Future<Output = ()>,
144) -> Result<Value, String> {
145    let records = Arc::clone(&delegation.registry);
146    let session = delegation.session.clone();
147    let result = call_once(
148        name,
149        adapter,
150        cwd,
151        arguments,
152        timeout,
153        delegation,
154        interrupted,
155    )
156    .await;
157    // Dropping the tool asked its agent to stop; wait for its whole group.
158    let deadline = tokio::time::Instant::now() + CHECK_STOP_WAIT;
159    loop {
160        let left: Vec<String> = records
161            .list(true)
162            .into_iter()
163            .filter(|entry| entry.record.session == session)
164            .map(|entry| entry.record.handle)
165            .collect();
166        if left.is_empty() {
167            break;
168        }
169        if tokio::time::Instant::now() >= deadline {
170            for handle in left {
171                let _ = records.kill(&handle).await;
172            }
173            break;
174        }
175        tokio::time::sleep(Duration::from_millis(100)).await;
176    }
177    result
178}
179
180async fn call_once(
181    name: &str,
182    adapter: AgentAdapterConfig,
183    cwd: &Path,
184    arguments: Value,
185    timeout: Duration,
186    delegation: DelegationContext,
187    interrupted: impl Future<Output = ()>,
188) -> Result<Value, String> {
189    let defaults = ToolsConfig::default();
190    let tools = ToolsConfig {
191        agent_timeout: timeout,
192        max_timeout: timeout.max(defaults.max_timeout),
193        max_background: 0,
194        prefer: vec![name.to_owned()],
195        delegation: Some(delegation),
196        precheck_agent_models: false,
197        ..defaults
198    };
199    let registry = builtin_registry(
200        tools,
201        SkillMap::new(),
202        Vec::new(),
203        0,
204        [(name.to_owned(), adapter)],
205    )
206    .map_err(|error| error.message)?;
207    let agent = registry.get(AGENT_TOOL).ok_or_else(|| {
208        format!(
209            "{name} is not offered here: it is not installed, or this SCV is at its delegation \
210             depth limit"
211        )
212    })?;
213    let cancellation = CancellationToken::new();
214    let call = agent.execute(
215        arguments,
216        ToolContext::new(cwd.to_owned(), cancellation.clone()),
217    );
218    tokio::pin!(call);
219    let result = tokio::select! {
220        result = &mut call => result,
221        () = interrupted => {
222            // The call stops its agent before it returns.
223            cancellation.cancel();
224            call.await
225        }
226    };
227    let output = result.map_err(|error| error.message)?;
228    serde_json::from_str(&output.content).map_err(|_| output.content)
229}
230
231/// How SCV reaches an agent, decided from its adapter settings and what is
232/// installed; the `agent` tool and `scv agents check` decide the same way.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum Reach {
235    /// Its Agent Client Protocol server.
236    Acp(PathBuf),
237    /// Its CLI, once per turn.
238    Cli(PathBuf),
239    /// A nested `scv server --stdio`.
240    Scv(PathBuf),
241    /// Not installed: the named command resolves nowhere, so the agent is
242    /// not offered.
243    Missing(String),
244}
245
246/// How SCV would reach the agent `adapter` configures.
247pub fn reach(adapter: &AgentAdapterConfig) -> Reach {
248    let resolve = |command: &str| adapters::resolve_agent_executable(command, &adapter.search_dirs);
249    if adapter.transport == Transport::ScvProtocol {
250        return resolve(&adapter.command)
251            .map_or_else(|| Reach::Missing(adapter.command.clone()), Reach::Scv);
252    }
253    if let Some(launch) = &adapter.acp {
254        if let Some(server) = resolve(&launch.command) {
255            return Reach::Acp(server);
256        }
257        // `transport = "acp"` never falls back to the CLI.
258        if launch.required {
259            return Reach::Missing(launch.command.clone());
260        }
261    }
262    resolve(&adapter.command).map_or_else(|| Reach::Missing(adapter.command.clone()), Reach::Cli)
263}
264
265/// The `model` hint for an agent reached over ACP before SCV has seen what
266/// its server offers; once it has, the description lists the values instead.
267const ACP_MODEL_HINT: &str = "a value its ACP server lists, which SCV has not seen yet; omit model \
268     unless the user names one, and a value the server does not list fails naming those it does";
269
270/// The `model` hint for an agent whose ACP server listed its options but no
271/// model to choose; a listed model replaces it.
272const ACP_NO_MODEL_HINT: &str = "its ACP server lists no model to choose, so omit model";
273
274/// The agent `name` as the `agent` tool offers it, on the backend its
275/// adapter and transport call for; `None` when it is not installed.
276fn offer(
277    name: String,
278    adapter: AgentAdapterConfig,
279    config: &ToolsConfig,
280    conversations: &Arc<ConversationStore>,
281) -> Option<Offered> {
282    let timeouts = Timeouts {
283        default: config.agent_timeout,
284        max: config.max_timeout,
285    };
286    let mut model_hint = adapter.model_hint.clone();
287    let mut offered = None;
288    let (backend, accepts): (Arc<dyn Backend>, _) = match reach(&adapter) {
289        Reach::Missing(_) => return None,
290        Reach::Scv(resolved) => (
291            Arc::new(ScvAgentTool {
292                name: name.clone(),
293                command: adapter.command.clone(),
294                resolved: Some(resolved),
295                args: adapter.args.clone(),
296                environment: adapter.environment.clone(),
297                timeouts,
298                output_limit: config.output_limit_bytes,
299                delegation: config.delegation.clone(),
300                conversations: Arc::clone(conversations),
301            }),
302            ScvAgentTool::ACCEPTS,
303        ),
304        Reach::Acp(resolved) => {
305            let launch = adapter.acp.clone()?;
306            let tool = AcpAgentTool::new(
307                name.clone(),
308                &adapter,
309                launch,
310                Some(resolved),
311                timeouts,
312                config.output_limit_bytes,
313                config.delegation.clone(),
314                Arc::clone(conversations),
315            )
316            .with_precheck(config.precheck_agent_models);
317            let accepts = tool.accepts();
318            offered = tool.offered();
319            // Its CLI's own model names may not be what the server accepts.
320            model_hint = if offered.is_some() {
321                ACP_NO_MODEL_HINT
322            } else {
323                ACP_MODEL_HINT
324            }
325            .to_owned();
326            (Arc::new(tool), accepts)
327        }
328        Reach::Cli(_) => {
329            let tool = NativeAgentTool::new(
330                name.clone(),
331                adapter.clone(),
332                timeouts,
333                config.output_limit_bytes,
334                config.delegation.clone(),
335                Arc::clone(conversations),
336            );
337            tool.resolved.as_ref()?;
338            let accepts = tool.accepts();
339            (Arc::new(tool), accepts)
340        }
341    };
342    Some(Offered {
343        name,
344        backend,
345        accepts,
346        model_hint,
347        offered,
348        use_for: adapter.use_for,
349        model: adapter.model,
350        effort: adapter.effort,
351    })
352}
353
354#[cfg(test)]
355mod tests;