1 //! Headless one-shot mode: `sigit run`.
2 //!
3 //! Runs a single task non-interactively and exits: the prompt arrives as a
4 //! CLI flag, progress is emitted as JSONL events on stdout (one object per
5 //! line), and logs stay on stderr. Built for cloud runners (siGit Code Cloud
6 //! Agent) and scripting, where nobody is present to answer a permission
7 //! prompt — on `Decision::Ask` the tool is declined with a pointer to
8 //! `SIGIT_PERMISSIONS=allow` instead of blocking.
9 //!
10 //! The tool loop mirrors the ACP prompt handler (`handle_prompt` in
11 //! `main.rs`): permission gate → execute → feed results back, with
12 //! auto-compaction between rounds and a forced text reply on the final
13 //! round. Keep the two in sync when changing loop semantics.
14
15 use std::io::Write as _;
16 use std::path::PathBuf;
17 use std::sync::Arc;
18
19 use serde_json::json;
20
21 use crate::backend::{self, InferenceBackend, OpenAiBackend, ToolResult, ToolSpec};
22 use crate::{permissions, provider, tools};
23
24 /// Headless runs default to a higher round cap than interactive prompts: an
25 /// autonomous task routinely needs long edit/build/test chains and there is
26 /// no user present to re-prompt a stopped run.
27 const DEFAULT_MAX_ROUNDS: usize = 40;
28
29 /// Cap on `arguments`/`output` strings embedded in JSONL events. Full outputs
30 /// still reach the model; events only need enough for a live transcript.
31 const EVENT_FIELD_MAX_CHARS: usize = 4_000;
32
33 const USAGE: &str = "usage: sigit run [--prompt <text> | --prompt-file <path>] \
34 [--cwd <dir>] [--max-rounds <n>] [--output jsonl|text]";
35
36 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
37 pub enum OutputMode {
38 Jsonl,
39 Text,
40 }
41
42 #[derive(Debug)]
43 pub struct HeadlessOptions {
44 pub prompt: String,
45 pub cwd: PathBuf,
46 pub max_rounds: usize,
47 pub output: OutputMode,
48 }
49
50 /// The value following a flag, or a usage error naming the flag.
51 fn next_value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String, String> {
52 args.next()
53 .ok_or_else(|| format!("{flag} needs a value\n{USAGE}"))
54 }
55
56 /// Parse `sigit run` arguments (everything after the subcommand).
57 pub fn parse_args(mut args: impl Iterator<Item = String>) -> Result<HeadlessOptions, String> {
58 let mut prompt: Option<String> = None;
59 let mut cwd: Option<PathBuf> = None;
60 let mut max_rounds = DEFAULT_MAX_ROUNDS;
61 let mut output = OutputMode::Jsonl;
62
63 while let Some(flag) = args.next() {
64 match flag.as_str() {
65 "--prompt" => {
66 let value = next_value(&mut args, "--prompt")?;
67 if prompt.is_some() {
68 return Err(format!("give --prompt or --prompt-file once\n{USAGE}"));
69 }
70 prompt = Some(value);
71 }
72 "--prompt-file" => {
73 let path = next_value(&mut args, "--prompt-file")?;
74 if prompt.is_some() {
75 return Err(format!("give --prompt or --prompt-file once\n{USAGE}"));
76 }
77 let text = std::fs::read_to_string(&path)
78 .map_err(|error| format!("cannot read --prompt-file {path}: {error}"))?;
79 prompt = Some(text);
80 }
81 "--cwd" => {
82 cwd = Some(PathBuf::from(next_value(&mut args, "--cwd")?));
83 }
84 "--max-rounds" => {
85 max_rounds = next_value(&mut args, "--max-rounds")?
86 .parse::<usize>()
87 .ok()
88 .filter(|n| *n > 0)
89 .ok_or_else(|| format!("--max-rounds needs a positive integer\n{USAGE}"))?;
90 }
91 "--output" => {
92 output = match next_value(&mut args, "--output")?.as_str() {
93 "jsonl" => OutputMode::Jsonl,
94 "text" => OutputMode::Text,
95 other => return Err(format!("unknown --output {other}\n{USAGE}")),
96 };
97 }
98 other => return Err(format!("unknown argument {other}\n{USAGE}")),
99 }
100 }
101
102 let prompt = prompt
103 .map(|text| text.trim().to_string())
104 .filter(|text| !text.is_empty())
105 .ok_or_else(|| format!("a non-empty --prompt or --prompt-file is required\n{USAGE}"))?;
106
107 let cwd = cwd.unwrap_or_else(|| PathBuf::from("."));
108 let cwd = cwd
109 .canonicalize()
110 .map_err(|error| format!("--cwd {}: {error}", cwd.display()))?;
111
112 Ok(HeadlessOptions {
113 prompt,
114 cwd,
115 max_rounds,
116 output,
117 })
118 }
119
120 /// Entry point for `sigit run`. Never returns on failure paths — exits the
121 /// process with 0 (run completed), 1 (run failed), or 2 (usage/config error).
122 pub async fn run(args: impl Iterator<Item = String>) -> anyhow::Result<()> {
123 let options = match parse_args(args) {
124 Ok(options) => options,
125 Err(message) => {
126 eprintln!("sigit run: {message}");
127 std::process::exit(2);
128 }
129 };
130
131 // Provider: the explicit override (env / providers.toml) first — this is
132 // how a cloud runner injects a per-run endpoint and token — then the
133 // signed-in cloud as a convenience. Never fall back to on-device: a
134 // headless host should not silently download a multi-GB model.
135 let Some(config) =
136 provider::active_provider().or_else(|| provider::cloud_tier_provider("large"))
137 else {
138 eprintln!(
139 "sigit run: no inference provider configured. Set OPENAI_BASE_URL and \
140 OPENAI_API_KEY (and optionally SIGIT_MODEL), configure providers.toml, \
141 or sign in with `sigit login`."
142 );
143 std::process::exit(2);
144 };
145
146 if let Err(error) = std::env::set_current_dir(&options.cwd) {
147 eprintln!("sigit run: cannot enter {}: {error}", options.cwd.display());
148 std::process::exit(2);
149 }
150
151 let system_prompt = format!(
152 "{}\n\n{}",
153 crate::system_prompt_for_model(true),
154 crate::session_context_message(&options.cwd)
155 );
156 let backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
157 config.base_url.clone(),
158 config.api_key.clone(),
159 config.model.clone(),
160 Some(system_prompt),
161 ));
162 crate::register_subagent_factory_for(&config);
163 let tools = crate::agent_tools_as_specs();
164
165 let emitter = Emitter {
166 mode: options.output,
167 };
168 emitter.event(json!({
169 "type": "run_started",
170 "cwd": options.cwd.display().to_string(),
171 "model": config.model,
172 "max_rounds": options.max_rounds,
173 }));
174
175 let rounds = match drive_loop(&backend, &tools, &options, &emitter).await {
176 Ok((summary, rounds)) => {
177 emitter.event(json!({
178 "type": "result",
179 "status": "completed",
180 "summary": summary,
181 "rounds": rounds,
182 }));
183 rounds
184 }
185 Err((error, rounds)) => {
186 emitter.event(json!({
187 "type": "result",
188 "status": "failed",
189 "error": error,
190 "rounds": rounds,
191 }));
192 std::process::exit(1);
193 }
194 };
195 log::info!("headless run completed after {rounds} tool round(s)");
196 Ok(())
197 }
198
199 /// The tool loop. Returns `(summary, rounds)` or `(error, rounds)`.
200 ///
201 /// Keep in sync with `handle_prompt` in `main.rs`: same permission gate, same
202 /// auto-compaction trigger, same force-text final round.
203 async fn drive_loop(
204 backend: &Arc<dyn InferenceBackend>,
205 tools: &[ToolSpec],
206 options: &HeadlessOptions,
207 emitter: &Emitter,
208 ) -> Result<(String, usize), (String, usize)> {
209 // Permission decisions are per-session state; a headless process is one
210 // session. There are no grants to accumulate (nobody can answer "always
211 // allow"), the id only namespaces the lookup.
212 let session = format!("headless-{}", std::process::id());
213
214 let mut result = backend
215 .send_message_with_tools(&options.prompt, tools, None)
216 .await
217 .map_err(|error| (format!("inference failed: {error}"), 0))?;
218 emitter.turn_text(&result.text);
219
220 let mut round = 0usize;
221
222 while !result.tool_calls.is_empty() && round < options.max_rounds {
223 round += 1;
224
225 // Auto-compaction: long tool runs grow history fast; fold it into a
226 // summary before the next round rather than blowing the window.
227 let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
228 if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
229 match backend.compact_history(backend::COMPACT_KEEP_LAST).await {
230 Ok(()) => {
231 let after = backend::estimate_tokens(&backend.history_snapshot().await);
232 emitter.event(json!({
233 "type": "compaction",
234 "approx_tokens_before": estimate,
235 "approx_tokens_after": after,
236 }));
237 }
238 Err(error) => log::warn!("headless compaction failed: {error}"),
239 }
240 }
241
242 let mut tool_results = Vec::new();
243 for call in &result.tool_calls {
244 emitter.tool_call(call);
245 let (output, denied) = match permissions::decision_for(&session, &call.name) {
246 permissions::Decision::Allow => (
247 tools::execute_tool(&call.name, &call.arguments).await,
248 false,
249 ),
250 permissions::Decision::Deny(reason) => {
251 log::info!("headless: {} denied by policy", call.name);
252 (reason, true)
253 }
254 permissions::Decision::Ask => {
255 log::info!("headless: {} needs approval, declining", call.name);
256 (
257 format!(
258 "`{}` was not executed: headless mode cannot prompt for \
259 permission. Run with SIGIT_PERMISSIONS=allow to auto-approve \
260 mutating tools, or grant this tool in settings.toml.",
261 call.name
262 ),
263 true,
264 )
265 }
266 };
267 emitter.tool_result(call, &output, denied);
268 tool_results.push(ToolResult {
269 tool_call_id: call.id.clone(),
270 content: output,
271 });
272 }
273
274 let next_tools = if round < options.max_rounds {
275 Some(tools)
276 } else {
277 None // last round: force a text reply
278 };
279 result = backend
280 .send_tool_results(tool_results, next_tools, None)
281 .await
282 .map_err(|error| (format!("inference failed: {error}"), round))?;
283 emitter.turn_text(&result.text);
284 }
285
286 let (_think, visible) = crate::chat::strip_think_blocks(&result.text);
287 let summary = if visible.trim().is_empty() {
288 "The run finished without a final summary.".to_string()
289 } else {
290 visible.trim().to_string()
291 };
292 Ok((summary, round))
293 }
294
295 // ── Event output ─────────────────────────────────────────────────────────────
296
297 struct Emitter {
298 mode: OutputMode,
299 }
300
301 impl Emitter {
302 /// Write one event. JSONL mode prints the object as-is; text mode renders
303 /// a human-oriented line per event kind.
304 fn event(&self, event: serde_json::Value) {
305 match self.mode {
306 OutputMode::Jsonl => {
307 let mut stdout = std::io::stdout().lock();
308 let _ = writeln!(stdout, "{event}");
309 let _ = stdout.flush();
310 }
311 OutputMode::Text => {
312 let line = match event["type"].as_str() {
313 Some("run_started") => format!(
314 "▶ run started in {} (model {})",
315 event["cwd"].as_str().unwrap_or("?"),
316 event["model"].as_str().unwrap_or("?"),
317 ),
318 Some("turn_text") => event["text"].as_str().unwrap_or_default().to_string(),
319 Some("tool_call") => format!(
320 "→ {}({})",
321 event["name"].as_str().unwrap_or("?"),
322 event["arguments"].as_str().unwrap_or_default(),
323 ),
324 Some("tool_result") => format!(
325 "← {} ({} chars{})",
326 event["name"].as_str().unwrap_or("?"),
327 event["output_chars"].as_u64().unwrap_or(0),
328 if event["denied"].as_bool().unwrap_or(false) {
329 ", denied"
330 } else {
331 ""
332 },
333 ),
334 Some("compaction") => "… compacted conversation history".to_string(),
335 Some("result") => match event["status"].as_str() {
336 Some("completed") => format!(
337 "✔ completed\n{}",
338 event["summary"].as_str().unwrap_or_default()
339 ),
340 _ => format!("✘ failed: {}", event["error"].as_str().unwrap_or("?")),
341 },
342 _ => event.to_string(),
343 };
344 if !line.is_empty() {
345 let mut stdout = std::io::stdout().lock();
346 let _ = writeln!(stdout, "{line}");
347 let _ = stdout.flush();
348 }
349 }
350 }
351 }
352
353 /// Emit the visible part of an assistant turn, skipping empty turns.
354 fn turn_text(&self, raw: &str) {
355 let (_think, visible) = crate::chat::strip_think_blocks(raw);
356 let visible = visible.trim();
357 if visible.is_empty() {
358 return;
359 }
360 let (text, truncated) = clip(visible, EVENT_FIELD_MAX_CHARS);
361 let mut event = json!({ "type": "turn_text", "text": text });
362 if truncated {
363 event["truncated"] = json!(true);
364 }
365 self.event(event);
366 }
367
368 fn tool_call(&self, call: &backend::ToolCall) {
369 let (arguments, truncated) = clip(&call.arguments, EVENT_FIELD_MAX_CHARS);
370 let mut event = json!({
371 "type": "tool_call",
372 "id": call.id,
373 "name": call.name,
374 "arguments": arguments,
375 });
376 if truncated {
377 event["truncated"] = json!(true);
378 }
379 self.event(event);
380 }
381
382 fn tool_result(&self, call: &backend::ToolCall, output: &str, denied: bool) {
383 let (clipped, truncated) = clip(output, EVENT_FIELD_MAX_CHARS);
384 let mut event = json!({
385 "type": "tool_result",
386 "id": call.id,
387 "name": call.name,
388 "output_chars": output.chars().count(),
389 "output": clipped,
390 "denied": denied,
391 });
392 if truncated {
393 event["truncated"] = json!(true);
394 }
395 self.event(event);
396 }
397 }
398
399 /// Truncate to `max` characters (not bytes — always on a char boundary).
400 fn clip(text: &str, max: usize) -> (String, bool) {
401 if text.chars().count() <= max {
402 (text.to_string(), false)
403 } else {
404 (text.chars().take(max).collect(), true)
405 }
406 }
407
408 #[cfg(test)]
409 mod tests {
410 use super::*;
411
412 fn args(list: &[&str]) -> impl Iterator<Item = String> {
413 list.iter()
414 .map(|s| s.to_string())
415 .collect::<Vec<_>>()
416 .into_iter()
417 }
418
419 #[test]
420 fn parses_prompt_and_defaults() {
421 let options = parse_args(args(&["--prompt", "fix the tests"])).expect("parses");
422 assert_eq!(options.prompt, "fix the tests");
423 assert_eq!(options.max_rounds, DEFAULT_MAX_ROUNDS);
424 assert_eq!(options.output, OutputMode::Jsonl);
425 assert!(options.cwd.is_absolute());
426 }
427
428 #[test]
429 fn requires_a_prompt() {
430 let error = parse_args(args(&[])).expect_err("missing prompt");
431 assert!(error.contains("--prompt"));
432 }
433
434 #[test]
435 fn rejects_empty_prompt() {
436 let error = parse_args(args(&["--prompt", " "])).expect_err("blank prompt");
437 assert!(error.contains("non-empty"));
438 }
439
440 #[test]
441 fn rejects_prompt_and_prompt_file_together() {
442 let file = std::env::temp_dir().join(format!("sigit-prompt-{}.txt", std::process::id()));
443 std::fs::write(&file, "task").unwrap();
444 let error = parse_args(args(&[
445 "--prompt",
446 "one",
447 "--prompt-file",
448 file.to_str().unwrap(),
449 ]))
450 .expect_err("both prompt flags");
451 assert!(error.contains("once"));
452 std::fs::remove_file(&file).ok();
453 }
454
455 #[test]
456 fn reads_prompt_file() {
457 let file = std::env::temp_dir().join(format!("sigit-promptf-{}.txt", std::process::id()));
458 std::fs::write(&file, "task from file\n").unwrap();
459 let options = parse_args(args(&["--prompt-file", file.to_str().unwrap()])).expect("parses");
460 assert_eq!(options.prompt, "task from file");
461 std::fs::remove_file(&file).ok();
462 }
463
464 #[test]
465 fn rejects_bad_flags_and_values() {
466 assert!(parse_args(args(&["--prompt", "x", "--max-rounds", "0"])).is_err());
467 assert!(parse_args(args(&["--prompt", "x", "--max-rounds", "abc"])).is_err());
468 assert!(parse_args(args(&["--prompt", "x", "--output", "yaml"])).is_err());
469 assert!(parse_args(args(&["--bogus"])).is_err());
470 assert!(parse_args(args(&["--prompt", "x", "--cwd", "/definitely/not/a/dir"])).is_err());
471 }
472
473 #[test]
474 fn parses_overrides() {
475 let options = parse_args(args(&[
476 "--prompt",
477 "x",
478 "--max-rounds",
479 "7",
480 "--output",
481 "text",
482 ]))
483 .expect("parses");
484 assert_eq!(options.max_rounds, 7);
485 assert_eq!(options.output, OutputMode::Text);
486 }
487
488 #[test]
489 fn clip_is_char_boundary_safe() {
490 let (out, truncated) = clip("héllo wörld", 5);
491 assert_eq!(out, "héllo");
492 assert!(truncated);
493 let (out, truncated) = clip("short", 10);
494 assert_eq!(out, "short");
495 assert!(!truncated);
496 }
497 }