Skip to main content

magi/
agent.rs

1//! Driving agent CLIs.
2//!
3//! Every agent in magi is a subscription CLI (`claude`, `opencode`, `agy`) or
4//! an arbitrary command, invoked headless in a working directory. There is no
5//! API-key path on purpose: the CLIs carry the operator's own plan, and they
6//! are the only interface that exposes an agent's whole tool loop rather than a
7//! single completion.
8//!
9//! # Seats, not agents
10//!
11//! Conversations are keyed by *seat* ([`SeatState::key`]), never by agent id. A
12//! model that implements candidate B and also sits as judge 3 gets two
13//! unrelated conversations, so the judge cannot recognise its own work from
14//! having written it. Sessions are what make deliberation affordable — a judge
15//! remembers its own argument instead of being re-fed the entire candidate set
16//! — and seat scoping is what keeps that from destroying blindness.
17//!
18//! # Session mechanics per CLI
19//!
20//! | CLI | open | resume |
21//! |-----|------|--------|
22//! | `claude` | `--session-id <uuid>` (magi mints it) | `--resume <uuid>` |
23//! | `opencode` | `--format json` reports `sessionID` | `-s <id>` |
24//! | `agy` | `--output-format json` reports `conversation_id` | `--conversation <id>` |
25//!
26//! Claude is the only one magi can address before the first turn; the other two
27//! report an id back, so [`SeatState::captured_session`] stays `None` until a
28//! turn has completed and [`has_session`] answers honestly instead of
29//! optimistically.
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::process::Stdio;
33use std::time::{Duration, Instant};
34
35use anyhow::{Context as _, Result, bail};
36use serde::{Deserialize, Serialize};
37use std::sync::{Arc, Mutex};
38
39use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
40use tokio::process::Command;
41
42use crate::config::{AgentKind, AgentSpec, Delivery};
43use crate::rng::SplitMix64;
44
45/// Conversation state for one seat, persisted with the run so `magi run
46/// --resume` continues the same CLI conversations.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SeatState {
49    /// Stable seat name, e.g. `impl-A`, `judge-2`, `review-1`, `fix`.
50    pub key: String,
51    /// Agent id occupying the seat.
52    pub agent: String,
53    /// Turns already taken in this seat.
54    pub turns: usize,
55    /// Claude session uuid, minted up front so the first turn and every resume
56    /// agree on it without parsing anything back.
57    pub claude_session: Option<String>,
58    /// Session id reported by a CLI that mints its own (`opencode`, `agy`).
59    pub captured_session: Option<String>,
60}
61
62impl SeatState {
63    /// New seat. `run_seed` scopes the minted Claude uuid to this run.
64    pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
65        let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
66        Self {
67            key: key.to_owned(),
68            agent: agent.to_owned(),
69            turns: 0,
70            claude_session: Some(rng.uuid_v4()),
71            captured_session: None,
72        }
73    }
74}
75
76/// Can a follow-up prompt rely on this seat remembering the conversation?
77pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
78    if !sessions_enabled || seat.turns == 0 {
79        return false;
80    }
81    match kind {
82        AgentKind::Claude => seat.claude_session.is_some(),
83        AgentKind::Opencode | AgentKind::Antigravity => seat.captured_session.is_some(),
84        AgentKind::Command => true,
85    }
86}
87
88/// One agent invocation.
89#[derive(Debug)]
90pub struct Invocation<'a> {
91    /// Working directory. Always a real checkout, so every CLI can read the
92    /// repository without per-vendor "extra directory" flags.
93    pub cwd: &'a Path,
94    /// The full prompt.
95    pub prompt: &'a str,
96    /// Wall-clock limit; the process tree is killed when it elapses.
97    pub timeout: Duration,
98    /// May the agent modify files? Judges and reviewers may not.
99    pub allow_write: bool,
100    /// Continue this seat's conversation when the CLI supports it.
101    pub sessions: bool,
102    /// Directory for prompt / stdout / stderr artifacts.
103    pub artifacts: &'a Path,
104    /// Artifact filename stem.
105    pub stem: &'a str,
106    /// Run this invocation belongs to. Exported as `MAGI_RUN` so an agent that
107    /// files a task with `magi task add` is attributed to the run that was
108    /// paying for it, rather than looking like a human wandered by.
109    pub run: &'a str,
110    /// Graph node being executed, e.g. `implement` or `review`. Exported as
111    /// `MAGI_NODE` for the same reason: "who asked for this" is the first
112    /// question about an autonomously created task.
113    pub node: &'a str,
114}
115
116/// Evidence that a CLI ran out of its rate limit / quota, distinct from an
117/// ordinary failure.
118///
119/// `reset` is free text: CLIs render the reset time in their own locale, and
120/// parsing it exactly would be a bug factory. When it is not readable we say
121/// nothing rather than invent a format.
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123pub struct Quota {
124    /// Human-readable reset time, when the CLI printed one.
125    #[serde(default)]
126    pub reset: Option<String>,
127}
128
129/// Result of an agent invocation.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct AgentOutput {
132    /// The agent's final message, extracted from whatever the CLI printed.
133    pub text: String,
134    /// Exit status code.
135    pub exit_code: Option<i32>,
136    /// Did the invocation hit its timeout?
137    pub timed_out: bool,
138    /// Wall-clock duration.
139    pub duration_ms: u64,
140    /// Artifact file names, relative to the run's `artifacts/` directory.
141    pub artifacts: Vec<String>,
142    /// Rate-limit / quota exhaustion, when it can be told apart from a normal
143    /// failure. `None` for a normal failure, a timeout, or a CLI we cannot
144    /// read — the conservative default.
145    #[serde(default)]
146    pub quota: Option<Quota>,
147}
148
149impl AgentOutput {
150    /// Did the CLI exit cleanly with something to say?
151    pub fn usable(&self) -> bool {
152        !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
153    }
154
155    /// Did this invocation run out of the CLI's rate limit / quota?
156    pub fn quota_exhausted(&self) -> bool {
157        self.quota.is_some()
158    }
159}
160
161/// How long to keep reading a pipe after the child is gone.
162///
163/// Bounded on purpose: a surviving grandchild can hold the write end open
164/// forever, and the graph must not hang on a process it has already killed.
165const PIPE_GRACE: Duration = Duration::from_secs(3);
166
167/// Bytes a pipe reader has accumulated so far, shared with whoever spawned it.
168type Captured = Arc<Mutex<Vec<u8>>>;
169
170/// Read `pipe` to end in its own task, appending into a buffer the caller can
171/// inspect at any time.
172///
173/// The buffer is shared rather than returned because the interesting moment is
174/// exactly the one where the reader has *not* finished: a killed agent's pipe
175/// may still be held open by a surviving grandchild, and the bytes that did
176/// arrive are the only evidence of what it was doing. An earlier version
177/// returned the buffer from the task and dropped it on timeout, which is how
178/// `<stem>.out` came to be empty on every timeout.
179fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
180where
181    R: tokio::io::AsyncRead + Unpin + Send + 'static,
182{
183    let buf: Captured = Arc::new(Mutex::new(Vec::new()));
184    let Some(mut pipe) = pipe else {
185        return (buf, None);
186    };
187    let sink = Arc::clone(&buf);
188    let handle = tokio::spawn(async move {
189        let mut chunk = [0u8; 8192];
190        loop {
191            match pipe.read(&mut chunk).await {
192                Ok(0) | Err(_) => break,
193                Ok(n) => {
194                    if let Ok(mut guard) = sink.lock() {
195                        guard.extend_from_slice(&chunk[..n]);
196                    }
197                }
198            }
199        }
200    });
201    (buf, Some(handle))
202}
203
204/// Take whatever a reader has captured, giving it at most `grace` to finish.
205///
206/// A reader still blocked after that is abandoned, not awaited — but its bytes
207/// come back either way, which is the whole point.
208async fn collect(
209    buf: &Captured,
210    handle: Option<tokio::task::JoinHandle<()>>,
211    grace: Duration,
212) -> String {
213    if let Some(handle) = handle {
214        if tokio::time::timeout(grace, handle).await.is_err() {
215            tracing::debug!("a pipe is still held open after the child exited");
216        }
217    }
218    let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
219    String::from_utf8_lossy(&bytes).into_owned()
220}
221
222/// Invoke `spec` for `seat`, updating the seat's conversation state.
223pub async fn invoke(
224    spec: &AgentSpec,
225    seat: &mut SeatState,
226    inv: &Invocation<'_>,
227) -> Result<AgentOutput> {
228    tokio::fs::create_dir_all(inv.artifacts)
229        .await
230        .with_context(|| format!("create {}", inv.artifacts.display()))?;
231    let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
232    tokio::fs::write(&prompt_path, inv.prompt)
233        .await
234        .with_context(|| format!("write {}", prompt_path.display()))?;
235
236    let plan = build_command(spec, seat, inv, &prompt_path)?;
237    tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
238
239    let started = Instant::now();
240    let mut cmd = Command::new(&plan.argv[0]);
241    cmd.args(&plan.argv[1..])
242        .current_dir(inv.cwd)
243        .envs(&spec.env)
244        .env("MAGI_SEAT", &seat.key)
245        .env("MAGI_TURN", seat.turns.to_string())
246        .env("MAGI_RUN", inv.run)
247        .env("MAGI_NODE", inv.node)
248        .env("MAGI_PROMPT_FILE", &prompt_path)
249        .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
250        .env("GIT_TERMINAL_PROMPT", "0")
251        .stdin(if plan.stdin.is_some() {
252            Stdio::piped()
253        } else {
254            Stdio::null()
255        })
256        .stdout(Stdio::piped())
257        .stderr(Stdio::piped())
258        .kill_on_drop(true);
259
260    let mut child = cmd
261        .spawn()
262        .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
263    // Feed stdin from a task rather than inline: a `command` agent that never
264    // reads its stdin, or a prompt larger than the pipe buffer, would
265    // otherwise deadlock here before the process is ever waited on.
266    if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
267        tokio::spawn(async move {
268            sink.write_all(body.as_bytes()).await.ok();
269            sink.shutdown().await.ok();
270        });
271    }
272
273    // Drain the pipes in their own tasks, and wait on the *process*, not on
274    // end-of-file. Two failures come out of conflating those:
275    //
276    // 1. `wait_with_output` returns when both pipes reach EOF, which is not
277    //    when the child exits. A CLI that leaves a helper process holding the
278    //    inherited stdout handle - normal on Windows, where a `.cmd` shim and
279    //    its grandchildren share handles - never closes the pipe, so a seat
280    //    that answered in five minutes was billed the full hour and then
281    //    recorded as a timeout. The answer was thrown away with it.
282    // 2. Cancelling `wait_with_output` at the timeout drops the buffers it
283    //    owned, so `<stem>.out` and `<stem>.err` were written empty exactly
284    //    when an operator needs them most. "It printed nothing" and "we
285    //    discarded what it printed" looked identical on disk.
286    //
287    // Now the readers own the bytes, so a timeout keeps whatever arrived, and
288    // the wait ends at exit even if a stray handle stays open.
289    let (out_buf, out_reader) = drain(child.stdout.take());
290    let (err_buf, err_reader) = drain(child.stderr.take());
291
292    let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
293        Ok(res) => {
294            let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
295            (status.code(), false)
296        }
297        Err(_) => {
298            tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
299            // Kill the tree so the readers see EOF instead of hanging with it.
300            child.start_kill().ok();
301            (None, true)
302        }
303    };
304
305    // The child is gone either way, so the readers are bounded now. A grace
306    // window rather than an unbounded await: a surviving grandchild can still
307    // hold the write end open, and losing a few trailing bytes beats hanging
308    // the graph on a process we no longer control.
309    let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
310    let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
311
312    let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
313    let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
314    tokio::fs::write(&out_path, &stdout).await.ok();
315    tokio::fs::write(&err_path, &stderr).await.ok();
316
317    let extracted = extract(spec.kind, &stdout);
318    if let Some(session) = extracted.session {
319        match spec.kind {
320            AgentKind::Claude => seat.claude_session = Some(session),
321            AgentKind::Opencode | AgentKind::Antigravity => seat.captured_session = Some(session),
322            AgentKind::Command => {}
323        }
324    }
325    if let Some(status) = &extracted.status
326        && !status.eq_ignore_ascii_case("success")
327    {
328        tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
329    }
330    let text = if extracted.text.trim().is_empty() {
331        // A CLI that printed only to stderr still told us something.
332        if stdout.trim().is_empty() {
333            stderr.trim().to_owned()
334        } else {
335            stdout.trim().to_owned()
336        }
337    } else {
338        extracted.text
339    };
340    seat.turns += 1;
341
342    Ok(AgentOutput {
343        text,
344        exit_code: code,
345        timed_out,
346        duration_ms: started.elapsed().as_millis() as u64,
347        artifacts: vec![
348            file_name(&prompt_path),
349            file_name(&out_path),
350            file_name(&err_path),
351        ],
352        quota: extracted.quota,
353    })
354}
355
356fn file_name(p: &Path) -> String {
357    p.file_name()
358        .unwrap_or_default()
359        .to_string_lossy()
360        .into_owned()
361}
362
363/// The argv plus optional stdin body for one invocation.
364#[derive(Debug)]
365struct Plan {
366    argv: Vec<String>,
367    stdin: Option<String>,
368}
369
370/// How a file-delivered prompt is pointed at, per CLI.
371///
372/// `agy` has a native file-context syntax, `@<path>`, and it is measurably the
373/// better contract: on the same trivial task it finished in 17s against 73s for
374/// the prose form, because prose makes the model spend a tool round-trip
375/// deciding to read the file. It is also the form yukimemi/rvpm proved out.
376///
377/// opencode has no equivalent, so it gets the prose. That is not a fallback
378/// worth apologising for — it works, and it is what the winning opencode
379/// candidates on this repository have been driven by all along.
380fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
381    if matches!(kind, AgentKind::Antigravity) {
382        return format!("@{}", prompt_path.display());
383    }
384    format!(
385        "Read the file at {} and follow every instruction in it exactly. That \
386         file is your complete task description; this message contains nothing \
387         else.",
388        prompt_path.display()
389    )
390}
391
392fn build_command(
393    spec: &AgentSpec,
394    seat: &SeatState,
395    inv: &Invocation<'_>,
396    prompt_path: &Path,
397) -> Result<Plan> {
398    let mut argv: Vec<String> = Vec::new();
399    let mut stdin: Option<String> = None;
400    let delivery = spec.delivery();
401    let resuming = has_session(spec.kind, seat, inv.sessions);
402
403    match spec.kind {
404        AgentKind::Claude => {
405            argv.push("claude".to_owned());
406            argv.push("-p".to_owned());
407            argv.push("--output-format".to_owned());
408            argv.push("json".to_owned());
409            if let Some(m) = &spec.model {
410                argv.push("--model".to_owned());
411                argv.push(m.clone());
412            }
413            if inv.sessions {
414                let uuid = seat
415                    .claude_session
416                    .as_deref()
417                    .context("claude seat is missing its session uuid")?;
418                argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
419                argv.push(uuid.to_owned());
420            }
421            argv.push("--permission-mode".to_owned());
422            argv.push("bypassPermissions".to_owned());
423            if !inv.allow_write {
424                argv.push("--disallowed-tools".to_owned());
425                argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
426            }
427        }
428        AgentKind::Opencode => {
429            argv.push("opencode".to_owned());
430            argv.push("run".to_owned());
431            argv.push("--format".to_owned());
432            argv.push("json".to_owned());
433            argv.push("--dir".to_owned());
434            argv.push(inv.cwd.to_string_lossy().into_owned());
435            // `--auto` gates *every* permission, reads included: without it a
436            // non-interactive opencode cannot even open the prompt file, and
437            // the seat drops out of the panel with "the user rejected
438            // permission to use this specific tool call". opencode has no
439            // read-only mode, so read-only seats rely on the prompt plus the
440            // fact that judge and reviewer worktrees are disposable — judges'
441            // are deleted after the tally, reviewers' are reset to the commit
442            // under review every round.
443            argv.push("--auto".to_owned());
444            if let Some(m) = &spec.model {
445                argv.push("-m".to_owned());
446                argv.push(m.clone());
447            }
448            if resuming {
449                argv.push("-s".to_owned());
450                argv.push(
451                    seat.captured_session
452                        .clone()
453                        .expect("has_session checked the id is present"),
454                );
455            }
456        }
457        AgentKind::Antigravity => {
458            argv.push("agy".to_owned());
459            argv.push("--output-format".to_owned());
460            argv.push("json".to_owned());
461            // agy's print mode gives up after 5 minutes by default, which is
462            // far below an implementation node's budget.
463            argv.push("--print-timeout".to_owned());
464            argv.push(format!("{}s", inv.timeout.as_secs()));
465            argv.push("--mode".to_owned());
466            argv.push(
467                if inv.allow_write {
468                    "accept-edits"
469                } else {
470                    "plan"
471                }
472                .to_owned(),
473            );
474            if inv.allow_write {
475                argv.push("--dangerously-skip-permissions".to_owned());
476            }
477            if let Some(m) = &spec.model {
478                argv.push("--model".to_owned());
479                argv.push(m.clone());
480            }
481            if resuming {
482                argv.push("--conversation".to_owned());
483                argv.push(
484                    seat.captured_session
485                        .clone()
486                        .expect("has_session checked the id is present"),
487                );
488            }
489            // The prompt file lives outside the worktree, so the workspace has
490            // to be widened to reach it.
491            if delivery == Delivery::File {
492                argv.push("--add-dir".to_owned());
493                argv.push(inv.artifacts.to_string_lossy().into_owned());
494            }
495        }
496        AgentKind::Command => {
497            if spec.command.is_empty() {
498                bail!("agent `{}` has kind = \"command\" but no command", spec.id);
499            }
500            let vars: BTreeMap<&str, String> = BTreeMap::from([
501                ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
502                ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
503                ("{label}", seat.key.clone()),
504                ("{session}", seat.claude_session.clone().unwrap_or_default()),
505            ]);
506            for raw in &spec.command {
507                let mut arg = raw.clone();
508                for (k, v) in &vars {
509                    if arg.contains(k) {
510                        arg = arg.replace(k, v);
511                    }
512                }
513                argv.push(arg);
514            }
515        }
516    }
517
518    argv.extend(spec.extra_args.iter().cloned());
519
520    // `agy` takes the prompt as the value of `-p`, so the flag has to be
521    // emitted right before whatever the delivery mode produces.
522    if spec.kind == AgentKind::Antigravity {
523        argv.push("-p".to_owned());
524    }
525    match delivery {
526        Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
527            // agy has no text stdin path; fall back to the pointer file.
528            argv.push(pointer(spec.kind, prompt_path));
529        }
530        Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
531        Delivery::Argv => argv.push(inv.prompt.to_owned()),
532        Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
533    }
534
535    Ok(Plan { argv, stdin })
536}
537
538/// What a CLI's stdout yielded.
539#[derive(Debug, Default)]
540struct Extracted {
541    text: String,
542    session: Option<String>,
543    status: Option<String>,
544    quota: Option<Quota>,
545}
546
547/// Pull the agent's message (and any session id) out of a CLI's stdout.
548fn extract(kind: AgentKind, stdout: &str) -> Extracted {
549    match kind {
550        AgentKind::Claude => {
551            let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
552                return Extracted {
553                    text: stdout.trim().to_owned(),
554                    ..Extracted::default()
555                };
556            };
557            Extracted {
558                text: v
559                    .get("result")
560                    .and_then(|r| r.as_str())
561                    .unwrap_or_default()
562                    .to_owned(),
563                session: v
564                    .get("session_id")
565                    .and_then(|s| s.as_str())
566                    .map(str::to_owned),
567                status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
568                    if e {
569                        "error".to_owned()
570                    } else {
571                        "success".to_owned()
572                    }
573                }),
574                quota: claude_quota(&v),
575            }
576        }
577        AgentKind::Opencode => {
578            // A JSONL event stream: text parts concatenated in arrival order.
579            let mut text = String::new();
580            let mut session = None;
581            for line in stdout.lines() {
582                let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
583                    continue;
584                };
585                if session.is_none() {
586                    session = v
587                        .get("sessionID")
588                        .and_then(|s| s.as_str())
589                        .map(str::to_owned);
590                }
591                let part = v.get("part").unwrap_or(&serde_json::Value::Null);
592                if part.get("type").and_then(|t| t.as_str()) == Some("text")
593                    && let Some(t) = part.get("text").and_then(|t| t.as_str())
594                {
595                    if !text.is_empty() {
596                        text.push('\n');
597                    }
598                    text.push_str(t);
599                }
600            }
601            Extracted {
602                text,
603                session,
604                status: None,
605                quota: None,
606            }
607        }
608        AgentKind::Antigravity => {
609            // agy prints warnings before the JSON object, so parse the last
610            // line that is one rather than the whole stream.
611            let obj = stdout
612                .lines()
613                .rev()
614                .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
615            let Some(v) = obj else {
616                return Extracted {
617                    text: stdout.trim().to_owned(),
618                    ..Extracted::default()
619                };
620            };
621            Extracted {
622                text: v
623                    .get("response")
624                    .and_then(|r| r.as_str())
625                    .unwrap_or_default()
626                    .trim()
627                    .to_owned(),
628                session: v
629                    .get("conversation_id")
630                    .and_then(|s| s.as_str())
631                    .map(str::to_owned),
632                status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
633                quota: None,
634            }
635        }
636        AgentKind::Command => {
637            // A `command` agent may wrap a subscription CLI (a fixture, or a
638            // thin shim around `claude`). If its output is the claude error
639            // shape we recognise the quota the same way, so tests and wrappers
640            // do not need their own detection; anything else is just text.
641            let quota = serde_json::from_str::<serde_json::Value>(stdout.trim())
642                .ok()
643                .and_then(|v| claude_quota(&v));
644            Extracted {
645                text: stdout.trim().to_owned(),
646                session: None,
647                status: None,
648                quota,
649            }
650        }
651    }
652}
653
654/// Recognise claude's rate-limit error shape, when it is present.
655///
656/// The only output we have observed is the JSON object carrying `is_error:
657/// true` and a `result` mentioning the session limit. We key on exactly that;
658/// every other CLI (and any future shape) returns `None` and is treated as an
659/// ordinary failure — the conservative side.
660fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
661    let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
662    if !is_err {
663        return None;
664    }
665    let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
666    if !result.to_lowercase().contains("session limit") {
667        return None;
668    }
669    // "…session limit · resets 4:50am (Asia/Tokyo)". The timezone read is not
670    // worth parsing exactly; keep the whole phrase after "resets" as free text.
671    let reset = result
672        .split("resets ")
673        .nth(1)
674        .map(str::trim)
675        .filter(|s| !s.is_empty())
676        .map(str::to_owned);
677    Some(Quota { reset })
678}
679
680/// Preflight: which configured agents are not runnable here?
681pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
682    let mut missing = Vec::new();
683    for s in specs {
684        let program = match s.kind {
685            AgentKind::Command => s.command.first().map(String::as_str),
686            other => other.program(),
687        };
688        if let Some(p) = program
689            && !crate::config::which(p)
690            && !Path::new(p).is_file()
691            && !missing.iter().any(|m: &String| m == p)
692        {
693            missing.push(p.to_owned());
694        }
695    }
696    missing
697}
698
699/// Absolute path of a run's artifact directory.
700pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
701    run_dir.join("artifacts")
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
709        AgentSpec {
710            id: "a".to_owned(),
711            kind,
712            model: model.map(str::to_owned),
713            command: vec!["echo".to_owned(), "{label}".to_owned()],
714            extra_args: Vec::new(),
715            env: BTreeMap::new(),
716            prompt_delivery: None,
717        }
718    }
719
720    fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
721        Invocation {
722            cwd,
723            prompt: "do the thing",
724            timeout: Duration::from_secs(900),
725            allow_write,
726            sessions: true,
727            artifacts: art,
728            stem: "t",
729            run: "test-run",
730            node: "test",
731        }
732    }
733
734    fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
735        build_command(
736            &spec(kind, None),
737            seat,
738            &inv(Path::new("."), Path::new("/art"), allow_write),
739            Path::new("/art/p.md"),
740        )
741        .unwrap()
742    }
743
744    #[test]
745    fn claude_mints_then_resumes_the_same_uuid() {
746        let mut seat = SeatState::new("judge-1", "a", 7);
747        let uuid = seat.claude_session.clone().unwrap();
748        let first = plan_for(AgentKind::Claude, &seat, true);
749        assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
750        assert!(!first.argv.iter().any(|a| a == "--resume"));
751
752        seat.turns = 1;
753        let second = plan_for(AgentKind::Claude, &seat, true);
754        assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
755        assert!(!second.argv.iter().any(|a| a == "--session-id"));
756    }
757
758    #[test]
759    fn read_only_seats_cannot_edit() {
760        let seat = SeatState::new("judge-1", "a", 7);
761        let claude = plan_for(AgentKind::Claude, &seat, false);
762        assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
763        assert!(
764            !plan_for(AgentKind::Claude, &seat, true)
765                .argv
766                .iter()
767                .any(|a| a == "--disallowed-tools")
768        );
769
770        let agy = plan_for(AgentKind::Antigravity, &seat, false);
771        assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
772        assert!(
773            !agy.argv
774                .iter()
775                .any(|a| a == "--dangerously-skip-permissions")
776        );
777        let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
778        assert!(
779            agy_rw
780                .argv
781                .windows(2)
782                .any(|w| w == ["--mode", "accept-edits"])
783        );
784        assert!(
785            agy_rw
786                .argv
787                .iter()
788                .any(|a| a == "--dangerously-skip-permissions")
789        );
790        // agy is pointed at its prompt with its own `@<path>` syntax, not with
791        // prose asking it to read a file. Measured on one trivial task: 17s
792        // against 73s, because prose costs a tool round-trip before the model
793        // has even seen its instructions. It is also the form rvpm proved.
794        let agy_prompt = agy_rw
795            .argv
796            .iter()
797            .position(|a| a == "-p")
798            .map(|i| agy_rw.argv[i + 1].clone())
799            .expect("agy takes its prompt with -p");
800        assert!(
801            agy_prompt.starts_with('@'),
802            "agy must get a file reference, got {agy_prompt:?}"
803        );
804        assert!(
805            !agy_prompt.contains("Read the file at"),
806            "the prose pointer is for CLIs with no file syntax"
807        );
808
809        // opencode is the exception: `--auto` also gates reads, so withholding
810        // it silently drops the seat out of the panel. Verified against the CLI
811        // — a read-only judge failed with "the user rejected permission to use
812        // this specific tool call" while trying to open its own prompt.
813        for allow_write in [false, true] {
814            assert!(
815                plan_for(AgentKind::Opencode, &seat, allow_write)
816                    .argv
817                    .iter()
818                    .any(|a| a == "--auto"),
819                "opencode needs --auto even to read (allow_write = {allow_write})"
820            );
821        }
822    }
823
824    #[test]
825    fn captured_sessions_resume_only_once_reported() {
826        let mut seat = SeatState::new("impl-A", "a", 7);
827        seat.turns = 1;
828        for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
829            assert!(!has_session(kind, &seat, true));
830            let p = plan_for(kind, &seat, true);
831            assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
832        }
833
834        seat.captured_session = Some("sid".to_owned());
835        assert!(has_session(AgentKind::Opencode, &seat, true));
836        assert!(
837            plan_for(AgentKind::Opencode, &seat, true)
838                .argv
839                .windows(2)
840                .any(|w| w == ["-s", "sid"])
841        );
842        assert!(
843            plan_for(AgentKind::Antigravity, &seat, true)
844                .argv
845                .windows(2)
846                .any(|w| w == ["--conversation", "sid"])
847        );
848    }
849
850    #[test]
851    fn sessions_disabled_never_resumes() {
852        let mut seat = SeatState::new("impl-A", "a", 7);
853        seat.turns = 3;
854        seat.captured_session = Some("sid".to_owned());
855        for kind in [
856            AgentKind::Claude,
857            AgentKind::Opencode,
858            AgentKind::Antigravity,
859        ] {
860            assert!(!has_session(kind, &seat, false));
861        }
862    }
863
864    #[test]
865    fn long_prompts_never_reach_argv_for_file_delivery_clis() {
866        let seat = SeatState::new("judge-1", "a", 7);
867        for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
868            let p = plan_for(kind, &seat, false);
869            assert!(
870                p.argv.iter().all(|a| a != "do the thing"),
871                "{kind:?} put the prompt on the command line"
872            );
873            assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
874        }
875        // agy has no text stdin, so its `-p` must always carry something.
876        let p = plan_for(AgentKind::Antigravity, &seat, false);
877        let at = p.argv.iter().position(|a| a == "-p").unwrap();
878        assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
879        assert!(p.stdin.is_none());
880    }
881
882    #[test]
883    fn agy_print_timeout_tracks_the_node_budget() {
884        let seat = SeatState::new("impl-A", "a", 7);
885        let p = build_command(
886            &spec(AgentKind::Antigravity, None),
887            &seat,
888            &Invocation {
889                cwd: Path::new("."),
890                prompt: "p",
891                timeout: Duration::from_secs(3600),
892                allow_write: true,
893                sessions: true,
894                artifacts: Path::new("/art"),
895                stem: "t",
896                run: "test-run",
897                node: "test",
898            },
899            Path::new("/art/p.md"),
900        )
901        .unwrap();
902        assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
903    }
904
905    #[test]
906    fn command_agents_get_placeholders_substituted() {
907        let seat = SeatState::new("impl-A", "a", 7);
908        let p = plan_for(AgentKind::Command, &seat, true);
909        assert_eq!(p.argv[0], "echo");
910        assert_eq!(p.argv[1], "impl-A");
911        assert_eq!(p.stdin.as_deref(), Some("do the thing"));
912    }
913
914    #[test]
915    fn claude_rate_limit_is_detected_and_reset_read_when_present() {
916        // The exact shape observed in the wild (run 20260831-031005-ae94).
917        let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
918                        "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
919                        "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
920        let out = extract(AgentKind::Claude, stdout);
921        let quota = out.quota.as_ref().expect("rate limit must be detected");
922        assert_eq!(
923            quota.reset.as_deref(),
924            Some("4:50am (Asia/Tokyo)"),
925            "reset time read from the body"
926        );
927    }
928
929    #[test]
930    fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
931        let out = extract(
932            AgentKind::Claude,
933            r#"{"is_error":true,"result":"session limit reached"}"#,
934        );
935        let quota = out.quota.expect("rate limit detected without a reset");
936        assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
937    }
938
939    #[test]
940    fn ordinary_failures_are_never_quota() {
941        // A normal failed claude call (is_error with a different message).
942        let claude_fail = extract(
943            AgentKind::Claude,
944            r#"{"is_error":true,"result":"account does not exist"}"#,
945        );
946        assert!(claude_fail.quota.is_none());
947
948        // A command agent that exits 1 with plain text.
949        let cmd_fail = extract(AgentKind::Command, "boom");
950        assert!(cmd_fail.quota.is_none());
951
952        // A successful call is not quota even if it mentions the phrase.
953        let success = extract(
954            AgentKind::Command,
955            r#"{"is_error":false,"result":"session limit is fine"}"#,
956        );
957        assert!(success.quota.is_none());
958    }
959
960    #[test]
961    fn command_agent_can_carry_the_claude_quota_shape() {
962        let out = extract(
963            AgentKind::Command,
964            r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
965        );
966        assert!(
967            out.quota.is_some(),
968            "a wrapper emitting the claude shape counts as quota"
969        );
970    }
971
972    #[test]
973    fn claude_json_result_is_extracted() {
974        let out = extract(
975            AgentKind::Claude,
976            r#"{"result":"all done","session_id":"abc","is_error":false}"#,
977        );
978        assert_eq!(out.text, "all done");
979        assert_eq!(out.session.as_deref(), Some("abc"));
980        assert_eq!(out.status.as_deref(), Some("success"));
981    }
982
983    #[test]
984    fn opencode_event_stream_is_concatenated() {
985        let stream = concat!(
986            r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
987            "\n",
988            r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
989            "\n",
990            "garbage line\n",
991            r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
992            "\n"
993        );
994        let out = extract(AgentKind::Opencode, stream);
995        assert_eq!(out.text, "first\nsecond");
996        assert_eq!(out.session.as_deref(), Some("ses_1"));
997    }
998
999    #[test]
1000    fn agy_json_survives_a_leading_warning_line() {
1001        let stdout = concat!(
1002            "warning: --mode plan has no effect while slash commands are disabled.\n",
1003            r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
1004            "\n"
1005        );
1006        let out = extract(AgentKind::Antigravity, stdout);
1007        assert_eq!(out.text, "persimmon");
1008        assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
1009        assert_eq!(out.status.as_deref(), Some("SUCCESS"));
1010    }
1011
1012    #[test]
1013    fn non_json_stdout_falls_back_to_raw_text() {
1014        let out = extract(AgentKind::Antigravity, "plain answer\n");
1015        assert_eq!(out.text, "plain answer");
1016        assert!(out.session.is_none());
1017    }
1018
1019    #[tokio::test]
1020    async fn command_agent_round_trip_writes_artifacts() {
1021        let dir = tempfile::tempdir().unwrap();
1022        let art = dir.path().join("artifacts");
1023        let mut seat = SeatState::new("impl-A", "a", 7);
1024        let mut s = spec(AgentKind::Command, None);
1025        s.command = vec!["echo".to_owned(), "hello {label}".to_owned()];
1026        let out = invoke(
1027            &s,
1028            &mut seat,
1029            &Invocation {
1030                cwd: dir.path(),
1031                prompt: "unused",
1032                timeout: Duration::from_secs(30),
1033                allow_write: true,
1034                sessions: true,
1035                artifacts: &art,
1036                stem: "impl-A",
1037                run: "test-run",
1038                node: "test",
1039            },
1040        )
1041        .await
1042        .unwrap();
1043        assert!(out.usable(), "{out:?}");
1044        assert!(out.text.contains("hello impl-A"), "{}", out.text);
1045        assert_eq!(seat.turns, 1);
1046        assert!(art.join("impl-A.prompt.md").is_file());
1047        assert!(art.join("impl-A.out").is_file());
1048    }
1049
1050    #[tokio::test]
1051    async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
1052        let dir = tempfile::tempdir().unwrap();
1053        let mut seat = SeatState::new("impl-A", "a", 7);
1054        let mut s = spec(AgentKind::Command, None);
1055        // `echo` never reads stdin, so an inline write_all would block once the
1056        // OS pipe buffer filled — long before the process could be waited on.
1057        s.command = vec!["echo".to_owned(), "done".to_owned()];
1058        let big = "x".repeat(1_000_000);
1059        let out = invoke(
1060            &s,
1061            &mut seat,
1062            &Invocation {
1063                cwd: dir.path(),
1064                prompt: &big,
1065                timeout: Duration::from_secs(60),
1066                allow_write: true,
1067                sessions: true,
1068                artifacts: &dir.path().join("artifacts"),
1069                stem: "big",
1070                run: "test-run",
1071                node: "test",
1072            },
1073        )
1074        .await
1075        .unwrap();
1076        assert!(out.usable(), "{out:?}");
1077        assert_eq!(out.text, "done");
1078    }
1079
1080    #[tokio::test]
1081    async fn timeout_is_reported_not_hung() {
1082        let dir = tempfile::tempdir().unwrap();
1083        let mut seat = SeatState::new("impl-A", "a", 7);
1084        let mut s = spec(AgentKind::Command, None);
1085        s.command = vec!["sleep".to_owned(), "30".to_owned()];
1086        let out = invoke(
1087            &s,
1088            &mut seat,
1089            &Invocation {
1090                cwd: dir.path(),
1091                prompt: "unused",
1092                timeout: Duration::from_millis(300),
1093                allow_write: true,
1094                sessions: true,
1095                artifacts: &dir.path().join("artifacts"),
1096                stem: "slow",
1097                run: "test-run",
1098                node: "test",
1099            },
1100        )
1101        .await
1102        .unwrap();
1103        assert!(out.timed_out);
1104        assert!(!out.usable());
1105    }
1106
1107    #[tokio::test]
1108    async fn a_timeout_keeps_what_the_agent_had_already_printed() {
1109        // The old implementation cancelled `wait_with_output`, which dropped
1110        // the buffers it owned, so `<stem>.out` was written empty on every
1111        // timeout. "It printed nothing" and "we discarded what it printed"
1112        // looked identical on disk — and one real hour-long stall was
1113        // diagnosed wrongly twice because of it.
1114        let dir = tempfile::tempdir().unwrap();
1115        let artifacts = dir.path().join("artifacts");
1116        let mut seat = SeatState::new("impl-A", "a", 7);
1117        let mut s = spec(AgentKind::Command, None);
1118        s.command = vec![
1119            "sh".to_owned(),
1120            "-c".to_owned(),
1121            "echo i-said-something; sleep 30".to_owned(),
1122        ];
1123        let out = invoke(
1124            &s,
1125            &mut seat,
1126            &Invocation {
1127                cwd: dir.path(),
1128                prompt: "unused",
1129                // Wide enough to cover process-spawn latency inside a loaded
1130                // parallel test run, not merely the echo. At two seconds this
1131                // passed alone and failed in the full suite, which is a dice
1132                // roll rather than a test.
1133                timeout: Duration::from_secs(10),
1134                allow_write: true,
1135                sessions: true,
1136                artifacts: &artifacts,
1137                stem: "chatty",
1138                run: "test-run",
1139                node: "test",
1140            },
1141        )
1142        .await
1143        .unwrap();
1144
1145        assert!(out.timed_out, "{out:?}");
1146        assert!(!out.usable(), "a cut-off answer is still not an answer");
1147        let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
1148        assert!(
1149            recorded.contains("i-said-something"),
1150            "the artifact must keep what arrived before the kill, got {recorded:?}"
1151        );
1152        assert!(
1153            out.text.contains("i-said-something"),
1154            "and the graph must be able to see it too, got {:?}",
1155            out.text
1156        );
1157    }
1158
1159    #[test]
1160    fn missing_programs_reports_command_binaries() {
1161        let mut s = spec(AgentKind::Command, None);
1162        s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
1163        assert_eq!(
1164            missing_programs(&[s]),
1165            ["definitely-not-a-real-binary-xyz".to_owned()]
1166        );
1167    }
1168}