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)]
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.
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() {
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}")),
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,
242
output,
243
allow_tools,
244
deny_tools,
245
+ max_tool_rounds,
246
}))
247
}
248
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,
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
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.
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());
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 =