mod multi;
mod seam;
#[cfg(test)]
mod tests;
use super::Resolved;
use super::stop_signal;
use super::transcript;
use crate::prompt::Deps;
use crate::prompt::Error;
use crate::prompt::compactor;
use crate::prompt::tool::{ExecError, ToolCall as ToolUse, ToolOutcome};
use crate::workspace::hold;
use brazen::Content;
use std::path::Path;
#[derive(Debug)]
pub(in crate::prompt) enum ToolWindow {
Completed,
Stopped,
Held,
}
pub(in crate::prompt) fn run_tool_calls(
conv_repo: &Path,
worktree: &Path,
conv_id: &str,
resolved: &Resolved<'_>,
step_dir_rel_str: &str,
assistant_content: &[Content],
deps: &Deps<'_>,
) -> Result<ToolWindow, Error> {
let step_dir_abs = conv_repo.join(step_dir_rel_str);
let control = resolved.workflow.tool_control.as_ref();
let mut marked = hold::read(conv_repo, conv_id, deps.git);
let committed = match marked {
Some(_) => transcript::committed_result_ids(worktree)?,
None => Default::default(),
};
for block in assistant_content {
let Content::ToolUse {
id, name, input, ..
} = block
else {
continue;
};
if committed.contains(id) {
continue;
}
let outcome = match refusal(resolved.grant.role, resolved.grant.tools, name) {
Some(decline) => ToolOutcome {
content: decline.into_bytes(),
is_error: true,
},
None => {
let gate = seam::adjudicate(
control,
resolved.grant.role,
id,
name,
input,
conv_repo,
conv_id,
deps.stop,
)?;
if matches!(gate, seam::Gate::Proceed | seam::Gate::Refuse(_))
&& marked.as_ref().is_some_and(|m| m.tool_use_id == *id)
{
hold::clear(conv_repo, conv_id, deps.git).map_err(|source| Error::Git {
op: "hold mark clear",
source,
})?;
marked = None;
}
match gate {
seam::Gate::Stopped => return Ok(ToolWindow::Stopped),
seam::Gate::Hold(reason) => {
let held = hold::Held {
tool_use_id: id.clone(),
tool: name.clone(),
reason,
};
hold::write(conv_repo, conv_id, &held, deps.git).map_err(|source| {
Error::Git {
op: "hold mark write",
source,
}
})?;
return Ok(ToolWindow::Held);
}
seam::Gate::Refuse(reason) => ToolOutcome {
content: seam::refusal_text(name, &reason).into_bytes(),
is_error: true,
},
seam::Gate::Proceed if name == multi::NAME => {
match multi::fan_out(
id,
input,
&step_dir_abs,
resolved,
conv_repo,
conv_id,
deps,
)? {
multi::Fanout::Outcome(outcome) => outcome,
multi::Fanout::Stopped => return Ok(ToolWindow::Stopped),
}
}
seam::Gate::Proceed => match deps.tool_executor.execute(
ToolUse { id, name, input },
&step_dir_abs,
deps.stop,
resolved.workflow.tool_output,
) {
Ok(outcome) => outcome,
Err(ExecError::KilledBySignal { .. })
if stop_signal::stopped(deps.stop) =>
{
return Ok(ToolWindow::Stopped);
}
Err(source) => {
return Err(Error::ToolExec {
tool: name.clone(),
source,
});
}
},
}
}
};
let tool_result = outcome_to_tool_result(id, &outcome);
transcript::commit_tool(worktree, conv_id, &tool_result, deps.git)?;
}
Ok(ToolWindow::Completed)
}
fn refusal(role: &str, grant: &[String], tool: &str) -> Option<String> {
let injected = compactor::injected(role);
if grant.iter().any(|granted| granted == tool) || injected.contains(&tool) {
return None;
}
let mut effective: Vec<&str> = grant.iter().map(String::as_str).collect();
effective.extend(injected);
let toolset = if effective.is_empty() {
"empty".to_string()
} else {
effective.join(", ")
};
Some(format!(
"{tool:?} is not callable by a {role}: it is declared only because \
the inherited transcript references it. The {role} toolset is \
{toolset} (ARCH §3.3, declaring is not permitting)."
))
}
fn outcome_to_tool_result(tool_use_id: &str, outcome: &ToolOutcome) -> Content {
Content::ToolResult {
tool_use_id: tool_use_id.to_string(),
content: vec![Content::Text(
String::from_utf8_lossy(&outcome.content).into_owned(),
)],
is_error: outcome.is_error,
}
}