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