use std::{collections::BTreeMap, fs, path::Path, time::Duration};
use crate::{
config::AgentStdin,
error::{Error, Result},
subproc::{self, CommandOutput},
};
pub struct AgentSpec<'a> {
pub command_template: &'a str,
pub prompt_file: &'a Path,
pub workdir: &'a Path,
pub iter: u64,
pub budget: Duration,
pub workdir_var: &'a str,
pub env: &'a BTreeMap<String, String>,
pub stdin: AgentStdin,
}
#[derive(Debug)]
pub struct AgentOutput {
pub exit_code: Option<i32>,
pub killed_by_budget: bool,
pub stdout: String,
pub stderr: String,
}
pub fn run_agent(spec: &AgentSpec) -> Result<AgentOutput> {
let command = substitute(
spec.command_template,
spec.prompt_file,
spec.workdir,
spec.iter,
)?;
let mut env = expand_env(spec.env);
env.insert(spec.workdir_var.to_string(), path_to_string(spec.workdir)?);
let stdin_payload = match spec.stdin {
AgentStdin::None => None,
AgentStdin::Prompt => Some(fs::read(spec.prompt_file)?),
};
let out: CommandOutput =
subproc::run_command_with_budget(&command, spec.workdir, spec.budget, &env, stdin_payload)?;
Ok(AgentOutput {
exit_code: out.exit_code,
killed_by_budget: out.timed_out,
stdout: out.stdout,
stderr: out.stderr,
})
}
fn substitute(tmpl: &str, prompt: &Path, workdir: &Path, iter: u64) -> Result<String> {
let prompt_s = path_to_string(prompt)?;
let workdir_s = path_to_string(workdir)?;
Ok(tmpl
.replace("{prompt_file}", &prompt_s)
.replace("{workdir}", &workdir_s)
.replace("{iter}", &iter.to_string()))
}
fn path_to_string(p: &Path) -> Result<String> {
p.to_str()
.map(str::to_string)
.ok_or_else(|| Error::Subproc(format!("path {p:?} is not valid UTF-8")))
}
fn expand_env(env: &BTreeMap<String, String>) -> BTreeMap<String, String> {
env.iter()
.map(|(k, v)| (k.clone(), expand_one(v)))
.collect()
}
fn expand_one(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' {
if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
if let Some(rel_end) = bytes[i + 2..].iter().position(|&b| b == b'}') {
let name = &s[i + 2..i + 2 + rel_end];
if is_valid_name(name) {
out.push_str(&std::env::var(name).unwrap_or_default());
i = i + 2 + rel_end + 1;
continue;
}
}
out.push('$');
i += 1;
continue;
}
let start = i + 1;
let mut end = start;
if end < bytes.len() && (bytes[end].is_ascii_alphabetic() || bytes[end] == b'_') {
end += 1;
while end < bytes.len()
&& (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
{
end += 1;
}
let name = &s[start..end];
out.push_str(&std::env::var(name).unwrap_or_default());
i = end;
continue;
}
out.push('$');
i += 1;
} else {
let next = bytes[i..]
.iter()
.position(|&b| b == b'$')
.map(|p| i + p)
.unwrap_or(bytes.len());
out.push_str(&s[i..next]);
i = next;
}
}
out
}
fn is_valid_name(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use std::{path::PathBuf, time::Instant};
use tempfile::tempdir;
use super::*;
fn empty_env() -> BTreeMap<String, String> {
BTreeMap::new()
}
#[test]
fn substitute_replaces_all_placeholders() {
let p = PathBuf::from("/tmp/p.md");
let w = PathBuf::from("/tmp/wd");
let out = substitute(
"agent --in {prompt_file} --wd {workdir} --iter {iter}",
&p,
&w,
7,
)
.unwrap();
assert_eq!(out, "agent --in /tmp/p.md --wd /tmp/wd --iter 7");
}
#[test]
fn expand_env_passthrough_simple() {
let var = "AUTORIZE_TEST_EXP_PASS";
unsafe { std::env::set_var(var, "bar") };
let mut env = BTreeMap::new();
env.insert("X".to_string(), format!("${var}"));
let out = expand_env(&env);
assert_eq!(out.get("X").map(String::as_str), Some("bar"));
unsafe { std::env::remove_var(var) };
}
#[test]
fn expand_env_braced_form() {
let var = "AUTORIZE_TEST_EXP_BRACED";
unsafe { std::env::set_var(var, "baz") };
let mut env = BTreeMap::new();
env.insert("X".to_string(), format!("${{{var}}}"));
let out = expand_env(&env);
assert_eq!(out.get("X").map(String::as_str), Some("baz"));
unsafe { std::env::remove_var(var) };
}
#[test]
fn expand_env_missing_var_to_empty() {
let var = "AUTORIZE_TEST_EXP_MISSING_DEF_NOT_SET";
unsafe { std::env::remove_var(var) };
let mut env = BTreeMap::new();
env.insert("X".to_string(), format!("${var}"));
let out = expand_env(&env);
assert_eq!(out.get("X").map(String::as_str), Some(""));
}
#[test]
fn expand_env_preserves_literal_text() {
let var = "AUTORIZE_TEST_EXP_LITERAL";
unsafe { std::env::set_var(var, "bar") };
let mut env = BTreeMap::new();
env.insert("X".to_string(), format!("prefix-${var}-suffix"));
let out = expand_env(&env);
assert_eq!(out.get("X").map(String::as_str), Some("prefix-bar-suffix"));
unsafe { std::env::remove_var(var) };
}
#[test]
fn expand_env_no_expansion_when_no_dollar() {
let mut env = BTreeMap::new();
env.insert("X".to_string(), "literal".to_string());
let out = expand_env(&env);
assert_eq!(out.get("X").map(String::as_str), Some("literal"));
}
#[test]
fn expand_env_preserves_dollar_followed_by_non_name() {
let mut env = BTreeMap::new();
env.insert("X".to_string(), "price $5".to_string());
let out = expand_env(&env);
assert_eq!(out.get("X").map(String::as_str), Some("price $5"));
}
#[test]
fn run_agent_prompt_file_mode() {
let dir = tempdir().unwrap();
let prompt = dir.path().join("prompt.md");
fs::write(&prompt, "prompt-body\n").unwrap();
let env = empty_env();
let spec = AgentSpec {
command_template: "cat {prompt_file}",
prompt_file: &prompt,
workdir: dir.path(),
iter: 0,
budget: Duration::from_secs(5),
workdir_var: "AUTORIZE_WORKDIR",
env: &env,
stdin: AgentStdin::None,
};
let out = run_agent(&spec).unwrap();
assert!(!out.killed_by_budget);
assert_eq!(out.stdout, "prompt-body\n");
}
#[test]
fn run_agent_stdin_prompt_mode() {
let dir = tempdir().unwrap();
let prompt = dir.path().join("prompt.md");
fs::write(&prompt, "via-stdin\n").unwrap();
let env = empty_env();
let spec = AgentSpec {
command_template: "cat",
prompt_file: &prompt,
workdir: dir.path(),
iter: 0,
budget: Duration::from_secs(5),
workdir_var: "AUTORIZE_WORKDIR",
env: &env,
stdin: AgentStdin::Prompt,
};
let out = run_agent(&spec).unwrap();
assert!(!out.killed_by_budget);
assert_eq!(out.stdout, "via-stdin\n");
}
#[test]
fn run_agent_sets_workdir_var() {
let dir = tempdir().unwrap();
let prompt = dir.path().join("prompt.md");
fs::write(&prompt, "x").unwrap();
let env = empty_env();
let spec = AgentSpec {
command_template: "echo $AUTORIZE_WORKDIR",
prompt_file: &prompt,
workdir: dir.path(),
iter: 0,
budget: Duration::from_secs(5),
workdir_var: "AUTORIZE_WORKDIR",
env: &env,
stdin: AgentStdin::Prompt,
};
let out = run_agent(&spec).unwrap();
assert_eq!(out.stdout.trim(), dir.path().to_str().unwrap());
}
#[test]
fn run_agent_iter_substituted() {
let dir = tempdir().unwrap();
let prompt = dir.path().join("prompt.md");
fs::write(&prompt, "x").unwrap();
let env = empty_env();
let spec = AgentSpec {
command_template: "echo {iter}",
prompt_file: &prompt,
workdir: dir.path(),
iter: 42,
budget: Duration::from_secs(5),
workdir_var: "AUTORIZE_WORKDIR",
env: &env,
stdin: AgentStdin::Prompt,
};
let out = run_agent(&spec).unwrap();
assert_eq!(out.stdout, "42\n");
}
#[test]
fn run_agent_kills_long_running() {
let dir = tempdir().unwrap();
let prompt = dir.path().join("prompt.md");
fs::write(&prompt, "x").unwrap();
let env = empty_env();
let spec = AgentSpec {
command_template: "sleep 30",
prompt_file: &prompt,
workdir: dir.path(),
iter: 0,
budget: Duration::from_secs(1),
workdir_var: "AUTORIZE_WORKDIR",
env: &env,
stdin: AgentStdin::Prompt,
};
let started = Instant::now();
let out = run_agent(&spec).unwrap();
let elapsed = started.elapsed();
assert!(out.killed_by_budget, "expected killed_by_budget: {out:?}");
assert!(elapsed < Duration::from_secs(8), "took: {elapsed:?}");
}
}