fix(acp): say so when a turn runs out of tool rounds

In an editor a long task could stop on a line like "Now commit:" with nothing after it and the plan still in progress. The turn had reached the cap of 24 tool rounds. The round after the cap is a forced text reply with no tools, the model did not know that, so it announced its next step and called a tool, and the call was dropped. Nothing told the user why the thread stopped. The round that reaches the cap now has a note appended to its last tool result, telling the model no more tools can run and asking for where things stand in place of a next step. The turn then always ends with a message that names the cap and says "continue" picks the task back up. That message replaces the generic one for a silent last round when the cap is the reason. SIGIT_MAX_TOOL_ROUNDS, which already set the cap for headless runs, now sets it for ACP prompt turns as well. A value that does not parse is logged and ignored there, since an editor has nowhere to show a startup error. Fixes #120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 5, 2026 at 10:53 UTC 0c4127628958617013c71c581f9247391ddfa617
5 files changed +208 -12
.agents/AGENTS.md
+7 -1
@@ -132,6 +132,11 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
132 `max_turn_requests`, `length` is `max_tokens`, `content_filter` is `refusal`. ACP defines a
133 refusal as a turn the next prompt will not include, so `handle_prompt` restores the history
134 it snapshotted before the turn instead of only relabelling the response.
135 + The round that reaches the cap (24, or `SIGIT_MAX_TOOL_ROUNDS`) is followed by one forced
136 + text reply with no tools. The model cannot see that the tools are gone, so
137 + `round_cap_note` is appended to that round's last tool result, and the turn always closes
138 + with `round_cap_stop_message`. Without both, the thread stops on the model announcing a
139 + step that never runs (issue #120).
140 Some models write tool calls into content as text; `src/inline_tool_calls.rs` recovers the
141 well-formed ones. A block that doesn't parse (or never closes) is dropped from both the reply
142 and history, and `OpenAiBackend::complete` retries once with a note telling the model the call
@@ -346,7 +351,8 @@ verbosity with `RUST_LOG`.
351
352 `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
353 default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
349 -`SIGIT_MODEL`, `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
354 +`SIGIT_MODEL`, `SIGIT_MAX_TOOL_ROUNDS` (1 to 500, default 24; the tool-round cap of a
355 +headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
356 smbCloud CLI server), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
357 server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
358 mutating tools; the escape hatch for clients without permission-request support),
CHANGELOG.md
+8
@@ -24,6 +24,14 @@
24 `in_progress` once approved. Calls allowed by policy still start out
25 `in_progress` (#138).
26
27 +- **A turn that runs out of tool rounds says so.** In an editor, a long task
28 + could stop on a line like "Now commit:" with nothing after it. The turn had
29 + used all 24 tool rounds, the model spent its last reply announcing the next
30 + step, and the call behind it was dropped. siGit Code now tells the model
31 + that its rounds are used up before that last reply, and ends the turn with a
32 + message saying why it stopped and that "continue" picks it back up.
33 + `SIGIT_MAX_TOOL_ROUNDS` now sets the cap for editor sessions too (#120).
34 +
35 - **A headless run no longer ends silently with no final message.** When the
36 last round came back empty, which is what happens when a run is cut off at
37 the tool-round cap mid-task, stdout was empty and the exit status was 0, so
src/headless.rs
+16 -1
@@ -72,7 +72,8 @@ pub struct HeadlessConfig {
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.
75 +/// Environment override for the tool-round cap of a headless run or an ACP
76 +/// prompt turn.
77 pub const MAX_TOOL_ROUNDS_ENV: &str = "SIGIT_MAX_TOOL_ROUNDS";
78
79 fn parse_tool_rounds(value: &str, source: &str) -> Result<usize, String> {
@@ -96,6 +97,20 @@ fn resolve_max_tool_rounds(flag: Option<usize>, env: Option<&str>) -> Result<usi
97 }
98 }
99
100 +/// The tool-round cap for a surface with no flag of its own (an ACP prompt
101 +/// turn). A value that does not parse is logged and ignored: the editor
102 +/// launched the agent, so there is nobody to show a startup error to.
103 +pub fn max_tool_rounds_from_env() -> usize {
104 + resolve_max_tool_rounds(None, std::env::var(MAX_TOOL_ROUNDS_ENV).ok().as_deref())
105 + .unwrap_or_else(|error| {
106 + log::warn!(
107 + "{error}; using the built-in cap of {}",
108 + crate::MAX_TOOL_ROUNDS
109 + );
110 + crate::MAX_TOOL_ROUNDS
111 + })
112 +}
113 +
114 /// What the model is told when a run would otherwise end with no final
115 /// message. `cap_reached` says the tool-round cap is why.
116 fn wrap_up_prompt(cap_reached: bool, max_tool_rounds: usize) -> String {
src/main.rs
+53 -6
@@ -297,6 +297,27 @@ fn stop_reason_for(round_cap_reached: bool, finish: backend::FinishReason) -> St
297 }
298 }
299
300 +/// What the user sees when a turn stops at the tool-round cap. Always sent,
301 +/// whatever the last round said: with no tools on offer that round tends to be
302 +/// the model announcing its next step ("Now commit:"), which reads as a stall
303 +/// when nothing follows it (issue #120).
304 +fn round_cap_stop_message(max_tool_rounds: usize) -> String {
305 + format!(
306 + "I stopped here because this turn used all {max_tool_rounds} of its tool rounds, so the task may not be finished. Reply \"continue\" to pick it back up."
307 + )
308 +}
309 +
310 +/// Appended to the last tool result of the round that reaches the cap. Without
311 +/// it the model has no way to know the tools are gone, so it spends its final
312 +/// reply saying what it is about to do instead of where things stand.
313 +fn round_cap_note(max_tool_rounds: usize) -> String {
314 + format!(
315 + "\n\n[siGit Code] That was tool round {max_tool_rounds} of {max_tool_rounds} for \
316 + this turn, so no more tools can run until the user replies. Do not announce a next \
317 + action. Say briefly what is done and what is left."
318 + )
319 +}
320 +
321 /// What the user sees when a turn that ran tools ends with no closing text.
322 fn silent_stop_message(repeated_tool: Option<&str>, rounds: usize) -> String {
323 match repeated_tool {
@@ -2373,9 +2394,10 @@ impl SiGitAgent {
2394
2395 // ── tool-calling loop ────────────────────────────────────────────
2396 // send message → execute any tool calls → feed results back
2376 - // repeat up to MAX_TOOL_ROUNDS, then force a text reply
2397 + // repeat up to the tool-round cap, then force a text reply
2398
2399 let tools = agent_tools_as_specs();
2400 + let max_tool_rounds = headless::max_tool_rounds_from_env();
2401
2402 // Token sink: backends stream assistant text through this while a turn
2403 // runs. We forward the visible portion to the editor as agent-message
@@ -2435,7 +2457,7 @@ impl SiGitAgent {
2457 // end of the turn can tell whether that round said anything at all.
2458 let mut sent_before_last_round = 0;
2459
2438 - while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
2460 + while !result.tool_calls.is_empty() && round < max_tool_rounds {
2461 if cancellation.cancelled.load(Ordering::Acquire) {
2462 self.finish_prompt(&session_id, &cancellation);
2463 return Ok(PromptResponse::new(StopReason::Cancelled));
@@ -2708,7 +2730,11 @@ impl SiGitAgent {
2730 }
2731 }
2732
2711 - let allow_tool_calls = round < MAX_TOOL_ROUNDS && !force_text;
2733 + let round_cap_reached = round >= max_tool_rounds;
2734 + let allow_tool_calls = !round_cap_reached && !force_text;
2735 + if round_cap_reached && let Some(last) = tool_results.last_mut() {
2736 + last.content.push_str(&round_cap_note(max_tool_rounds));
2737 + }
2738
2739 // Whatever this round says starts a new paragraph rather than
2740 // continuing the sentence the tool calls interrupted.
@@ -2752,10 +2778,14 @@ impl SiGitAgent {
2778 // If anything streamed, the visible reply is already on the wire; only
2779 // send a trailing block for the non-streamed path (e.g. on-device direct
2780 // answers, which onde can't stream while tools are on offer).
2781 + let round_cap_reached = round >= max_tool_rounds;
2782 if !reply.streamed_any {
2783 let reply_text = result.text.trim().to_string();
2784 let final_text = if reply_text.is_empty() {
2758 - if round > 0 {
2785 + if round_cap_reached {
2786 + // The cap notice below covers it.
2787 + String::new()
2788 + } else if round > 0 {
2789 log::warn!(
2790 "prompt({}) — model returned empty reply after {} tool round(s)",
2791 session_id,
@@ -2779,7 +2809,7 @@ impl SiGitAgent {
2809 self.send_assistant_message(cx, session_id.clone(), final_text)
2810 .ok();
2811 }
2782 - } else if round > 0 && reply.sent.len() == sent_before_last_round {
2812 + } else if round > 0 && !round_cap_reached && reply.sent.len() == sent_before_last_round {
2813 // Earlier rounds streamed text, but the last one (typically the
2814 // forced no-tools round) said nothing. Without a closing message
2815 // the turn just stops, and the user can't tell whether it ended,
@@ -2800,7 +2830,24 @@ impl SiGitAgent {
2830 .ok();
2831 }
2832
2803 - let stop_reason = stop_reason_for(round >= MAX_TOOL_ROUNDS, result.finish);
2833 + if round_cap_reached {
2834 + log::warn!(
2835 + "prompt({}) stopped at the cap of {} tool round(s)",
2836 + session_id,
2837 + max_tool_rounds
2838 + );
2839 + self.send_assistant_message(
2840 + cx,
2841 + session_id.clone(),
2842 + format!(
2843 + "{PARAGRAPH_BREAK}{}",
2844 + round_cap_stop_message(max_tool_rounds)
2845 + ),
2846 + )
2847 + .ok();
2848 + }
2849 +
2850 + let stop_reason = stop_reason_for(round_cap_reached, result.finish);
2851
2852 // ACP defines a refusal as a turn the next prompt will not include:
2853 // the client drops the user message and everything after it from its
tests/acp_stop_reasons.rs
+124 -4
@@ -113,6 +113,14 @@ struct AgentUnderTest {
113 }
114
115 fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
116 + spawn_agent_with_env(port, config_dir, &[])
117 +}
118 +
119 +fn spawn_agent_with_env(
120 + port: u16,
121 + config_dir: &std::path::Path,
122 + env: &[(&str, &str)],
123 +) -> AgentUnderTest {
124 let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
125 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
126 .env("OPENAI_API_KEY", "test-key")
@@ -121,6 +129,8 @@ fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
129 .env("SIGIT_MCP", "off")
130 .env("SIGIT_PERMISSIONS", "allow")
131 .env_remove("SIGIT_LOCAL_INFERENCE")
132 + .env_remove("SIGIT_MAX_TOOL_ROUNDS")
133 + .envs(env.iter().copied())
134 .stdin(Stdio::piped())
135 .stdout(Stdio::piped())
136 .stderr(Stdio::null())
@@ -194,14 +204,39 @@ impl AgentUnderTest {
204
205 /// Send one text prompt and return the stop reason the turn ended with.
206 fn prompt(&mut self, session_id: &str, text: &str) -> String {
207 + self.prompt_with_reply(session_id, text).0
208 + }
209 +
210 + /// Like [`Self::prompt`], also returning the assistant text the client
211 + /// was sent on the way, joined the way a client renders it.
212 + fn prompt_with_reply(&mut self, session_id: &str, text: &str) -> (String, String) {
213 let id = self.request(
214 "session/prompt",
215 json!({"sessionId": session_id, "prompt": [{"type": "text", "text": text}]}),
216 );
201 - self.wait_for_response(id)["result"]["stopReason"]
202 - .as_str()
203 - .expect("stop reason")
204 - .to_string()
217 + let mut rendered = String::new();
218 + let deadline = Instant::now() + TIMEOUT;
219 + loop {
220 + let remaining = deadline.saturating_duration_since(Instant::now());
221 + let Ok(message) = self.incoming.recv_timeout(remaining) else {
222 + panic!("timed out waiting for the response to request {id}");
223 + };
224 + let update = &message["params"]["update"];
225 + if message["method"] == "session/update"
226 + && update["sessionUpdate"] == "agent_message_chunk"
227 + && let Some(chunk) = update["content"]["text"].as_str()
228 + {
229 + rendered.push_str(chunk);
230 + }
231 + if message["id"] == id && message.get("method").is_none() {
232 + assert!(message.get("error").is_none(), "prompt failed: {message}");
233 + let stop_reason = message["result"]["stopReason"]
234 + .as_str()
235 + .expect("stop reason")
236 + .to_string();
237 + return (stop_reason, rendered);
238 + }
239 + }
240 }
241 }
242
@@ -317,3 +352,88 @@ fn a_turn_stopped_at_the_tool_round_cap_ends_with_max_turn_requests() {
352
353 std::fs::remove_dir_all(&dir).ok();
354 }
355 +
356 +/// What issue #120 looked like from the editor: the turn reached the cap, the
357 +/// model used its last reply to announce the next step, the call behind it was
358 +/// dropped, and the thread stopped on "Now commit:" with nothing after it.
359 +#[test]
360 +fn a_turn_stopped_at_the_cap_says_so_to_the_user_and_the_model() {
361 + let dir = scratch("cap_notice");
362 + let endpoint = start_fake_endpoint(vec![
363 + sse_tool_call("call_1", "run_command", r#"{"command":"echo round-1"}"#),
364 + sse_tool_call("call_2", "run_command", r#"{"command":"echo round-2"}"#),
365 + sse_text("Now commit:", "stop"),
366 + ]);
367 + let mut agent = spawn_agent_with_env(
368 + endpoint.port,
369 + &dir.join("config"),
370 + &[("SIGIT_MAX_TOOL_ROUNDS", "2")],
371 + );
372 + let session_id = agent.open_session(&dir.join("work"));
373 +
374 + let (stop_reason, reply) = agent.prompt_with_reply(&session_id, "ship it");
375 +
376 + assert_eq!(stop_reason, "max_turn_requests");
377 + assert!(
378 + reply.contains("Now commit:") && reply.contains("all 2 of its tool rounds"),
379 + "the user must be told why the turn stopped: {reply:?}"
380 + );
381 +
382 + // The model is told before its last reply, on the result of the last
383 + // call, and only there.
384 + let requests = endpoint.requests.lock().unwrap();
385 + assert_eq!(requests.len(), 3);
386 + let tool_results = |request: &Value| -> Vec<String> {
387 + request["messages"]
388 + .as_array()
389 + .expect("messages")
390 + .iter()
391 + .filter(|message| message["role"] == "tool")
392 + .map(|message| message["content"].as_str().unwrap_or_default().to_string())
393 + .collect()
394 + };
395 + let before_cap = tool_results(&requests[1]);
396 + assert!(
397 + before_cap
398 + .iter()
399 + .all(|content| !content.contains("[siGit Code]")),
400 + "no note while rounds remain: {before_cap:?}"
401 + );
402 + let at_cap = tool_results(&requests[2]);
403 + assert!(
404 + at_cap.last().is_some_and(|content| {
405 + content.contains("round-2") && content.contains("no more tools can run")
406 + }),
407 + "the last tool result carries the note: {at_cap:?}"
408 + );
409 + assert!(
410 + requests[2].get("tools").is_none(),
411 + "the closing round offers no tools: {}",
412 + requests[2]
413 + );
414 +
415 + std::fs::remove_dir_all(&dir).ok();
416 +}
417 +
418 +/// A turn that finishes inside its rounds gets no notice.
419 +#[test]
420 +fn a_turn_that_finishes_inside_the_cap_gets_no_notice() {
421 + let dir = scratch("under_cap");
422 + let endpoint = start_fake_endpoint(vec![
423 + sse_tool_call("call_1", "run_command", r#"{"command":"echo round-1"}"#),
424 + sse_text("Committed.", "stop"),
425 + ]);
426 + let mut agent = spawn_agent_with_env(
427 + endpoint.port,
428 + &dir.join("config"),
429 + &[("SIGIT_MAX_TOOL_ROUNDS", "2")],
430 + );
431 + let session_id = agent.open_session(&dir.join("work"));
432 +
433 + let (stop_reason, reply) = agent.prompt_with_reply(&session_id, "ship it");
434 +
435 + assert_eq!(stop_reason, "end_turn");
436 + assert_eq!(reply.trim(), "Committed.");
437 +
438 + std::fs::remove_dir_all(&dir).ok();
439 +}