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 }))?;
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
125const CHECK_STOP_WAIT: Duration = Duration::from_secs(10);
128
129pub 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum Reach {
235 Acp(PathBuf),
237 Cli(PathBuf),
239 Scv(PathBuf),
241 Missing(String),
244}
245
246pub 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 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
265const 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
270const ACP_NO_MODEL_HINT: &str = "its ACP server lists no model to choose, so omit model";
273
274fn 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 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;