use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use futures_util::future::join_all;
use serde::Deserialize;
use tokio::sync::{Mutex, Semaphore};
use tokio_util::sync::CancellationToken;
use crate::model::{StreamEvent, ToolCall, ToolResult};
use crate::provider::EventSink;
use super::bash::{BashArgs, BashOptions, OutputSink, execute_bash_cancellable};
use super::patch::{FileSnapshot, affected_paths, apply_patch_with_snapshots};
use super::read::{ReadArgs, read_text_file_bounded};
#[derive(Debug, Clone)]
pub struct ToolOutcome {
pub result: ToolResult,
pub snapshots: Vec<FileSnapshot>,
}
impl From<ToolResult> for ToolOutcome {
fn from(result: ToolResult) -> Self {
Self {
result,
snapshots: Vec::new(),
}
}
}
#[async_trait]
pub trait ToolExecutor: Send + Sync {
async fn execute(&self, call: ToolCall) -> ToolOutcome;
async fn execute_with(
&self,
call: ToolCall,
_events: EventSink,
_cancel: CancellationToken,
) -> ToolOutcome {
self.execute(call).await
}
}
pub struct CoreToolExecutor {
cwd: PathBuf,
read_max_lines: usize,
max_output_bytes: usize,
snapshot_max_bytes: usize,
bash_options: BashOptions,
}
impl CoreToolExecutor {
pub fn new(
cwd: PathBuf,
read_max_lines: usize,
bash_timeout: Duration,
max_output_bytes: usize,
) -> Self {
Self::with_snapshot_limit(
cwd,
read_max_lines,
BashOptions {
timeout: bash_timeout,
max_timeout: bash_timeout,
max_output_bytes,
},
max_output_bytes,
1024 * 1024,
)
}
pub fn with_snapshot_limit(
cwd: PathBuf,
read_max_lines: usize,
bash_options: BashOptions,
max_output_bytes: usize,
snapshot_max_bytes: usize,
) -> Self {
Self {
cwd,
read_max_lines,
max_output_bytes,
snapshot_max_bytes,
bash_options,
}
}
}
#[derive(Deserialize)]
struct PatchArgs {
patch: String,
}
#[async_trait]
impl ToolExecutor for CoreToolExecutor {
async fn execute(&self, call: ToolCall) -> ToolOutcome {
self.execute_with(call, EventSink::default(), CancellationToken::new())
.await
}
async fn execute_with(
&self,
call: ToolCall,
events: EventSink,
cancel: CancellationToken,
) -> ToolOutcome {
if cancel.is_cancelled() {
return ToolResult::error(call.id, "tool execution cancelled").into();
}
let call_id = call.id.clone();
let mut snapshots = Vec::new();
let result = match call.name.as_str() {
"read" => match serde_json::from_str::<ReadArgs>(&call.arguments) {
Ok(args) => {
read_text_file_bounded(
&self.cwd,
&args,
self.read_max_lines,
self.max_output_bytes,
)
.await
}
Err(error) => Err(error.into()),
},
"apply_patch" => match patch_text(&call.arguments) {
Ok(patch) => apply_patch_with_snapshots(&self.cwd, &patch, self.snapshot_max_bytes)
.await
.map(|summary| {
snapshots = summary.snapshots;
summary
.files
.iter()
.map(|file| {
format!("{} (+{} -{})", file.path, file.added, file.removed)
})
.collect::<Vec<_>>()
.join("\n")
}),
Err(error) => Err(error),
},
"bash" => match serde_json::from_str::<BashArgs>(&call.arguments) {
Ok(args) => {
let output_events = events.clone();
let output_id = call_id.clone();
let output_sink: OutputSink = Arc::new(move |delta| {
output_events.emit(StreamEvent::ToolExecutionOutput {
id: output_id.clone(),
delta,
});
});
execute_bash_cancellable(
&self.cwd,
&args,
&self.bash_options,
Some(output_sink),
cancel,
)
.await
.map(|result| {
format!(
"{}\n[exit code: {}]",
result.output,
result
.exit_code
.map_or_else(|| "signal".into(), |code| code.to_string())
)
})
}
Err(error) => Err(error.into()),
},
name => Err(anyhow::anyhow!(
"unknown tool '{name}'; available tools: read, apply_patch, bash"
)),
};
let result = match result {
Ok(output)
if call.name == "bash"
&& (output.contains("[bash cancelled]")
|| output.contains("[bash timed out after ")) =>
{
ToolResult::error(call.id, output)
}
Ok(output) => ToolResult::success(call.id, output),
Err(error) => ToolResult::error(call.id, error.to_string()),
};
ToolOutcome { result, snapshots }
}
}
pub struct ToolRunner {
executor: Arc<dyn ToolExecutor>,
semaphore: Arc<Semaphore>,
path_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
}
impl ToolRunner {
pub fn new(executor: Arc<dyn ToolExecutor>, max_parallel: usize) -> Self {
Self {
executor,
semaphore: Arc::new(Semaphore::new(max_parallel.max(1))),
path_locks: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn execute(&self, calls: Vec<ToolCall>) -> Vec<ToolOutcome> {
self.execute_with(calls, EventSink::default(), CancellationToken::new())
.await
}
pub async fn execute_with(
&self,
calls: Vec<ToolCall>,
events: EventSink,
cancel: CancellationToken,
) -> Vec<ToolOutcome> {
let futures = calls.into_iter().map(|call| {
let executor = self.executor.clone();
let semaphore = self.semaphore.clone();
let path_locks = self.path_locks.clone();
let events = events.clone();
let cancel = cancel.clone();
async move {
let _permit = semaphore.acquire_owned().await.expect("semaphore closed");
let paths = scheduling_paths(&call);
let locks = {
let mut registry = path_locks.lock().await;
paths
.into_iter()
.map(|path| registry.entry(path).or_default().clone())
.collect::<Vec<_>>()
};
let mut guards = Vec::with_capacity(locks.len());
for lock in locks {
guards.push(lock.lock_owned().await);
}
let result = executor.execute_with(call, events, cancel).await;
drop(guards);
result
}
});
join_all(futures).await
}
}
fn scheduling_paths(call: &ToolCall) -> Vec<String> {
if call.name != "apply_patch" {
return Vec::new();
}
patch_text(&call.arguments)
.and_then(|patch| affected_paths(&patch))
.unwrap_or_default()
}
fn patch_text(arguments: &str) -> anyhow::Result<String> {
if arguments.trim_start().starts_with("*** Begin Patch") {
return Ok(arguments.to_owned());
}
Ok(serde_json::from_str::<PatchArgs>(arguments)?.patch)
}