Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions examples/assistant/jobs/daily-inbox-triage.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
+++
version = 1
timeout = "10m"
workdir = "/Users/owainlewis/.cos/work"
backend = "codex"

[[triggers]]
id = "daily-evening"
kind = "cron"
schedule = "30 17 * * *"
timezone = "Europe/London"
enabled = true
# Enable only after configuring Gmail tools and a primary delivery route.
enabled = false
+++

Triage Owain's Gmail inbox from the last 48 hours and apply the existing
Expand Down
6 changes: 5 additions & 1 deletion src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,15 @@ impl Runner {
&self,
req: Request<'_>,
timeout: Duration,
trust_project_resources: bool,
) -> Result<RunOutput, RunError> {
match self {
Runner::Claude(r) => r.run_unattended(req, timeout).await,
Runner::Codex(r) => r.run_unattended(req, timeout).await,
Runner::Pi(r) => r.run(req, timeout).await,
Runner::Pi(r) => {
r.run_unattended(req, timeout, trust_project_resources)
.await
}
#[cfg(test)]
Runner::Fake(r) => r.run(req, timeout).await,
}
Expand Down
50 changes: 43 additions & 7 deletions src/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1941,6 +1941,8 @@ async fn execute(cfg: &Config, job: &Job) -> std::result::Result<String, Executi
let runner = Runner::for_backend(job.backend, cfg);
let session_id = runner.initial_session_id();
let workdir = job.workdir.to_string_lossy().to_string();
let trust_project_resources = std::fs::canonicalize(&cfg.assistant_root)
.is_ok_and(|assistant_root| assistant_root == current_workdir);
cfg.backend_context_dir()
.map_err(|error| ExecutionError::Failed(format!("prepare assistant context: {error}")))?;
let request = Request {
Expand All @@ -1957,7 +1959,10 @@ async fn execute(cfg: &Config, job: &Job) -> std::result::Result<String, Executi
workdir,
humantime::format_duration(job.timeout),
);
match runner.run_unattended(request, job.timeout).await {
match runner
.run_unattended(request, job.timeout, trust_project_resources)
.await
{
Ok(output) => Ok(output.reply),
Err(RunError::Timeout) => Err(ExecutionError::Timeout),
Err(RunError::Failed(error) | RunError::SessionMissing(error)) => {
Expand Down Expand Up @@ -2275,18 +2280,19 @@ mod tests {
#[test]
fn daily_inbox_example_is_a_valid_scheduled_job() {
let jobs_dir = temp_dir("inbox-example-jobs");
let workdir = temp_dir("inbox-example-work");
let database = temp_path("inbox-example-db");
let run_dir = temp_dir("inbox-example-run");
let contents = include_str!("../examples/assistant/jobs/daily-inbox-triage.md").replace(
"~/.push/workspaces/daily-inbox-triage",
&workdir.to_string_lossy(),
);
write_job(&jobs_dir, "daily-inbox-triage", &contents);
let contents = include_str!("../examples/assistant/jobs/daily-inbox-triage.md");
write_job(&jobs_dir, "daily-inbox-triage", contents);
let cfg = cfg(&jobs_dir, &database, &run_dir);

let job = Catalog::load_named(&cfg, "daily-inbox-triage").unwrap();

assert!(!contents.contains("/Users/"));
assert_eq!(
job.workdir,
std::fs::canonicalize(&cfg.assistant_root).unwrap()
);
assert_eq!(job.triggers.len(), 1);
assert!(!job.triggers[0].enabled);
}
Expand Down Expand Up @@ -4009,10 +4015,40 @@ printf '%s\n' ok > {}
let args = std::fs::read_to_string(&args_path).unwrap();
assert!(args.lines().any(|line| line == "--mode"));
assert!(args.lines().any(|line| line == "json"));
assert!(args.lines().any(|line| line == "--no-approve"));
assert!(!args.lines().any(|line| line == "--approve"));
assert!(!args.lines().any(|line| line == "--session"));
assert_eq!(
std::fs::read_to_string(format!("{}.stdin", args_path.to_string_lossy())).unwrap(),
"\nInspect this directory.\n"
);
}

#[tokio::test]
async fn pi_job_trusts_resources_only_at_the_assistant_root() {
let jobs_dir = temp_dir("jobs-pi-assistant-root");
let database = temp_path("jobs-pi-assistant-root-db");
let run_dir = temp_dir("jobs-pi-assistant-root-run");
let args_path = temp_path("jobs-pi-assistant-root-args");
let script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\ncat > {}.stdin\nprintf '%s\\n' '{{\"type\":\"session\",\"id\":\"pi-job-session\"}}'\nprintf '%s\\n' '{{\"type\":\"message_end\",\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"pi result\"}}],\"stopReason\":\"stop\"}}}}'\n",
sh_arg(&args_path),
sh_arg(&args_path)
);
let cli = FakeCli::new("pi", &script);
let mut cfg = cfg(&jobs_dir, &database, &run_dir);
cfg.agent_commands.pi = cli.bin();
let runbook =
"+++\nversion = 1\ntimeout = \"5s\"\nbackend = \"pi\"\n+++\n\nInspect this directory.\n";
write_job(&jobs_dir, "pi-job", runbook);

let output = run_manual(&cfg, Catalog::load_named(&cfg, "pi-job").unwrap())
.await
.unwrap();

assert_eq!(output.1, "pi result");
let args = std::fs::read_to_string(args_path).unwrap();
assert!(args.lines().any(|line| line == "--approve"));
assert!(!args.lines().any(|line| line == "--no-approve"));
}
}
93 changes: 86 additions & 7 deletions src/pi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,51 @@ pub struct Runner {
pub bin: String,
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum RunMode {
Configured,
Unattended { trust_project_resources: bool },
Evaluator,
}

impl Runner {
/// Executes one turn and returns Pi's final reply plus its stable session id.
pub async fn run(&self, req: Request<'_>, timeout: Duration) -> Result<RunOutput, RunError> {
self.run_with_mode(req, timeout, false).await
self.run_with_mode(req, timeout, RunMode::Configured).await
}

pub async fn run_unattended(
&self,
req: Request<'_>,
timeout: Duration,
trust_project_resources: bool,
) -> Result<RunOutput, RunError> {
self.run_with_mode(
req,
timeout,
RunMode::Unattended {
trust_project_resources,
},
)
.await
}

pub async fn run_evaluator(
&self,
req: Request<'_>,
timeout: Duration,
) -> Result<RunOutput, RunError> {
self.run_with_mode(req, timeout, true).await
self.run_with_mode(req, timeout, RunMode::Evaluator).await
}

async fn run_with_mode(
&self,
req: Request<'_>,
timeout: Duration,
evaluator: bool,
mode: RunMode,
) -> Result<RunOutput, RunError> {
let attempt = crate::agent::output_with_retry(|| {
let mut cmd = self.command(&req, evaluator);
let mut cmd = self.command(&req, mode);
let prompt = req.prompt.as_bytes().to_vec();
async move {
let mut child = cmd.spawn()?;
Expand Down Expand Up @@ -92,16 +115,26 @@ impl Runner {
})
}

fn command(&self, req: &Request<'_>, evaluator: bool) -> Command {
fn command(&self, req: &Request<'_>, mode: RunMode) -> Command {
let mut cmd = Command::new(&self.bin);
cmd.arg("--print").arg("--mode").arg("json");
if evaluator {
cmd.arg("--no-tools")
if mode == RunMode::Evaluator {
cmd.arg("--no-approve")
.arg("--no-tools")
.arg("--no-extensions")
.arg("--no-skills")
.arg("--no-prompt-templates")
.arg("--no-context-files")
.arg("--no-session");
} else if let RunMode::Unattended {
trust_project_resources,
} = mode
{
cmd.arg(if trust_project_resources {
"--approve"
} else {
"--no-approve"
});
}
if !req.instructions.trim().is_empty() {
cmd.arg("--append-system-prompt")
Expand Down Expand Up @@ -252,12 +285,56 @@ mod tests {
let args = read_args(&args_path);
assert_arg_pair(&args, "--mode", "json");
assert_arg_pair(&args, "--append-system-prompt", "SOUL instructions");
assert!(!args.contains(&"--approve".to_string()));
assert!(!args.contains(&"--no-approve".to_string()));
assert!(!args.contains(&"--tools".to_string()));
assert!(!args.contains(&"--session".to_string()));
assert_eq!(read_prompt(&args_path), "user message");
assert!(!args.contains(&"user message".to_string()));
}

#[tokio::test]
async fn unattended_run_trusts_project_local_resources() {
let args_path = temp_path("pi-unattended-args");
let work_dir = temp_dir("pi-unattended-work");
let cli = FakeCli::new("pi", &success_script(&args_path, "pi-session", "done"));
let runner = Runner { bin: cli.bin() };

runner
.run_unattended(
request(work_dir.to_str().unwrap(), true),
Duration::from_secs(5),
true,
)
.await
.unwrap();

let args = read_args(&args_path);
assert!(args.iter().any(|arg| arg == "--approve"));
assert!(!args.iter().any(|arg| arg == "--no-approve"));
}

#[tokio::test]
async fn unattended_run_does_not_trust_external_project_resources() {
let args_path = temp_path("pi-unattended-untrusted-args");
let work_dir = temp_dir("pi-unattended-untrusted-work");
let cli = FakeCli::new("pi", &success_script(&args_path, "pi-session", "done"));
let runner = Runner { bin: cli.bin() };

runner
.run_unattended(
request(work_dir.to_str().unwrap(), true),
Duration::from_secs(5),
false,
)
.await
.unwrap();

let args = read_args(&args_path);
assert!(args.iter().any(|arg| arg == "--no-approve"));
assert!(!args.iter().any(|arg| arg == "--approve"));
}

#[tokio::test]
async fn sends_option_and_file_like_prompts_verbatim_over_stdin() {
for prompt in [
Expand Down Expand Up @@ -455,6 +532,8 @@ printf '%s\n' '{"type":"message_end","message":{"role":"assistant","content":[{"
assert!(args.iter().any(|arg| arg == "--no-extensions"));
assert!(args.iter().any(|arg| arg == "--no-skills"));
assert!(args.iter().any(|arg| arg == "--no-context-files"));
assert!(args.iter().any(|arg| arg == "--no-approve"));
assert!(!args.iter().any(|arg| arg == "--approve"));
}

fn success_script(args_path: &std::path::Path, session: &str, reply: &str) -> String {
Expand Down
Loading