1use std::path::Path;
18use std::time::Duration;
19
20use serde::Deserialize;
21use serde_json::{Value, json};
22
23use crate::acp::agent::{self, Agent};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Kind {
28 Acp(Agent),
30 Command(Vec<String>),
32}
33
34impl Kind {
35 #[must_use]
37 pub fn name(&self) -> &str {
38 match self {
39 Self::Acp(a) => &a.id,
40 Self::Command(_) => "command",
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Executor {
48 pub kind: Kind,
50 pub model: Option<String>,
52 pub timeout: Duration,
54}
55
56#[derive(Debug, Deserialize)]
57#[serde(deny_unknown_fields)]
58struct Raw {
59 #[serde(default)]
60 kind: Option<String>,
61 #[serde(default)]
62 agent: Option<String>,
63 #[serde(default)]
64 argv: Vec<String>,
65 model: Option<String>,
66 mode: Option<String>,
67 timeout_secs: Option<u64>,
68}
69
70pub fn for_agent(id: &str, configured: Option<&Executor>) -> Result<Executor, String> {
76 let p = agent::preset(id)
77 .ok_or_else(|| format!("no agent `{id}` ({}, or `native`)", agent::ids()))?;
78 let same = configured.filter(|e| e.kind.name() == id);
79 Ok(Executor {
80 kind: Kind::Acp(Agent::of(
81 p,
82 same.and_then(|e| match &e.kind {
83 Kind::Acp(a) => a.mode.clone(),
84 Kind::Command(_) => None,
85 }),
86 )),
87 model: same.and_then(|e| e.model.clone()),
88 timeout: configured.map_or(Duration::from_secs(1800), |e| e.timeout),
89 })
90}
91
92pub fn parse(text: &str) -> Result<Option<Executor>, String> {
94 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
95 let Some(v) = table.get("executor") else {
96 return Ok(None);
97 };
98 let raw: Raw = v
99 .clone()
100 .try_into()
101 .map_err(|e| format!("[executor]: {e}"))?;
102 let kind = match (raw.kind.as_deref(), raw.agent.as_deref()) {
103 (Some("native"), None) => return Ok(None),
104 (Some(old @ ("claude" | "codex")), None) => {
105 return Err(format!(
106 "[executor]: kind = \"{old}\" is now agent = \"{old}\" (it runs over ACP)"
107 ));
108 }
109 (None | Some("acp"), Some(id)) => {
110 if !raw.argv.is_empty() {
111 return Err(
112 "[executor]: `agent` and `argv` exclusive: an agent of your own is kind = \"acp\" with `argv`".to_owned(),
113 );
114 }
115 let p = agent::preset(id)
116 .ok_or_else(|| format!("[executor]: agent = \"{id}\" ({})", agent::ids()))?;
117 Kind::Acp(Agent::of(p, raw.mode.clone()))
118 }
119 (Some(k @ ("acp" | "command")), None) => {
120 if raw.argv.first().is_none_or(String::is_empty) {
121 return Err(format!("[executor]: kind = \"{k}\" needs `argv`"));
122 }
123 if k == "acp" {
124 Kind::Acp(Agent::custom(raw.argv, raw.mode.clone()))
125 } else {
126 Kind::Command(raw.argv)
127 }
128 }
129 (None, None) => {
130 return Err(format!(
131 "[executor]: say which agent: agent = \"…\" ({}), or kind = \"acp\" / \"command\" with `argv`",
132 agent::ids()
133 ));
134 }
135 (Some(other), _) => {
136 return Err(format!(
137 "[executor]: kind = \"{other}\" (native, acp or command; an agent is agent = \"…\")"
138 ));
139 }
140 };
141 if raw.mode.is_some() && matches!(kind, Kind::Command(_)) {
142 return Err("[executor]: `mode` is an ACP agent's permission mode".to_owned());
143 }
144 Ok(Some(Executor {
145 kind,
146 model: raw.model,
147 timeout: Duration::from_secs(raw.timeout_secs.unwrap_or(1800)),
148 }))
149}
150
151#[must_use]
154pub fn prompt(bundle: &Value, digest: Option<&Value>) -> String {
155 prompt_in(crate::format::reference().wording(), bundle, digest)
156}
157
158#[must_use]
160pub fn prompt_in(w: &crate::format::Wording, bundle: &Value, digest: Option<&Value>) -> String {
161 let s = |k: &str| bundle[k].as_str().unwrap_or_default();
162 let task = &bundle["task"];
163 let mut out = format!(
164 "You are implementing exactly one task of a {product} {unit} in this git worktree.\n\n\
165Rules — speccoder checks them after you finish, from the diff:\n\
166- Change only what the task needs. Do not edit the spec ({intent}), the checks' configuration (`speccoder.toml`, CI files, test-runner config) or anything under `.git`.\n\
167- Test files that exist now are frozen: add new tests in new files; never weaken, skip or delete tests.\n\
168- Do not commit, branch or push; speccoder commits a task that passes.\n\
169- Run the project's tests to see whether your change works; speccoder runs them again and decides.\n\
170- If the task cannot be done as written (it contradicts the spec, a test is wrong, information is missing), stop and make your final message start with `BLOCKED:` and the reason.\n\n\
171# Task {}\n\n{}\n\nPhase: {}\n",
172 task["id"].as_str().unwrap_or_default(),
173 task["title"].as_str().unwrap_or_default(),
174 task["phase"].as_str().unwrap_or_default(),
175 product = w.product,
176 unit = w.unit,
177 intent = w.intent_code,
178 );
179 for (title, key) in [
180 (w.story, "story"),
181 (w.plan_short, "plan"),
182 (w.constitution, "constitution"),
183 ] {
184 if !s(key).trim().is_empty() {
185 out.push_str(&format!("\n# {title}\n\n{}\n", s(key)));
186 }
187 }
188 out.push_str(&crate::substrate::propose::oracle_text(bundle));
189 if let Some(d) = digest.filter(|d| !d.is_null()) {
190 out.push_str(&format!(
191 "\n# Previous attempt (failed; its changes were undone)\n\nThe verifier's facts:\n\n```json\n{}\n```\n",
192 serde_json::to_string_pretty(d).unwrap_or_default()
193 ));
194 }
195 out
196}
197
198#[derive(Debug, Clone, PartialEq)]
200pub struct Outcome {
201 pub run_id: String,
204 pub output: Value,
206}
207
208#[must_use]
211pub fn interpret(exit: Option<i32>, stdout: &str) -> Value {
212 let message = stdout.trim().to_owned();
213 let summary: String = message.chars().take(2000).collect();
214 let (status, extra) = if exit != Some(0) {
215 (
216 "failed",
217 json!({ "reason": format!("the agent exited {}: {}", exit.map_or("by signal".to_owned(), |c| c.to_string()), summary.chars().take(500).collect::<String>()) }),
218 )
219 } else {
220 return judged(&message);
221 };
222 let mut out = json!({ "status": status, "tool_calls": [], "usage": Value::Null });
223 if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
224 o.extend(e.clone());
225 }
226 out
227}
228
229#[must_use]
231pub fn judged(message: &str) -> Value {
232 let message = message.trim();
233 let summary: String = message.chars().take(2000).collect();
234 let mut out = json!({ "tool_calls": [], "usage": Value::Null });
235 if let Some(reason) = message.strip_prefix("BLOCKED:") {
236 out["status"] = json!("blocked");
237 out["reason"] = json!(reason.trim());
238 } else if message.starts_with("FAILED:") {
239 out["status"] = json!("failed");
240 out["reason"] = json!(summary);
241 } else {
242 out["status"] = json!("completed");
243 out["report"] = json!({ "summary": summary, "status": "done", "files_changed": [], "checks_run": [], "notes": ["external executor"] });
244 }
245 out
246}
247
248#[must_use]
250pub fn run(
251 argv: &[String],
252 timeout: Duration,
253 worktree: &Path,
254 prompt: &str,
255 tmp: &Path,
256) -> Outcome {
257 use crate::verify::process::{CommandRunner as _, Invocation, LocalRunner, OUTPUT_CAP};
258 let _ = std::fs::create_dir_all(tmp);
259 let stamp = std::time::SystemTime::now()
260 .duration_since(std::time::UNIX_EPOCH)
261 .map_or(0, |d| d.as_secs());
262 let prompt_file = tmp.join(format!("external-{stamp}.prompt"));
263 let _ = std::fs::write(&prompt_file, prompt);
264 let quoted: Vec<String> = argv
268 .iter()
269 .map(|a| format!("'{}'", a.replace('\'', "'\\''")))
270 .collect();
271 let script = format!(
272 "exec {} < '{}'",
273 quoted.join(" "),
274 prompt_file.display().to_string().replace('\'', "'\\''")
275 );
276 let out = LocalRunner.run(&Invocation {
277 argv: vec!["sh".to_owned(), "-c".to_owned(), script],
278 cwd: worktree.to_path_buf(),
279 env: std::env::vars().collect(),
280 timeout,
281 output_cap: OUTPUT_CAP,
282 });
283 let stdout = String::from_utf8_lossy(&out.stdout.kept);
284 let mut output = interpret(out.exit_code, &stdout);
285 if out.exit_code != Some(0) && stdout.trim().is_empty() {
287 let stderr = String::from_utf8_lossy(&out.stderr.kept);
288 let lines: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
289 let tail = lines[lines.len().saturating_sub(5)..].join(" | ");
290 output["reason"] = json!(format!(
291 "the agent exited {}: {}",
292 out.exit_code
293 .map_or("by signal".to_owned(), |c| c.to_string()),
294 tail.chars().take(500).collect::<String>()
295 ));
296 }
297 if let Some(e) = &out.spawn_error {
298 output["status"] = json!("failed");
299 output["reason"] = json!(format!("the agent could not be started: {e}"));
300 }
301 if out.timed_out {
302 output["status"] = json!("failed");
303 output["reason"] = json!(format!(
304 "the agent did not finish within {} s",
305 timeout.as_secs()
306 ));
307 }
308 let _ = std::fs::remove_file(&prompt_file);
309 Outcome {
310 run_id: format!("external_command_{stamp}"),
311 output,
312 }
313}
314
315#[derive(Debug, Clone)]
318pub struct AcpOutcome {
319 pub outcome: Outcome,
321 pub sign_in: Option<Vec<crate::acp::session::SignIn>>,
323 pub agent: String,
325}
326
327#[must_use]
330pub fn tally(log: &[Value]) -> Value {
331 let mut groups: Vec<(Value, Vec<&Value>)> = Vec::new();
334 for (i, e) in log
335 .iter()
336 .enumerate()
337 .filter(|(_, e)| e["decision"] != "reported")
338 {
339 let key = if e["request"].is_null() {
340 json!(i)
341 } else {
342 e["request"].clone()
343 };
344 match groups.iter_mut().find(|(k, _)| *k == key) {
345 Some((_, g)) => g.push(e),
346 None => groups.push((key, vec![e])),
347 }
348 }
349 let person = |e: &&Value| {
350 (e["by"] == "person" && !e["scope"].as_str().is_some_and(|s| s.ends_with("-grant")))
351 || e["decision"] == "approval"
352 };
353 let refused = |g: &Vec<&Value>| g.iter().any(|e| e["decision"] == "deny");
354 let waiting = |g: &Vec<&Value>| g.iter().any(|e| e["decision"] == "approval");
355 json!({
356 "asked": groups.len(),
357 "allowed": groups.iter().filter(|(_, g)| !refused(g) && !waiting(g)).count(),
358 "waited": groups.iter().filter(|(_, g)| g.iter().any(person)).count(),
359 "refused": groups.iter().filter(|(_, g)| refused(g)).count(),
360 "reported": log.iter().filter(|e| e["decision"] == "reported").count(),
361 })
362}
363
364fn login_line(ways: &[crate::acp::session::SignIn], agent: &Agent) -> String {
367 ways.iter()
368 .find_map(|w| w.command.clone().map(|c| format!("`{c}`")))
369 .or_else(|| ways.iter().find_map(|w| w.description.clone()))
370 .or_else(|| agent.preset().map(|p| format!("`{}`", p.login)))
371 .unwrap_or_else(|| "its own login".to_owned())
372}
373
374#[must_use]
376pub fn agent_attempt(output: &Value) -> Option<speccoder_core::AgentAttempt> {
377 let a = output.get("acp")?;
378 let n = |k: &str| {
379 a["tally"][k]
380 .as_u64()
381 .and_then(|n| u32::try_from(n).ok())
382 .unwrap_or(0)
383 };
384 Some(speccoder_core::AgentAttempt {
385 agent: a["agent"].as_str().unwrap_or_default().to_owned(),
386 preset: a["preset"].as_str().unwrap_or_default().to_owned(),
387 mode: a["mode"].is_object().then(|| {
388 serde_json::from_value::<crate::acp::modes::Statement>(a["mode"].clone())
389 .map(|s| crate::secrets::mask(&s.sentence()))
390 .unwrap_or_default()
391 }),
392 decides_alone: a["decides_alone"] == true,
393 known: a["known"] == true,
394 unasked: a["unasked"]
395 .as_u64()
396 .and_then(|n| u32::try_from(n).ok())
397 .unwrap_or(0),
398 tally: speccoder_core::PermissionTally {
399 asked: n("asked"),
400 allowed: n("allowed"),
401 waited: n("waited"),
402 refused: n("refused"),
403 reported: n("reported"),
404 },
405 plan: a["plan"]
406 .as_array()
407 .into_iter()
408 .flatten()
409 .map(|e| speccoder_core::AgentPlanStep {
410 content: e["content"].as_str().unwrap_or_default().to_owned(),
411 status: e["status"].as_str().unwrap_or("pending").to_owned(),
412 })
413 .collect(),
414 })
415}
416
417#[must_use]
420pub fn run_acp(
421 exec: &Executor,
422 agent: &Agent,
423 worktree: &Path,
424 prompt: &str,
425 runner: &crate::sandbox::Runner,
426 governor: std::sync::Arc<crate::acp::govern::Governor>,
427 progress: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
428) -> AcpOutcome {
429 let stamp = std::time::SystemTime::now()
430 .duration_since(std::time::UNIX_EPOCH)
431 .map_or(0, |d| d.as_secs());
432 let attempt = crate::acp::session::Attempt {
433 agent: agent.clone(),
434 argv: agent.command(agent::on_path),
435 cwd: governor.root().to_path_buf(),
436 prompt: format!(
437 "{prompt}\n# Commands\n\nRun commands through the client's terminal where you can: speccoder runs them in its sandbox. A command you run with your own tools runs outside it, so the person is asked about each one first.\n"
438 ),
439 model: exec.model.clone(),
440 timeout: exec.timeout,
441 runner: runner.clone(),
442 progress,
443 };
444 let _ = worktree;
445 let f = crate::acp::session::run(&attempt, std::sync::Arc::clone(&governor));
446 let shown = f.agent_name.clone().unwrap_or_else(|| agent.name.clone());
447 let log = governor.log();
448 let pending = governor.pending();
449 let mut output = if let Some(why) = &f.left {
450 json!({ "status": "blocked", "reason": format!("{why}; its turn was cancelled") })
453 } else if let Some(p) = &pending {
454 json!({ "status": "needs_approval", "pending": p })
455 } else if f.sign_in.is_some() && f.prompted {
456 json!({ "status": "failed", "reason": format!("{} — sign {shown} in again: {}", f.error.clone().unwrap_or_default(), login_line(f.sign_in.as_deref().unwrap_or_default(), agent)) })
459 } else if f.sign_in.is_some() {
460 json!({ "status": "failed", "reason": f.error.clone().unwrap_or_default() })
461 } else if f.timed_out {
462 json!({ "status": "failed", "reason": format!("{shown} did not finish within {} s; its turn was cancelled", exec.timeout.as_secs()) })
463 } else {
464 match (f.stop_reason.as_deref(), &f.error) {
465 (Some("end_turn"), _) => judged(&f.message),
466 (Some("max_tokens" | "max_turn_requests"), _) => {
467 let mut o = judged(&f.message);
468 if o["status"] == "completed" {
469 o["status"] = json!("incomplete");
470 o["report"]["status"] = json!("incomplete");
471 o["report"]["notes"] = json!([format!(
472 "{shown} stopped at its own limit ({})",
473 f.stop_reason.as_deref().unwrap_or_default()
474 )]);
475 }
476 o
477 }
478 (Some("refusal"), _) => {
479 json!({ "status": "failed", "reason": format!("{shown} refused the task: {}", f.message.chars().take(500).collect::<String>()) })
480 }
481 (Some(other), _) => {
482 json!({ "status": "failed", "reason": format!("{shown} ended its turn: {other}") })
483 }
484 (None, Some(e)) => json!({ "status": "failed", "reason": e }),
485 (None, None) => {
486 json!({ "status": "failed", "reason": format!("{shown} ended without an answer") })
487 }
488 }
489 };
490 if output["status"] == "failed"
492 && let Some(line) = f.stderr.last()
493 && !output["reason"]
494 .as_str()
495 .unwrap_or_default()
496 .contains(line.as_str())
497 {
498 output["reason"] = json!(format!(
499 "{} — {shown} said: {line}",
500 output["reason"].as_str().unwrap_or_default()
501 ));
502 }
503 output["tool_calls"] = json!(log);
504 output["usage"] = f.usage.clone().unwrap_or(Value::Null);
505 output["asked"] = json!(governor.asked());
506 let mut notices = f.notices.clone();
507 if governor.unasked() > 0 {
508 notices.push(format!(
509 "{shown} changed something without asking {} time(s): its own settings let it decide those calls, which never reached speccoder",
510 governor.unasked()
511 ));
512 }
513 output["acp"] = json!({
514 "agent": shown,
515 "preset": agent.id,
516 "mode": f.mode,
517 "decides_alone": f.mode.as_ref().is_some_and(|m| m.asks() == Some(false)) || governor.unasked() > 0,
518 "known": f.mode.as_ref().is_some_and(|m| m.asks().is_some()),
519 "unasked": governor.unasked(),
520 "tally": tally(&log),
521 "plan": f.plan,
522 "notices": notices,
523 });
524 crate::secrets::mask_json(&mut output);
525 AcpOutcome {
526 outcome: Outcome {
527 run_id: format!("external_{}_{stamp}", agent.id),
528 output,
529 },
530 sign_in: f.sign_in.filter(|_| !f.prompted),
532 agent: shown,
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 fn kind(text: &str) -> Result<Option<Kind>, String> {
541 parse(text).map(|e| e.map(|e| e.kind))
542 }
543
544 #[test]
545 fn an_executor_is_an_agent_an_acp_argv_or_a_command() {
546 assert_eq!(kind(""), Ok(None));
547 assert_eq!(kind("[executor]\nkind = \"native\"\n"), Ok(None));
548 let Ok(Some(Kind::Acp(a))) = kind("[executor]\nagent = \"copilot\"\nmode = \"x\"\n") else {
549 panic!("copilot")
550 };
551 assert_eq!((a.id.as_str(), a.mode.as_deref()), ("copilot", Some("x")));
552 for id in ["claude", "codex", "gemini", "opencode"] {
553 assert!(
554 matches!(
555 kind(&format!("[executor]\nagent = \"{id}\"\n")),
556 Ok(Some(Kind::Acp(_)))
557 ),
558 "{id}"
559 );
560 }
561 let Ok(Some(Kind::Acp(own))) =
562 kind("[executor]\nkind = \"acp\"\nargv = [\"my-agent\", \"--acp\"]\n")
563 else {
564 panic!("argv")
565 };
566 assert_eq!((own.id.as_str(), own.argv.len()), ("acp", 2));
567 assert!(matches!(
568 kind("[executor]\nkind = \"command\"\nargv = [\"x\"]\n"),
569 Ok(Some(Kind::Command(_)))
570 ));
571 assert!(kind("[executor]\nkind = \"command\"\n").is_err());
572 assert!(kind("[executor]\nagent = \"gpt\"\n").is_err());
573 assert!(kind("[executor]\nagent = \"copilot\"\nargv = [\"x\"]\n").is_err());
574 assert!(kind("[executor]\nkind = \"command\"\nargv = [\"x\"]\nmode = \"y\"\n").is_err());
575 assert!(kind("[executor]\n").is_err());
576 }
577
578 #[test]
579 fn the_old_claude_and_codex_kinds_name_their_replacement() {
580 for old in ["claude", "codex"] {
581 let e = kind(&format!("[executor]\nkind = \"{old}\"\n"))
582 .err()
583 .unwrap_or_default();
584 assert!(e.contains(&format!("agent = \"{old}\"")), "{e}");
585 }
586 }
587
588 #[test]
589 fn one_run_can_take_another_agent() {
590 let configured =
591 parse("[executor]\nagent = \"copilot\"\nmodel = \"m\"\ntimeout_secs = 60\n")
592 .ok()
593 .flatten();
594 let e = for_agent("opencode", configured.as_ref()).unwrap_or_else(|e| panic!("{e}"));
595 assert_eq!(
596 (e.kind.name(), e.model, e.timeout.as_secs()),
597 ("opencode", None, 60)
598 );
599 let same = for_agent("copilot", configured.as_ref()).unwrap_or_else(|e| panic!("{e}"));
600 assert_eq!(same.model.as_deref(), Some("m"));
601 assert!(for_agent("nope", None).is_err());
602 }
603
604 #[test]
605 fn final_messages_become_statuses() {
606 assert_eq!(judged("Implemented add.")["status"], "completed");
607 let blocked = judged("BLOCKED: the test asserts 2+2=5");
608 assert_eq!(
609 (blocked["status"].as_str(), blocked["reason"].as_str()),
610 (Some("blocked"), Some("the test asserts 2+2=5"))
611 );
612 assert_eq!(interpret(Some(2), "boom")["status"], "failed");
613 }
614}