use crate::config::LimitsConfig;
use crate::providers::AgentProvider;
use std::path::{Path, PathBuf};
use std::process::Output;
use std::time::Duration;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct AgentResult {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub elapsed: Duration,
pub attempt: u32,
pub attempts: Vec<AttemptTrace>,
}
#[derive(Debug, Clone)]
pub struct AttemptTrace {
pub attempt: u32,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, Default)]
pub struct AgentOptions {
pub story_id: Option<String>,
pub decisions_dir: Option<PathBuf>,
pub inject_feedback: bool,
}
pub fn invoke_with_retry(
provider: &dyn AgentProvider,
instruction_path: &Path,
prompt: &str,
limits: &LimitsConfig,
opts: &AgentOptions,
) -> anyhow::Result<AgentResult> {
let mut attempt = 1u32;
let mut delay = Duration::from_secs(limits.retry_delay_base_seconds);
let timeout = Duration::from_secs(limits.agent_timeout_seconds);
let max_retries = limits.max_retries_per_step;
let mut attempts: Vec<AttemptTrace> = vec![];
let mut current_prompt = prompt.to_string();
loop {
tracing::info!(
" [{attempt}/{max_retries}] invocando {} ({})",
provider.display_name(),
instruction_path.display()
);
match invoke_once(provider, instruction_path, ¤t_prompt, timeout) {
Ok(output) if output.status.success() => {
tracing::info!(" ✓ agente completado (intento {attempt})");
let trace = trace_from_output(attempt, &output);
attempts.push(trace);
save_agent_decision(opts, instruction_path, &attempts, true);
return Ok(AgentResult {
exit_code: output.status.code().unwrap_or(0),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
elapsed: Duration::default(),
attempt,
attempts,
});
}
Ok(output) => {
let code = output.status.code().unwrap_or(-1);
let stderr_str = String::from_utf8_lossy(&output.stderr).to_string();
let stdout_str = String::from_utf8_lossy(&output.stdout).to_string();
tracing::warn!(
" ✗ agente falló (exit code {code}) — intento {attempt}/{max_retries}"
);
let trace = AttemptTrace {
attempt,
exit_code: code,
stdout: stdout_str.clone(),
stderr: stderr_str.clone(),
};
attempts.push(trace.clone());
save_agent_decision(opts, instruction_path, &attempts, false);
if opts.inject_feedback && attempt < max_retries {
current_prompt = build_feedback_prompt(prompt, &trace);
}
}
Err(e) => {
let err_msg = format!("{e}");
tracing::warn!(
" ✗ error invocando agente: {err_msg} — intento {attempt}/{max_retries}"
);
let trace = AttemptTrace {
attempt,
exit_code: -1,
stdout: String::new(),
stderr: err_msg.clone(),
};
attempts.push(trace.clone());
save_agent_decision(opts, instruction_path, &attempts, false);
if opts.inject_feedback && attempt < max_retries {
current_prompt = build_feedback_prompt(prompt, &trace);
}
}
}
if attempt >= max_retries {
anyhow::bail!(
"agotados {max_retries} reintentos invocando {} ({})",
provider.display_name(),
instruction_path.display()
);
}
tracing::info!(" ↻ reintentando en {}s...", delay.as_secs());
std::thread::sleep(delay);
attempt += 1;
delay *= 2; }
}
fn build_feedback_prompt(original_prompt: &str, trace: &AttemptTrace) -> String {
let feedback = if !trace.stderr.is_empty() {
&trace.stderr
} else {
&trace.stdout
};
let truncated: String = if feedback.len() > 2000 {
format!(
"{}...\n[output truncado, {} bytes totales]",
&feedback[..2000],
feedback.len()
)
} else {
feedback.clone()
};
format!(
"⚠️ Tu intento anterior (intento {}) falló. Esto fue lo que ocurrió:\n\
\n\
```\n\
{}\n\
```\n\
\n\
Corrige el error e inténtalo de nuevo.\n\
\n\
---\n\
\n\
{}",
trace.attempt, truncated, original_prompt
)
}
fn save_agent_decision(
opts: &AgentOptions,
instruction_path: &Path,
attempts: &[AttemptTrace],
success: bool,
) {
let Some(ref story_id) = opts.story_id else {
return;
};
let Some(ref decisions_dir) = opts.decisions_dir else {
return;
};
let _ = std::fs::create_dir_all(decisions_dir);
let actor = instruction_path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("agent");
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S");
let filename = format!("{story_id}-{actor}-{ts}.md");
let path = decisions_dir.join(&filename);
let status = if success {
"✅ Éxito"
} else {
"❌ Fallo parcial"
};
let mut content = format!("# {story_id} — {actor} — {ts}\n\n## Resultado\n{status}\n\n");
for trace in attempts {
content.push_str(&format!(
"\n### Intento {} (exit code: {})\n\n```\n{}\n```\n",
trace.attempt, trace.exit_code, trace.stderr
));
if !trace.stdout.is_empty() {
content.push_str(&format!(
"\n### stdout (intento {})\n\n```\n{}\n```\n",
trace.attempt, trace.stdout
));
}
}
if let Err(e) = std::fs::write(&path, &content) {
tracing::warn!(" ⚠️ no se pudo guardar decisión del agente: {e}");
} else {
tracing::debug!(" 📄 decisión guardada: {}", filename);
}
}
fn invoke_once(
provider: &dyn AgentProvider,
instruction: &Path,
prompt: &str,
_timeout: Duration,
) -> anyhow::Result<Output> {
let args = provider.build_args(instruction, prompt);
let result = std::process::Command::new(provider.binary())
.args(&args)
.output();
match result {
Ok(output) => Ok(output),
Err(e) => {
anyhow::bail!(
"no se pudo ejecutar '{}': {e}. ¿Está instalado?",
provider.binary()
);
}
}
}
fn trace_from_output(attempt: u32, output: &Output) -> AttemptTrace {
AttemptTrace {
attempt,
exit_code: output.status.code().unwrap_or(0),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::PiProvider;
#[test]
fn build_feedback_prompt_includes_error() {
let trace = AttemptTrace {
attempt: 1,
exit_code: 1,
stdout: String::new(),
stderr: "error: no se encontró el archivo src/lib.rs".into(),
};
let prompt = build_feedback_prompt("prompt original", &trace);
assert!(prompt.contains("Tu intento anterior"));
assert!(prompt.contains("src/lib.rs"));
assert!(prompt.contains("prompt original"));
}
#[test]
fn build_feedback_prompt_truncates_long_output() {
let long_err = "x".repeat(3000);
let trace = AttemptTrace {
attempt: 2,
exit_code: 1,
stdout: String::new(),
stderr: long_err,
};
let prompt = build_feedback_prompt("test", &trace);
assert!(prompt.contains("truncado"));
assert!(prompt.len() < 4000); }
#[test]
fn agent_options_default() {
let opts = AgentOptions::default();
assert!(opts.story_id.is_none());
assert!(opts.decisions_dir.is_none());
assert!(!opts.inject_feedback);
}
#[test]
#[ignore = "requiere pi instalado"]
fn invoke_with_retry_fails_when_agent_not_installed() {
let limits = LimitsConfig {
max_retries_per_step: 1,
retry_delay_base_seconds: 0,
agent_timeout_seconds: 5,
..Default::default()
};
let opts = AgentOptions::default();
let provider = PiProvider;
let result = invoke_with_retry(
&provider,
Path::new("/nonexistent/skill.md"),
"test",
&limits,
&opts,
);
assert!(result.is_err());
}
}