Fix ACP handling of malformed Chinese-model tool calls

Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 14, 2026 at 18:59 UTC e1f378736c73f6f856207abbbe593f53300e1e93
7 files changed +294 -69
src/backend.rs
+67 -34
@@ -159,12 +159,15 @@ pub trait InferenceBackend: Send + Sync {
159 sink: Option<&TokenSink>,
160 ) -> Result<TurnResult, BackendError>;
161
162 - /// Continue the turn by returning tool results. `tools` may be `None` on the
163 - /// final round to force a text answer. `sink` streams that text when set.
162 + /// Continue the turn by returning tool results. `allow_tool_calls` controls
163 + /// whether `tools` are offered again; the complete catalog remains available
164 + /// so a disabled round can recognize and suppress tool-shaped model output.
165 + /// `sink` streams assistant text when set.
166 async fn send_tool_results(
167 &self,
168 results: Vec<ToolResult>,
167 - tools: Option<&[ToolSpec]>,
169 + tools: &[ToolSpec],
170 + allow_tool_calls: bool,
171 sink: Option<&TokenSink>,
172 ) -> Result<TurnResult, BackendError>;
173
@@ -257,7 +260,8 @@ impl InferenceBackend for LocalBackend {
260 async fn send_tool_results(
261 &self,
262 results: Vec<ToolResult>,
260 - tools: Option<&[ToolSpec]>,
263 + tools: &[ToolSpec],
264 + allow_tool_calls: bool,
265 sink: Option<&TokenSink>,
266 ) -> Result<TurnResult, BackendError> {
267 let onde_results: Vec<onde::inference::ToolResult> = results
@@ -268,10 +272,10 @@ impl InferenceBackend for LocalBackend {
272 })
273 .collect();
274
271 - // The final round passes `tools = None` to force a text answer; that's
272 - // the only round onde can stream, since no further tool calls are parsed.
275 + // A forced-text round is the only round onde can stream, since no
276 + // further tool calls are parsed.
277 if let Some(sink) = sink
274 - && tools.is_none()
278 + && !allow_tool_calls
279 {
280 let rx = self
281 .engine
@@ -281,7 +285,7 @@ impl InferenceBackend for LocalBackend {
285 return drain_onde_stream(rx, sink).await;
286 }
287
284 - let onde_tools = tools.map(to_onde_tools);
288 + let onde_tools = allow_tool_calls.then(|| to_onde_tools(tools));
289 let result = self
290 .engine
291 .send_tool_results(onde_results, onde_tools.as_deref())
@@ -485,12 +489,14 @@ impl OpenAiBackend {
489 .collect()
490 }
491
488 - /// POST the current history (plus `tools`) and apply the assistant reply to
489 - /// history, returning the neutral turn result. Streams via SSE when `sink`
490 - /// is set; otherwise reads a single JSON response.
492 + /// POST the current history and apply the assistant reply to history.
493 + /// `tools` is always the known catalog, while `allow_tool_calls` determines
494 + /// whether it is advertised to the model and whether returned calls may run.
495 + /// Streams via SSE when `sink` is set; otherwise reads a single JSON response.
496 async fn complete(
497 &self,
493 - tools: Option<&[ToolSpec]>,
498 + tools: &[ToolSpec],
499 + allow_tool_calls: bool,
500 sink: Option<&TokenSink>,
501 ) -> Result<TurnResult, BackendError> {
502 let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
@@ -501,9 +507,7 @@ impl OpenAiBackend {
507 "messages": *self.history.lock().await,
508 "stream": streaming,
509 });
504 - if let Some(tools) = tools
505 - && !tools.is_empty()
506 - {
510 + if allow_tool_calls && !tools.is_empty() {
511 body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
512 // OpenAI specifies `auto` as the default when tools are present,
513 // but not every OpenAI-compatible gateway implements that default.
@@ -528,13 +532,11 @@ impl OpenAiBackend {
532 return Err(describe_api_error(status, &body));
533 }
534
531 - // The specs are needed downstream to type the arguments of any tool
532 - // call the model emitted as text rather than as a structured call.
533 - let tools = tools.unwrap_or(&[]);
535 if let Some(sink) = sink {
535 - self.consume_stream(response, sink, tools).await
536 + self.consume_stream(response, sink, tools, allow_tool_calls)
537 + .await
538 } else {
537 - self.consume_json(response, tools).await
539 + self.consume_json(response, tools, allow_tool_calls).await
540 }
541 }
542
@@ -543,6 +545,7 @@ impl OpenAiBackend {
545 &self,
546 response: reqwest::Response,
547 tools: &[ToolSpec],
548 + allow_tool_calls: bool,
549 ) -> Result<TurnResult, BackendError> {
550 let parsed: ChatCompletion = response
551 .json()
@@ -556,7 +559,7 @@ impl OpenAiBackend {
559 .map(|choice| choice.message)
560 .ok_or_else(|| "endpoint returned no choices".to_string())?;
561
559 - let text = message.content.clone().unwrap_or_default();
562 + let mut text = message.content.clone().unwrap_or_default();
563 let tool_calls: Vec<ToolCall> = message
564 .tool_calls
565 .iter()
@@ -568,6 +571,23 @@ impl OpenAiBackend {
571 })
572 .collect();
573
574 + if !allow_tool_calls {
575 + let (cleaned, recovered) = crate::inline_tool_calls::extract(&text, tools);
576 + text = cleaned;
577 + let suppressed = tool_calls.len() + recovered.len();
578 + if suppressed > 0 {
579 + log::warn!("suppressed {suppressed} tool call(s) from a forced-text response");
580 + }
581 + self.history
582 + .lock()
583 + .await
584 + .push(streamed_assistant_history(&text, &[]));
585 + return Ok(TurnResult {
586 + text,
587 + tool_calls: Vec::new(),
588 + });
589 + }
590 +
591 // Some models write a tool call out as literal `<tool_call>` text
592 // instead of using the structured field (see `inline_tool_calls`).
593 // Recover it, or the turn ends with the tag rendered as prose and
@@ -617,6 +637,7 @@ impl OpenAiBackend {
637 response: reqwest::Response,
638 sink: &TokenSink,
639 tools: &[ToolSpec],
640 + allow_tool_calls: bool,
641 ) -> Result<TurnResult, BackendError> {
642 use futures::StreamExt;
643
@@ -691,15 +712,22 @@ impl OpenAiBackend {
712 }
713 }
714 crate::inline_tool_calls::ScanEvent::ToolCall(call) => {
694 - log::warn!(
695 - "recovered tool call '{}' the model emitted as text instead of a structured call",
696 - call.name
697 - );
698 - recovered.push(ToolCall {
699 - id: format!("call_recovered_{}", recovered.len()),
700 - name: call.name,
701 - arguments: call.arguments,
702 - });
715 + if allow_tool_calls {
716 + log::warn!(
717 + "recovered tool call '{}' the model emitted as text instead of a structured call",
718 + call.name
719 + );
720 + recovered.push(ToolCall {
721 + id: format!("call_recovered_{}", recovered.len()),
722 + name: call.name,
723 + arguments: call.arguments,
724 + });
725 + } else {
726 + log::warn!(
727 + "suppressed inline tool call '{}' from a forced-text response",
728 + call.name
729 + );
730 + }
731 }
732 }
733 }
@@ -709,6 +737,10 @@ impl OpenAiBackend {
737 }
738 }
739 for delta in choice.delta.tool_calls.into_iter().flatten() {
740 + if !allow_tool_calls {
741 + log::warn!("suppressed a structured tool call from a forced-text response");
742 + continue;
743 + }
744 let index = delta.index.unwrap_or(0) as usize;
745 if tool_accum.len() <= index {
746 tool_accum.resize_with(index + 1, StreamingToolCall::default);
@@ -813,13 +845,14 @@ impl InferenceBackend for OpenAiBackend {
845 .lock()
846 .await
847 .push(serde_json::json!({ "role": "user", "content": text }));
816 - self.complete(Some(tools), sink).await
848 + self.complete(tools, true, sink).await
849 }
850
851 async fn send_tool_results(
852 &self,
853 results: Vec<ToolResult>,
822 - tools: Option<&[ToolSpec]>,
854 + tools: &[ToolSpec],
855 + allow_tool_calls: bool,
856 sink: Option<&TokenSink>,
857 ) -> Result<TurnResult, BackendError> {
858 {
@@ -832,7 +865,7 @@ impl InferenceBackend for OpenAiBackend {
865 }));
866 }
867 }
835 - self.complete(tools, sink).await
868 + self.complete(tools, allow_tool_calls, sink).await
869 }
870
871 async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>) {
@@ -887,7 +920,7 @@ impl InferenceBackend for OpenAiBackend {
920 }));
921 *self.history.lock().await = request;
922
890 - let summary = match self.complete(None, None).await {
923 + let summary = match self.complete(&[], false, None).await {
924 Ok(result) => result.text,
925 Err(error) => {
926 // Roll back the summarization request; the turn never happened.
src/chat.rs
+3 -7
@@ -3346,12 +3346,8 @@ mod tui {
3346
3347 // on the last round, pass no tools so the model must produce text —
3348 // that's also the round we can stream on-device.
3349 - let next_tools = if round < MAX_TOOL_ROUNDS {
3350 - Some(tools.as_slice())
3351 - } else {
3352 - None
3353 - };
3354 - let sink = if next_tools.is_none() {
3349 + let allow_tool_calls = round < MAX_TOOL_ROUNDS;
3350 + let sink = if !allow_tool_calls {
3351 streamed = true;
3352 Some(&delta_tx)
3353 } else {
@@ -3359,7 +3355,7 @@ mod tui {
3355 };
3356
3357 match backend
3362 - .send_tool_results(tool_results, next_tools, sink)
3358 + .send_tool_results(tool_results, &tools, allow_tool_calls, sink)
3359 .await
3360 {
3361 Ok(r) => result = r,
src/headless.rs
+2 -6
@@ -304,18 +304,14 @@ async fn run_prompt(
304 });
305 }
306
307 - let next_tools = if round < crate::MAX_TOOL_ROUNDS {
308 - Some(tools.as_slice())
309 - } else {
310 - None // last round: force text
311 - };
307 + let allow_tool_calls = round < crate::MAX_TOOL_ROUNDS;
308
309 // Whatever this round says starts a new paragraph rather than
310 // continuing the sentence the tool calls interrupted.
311 reply.interrupt();
312
313 result = drain_to_stdout(
318 - backend.send_tool_results(tool_results, next_tools, sink_opt),
314 + backend.send_tool_results(tool_results, &tools, allow_tool_calls, sink_opt),
315 &mut sink_rx,
316 &mut reply,
317 )
src/inline_tool_calls.rs
+104 -10
@@ -83,16 +83,22 @@ pub fn extract(text: &str, tools: &[ToolSpec]) -> (String, Vec<Recovered>) {
83 fn parse_xml_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
84 let key_idx = inner.find("<arg_key>");
85 let malformed_key_idx = inner.find(XML_OPEN_TAG);
86 - let (name, mut rest, malformed_first_key) = match (key_idx, malformed_key_idx) {
87 - (Some(key_idx), Some(malformed_key_idx)) if malformed_key_idx < key_idx => (
88 - inner[..malformed_key_idx].trim(),
89 - &inner[malformed_key_idx..],
90 - true,
91 - ),
92 - (Some(idx), _) => (inner[..idx].trim(), &inner[idx..], false),
93 - (None, Some(idx)) => (inner[..idx].trim(), &inner[idx..], true),
94 - (None, None) => (inner.trim(), "", false),
95 - };
86 + let check_status_name = key_idx.and_then(|idx| parse_check_status_preamble(&inner[..idx]));
87 + let (name, mut rest, malformed_first_key) =
88 + if let (Some(idx), Some(name)) = (key_idx, check_status_name) {
89 + (name, &inner[idx..], false)
90 + } else {
91 + match (key_idx, malformed_key_idx) {
92 + (Some(key_idx), Some(malformed_key_idx)) if malformed_key_idx < key_idx => (
93 + inner[..malformed_key_idx].trim(),
94 + &inner[malformed_key_idx..],
95 + true,
96 + ),
97 + (Some(idx), _) => (inner[..idx].trim(), &inner[idx..], false),
98 + (None, Some(idx)) => (inner[..idx].trim(), &inner[idx..], true),
99 + (None, None) => (inner.trim(), "", false),
100 + }
101 + };
102 if name.is_empty() || name.contains(['<', '>']) || !offered_tool(tools, name) {
103 return None;
104 }
@@ -132,6 +138,24 @@ fn parse_xml_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
138 })
139 }
140
141 +/// Some GLM-family responses prefix the real arguments with generation-control
142 +/// metadata shaped like `tool_name CheckStatus=value</arg_value>`. It is not a
143 +/// tool argument: accept that exact bounded preamble and discard it, while
144 +/// leaving every other malformed prefix untouched.
145 +fn parse_check_status_preamble(prefix: &str) -> Option<&str> {
146 + let (name, status) = prefix.trim().split_once(" CheckStatus=")?;
147 + let status = status.strip_suffix("</arg_value>")?;
148 + if name.is_empty()
149 + || status.is_empty()
150 + || !status
151 + .chars()
152 + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
153 + {
154 + return None;
155 + }
156 + Some(name)
157 +}
158 +
159 fn parse_k3_tools_block(inner: &str, tools: &[ToolSpec]) -> Option<Vec<Recovered>> {
160 let mut calls = Vec::new();
161 let mut rest = inner;
@@ -565,6 +589,47 @@ mod tests {
589 assert_eq!(args["cwd"], "/tmp");
590 }
591
592 + #[test]
593 + fn recovers_glm_call_with_check_status_before_real_arguments() {
594 + let tools = vec![command_output_spec()];
595 + let (text, calls) = extract(
596 + "polling <tool_call>command_output CheckStatus=true_or_poll_again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>",
597 + &tools,
598 + );
599 +
600 + assert_eq!(text, "polling ");
601 + assert_eq!(calls.len(), 1);
602 + assert_eq!(calls[0].name, "command_output");
603 + let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
604 + assert_eq!(args["task_id"], 1);
605 + assert!(args["task_id"].is_number());
606 + assert!(args.get("CheckStatus").is_none());
607 + }
608 +
609 + #[test]
610 + fn malformed_check_status_preamble_is_left_as_text() {
611 + let text = "<tool_call>command_output CheckStatus=keep going</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>";
612 + let (out, calls) = extract(text, &[command_output_spec()]);
613 + assert_eq!(out, text);
614 + assert!(calls.is_empty());
615 + }
616 +
617 + #[test]
618 + fn check_status_after_a_real_argument_is_left_as_text() {
619 + let text = "<tool_call>command_output<arg_key>task_id</arg_key><arg_value>1</arg_value> CheckStatus=true_or_poll_again_with_different_params</arg_value></tool_call>";
620 + let (out, calls) = extract(text, &[command_output_spec()]);
621 + assert_eq!(out, text);
622 + assert!(calls.is_empty());
623 + }
624 +
625 + #[test]
626 + fn check_status_for_an_unknown_tool_is_left_as_text() {
627 + let text = "<tool_call>not_offered CheckStatus=true_or_poll_again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>";
628 + let (out, calls) = extract(text, &[command_output_spec()]);
629 + assert_eq!(out, text);
630 + assert!(calls.is_empty());
631 + }
632 +
633 #[test]
634 fn unknown_glm_tool_is_left_as_text() {
635 let text = "<tool_call>not_offered<tool_call>command</arg_key><arg_value>pwd</arg_value></tool_call>";
@@ -773,6 +838,35 @@ mod tests {
838 assert_eq!(args["command"], "pwd");
839 }
840
841 + #[test]
842 + fn scanner_recovers_check_status_call_split_across_chunks() {
843 + let tools = vec![command_output_spec()];
844 + let mut scanner = StreamScanner::new(&tools);
845 + let mut text = String::new();
846 + let mut calls = Vec::new();
847 +
848 + for chunk in [
849 + "before <tool_call>command_output CheckStatus=true_or_poll_",
850 + "again_with_different_params</arg_",
851 + "value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_",
852 + "call> after",
853 + ] {
854 + for event in scanner.push(chunk) {
855 + match event {
856 + ScanEvent::Text(value) => text.push_str(&value),
857 + ScanEvent::ToolCall(call) => calls.push(call),
858 + }
859 + }
860 + }
861 + if let Some(rest) = scanner.take_pending() {
862 + text.push_str(&rest);
863 + }
864 +
865 + assert_eq!(text, "before after");
866 + assert_eq!(calls.len(), 1);
867 + assert_eq!(calls[0].name, "command_output");
868 + }
869 +
870 #[test]
871 fn scanner_recovers_kimi_k3_tools_split_across_chunk_boundaries() {
872 let tools = vec![run_command_spec()];
src/main.rs
+2 -6
@@ -2044,11 +2044,7 @@ impl SiGitAgent {
2044 });
2045 }
2046
2047 - let next_tools = if round < MAX_TOOL_ROUNDS && !force_text {
2048 - Some(tools.as_slice())
2049 - } else {
2050 - None // last round: force text
2051 - };
2047 + let allow_tool_calls = round < MAX_TOOL_ROUNDS && !force_text;
2048
2049 // Whatever this round says starts a new paragraph rather than
2050 // continuing the sentence the tool calls interrupted.
@@ -2058,7 +2054,7 @@ impl SiGitAgent {
2054 .drain_turn(
2055 cx,
2056 &session_id,
2061 - backend.send_tool_results(tool_results, next_tools, Some(&sink)),
2057 + backend.send_tool_results(tool_results, &tools, allow_tool_calls, Some(&sink)),
2058 &mut sink_rx,
2059 &mut reply,
2060 )
src/tools.rs
+2 -6
@@ -939,13 +939,9 @@ async fn run_subagent(backend: &dyn InferenceBackend, prompt: &str, allowed: &[S
939 }
940
941 // On the last round, offer no tools so the model must produce text.
942 - let next_tools = if round < SUBAGENT_MAX_TOOL_ROUNDS {
943 - Some(specs.as_slice())
944 - } else {
945 - None
946 - };
942 + let allow_tool_calls = round < SUBAGENT_MAX_TOOL_ROUNDS;
943 result = match backend
948 - .send_tool_results(tool_results, next_tools, None)
944 + .send_tool_results(tool_results, &specs, allow_tool_calls, None)
945 .await
946 {
947 Ok(r) => r,
tests/acp_permissions.rs
+114
@@ -943,6 +943,120 @@ fn a_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
943 let _ = std::fs::remove_dir_all(&scratch);
944 }
945
946 +/// Once the repetition guard removes tools from a request, a model may still
947 +/// emit its learned tool syntax. Known calls must be removed from the visible
948 +/// response without being executed or recorded as orphaned history entries.
949 +#[test]
950 +fn forced_text_suppresses_glm_check_status_tool_markup() {
951 + let repeated_arguments = json!({"task_id": 1}).to_string();
952 + let endpoint = start_fake_endpoint(vec![
953 + sse_tool_call("call_1", "command_output", &repeated_arguments),
954 + sse_tool_call("call_2", "command_output", &repeated_arguments),
955 + sse_tool_call("call_3", "command_output", &repeated_arguments),
956 + sse_body(&[
957 + json!({"choices": [{"delta": {"content": "Build is still running. <tool_call>command_output CheckStatus=true_or_poll_"}}]}),
958 + json!({"choices": [{"delta": {"content": "again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>"}}]}),
959 + // Also enforce the forced-text boundary for a structured call.
960 + json!({"choices": [{"delta": {"tool_calls": [{
961 + "index": 0,
962 + "id": "call_4",
963 + "function": {"name": "command_output", "arguments": "{\"task_id\":999}"},
964 + }]}}]}),
965 + ]),
966 + sse_text("Next turn."),
967 + ]);
968 +
969 + let scratch =
970 + std::env::temp_dir().join(format!("sigit_acp_forced_text_{}", std::process::id()));
971 + let config_dir = scratch.join("config");
972 + let cwd = scratch.join("cwd");
973 + std::fs::create_dir_all(&config_dir).unwrap();
974 + std::fs::create_dir_all(&cwd).unwrap();
975 +
976 + let mut agent = spawn_agent(endpoint.port, &config_dir);
977 +
978 + let id = agent.request(
979 + "initialize",
980 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
981 + );
982 + agent.wait_for_response(id);
983 +
984 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
985 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
986 + .as_str()
987 + .expect("session id")
988 + .to_string();
989 +
990 + let prompt_id = agent.request(
991 + "session/prompt",
992 + json!({
993 + "sessionId": session_id,
994 + "prompt": [{"type": "text", "text": "keep polling"}],
995 + }),
996 + );
997 + let (response, rendered) = agent.wait_for_prompt(prompt_id);
998 + assert_eq!(response["result"]["stopReason"], "end_turn");
999 + assert!(
1000 + rendered.contains("Build is still running."),
1001 + "surrounding prose should remain visible: {rendered:?}"
1002 + );
1003 + assert!(
1004 + !rendered.contains("<tool_call>")
1005 + && !rendered.contains("<arg_key>")
1006 + && !rendered.contains("CheckStatus"),
1007 + "forced tool markup reached the client: {rendered:?}"
1008 + );
1009 +
1010 + {
1011 + let requests = endpoint.requests.lock().unwrap();
1012 + assert_eq!(
1013 + requests.len(),
1014 + 4,
1015 + "a forced-text tool call must not start another inference round"
1016 + );
1017 + assert!(
1018 + requests[3].get("tools").is_none(),
1019 + "the repetition guard must not advertise tools: {:?}",
1020 + requests[3]
1021 + );
1022 + }
1023 +
1024 + // Start another turn so the prior assistant message is replayed and its
1025 + // sanitized history shape can be inspected in the recorded request.
1026 + let next_prompt_id = agent.request(
1027 + "session/prompt",
1028 + json!({
1029 + "sessionId": session_id,
1030 + "prompt": [{"type": "text", "text": "what happened?"}],
1031 + }),
1032 + );
1033 + agent.wait_for_prompt(next_prompt_id);
1034 +
1035 + let requests = endpoint.requests.lock().unwrap();
1036 + let replayed_messages = requests[4]["messages"].as_array().expect("messages");
1037 + let prior_assistant = replayed_messages
1038 + .iter()
1039 + .rev()
1040 + .find(|message| {
1041 + message["role"] == "assistant"
1042 + && message["content"]
1043 + .as_str()
1044 + .is_some_and(|content| content.contains("Build is still running."))
1045 + })
1046 + .expect("sanitized forced-text assistant response");
1047 + assert!(prior_assistant.get("tool_calls").is_none());
1048 + assert!(
1049 + !prior_assistant["content"]
1050 + .as_str()
1051 + .expect("assistant content")
1052 + .contains("<tool_call>")
1053 + );
1054 + drop(requests);
1055 +
1056 + drop(agent);
1057 + let _ = std::fs::remove_dir_all(&scratch);
1058 +}
1059 +
1060 /// Kimi K3 may emit tool calls in XTML content blocks even when the endpoint is
1061 /// OpenAI-compatible. The backend should recover those before ACP sees them,
1062 /// unwrap visible response text, and hide private thinking text.