use serde_json::json;
use std::fmt::Write as _;
use crate::engine::{ExecutionRecipe, SymbolicAnswer};
use crate::protocol::ChatMessage;
use super::planner::{
tool_capability, tool_for, write_arguments, AgenticPlan, Capability, PlannedToolCall,
};
#[must_use]
pub fn plan_symbolic_command_reroute(
messages: &[ChatMessage],
tool_names: &[&str],
symbolic_answer: &SymbolicAnswer,
) -> Option<AgenticPlan> {
let recipe = symbolic_answer.execution_recipe.as_ref()?;
let write_tool = tool_for(tool_names, Capability::Write)?;
let run_tool = tool_for(tool_names, Capability::Run)?;
let progress = RecipeProgress::after_latest_user(messages);
if let Some(failure) = &progress.failure {
return Some(AgenticPlan::Final(format!(
"The agentic CLI harness could not complete `{}`. The tool reported:\n\n```text\n{}\n```",
recipe.path,
failure.trim()
)));
}
if !progress.write_done {
return Some(one_call(
write_tool,
write_arguments(&recipe.path, &recipe.source),
));
}
if let Some(command) = recipe.commands.get(progress.commands_done) {
return Some(one_call(
run_tool,
json!({ "command": command }).to_string(),
));
}
Some(AgenticPlan::Final(
recipe.final_answer(&progress.command_outputs),
))
}
fn one_call(tool: &str, arguments: String) -> AgenticPlan {
AgenticPlan::ToolCalls(vec![PlannedToolCall {
tool: tool.to_owned(),
arguments,
}])
}
impl ExecutionRecipe {
fn final_answer(&self, outputs: &[String]) -> String {
let mut answer = format!(
"Created and verified `{}` through the agentic CLI harness.\n\n```{}\n{}\n```\n\nCommands executed by the harness:\n",
self.path, self.language, self.source
);
for command in &self.commands {
let _ = writeln!(answer, "- `{command}`");
}
let actual = outputs
.iter()
.rev()
.find(|output| !output.trim().is_empty())
.map_or("(command completed without output)", |output| output.trim());
let _ = write!(answer, "\nActual tool output:\n\n```text\n{actual}\n```");
answer
}
}
#[derive(Default)]
struct RecipeProgress {
write_done: bool,
commands_done: usize,
command_outputs: Vec<String>,
failure: Option<String>,
}
impl RecipeProgress {
fn after_latest_user(messages: &[ChatMessage]) -> Self {
let start = messages
.iter()
.rposition(|message| message.role == "user")
.map_or(0, |index| index + 1);
let mut progress = Self::default();
for message in &messages[start..] {
if message.role != "tool" {
continue;
}
let capability = message
.name
.as_deref()
.and_then(tool_capability)
.or_else(|| capability_from_call_id(messages, message.tool_call_id.as_deref()));
let output = message.content.plain_text();
if tool_result_failed(&output) {
progress.failure = Some(output);
break;
}
match capability {
Some(Capability::Write) => progress.write_done = true,
Some(Capability::Run) => {
progress.commands_done += 1;
progress.command_outputs.push(output);
}
_ => {}
}
}
progress
}
}
fn tool_result_failed(output: &str) -> bool {
let normalized = output.to_ascii_lowercase();
let explicit_failure = [
"command exited with status ",
"command timed out",
"command terminated without an exit status",
"error:",
"failed:",
"permission denied",
"no such file or directory",
]
.iter()
.any(|marker| normalized.contains(marker));
explicit_failure
|| normalized.lines().any(|line| {
["exit code:", "exit status:"]
.iter()
.find_map(|prefix| line.trim().strip_prefix(prefix))
.and_then(|value| value.trim().parse::<i32>().ok())
.is_some_and(|code| code != 0)
})
}
fn capability_from_call_id(messages: &[ChatMessage], call_id: Option<&str>) -> Option<Capability> {
let call_id = call_id?;
messages
.iter()
.flat_map(|message| &message.tool_calls)
.find(|call| call.id == call_id)
.and_then(|call| tool_capability(&call.function.name))
}