use std::{
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use scv_core::{ToolContext, ToolError, ToolRegistry};
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::{
AgentAdapterConfig, DelegationContext, SkillMap, ToolsConfig,
args::Timeouts,
builtin::{
chat_attach,
chat_history::{ChatHistoryTool, ChatKeepTool},
fs::{ReadTool, WriteTool},
shell::BashTool,
skill::ReadSkillTool,
},
delegate::{
acp::AcpAgentTool,
adapters::{self, Transport},
agent::{AGENT_TOOL, AgentTool, Backend, Offered},
background,
conversation::ConversationStore,
native::NativeAgentTool,
records,
scv::ScvAgentTool,
},
};
pub fn builtin_registry(
config: ToolsConfig,
skills: SkillMap,
skill_roots: Vec<PathBuf>,
max_skill_bytes: usize,
adapters: impl IntoIterator<Item = (String, AgentAdapterConfig)>,
) -> Result<ToolRegistry, ToolError> {
let mut registry = ToolRegistry::default();
registry.register(Arc::new(ReadTool {
max_bytes: config.max_read_bytes,
}))?;
registry.register(Arc::new(ReadSkillTool {
skills,
roots: skill_roots,
max_bytes: max_skill_bytes,
}))?;
registry.register(Arc::new(WriteTool {
max_bytes: config.max_write_bytes,
}))?;
registry.register(Arc::new(BashTool {
timeout: config.command_timeout,
max_timeout: config.max_timeout,
output_limit: config.output_limit_bytes,
}))?;
if let Some(chat) = config.chat_attach.clone() {
registry.register(Arc::new(chat_attach::ChatAttachTool { config: chat }))?;
}
if let Some(history) = config.chat_history.clone() {
registry.register(Arc::new(ChatHistoryTool {
config: history.clone(),
}))?;
registry.register(Arc::new(ChatKeepTool { config: history }))?;
}
let depth = config
.delegation
.as_ref()
.map_or_else(records::current_depth, DelegationContext::owner_depth);
if depth >= config.max_delegation_depth {
return Ok(registry);
}
let conversations = Arc::new(ConversationStore::new(
config.conversations,
config
.delegation
.as_ref()
.map(|context| context.registry.conversation_dir().to_owned()),
));
let offered: Vec<Offered> = adapters
.into_iter()
.filter_map(|(name, adapter)| offer(name, adapter, &config, &conversations))
.collect();
if offered.is_empty() {
return Ok(registry);
}
let timeouts = Timeouts {
default: config.agent_timeout,
max: config.max_timeout,
};
let agent = Arc::new(AgentTool::new(offered, &config.prefer, timeouts));
if config.max_background == 0 {
registry.register(agent)?;
return Ok(registry);
}
let jobs = config
.background
.clone()
.unwrap_or_else(|| Arc::new(background::BackgroundJobs::new(config.max_background, None)));
registry.register(Arc::new(background::BackgroundCapable {
inner: agent,
jobs: Arc::clone(&jobs),
}))?;
registry.register(Arc::new(background::WaitTool {
jobs: Arc::clone(&jobs),
timeouts,
}))?;
registry.register(Arc::new(background::StatusTool {
jobs: Arc::clone(&jobs),
}))?;
registry.register(Arc::new(background::CancelTool { jobs }))?;
Ok(registry)
}
const CHECK_STOP_WAIT: Duration = Duration::from_secs(10);
pub async fn call_agent(
name: &str,
adapter: AgentAdapterConfig,
cwd: &Path,
arguments: Value,
timeout: Duration,
delegation: DelegationContext,
interrupted: impl Future<Output = ()>,
) -> Result<Value, String> {
let records = Arc::clone(&delegation.registry);
let session = delegation.session.clone();
let result = call_once(
name,
adapter,
cwd,
arguments,
timeout,
delegation,
interrupted,
)
.await;
let deadline = tokio::time::Instant::now() + CHECK_STOP_WAIT;
loop {
let left: Vec<String> = records
.list(true)
.into_iter()
.filter(|entry| entry.record.session == session)
.map(|entry| entry.record.handle)
.collect();
if left.is_empty() {
break;
}
if tokio::time::Instant::now() >= deadline {
for handle in left {
let _ = records.kill(&handle).await;
}
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
result
}
async fn call_once(
name: &str,
adapter: AgentAdapterConfig,
cwd: &Path,
arguments: Value,
timeout: Duration,
delegation: DelegationContext,
interrupted: impl Future<Output = ()>,
) -> Result<Value, String> {
let defaults = ToolsConfig::default();
let tools = ToolsConfig {
agent_timeout: timeout,
max_timeout: timeout.max(defaults.max_timeout),
max_background: 0,
prefer: vec![name.to_owned()],
delegation: Some(delegation),
precheck_agent_models: false,
..defaults
};
let registry = builtin_registry(
tools,
SkillMap::new(),
Vec::new(),
0,
[(name.to_owned(), adapter)],
)
.map_err(|error| error.message)?;
let agent = registry.get(AGENT_TOOL).ok_or_else(|| {
format!(
"{name} is not offered here: it is not installed, or this SCV is at its delegation \
depth limit"
)
})?;
let cancellation = CancellationToken::new();
let call = agent.execute(
arguments,
ToolContext::new(cwd.to_owned(), cancellation.clone()),
);
tokio::pin!(call);
let result = tokio::select! {
result = &mut call => result,
() = interrupted => {
cancellation.cancel();
call.await
}
};
let output = result.map_err(|error| error.message)?;
serde_json::from_str(&output.content).map_err(|_| output.content)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reach {
Acp(PathBuf),
Cli(PathBuf),
Scv(PathBuf),
Missing(String),
}
pub fn reach(adapter: &AgentAdapterConfig) -> Reach {
let resolve = |command: &str| adapters::resolve_agent_executable(command, &adapter.search_dirs);
if adapter.transport == Transport::ScvProtocol {
return resolve(&adapter.command)
.map_or_else(|| Reach::Missing(adapter.command.clone()), Reach::Scv);
}
if let Some(launch) = &adapter.acp {
if let Some(server) = resolve(&launch.command) {
return Reach::Acp(server);
}
if launch.required {
return Reach::Missing(launch.command.clone());
}
}
resolve(&adapter.command).map_or_else(|| Reach::Missing(adapter.command.clone()), Reach::Cli)
}
const ACP_MODEL_HINT: &str = "a value its ACP server lists, which SCV has not seen yet; omit model \
unless the user names one, and a value the server does not list fails naming those it does";
const ACP_NO_MODEL_HINT: &str = "its ACP server lists no model to choose, so omit model";
fn offer(
name: String,
adapter: AgentAdapterConfig,
config: &ToolsConfig,
conversations: &Arc<ConversationStore>,
) -> Option<Offered> {
let timeouts = Timeouts {
default: config.agent_timeout,
max: config.max_timeout,
};
let mut model_hint = adapter.model_hint.clone();
let mut offered = None;
let (backend, accepts): (Arc<dyn Backend>, _) = match reach(&adapter) {
Reach::Missing(_) => return None,
Reach::Scv(resolved) => (
Arc::new(ScvAgentTool {
name: name.clone(),
command: adapter.command.clone(),
resolved: Some(resolved),
args: adapter.args.clone(),
environment: adapter.environment.clone(),
timeouts,
output_limit: config.output_limit_bytes,
delegation: config.delegation.clone(),
conversations: Arc::clone(conversations),
}),
ScvAgentTool::ACCEPTS,
),
Reach::Acp(resolved) => {
let launch = adapter.acp.clone()?;
let tool = AcpAgentTool::new(
name.clone(),
&adapter,
launch,
Some(resolved),
timeouts,
config.output_limit_bytes,
config.delegation.clone(),
Arc::clone(conversations),
)
.with_precheck(config.precheck_agent_models);
let accepts = tool.accepts();
offered = tool.offered();
model_hint = if offered.is_some() {
ACP_NO_MODEL_HINT
} else {
ACP_MODEL_HINT
}
.to_owned();
(Arc::new(tool), accepts)
}
Reach::Cli(_) => {
let tool = NativeAgentTool::new(
name.clone(),
adapter.clone(),
timeouts,
config.output_limit_bytes,
config.delegation.clone(),
Arc::clone(conversations),
);
tool.resolved.as_ref()?;
let accepts = tool.accepts();
(Arc::new(tool), accepts)
}
};
Some(Offered {
name,
backend,
accepts,
model_hint,
offered,
use_for: adapter.use_for,
model: adapter.model,
effort: adapter.effort,
})
}
#[cfg(test)]
mod tests;