1use 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
35pub 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 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 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 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 conversations,
114 reviews: config.reviews.clone(),
115 session: config
116 .delegation
117 .as_ref()
118 .map(|context| context.session.clone()),
119 }))?;
120 registry.register(Arc::new(background::WaitTool {
121 jobs: Arc::clone(&jobs),
122 timeouts,
123 }))?;
124 registry.register(Arc::new(background::StatusTool {
125 jobs: Arc::clone(&jobs),
126 }))?;
127 registry.register(Arc::new(background::CancelTool { jobs }))?;
128 Ok(registry)
129}
130
131const CHECK_STOP_WAIT: Duration = Duration::from_secs(10);
134
135pub async fn call_agent(
143 name: &str,
144 adapter: AgentAdapterConfig,
145 cwd: &Path,
146 arguments: Value,
147 timeout: Duration,
148 delegation: DelegationContext,
149 interrupted: impl Future<Output = ()>,
150) -> Result<Value, String> {
151 let records = Arc::clone(&delegation.registry);
152 let session = delegation.session.clone();
153 let result = call_once(
154 name,
155 adapter,
156 cwd,
157 arguments,
158 timeout,
159 delegation,
160 interrupted,
161 )
162 .await;
163 let deadline = tokio::time::Instant::now() + CHECK_STOP_WAIT;
165 loop {
166 let left: Vec<String> = records
167 .list(true)
168 .into_iter()
169 .filter(|entry| entry.record.session == session)
170 .map(|entry| entry.record.handle)
171 .collect();
172 if left.is_empty() {
173 break;
174 }
175 if tokio::time::Instant::now() >= deadline {
176 for handle in left {
177 let _ = records.kill(&handle).await;
178 }
179 break;
180 }
181 tokio::time::sleep(Duration::from_millis(100)).await;
182 }
183 result
184}
185
186async fn call_once(
187 name: &str,
188 adapter: AgentAdapterConfig,
189 cwd: &Path,
190 arguments: Value,
191 timeout: Duration,
192 delegation: DelegationContext,
193 interrupted: impl Future<Output = ()>,
194) -> Result<Value, String> {
195 let defaults = ToolsConfig::default();
196 let tools = ToolsConfig {
197 agent_timeout: timeout,
198 max_timeout: timeout.max(defaults.max_timeout),
199 max_background: 0,
200 prefer: vec![name.to_owned()],
201 delegation: Some(delegation),
202 precheck_agent_models: false,
203 ..defaults
204 };
205 let registry = builtin_registry(
206 tools,
207 SkillMap::new(),
208 Vec::new(),
209 0,
210 [(name.to_owned(), adapter)],
211 )
212 .map_err(|error| error.message)?;
213 let agent = registry.get(AGENT_TOOL).ok_or_else(|| {
214 format!(
215 "{name} is not offered here: it is not installed, or this SCV is at its delegation \
216 depth limit"
217 )
218 })?;
219 let cancellation = CancellationToken::new();
220 let call = agent.execute(
221 arguments,
222 ToolContext::new(cwd.to_owned(), cancellation.clone()),
223 );
224 tokio::pin!(call);
225 let result = tokio::select! {
226 result = &mut call => result,
227 () = interrupted => {
228 cancellation.cancel();
230 call.await
231 }
232 };
233 let output = result.map_err(|error| error.message)?;
234 serde_json::from_str(&output.content).map_err(|_| output.content)
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum Reach {
241 Acp(PathBuf),
243 Cli(PathBuf),
245 Scv(PathBuf),
247 Missing(String),
250}
251
252pub fn reach(adapter: &AgentAdapterConfig) -> Reach {
254 let resolve = |command: &str| adapters::resolve_agent_executable(command, &adapter.search_dirs);
255 if adapter.transport == Transport::ScvProtocol {
256 return resolve(&adapter.command)
257 .map_or_else(|| Reach::Missing(adapter.command.clone()), Reach::Scv);
258 }
259 if let Some(launch) = &adapter.acp {
260 if let Some(server) = resolve(&launch.command) {
261 return Reach::Acp(server);
262 }
263 if launch.required {
265 return Reach::Missing(launch.command.clone());
266 }
267 }
268 resolve(&adapter.command).map_or_else(|| Reach::Missing(adapter.command.clone()), Reach::Cli)
269}
270
271const ACP_MODEL_HINT: &str = "a value its ACP server lists, which SCV has not seen yet; omit model \
274 unless the user names one, and a value the server does not list fails naming those it does";
275
276const ACP_NO_MODEL_HINT: &str = "its ACP server lists no model to choose, so omit model";
279
280fn offer(
283 name: String,
284 adapter: AgentAdapterConfig,
285 config: &ToolsConfig,
286 conversations: &Arc<ConversationStore>,
287) -> Option<Offered> {
288 let timeouts = Timeouts {
289 default: config.agent_timeout,
290 max: config.max_timeout,
291 };
292 let mut model_hint = adapter.model_hint.clone();
293 let mut offered = None;
294 let reached = reach(&adapter);
295 let holds_settings = !matches!(reached, Reach::Cli(_));
298 let (backend, accepts): (Arc<dyn Backend>, _) = match reached {
299 Reach::Missing(_) => return None,
300 Reach::Scv(resolved) => (
301 Arc::new(ScvAgentTool {
302 name: name.clone(),
303 command: adapter.command.clone(),
304 resolved: Some(resolved),
305 args: adapter.args.clone(),
306 environment: adapter.environment.clone(),
307 timeouts,
308 output_limit: config.output_limit_bytes,
309 delegation: config.delegation.clone(),
310 conversations: Arc::clone(conversations),
311 }),
312 ScvAgentTool::ACCEPTS,
313 ),
314 Reach::Acp(resolved) => {
315 let launch = adapter.acp.clone()?;
316 let tool = AcpAgentTool::new(
317 name.clone(),
318 &adapter,
319 launch,
320 Some(resolved),
321 timeouts,
322 config.output_limit_bytes,
323 config.delegation.clone(),
324 Arc::clone(conversations),
325 )
326 .with_precheck(config.precheck_agent_models);
327 let accepts = tool.accepts();
328 offered = tool.offered();
329 model_hint = if offered.is_some() {
331 ACP_NO_MODEL_HINT
332 } else {
333 ACP_MODEL_HINT
334 }
335 .to_owned();
336 (Arc::new(tool), accepts)
337 }
338 Reach::Cli(_) => {
339 let tool = NativeAgentTool::new(
340 name.clone(),
341 adapter.clone(),
342 timeouts,
343 config.output_limit_bytes,
344 config.delegation.clone(),
345 Arc::clone(conversations),
346 );
347 tool.resolved.as_ref()?;
348 let accepts = tool.accepts();
349 (Arc::new(tool), accepts)
350 }
351 };
352 Some(Offered {
353 name,
354 backend,
355 accepts,
356 model_hint,
357 offered,
358 use_for: adapter.use_for,
359 defaults: adapter.defaults,
360 holds_settings,
361 busy: adapter.busy,
362 })
363}
364
365#[cfg(test)]
366mod tests;