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(
100        AgentTool::new(offered, &config.prefer, timeouts)
101            .with_no_timeout(config.allow_no_timeout && config.max_background > 0)
102            .with_worktrees(config.worktrees.clone()),
103    );
104    if config.max_background == 0 {
105        registry.register(agent)?;
106        return Ok(registry);
107    }
108    // One job store per session, dropped with it, which cancels the jobs
109    // still running.
110    let session = config
111        .delegation
112        .as_ref()
113        .map(|context| context.session.clone());
114    let jobs = config.background.clone().unwrap_or_else(|| {
115        let mut jobs = background::BackgroundJobs::new(config.max_background, None);
116        if let (Some(root), Some(session)) = (&config.jobs, &session)
117            && let Some(ledger) =
118                background::ledger::JobLedger::new(root, session, &config.label, &config.workspace)
119        {
120            jobs = jobs.with_ledger(ledger);
121        }
122        Arc::new(jobs)
123    });
124    // A session back under its ID never reuses a job ID or conversation
125    // handle its records name, or its runs': a run names its conversation
126    // from the start, a job only once it succeeds, and a run's state (and
127    // log) can outlive the records of the jobs it served.
128    let mut used = jobs.recorded_conversations();
129    if let (Some(context), Some(session)) = (&config.delegation, &session) {
130        let mut served = Vec::new();
131        for state in crate::delegate::activity::list_states(context.registry.runs_dir())
132            .into_iter()
133            .filter(|state| &state.session == session)
134        {
135            used.extend(state.conversation);
136            served.extend(state.job.into_iter().chain(state.jobs));
137        }
138        jobs.continue_after(served.iter().map(String::as_str));
139    }
140    conversations.continue_after(used.iter().map(String::as_str));
141    let capable = Arc::new(background::BackgroundCapable {
142        inner: agent,
143        jobs: Arc::clone(&jobs),
144        conversations,
145        reviews: config.reviews.clone(),
146        session: session.clone(),
147    });
148    if let (Some(workbench), Some(session)) = (&config.workbench, &session) {
149        workbench.attach(session, Arc::downgrade(&capable), Arc::downgrade(&jobs));
150    }
151    registry.register(capable)?;
152    registry.register(Arc::new(background::WaitTool {
153        jobs: Arc::clone(&jobs),
154        timeouts,
155    }))?;
156    registry.register(Arc::new(background::StatusTool {
157        jobs: Arc::clone(&jobs),
158    }))?;
159    registry.register(Arc::new(background::CancelTool { jobs }))?;
160    Ok(registry)
161}
162
163/// How long `call_agent` waits for its agent to shut down after the call
164/// before stopping what is left of it.
165const CHECK_STOP_WAIT: Duration = Duration::from_secs(10);
166
167/// Make one `agent` call to `name` from `cwd`, as a session's `agent` tool
168/// makes it, for `scv agents check`. The call runs in the foreground and
169/// may take `timeout`; when `interrupted` completes first, it is cancelled.
170/// The agent's model list decides, not a saved one. The run is recorded
171/// under `delegation`, like a session's, and this returns only once the
172/// agent's process group is gone. The result is the tool's JSON result, or
173/// SCV's own error when the call was refused or nothing could run.
174pub async fn call_agent(
175    name: &str,
176    adapter: AgentAdapterConfig,
177    cwd: &Path,
178    arguments: Value,
179    timeout: Duration,
180    delegation: DelegationContext,
181    interrupted: impl Future<Output = ()>,
182) -> Result<Value, String> {
183    let records = Arc::clone(&delegation.registry);
184    let session = delegation.session.clone();
185    let result = call_once(
186        name,
187        adapter,
188        cwd,
189        arguments,
190        timeout,
191        delegation,
192        interrupted,
193    )
194    .await;
195    // Dropping the tool asked its agent to stop; wait for its whole group.
196    let deadline = tokio::time::Instant::now() + CHECK_STOP_WAIT;
197    loop {
198        let left: Vec<String> = records
199            .list(true)
200            .into_iter()
201            .filter(|entry| entry.record.session == session)
202            .map(|entry| entry.record.handle)
203            .collect();
204        if left.is_empty() {
205            break;
206        }
207        if tokio::time::Instant::now() >= deadline {
208            for handle in left {
209                let _ = records.kill(&handle).await;
210            }
211            break;
212        }
213        tokio::time::sleep(Duration::from_millis(100)).await;
214    }
215    result
216}
217
218async fn call_once(
219    name: &str,
220    adapter: AgentAdapterConfig,
221    cwd: &Path,
222    arguments: Value,
223    timeout: Duration,
224    delegation: DelegationContext,
225    interrupted: impl Future<Output = ()>,
226) -> Result<Value, String> {
227    let defaults = ToolsConfig::default();
228    let tools = ToolsConfig {
229        agent_timeout: timeout,
230        max_timeout: timeout.max(defaults.max_timeout),
231        max_background: 0,
232        prefer: vec![name.to_owned()],
233        delegation: Some(delegation),
234        precheck_agent_models: false,
235        ..defaults
236    };
237    let registry = builtin_registry(
238        tools,
239        SkillMap::new(),
240        Vec::new(),
241        0,
242        [(name.to_owned(), adapter)],
243    )
244    .map_err(|error| error.message)?;
245    let agent = registry.get(AGENT_TOOL).ok_or_else(|| {
246        format!(
247            "{name} is not offered here: it is not installed, or this SCV is at its delegation \
248             depth limit"
249        )
250    })?;
251    let cancellation = CancellationToken::new();
252    let call = agent.execute(
253        arguments,
254        ToolContext::new(cwd.to_owned(), cancellation.clone()),
255    );
256    tokio::pin!(call);
257    let result = tokio::select! {
258        result = &mut call => result,
259        () = interrupted => {
260            // The call stops its agent before it returns.
261            cancellation.cancel();
262            call.await
263        }
264    };
265    let output = result.map_err(|error| error.message)?;
266    serde_json::from_str(&output.content).map_err(|_| output.content)
267}
268
269/// How SCV reaches an agent, decided from its adapter settings and what is
270/// installed; the `agent` tool and `scv agents check` decide the same way.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub enum Reach {
273    /// Its Agent Client Protocol server.
274    Acp(PathBuf),
275    /// Its CLI, once per turn.
276    Cli(PathBuf),
277    /// A nested `scv server --stdio`.
278    Scv(PathBuf),
279    /// Not installed: the named command resolves nowhere, so the agent is
280    /// not offered.
281    Missing(String),
282}
283
284/// How SCV would reach the agent `adapter` configures.
285pub fn reach(adapter: &AgentAdapterConfig) -> Reach {
286    let resolve = |command: &str| adapters::resolve_agent_executable(command, &adapter.search_dirs);
287    if adapter.transport == Transport::ScvProtocol {
288        return resolve(&adapter.command)
289            .map_or_else(|| Reach::Missing(adapter.command.clone()), Reach::Scv);
290    }
291    if let Some(launch) = &adapter.acp {
292        if let Some(server) = resolve(&launch.command) {
293            return Reach::Acp(server);
294        }
295        // `transport = "acp"` never falls back to the CLI.
296        if launch.required {
297            return Reach::Missing(launch.command.clone());
298        }
299    }
300    resolve(&adapter.command).map_or_else(|| Reach::Missing(adapter.command.clone()), Reach::Cli)
301}
302
303/// The `model` hint for an agent reached over ACP before SCV has seen what
304/// its server offers; once it has, the description lists the values instead.
305const ACP_MODEL_HINT: &str = "a value its ACP server lists, which SCV has not seen yet; omit model \
306     unless the user names one, and a value the server does not list fails naming those it does";
307
308/// The `model` hint for an agent whose ACP server listed its options but no
309/// model to choose; a listed model replaces it.
310const ACP_NO_MODEL_HINT: &str = "its ACP server lists no model to choose, so omit model";
311
312/// The agent `name` as the `agent` tool offers it, on the backend its
313/// adapter and transport call for; `None` when it is not installed.
314fn offer(
315    name: String,
316    adapter: AgentAdapterConfig,
317    config: &ToolsConfig,
318    conversations: &Arc<ConversationStore>,
319) -> Option<Offered> {
320    let timeouts = Timeouts {
321        default: config.agent_timeout,
322        max: config.max_timeout,
323    };
324    let mut model_hint = adapter.model_hint.clone();
325    let mut offered = None;
326    let reached = reach(&adapter);
327    // An ACP server or nested SCV keeps a conversation's model and effort;
328    // a CLI started once per turn is given them again on every turn.
329    let holds_settings = !matches!(reached, Reach::Cli(_));
330    let (backend, accepts): (Arc<dyn Backend>, _) = match reached {
331        Reach::Missing(_) => return None,
332        Reach::Scv(resolved) => (
333            Arc::new(ScvAgentTool {
334                name: name.clone(),
335                command: adapter.command.clone(),
336                resolved: Some(resolved),
337                args: adapter.args.clone(),
338                environment: adapter.environment.clone(),
339                timeouts,
340                output_limit: config.output_limit_bytes,
341                delegation: config.delegation.clone(),
342                conversations: Arc::clone(conversations),
343                worktrees: config.worktrees.clone(),
344            }),
345            ScvAgentTool::ACCEPTS,
346        ),
347        Reach::Acp(resolved) => {
348            let launch = adapter.acp.clone()?;
349            let tool = AcpAgentTool::new(
350                name.clone(),
351                &adapter,
352                launch,
353                Some(resolved),
354                timeouts,
355                config.output_limit_bytes,
356                config.delegation.clone(),
357                Arc::clone(conversations),
358            )
359            .with_precheck(config.precheck_agent_models)
360            .with_worktrees(config.worktrees.clone());
361            let accepts = tool.accepts();
362            offered = tool.offered();
363            // Its CLI's own model names may not be what the server accepts.
364            model_hint = if offered.is_some() {
365                ACP_NO_MODEL_HINT
366            } else {
367                ACP_MODEL_HINT
368            }
369            .to_owned();
370            (Arc::new(tool), accepts)
371        }
372        Reach::Cli(_) => {
373            let tool = NativeAgentTool::new(
374                name.clone(),
375                adapter.clone(),
376                timeouts,
377                config.output_limit_bytes,
378                config.delegation.clone(),
379                Arc::clone(conversations),
380            )
381            .with_worktrees(config.worktrees.clone());
382            tool.resolved.as_ref()?;
383            let accepts = tool.accepts();
384            (Arc::new(tool), accepts)
385        }
386    };
387    Some(Offered {
388        name,
389        backend,
390        accepts,
391        model_hint,
392        offered,
393        use_for: adapter.use_for,
394        defaults: adapter.defaults,
395        holds_settings,
396        busy: adapter.busy,
397    })
398}
399
400#[cfg(test)]
401mod tests;