fix(headless): ask for a final message when a run ends with none

A headless run that used all 24 tool rounds could finish with empty stdout and exit status 0. The last round is forced to answer in text, and when the model answered with nothing there was no final message, so a caller could not tell a run that was cut off from one with nothing to report. When the last reply is empty, the run now asks the model once for its final message, with no tools offered, and says so when the cap is the reason. If that request fails the run ends as before. The cap can be set for a headless run with --max-tool-rounds or SIGIT_MAX_TOOL_ROUNDS, from 1 to 500. The flag wins over the variable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 5, 2026 at 08:37 UTC 454930a93e85c8f1becd6399f2fb9ad7949b8f35
3 files changed +161 -5
CHANGELOG.md
+16
@@ -1,5 +1,21 @@
1 # Changelog
2
3 +## Unreleased
4 +
5 +### Added
6 +
7 +- **The tool-round cap of a headless run can be set.** `--max-tool-rounds <n>`
8 + or `SIGIT_MAX_TOOL_ROUNDS` (1 to 500) replaces the built-in 24 for
9 + `sigit run` and `sigit -p`. The flag wins over the variable.
10 +
11 +### Fixed
12 +
13 +- **A headless run no longer ends silently with no final message.** When the
14 + last round came back empty, which is what happens when a run is cut off at
15 + the tool-round cap mid-task, stdout was empty and the exit status was 0, so
16 + a caller could not tell it from a run with nothing to report. siGit Code now
17 + asks the model once for its final message before finishing.
18 +
19 ## 1.6.0
20
21 ### Added
docs/headless.md
+5
@@ -27,6 +27,11 @@ for interactive permission are denied in headless mode unless granted with
27 `--allow-tool <name>`. A repeatable `--deny-tool <name>` takes precedence over grants and
28 settings.
29
30 +A run makes at most 24 rounds of tool calls. `--max-tool-rounds <n>`, or the
31 +`SIGIT_MAX_TOOL_ROUNDS` environment variable, sets a different cap from 1 to 500; the flag wins.
32 +When the cap is reached, or the model otherwise ends without saying anything, siGit Code asks it
33 +once for a final message, so stdout is not left empty by a run that was cut off mid-task.
34 +
35 ## JSONL output
36
37 Pass `--output jsonl` for a machine-readable stdout stream:
src/headless.rs
+140 -5
@@ -29,7 +29,8 @@ use crate::{permissions, provider, session_store, settings, tools};
29
30 pub const USAGE: &str = "Usage: sigit run \"<prompt>\" [--cwd <dir>] [--add-dir <dir>]... \
31 [--output text|jsonl] [--quiet] [--resume <session-id>] \
32 - [--allow-tool <name>]... [--deny-tool <name>]...\n \
32 + [--allow-tool <name>]... [--deny-tool <name>]... \
33 + [--max-tool-rounds <n>]\n \
34 sigit -p \"<prompt>\" [same options]";
35
36 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -62,6 +63,51 @@ pub struct HeadlessConfig {
63 pub allow_tools: Vec<String>,
64 /// Tools blocked for the run, overriding even settings-level allow.
65 pub deny_tools: Vec<String>,
66 + /// How many rounds of tool calls the run may make before the model is
67 + /// asked for its final message. `--max-tool-rounds`, else
68 + /// `SIGIT_MAX_TOOL_ROUNDS`, else the built-in cap.
69 + pub max_tool_rounds: usize,
70 +}
71 +
72 +/// Upper bound for `--max-tool-rounds` / `SIGIT_MAX_TOOL_ROUNDS`.
73 +pub const MAX_TOOL_ROUNDS_LIMIT: usize = 500;
74 +
75 +/// Environment override for the tool-round cap of a headless run.
76 +pub const MAX_TOOL_ROUNDS_ENV: &str = "SIGIT_MAX_TOOL_ROUNDS";
77 +
78 +fn parse_tool_rounds(value: &str, source: &str) -> Result<usize, String> {
79 + match value.trim().parse::<usize>() {
80 + Ok(rounds) if (1..=MAX_TOOL_ROUNDS_LIMIT).contains(&rounds) => Ok(rounds),
81 + _ => Err(format!(
82 + "{source} must be a whole number from 1 to {MAX_TOOL_ROUNDS_LIMIT}"
83 + )),
84 + }
85 +}
86 +
87 +/// The tool-round cap for a run: the flag wins, then the environment, then
88 +/// the built-in cap. An empty environment value counts as unset.
89 +fn resolve_max_tool_rounds(flag: Option<usize>, env: Option<&str>) -> Result<usize, String> {
90 + if let Some(rounds) = flag {
91 + return Ok(rounds);
92 + }
93 + match env.map(str::trim).filter(|value| !value.is_empty()) {
94 + Some(value) => parse_tool_rounds(value, MAX_TOOL_ROUNDS_ENV),
95 + None => Ok(crate::MAX_TOOL_ROUNDS),
96 + }
97 +}
98 +
99 +/// What the model is told when a run would otherwise end with no final
100 +/// message. `cap_reached` says the tool-round cap is why.
101 +fn wrap_up_prompt(cap_reached: bool, max_tool_rounds: usize) -> String {
102 + let lead = if cap_reached {
103 + format!("You have used all {max_tool_rounds} tool rounds available for this run. ")
104 + } else {
105 + String::new()
106 + };
107 + format!(
108 + "{lead}Write your final message now, without calling any tools: what you did, \
109 + what you found, and anything left unfinished."
110 + )
111 }
112
113 /// Parse the process arguments (without argv[0]) for headless mode.
@@ -85,6 +131,7 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
131 let mut resume_session: Option<String> = None;
132 let mut allow_tools: Vec<String> = Vec::new();
133 let mut deny_tools: Vec<String> = Vec::new();
134 + let mut max_tool_rounds: Option<usize> = None;
135
136 let mut iter = args.iter().skip(usize::from(run_command));
137 while let Some(arg) = iter.next() {
@@ -143,6 +190,12 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
190 .ok_or_else(|| "--deny-tool requires a tool name".to_string())?;
191 deny_tools.push(value.clone());
192 }
193 + "--max-tool-rounds" => {
194 + let value = iter
195 + .next()
196 + .ok_or_else(|| "--max-tool-rounds requires a number".to_string())?;
197 + max_tool_rounds = Some(parse_tool_rounds(value, "--max-tool-rounds")?);
198 + }
199 // A mistyped flag must not become the prompt. A prompt that
200 // really starts with '-' can still go through -p.
201 other if other.starts_with('-') => return Err(format!("unknown argument: {other}")),
@@ -174,6 +227,10 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
227
228 let resume = resume_session.is_some();
229 let session_id = resume_session.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
230 + let max_tool_rounds = resolve_max_tool_rounds(
231 + max_tool_rounds,
232 + std::env::var(MAX_TOOL_ROUNDS_ENV).ok().as_deref(),
233 + )?;
234
235 Ok(Some(HeadlessConfig {
236 prompt,
@@ -185,6 +242,7 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
242 output,
243 allow_tools,
244 deny_tools,
245 + max_tool_rounds,
246 }))
247 }
248
@@ -350,8 +408,8 @@ fn emit_error(config: &HeadlessConfig, message: &str) {
408 }
409
410 /// The turn loop: send the prompt, execute tool calls under the permission
353 -/// policy, feed results back, repeat up to `MAX_TOOL_ROUNDS` — the same shape
354 -/// as the ACP `handle_prompt`, minus the ACP notifications.
411 +/// policy, feed results back, repeat up to `config.max_tool_rounds` — the same
412 +/// shape as the ACP `handle_prompt`, minus the ACP notifications.
413 async fn run_prompt(
414 backend: &dyn InferenceBackend,
415 config: &HeadlessConfig,
@@ -376,7 +434,7 @@ async fn run_prompt(
434
435 let mut round = 0;
436
379 - while !result.tool_calls.is_empty() && round < crate::MAX_TOOL_ROUNDS {
437 + while !result.tool_calls.is_empty() && round < config.max_tool_rounds {
438 round += 1;
439
440 // Auto-compaction: long tool runs grow history fast; fold it into a
@@ -451,7 +509,7 @@ async fn run_prompt(
509 });
510 }
511
454 - let allow_tool_calls = round < crate::MAX_TOOL_ROUNDS;
512 + let allow_tool_calls = round < config.max_tool_rounds;
513
514 // Whatever this round says starts a new paragraph rather than
515 // continuing the sentence the tool calls interrupted.
@@ -466,6 +524,40 @@ async fn run_prompt(
524 .await?;
525 }
526
527 + // A run can reach this point with nothing to show: the last round was
528 + // forced to answer in text and the model answered with nothing, most
529 + // often because it was cut off at the tool-round cap mid-task. A caller
530 + // reading stdout could not tell that from a run with nothing to report,
531 + // so ask once, plainly, for the final message.
532 + if crate::chat::strip_think_blocks(result.text.trim())
533 + .1
534 + .is_empty()
535 + {
536 + let cap_reached = round >= config.max_tool_rounds;
537 + if cap_reached {
538 + log::warn!(
539 + "headless: reached the cap of {} tool round(s) without a final message",
540 + config.max_tool_rounds
541 + );
542 + }
543 + reply.interrupt();
544 + match drain_to_stdout(
545 + backend.send_message_with_tools(
546 + &wrap_up_prompt(cap_reached, config.max_tool_rounds),
547 + &[],
548 + sink_opt,
549 + ),
550 + &mut sink_rx,
551 + &mut reply,
552 + config,
553 + )
554 + .await
555 + {
556 + Ok(wrap_up) => result = wrap_up,
557 + Err(error) => log::warn!("headless: asking for a final message failed: {error}"),
558 + }
559 + }
560 +
561 // Final text: in quiet mode nothing streamed, so print the final assistant
562 // message now; otherwise print only what streaming did not already cover.
563 let (_think, final_visible) = crate::chat::strip_think_blocks(result.text.trim());
@@ -637,6 +729,49 @@ mod tests {
729 assert!(error.contains("session ids"), "{error}");
730 }
731
732 + #[test]
733 + fn max_tool_rounds_flag_is_parsed_and_bounded() {
734 + let config = parse_args(&args(&["-p", "work", "--max-tool-rounds", "60"]))
735 + .unwrap()
736 + .unwrap();
737 + assert_eq!(config.max_tool_rounds, 60);
738 +
739 + for bad in ["0", "501", "-1", "many", ""] {
740 + let error = parse_args(&args(&["-p", "work", "--max-tool-rounds", bad])).unwrap_err();
741 + assert!(error.contains("--max-tool-rounds"), "{bad}: {error}");
742 + }
743 + assert!(parse_args(&args(&["-p", "work", "--max-tool-rounds"])).is_err());
744 + }
745 +
746 + #[test]
747 + fn max_tool_rounds_resolves_flag_then_environment_then_default() {
748 + assert_eq!(resolve_max_tool_rounds(Some(7), Some("60")), Ok(7));
749 + assert_eq!(resolve_max_tool_rounds(None, Some(" 60 ")), Ok(60));
750 + assert_eq!(
751 + resolve_max_tool_rounds(None, None),
752 + Ok(crate::MAX_TOOL_ROUNDS)
753 + );
754 + // An empty variable is unset, not an error.
755 + assert_eq!(
756 + resolve_max_tool_rounds(None, Some("")),
757 + Ok(crate::MAX_TOOL_ROUNDS)
758 + );
759 + let error = resolve_max_tool_rounds(None, Some("lots")).unwrap_err();
760 + assert!(error.contains(MAX_TOOL_ROUNDS_ENV), "{error}");
761 + assert!(resolve_max_tool_rounds(None, Some("0")).is_err());
762 + }
763 +
764 + #[test]
765 + fn wrap_up_prompt_names_the_cap_only_when_it_was_reached() {
766 + let capped = wrap_up_prompt(true, 24);
767 + assert!(capped.contains("all 24 tool rounds"), "{capped}");
768 + assert!(capped.contains("without calling any tools"));
769 +
770 + let plain = wrap_up_prompt(false, 24);
771 + assert!(!plain.contains("tool rounds"), "{plain}");
772 + assert!(plain.starts_with("Write your final message now"));
773 + }
774 +
775 #[test]
776 fn quiet_and_jsonl_are_mutually_exclusive() {
777 let error =