@sigitcode-old / sigit / commits / f36bb8f
Fork 1

I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

Tell the model in headless mode when its last tool round is the cap

At the cap the final round is forced to answer in text, but nothing told the model the tools were gone. It narrated its next step, and because that text was not empty, it became the run's final message. ACP mode already appends a note to the last tool result; headless now does too, with wording that fits a run nobody can reply to and asks for what is committed. The empty-reply wrap-up request stays as the fallback. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 18, 2026 at 18:56 UTC f36bb8f7eb90bf7706fad187f3fdf5cc833c3dfc
3 files changed +91
crates/cli/src/headless.rs
+8
@@ -526,6 +526,14 @@ async fn run_prompt(
526
527 let allow_tool_calls = round < config.max_tool_rounds;
528
529 + // The round that reaches the cap is forced to answer in text. Say so,
530 + // or the model spends its last reply narrating the step it can no
531 + // longer take.
532 + if !allow_tool_calls && let Some(last) = tool_results.last_mut() {
533 + last.content
534 + .push_str(&crate::headless_round_cap_note(config.max_tool_rounds));
535 + }
536 +
537 // Whatever this round says starts a new paragraph rather than
538 // continuing the sentence the tool calls interrupted.
539 reply.interrupt();
crates/cli/src/main.rs
+11
@@ -183,6 +183,17 @@ fn round_cap_note(max_tool_rounds: usize) -> String {
183 )
184 }
185
186 +/// The headless counterpart of [`round_cap_note`]. Nobody replies to a headless
187 +/// run, so it says what the final message is for instead of promising a
188 +/// follow-up, and it names commits because that is all the run leaves behind.
189 +fn headless_round_cap_note(max_tool_rounds: usize) -> String {
190 + format!(
191 + "\n\n[siGit Code] That was tool round {max_tool_rounds} of {max_tool_rounds} for \
192 + this run, so no more tools can run. Do not announce a next action. Say what is \
193 + done, what is committed, and what is left."
194 + )
195 +}
196 +
197 /// What the user sees when a turn that ran tools ends with no closing text.
198 fn silent_stop_message(repeated_tool: Option<&str>, rounds: usize) -> String {
199 match repeated_tool {
crates/cli/tests/headless_mode.rs
+72
@@ -593,6 +593,78 @@ fn run_cut_off_at_the_round_cap_still_ends_with_a_final_message() {
593 assert!(asked.contains("final message"), "got: {asked}");
594 }
595
596 +/// The round that reaches the cap is forced to answer in text, and the model
597 +/// cannot tell the tools are gone unless the last tool result says so. Without
598 +/// the note it narrates its next step and that becomes the final message.
599 +#[test]
600 +fn round_cap_note_reaches_the_model_only_on_the_capped_round() {
601 + let endpoint = start_fake_endpoint(vec![
602 + json_tool_call("call_1", "run_command", r#"{"command":"echo one"}"#),
603 + json_tool_call("call_2", "run_command", r#"{"command":"echo two"}"#),
604 + json_text("Ran two commands; nothing is committed and the tests are left."),
605 + ]);
606 + let scratch = scratch("round_cap_note");
607 + let cwd = scratch.cwd.to_str().unwrap().to_string();
608 +
609 + let output = run_headless(
610 + endpoint.port,
611 + &scratch,
612 + &[
613 + "-p",
614 + "look into it",
615 + "--quiet",
616 + "--max-tool-rounds",
617 + "2",
618 + "--allow-tool",
619 + "run_command",
620 + "--cwd",
621 + &cwd,
622 + ],
623 + );
624 +
625 + assert_eq!(output.status.code(), Some(0));
626 + assert_eq!(
627 + stdout_of(&output),
628 + "Ran two commands; nothing is committed and the tests are left.\n"
629 + );
630 +
631 + let requests = endpoint.requests.lock().unwrap();
632 + assert_eq!(
633 + requests.len(),
634 + 3,
635 + "prompt and two tool rounds; a real final reply needs no wrap-up"
636 + );
637 + let last_tool_message = |request: &Value| {
638 + request["messages"]
639 + .as_array()
640 + .unwrap()
641 + .iter()
642 + .rfind(|m| m["role"] == "tool")
643 + .map(|m| m["content"].as_str().unwrap_or_default().to_string())
644 + .expect("a tool result")
645 + };
646 + let before_cap = last_tool_message(&requests[1]);
647 + assert!(
648 + !before_cap.contains("[siGit Code]"),
649 + "no note before the cap: {before_cap}"
650 + );
651 + assert!(requests[1].get("tools").is_some());
652 + let at_cap = last_tool_message(&requests[2]);
653 + assert!(
654 + at_cap.contains("tool round 2 of 2 for this run"),
655 + "got: {at_cap}"
656 + );
657 + assert!(
658 + at_cap.contains("Do not announce a next action"),
659 + "got: {at_cap}"
660 + );
661 + assert!(
662 + !at_cap.contains("until the user replies"),
663 + "headless has nobody to reply: {at_cap}"
664 + );
665 + assert!(requests[2].get("tools").is_none());
666 +}
667 +
668 /// A run that ends with a real final message is left alone: no extra request.
669 #[test]
670 fn run_with_a_final_message_makes_no_wrap_up_request() {