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(
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 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 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
163const CHECK_STOP_WAIT: Duration = Duration::from_secs(10);
166
167pub 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
272pub enum Reach {
273 Acp(PathBuf),
275 Cli(PathBuf),
277 Scv(PathBuf),
279 Missing(String),
282}
283
284pub 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 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
303const 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
308const ACP_NO_MODEL_HINT: &str = "its ACP server lists no model to choose, so omit model";
311
312fn 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 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 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;