Hide tool-call markup that can't be parsed, and retry it
Some models write tool calls into the reply as text. Well-formed blocks were already recovered and run, but a block that failed to parse, or never closed, was printed to the editor as-is. It also stayed in history, so the model later read back a call it had "made" with no result, and began writing invented <function_results> of its own. The scanner now reports those blocks as malformed. The backend drops them from the reply and from history. When a reply's only tool call was malformed, the model gets one retry with a short note saying the call didn't run and should go through the tool-calling interface. The well-formed blocks from #97 are covered by new tests and already recovered on development. Fixes #97 Fixes #105
Seto Elkahfi committed
Sep 16, 2026 at 22:44 UTC
b24742b722cbe4207fb35d853631c13ef7cfdf53
4 files changed
+473
-105
.agents/AGENTS.md
+4
@@ -127,6 +127,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
127
endpoint can also fail *after* the response is open, reporting it as a `data:` frame holding
128
the same envelope; that frame has no `choices`, so `consume_stream` has to check for it
129
explicitly or it parses as an empty chunk and the turn ends looking like an empty answer.
130
+ Some models write tool calls into content as text; `src/inline_tool_calls.rs` recovers the
131
+ well-formed ones. A block that doesn't parse (or never closes) is dropped from both the reply
132
+ and history, and `OpenAiBackend::complete` retries once with a note telling the model the call
133
+ didn't run. Leaving the raw block in history makes the model invent `<function_results>` later.
134
- **`src/provider.rs`** — decides *which* backend serves inference. Resolution order, first match
135
wins: (1) override via `OPENAI_BASE_URL`+`OPENAI_API_KEY` or active profile in
136
`~/.config/sigit/providers.toml`; (2) siGit Code Cloud when logged in; (3) on-device.
src/backend.rs
+139
-23
@@ -529,12 +529,52 @@ impl OpenAiBackend {
529
/// `tools` is always the known catalog, while `allow_tool_calls` determines
530
/// whether it is advertised to the model and whether returned calls may run.
531
/// Streams via SSE when `sink` is set; otherwise reads a single JSON response.
532
+ ///
533
+ /// A reply whose only attempt at a tool call was a block that couldn't be
534
+ /// parsed gets one retry: the model is told the call didn't run and asked
535
+ /// to make it again through the structured interface. Without that the
536
+ /// turn ends on whatever prose preceded the broken block, and the call is
537
+ /// silently lost.
538
async fn complete(
539
&self,
540
tools: &[ToolSpec],
541
allow_tool_calls: bool,
542
sink: Option<&TokenSink>,
543
) -> Result<TurnResult, BackendError> {
544
+ let (result, malformed) = self.request(tools, allow_tool_calls, sink).await?;
545
+ if malformed == 0 || !allow_tool_calls || !result.tool_calls.is_empty() {
546
+ return Ok(result);
547
+ }
548
+
549
+ log::warn!(
550
+ "dropped {malformed} unparseable inline tool call(s); asking the model to retry"
551
+ );
552
+ self.history.lock().await.push(serde_json::json!({
553
+ "role": "user",
554
+ "content": MALFORMED_TOOL_CALL_RETRY,
555
+ }));
556
+ if let Some(sink) = sink
557
+ && !result.text.trim().is_empty()
558
+ {
559
+ // The retry's text follows what already streamed; keep it from
560
+ // running on into the previous sentence.
561
+ let _ = sink.send("\n\n".to_string());
562
+ }
563
+ let (retry, _) = self.request(tools, allow_tool_calls, sink).await?;
564
+ Ok(TurnResult {
565
+ text: join_reply_text(&result.text, &retry.text),
566
+ tool_calls: retry.tool_calls,
567
+ })
568
+ }
569
+
570
+ /// One chat-completion round trip. Returns the turn plus how many inline
571
+ /// tool-call blocks were dropped because they couldn't be parsed.
572
+ async fn request(
573
+ &self,
574
+ tools: &[ToolSpec],
575
+ allow_tool_calls: bool,
576
+ sink: Option<&TokenSink>,
577
+ ) -> Result<(TurnResult, usize), BackendError> {
578
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
579
let streaming = sink.is_some();
580
@@ -582,7 +622,7 @@ impl OpenAiBackend {
622
response: reqwest::Response,
623
tools: &[ToolSpec],
624
allow_tool_calls: bool,
585
- ) -> Result<TurnResult, BackendError> {
625
+ ) -> Result<(TurnResult, usize), BackendError> {
626
let parsed: ChatCompletion = response
627
.json()
628
.await
@@ -607,9 +647,12 @@ impl OpenAiBackend {
647
})
648
.collect();
649
650
+ let extracted = crate::inline_tool_calls::extract(&text, tools);
651
+ let malformed = extracted.malformed;
652
+
653
if !allow_tool_calls {
611
- let (cleaned, recovered) = crate::inline_tool_calls::extract(&text, tools);
612
- text = cleaned;
654
+ let recovered = extracted.calls;
655
+ text = extracted.text;
656
let suppressed = tool_calls.len() + recovered.len();
657
if suppressed > 0 {
658
let names = tool_calls
@@ -626,10 +669,13 @@ impl OpenAiBackend {
669
.lock()
670
.await
671
.push(streamed_assistant_history(&text, &[]));
629
- return Ok(TurnResult {
630
- text,
631
- tool_calls: Vec::new(),
632
- });
672
+ return Ok((
673
+ TurnResult {
674
+ text,
675
+ tool_calls: Vec::new(),
676
+ },
677
+ malformed,
678
+ ));
679
}
680
681
// Some models write a tool call out as literal `<tool_call>` text
@@ -637,7 +683,8 @@ impl OpenAiBackend {
683
// Recover it, or the turn ends with the tag rendered as prose and
684
// whatever the model meant to do is dropped.
685
if tool_calls.is_empty() {
640
- let (cleaned, recovered) = crate::inline_tool_calls::extract(&text, tools);
686
+ let cleaned = extracted.text;
687
+ let recovered = extracted.calls;
688
if !recovered.is_empty() {
689
log::warn!(
690
"recovered {} tool call(s) the model emitted as text instead of a structured call",
@@ -660,17 +707,44 @@ impl OpenAiBackend {
707
.lock()
708
.await
709
.push(streamed_assistant_history(&cleaned, &tool_calls));
663
- return Ok(TurnResult {
664
- text: cleaned,
665
- tool_calls,
666
- });
710
+ return Ok((
711
+ TurnResult {
712
+ text: cleaned,
713
+ tool_calls,
714
+ },
715
+ malformed,
716
+ ));
717
+ }
718
+ if malformed > 0 {
719
+ // Keep the broken block out of history as well as the reply;
720
+ // see `inline_tool_calls` for why it poisons later turns.
721
+ self.push_reply_without_calls(&cleaned).await;
722
+ return Ok((
723
+ TurnResult {
724
+ text: cleaned,
725
+ tool_calls,
726
+ },
727
+ malformed,
728
+ ));
729
}
730
}
731
732
// Record the assistant turn so later tool results have context.
733
self.history.lock().await.push(message.into_history_value());
734
673
- Ok(TurnResult { text, tool_calls })
735
+ Ok((TurnResult { text, tool_calls }, 0))
736
+ }
737
+
738
+ /// Record a text-only assistant reply. An empty one is skipped: strict
739
+ /// endpoints reject an assistant message with neither content nor tool
740
+ /// calls, and there is nothing in it worth replaying.
741
+ async fn push_reply_without_calls(&self, text: &str) {
742
+ if !text.is_empty() {
743
+ self.history
744
+ .lock()
745
+ .await
746
+ .push(streamed_assistant_history(text, &[]));
747
+ }
748
}
749
750
/// Consume an OpenAI Server-Sent Events stream, forwarding content deltas to
@@ -682,7 +756,7 @@ impl OpenAiBackend {
756
sink: &TokenSink,
757
tools: &[ToolSpec],
758
allow_tool_calls: bool,
685
- ) -> Result<TurnResult, BackendError> {
759
+ ) -> Result<(TurnResult, usize), BackendError> {
760
use futures::StreamExt;
761
762
let mut stream = response.bytes_stream();
@@ -699,6 +773,7 @@ impl OpenAiBackend {
773
// already been rendered. See `inline_tool_calls`.
774
let mut scanner = crate::inline_tool_calls::StreamScanner::new(tools);
775
let mut recovered: Vec<ToolCall> = Vec::new();
776
+ let mut malformed = 0usize;
777
778
while let Some(item) = stream.next().await {
779
let bytes = item.map_err(|error| format!("stream read error: {error}"))?;
@@ -768,6 +843,10 @@ impl OpenAiBackend {
843
arguments: call.arguments,
844
});
845
}
846
+ crate::inline_tool_calls::ScanEvent::Malformed(block) => {
847
+ log_malformed_block(&block);
848
+ malformed += 1;
849
+ }
850
}
851
}
852
if cancelled {
@@ -800,10 +879,19 @@ impl OpenAiBackend {
879
}
880
}
881
803
- // Text held back waiting on a tag that never closed is just text.
804
- if let Some(leftover) = scanner.take_pending() {
805
- text.push_str(&leftover);
806
- let _ = sink.send(leftover);
882
+ // Flush what was held back. A partial marker is just text; a
883
+ // tool-call block that never closed is dropped like any other
884
+ // unparseable one.
885
+ match scanner.finish() {
886
+ Some(crate::inline_tool_calls::ScanEvent::Text(leftover)) => {
887
+ text.push_str(&leftover);
888
+ let _ = sink.send(leftover);
889
+ }
890
+ Some(crate::inline_tool_calls::ScanEvent::Malformed(block)) => {
891
+ log_malformed_block(&block);
892
+ malformed += 1;
893
+ }
894
+ Some(crate::inline_tool_calls::ScanEvent::ToolCall(_)) | None => {}
895
}
896
897
let mut tool_calls: Vec<ToolCall> = tool_accum
@@ -836,12 +924,40 @@ impl OpenAiBackend {
924
}
925
926
// Record the assistant turn so later tool results have context.
839
- self.history
840
- .lock()
841
- .await
842
- .push(streamed_assistant_history(&text, &tool_calls));
927
+ if malformed > 0 && tool_calls.is_empty() {
928
+ self.push_reply_without_calls(&text).await;
929
+ } else {
930
+ self.history
931
+ .lock()
932
+ .await
933
+ .push(streamed_assistant_history(&text, &tool_calls));
934
+ }
935
+
936
+ Ok((TurnResult { text, tool_calls }, malformed))
937
+ }
938
+}
939
+
940
+/// Sent as a user turn after a reply whose tool call couldn't be parsed.
941
+const MALFORMED_TOOL_CALL_RETRY: &str = "[siGit Code] Your last reply tried to call a tool by \
942
+ writing the call out as text, and it could not be parsed, so nothing ran and the user did \
943
+ not see it. Do not write tool calls or tool results as text. Make the call again using the \
944
+ tool-calling interface, or answer in plain prose if no tool is needed.";
945
+
946
+fn log_malformed_block(block: &str) {
947
+ log::warn!(
948
+ "dropped an unparseable inline tool call ({} chars): {}",
949
+ block.len(),
950
+ block.chars().take(200).collect::<String>()
951
+ );
952
+}
953
844
- Ok(TurnResult { text, tool_calls })
954
+/// Join the visible text of a reply and its retry the way the stream showed
955
+/// them: as separate paragraphs.
956
+fn join_reply_text(first: &str, retry: &str) -> String {
957
+ match (first.trim().is_empty(), retry.trim().is_empty()) {
958
+ (true, _) => retry.to_string(),
959
+ (false, true) => first.to_string(),
960
+ (false, false) => format!("{first}\n\n{retry}"),
961
}
962
}
963
src/inline_tool_calls.rs
+240
-82
@@ -18,11 +18,15 @@
18
//! OpenAI-compatible base URL from `providers.toml` or `OPENAI_BASE_URL`, and
19
//! on-device models. A fix in any single upstream leaves the others exposed.
20
//!
21
-//! Only well-formed shapes are recovered. A block that doesn't match is left
22
-//! in the text untouched, deliberately. Reissuing a `run_command` is cheap,
23
-//! but guessing wrong at a half-parsed `edit_file` would write the wrong
24
-//! change to a file, so a malformed block stays visible rather than being
25
-//! silently misinterpreted.
21
+//! Only well-formed shapes are recovered. A block that doesn't match is never
22
+//! guessed at: reissuing a `run_command` is cheap, but guessing wrong at a
23
+//! half-parsed `edit_file` would write the wrong change to a file. It isn't
24
+//! shown either. A tool-call block that fails to parse, or that is still open
25
+//! when the reply ends, is reported as [`ScanEvent::Malformed`] and kept out of
26
+//! both the UI and the conversation history. Left in the history, the model
27
+//! reads back a call it "made" with no result attached, and later turns start
28
+//! inventing `<function_results>` blocks of their own. The backend tells the
29
+//! model the call didn't run and asks for it again instead.
30
31
use std::collections::HashMap;
32
@@ -53,26 +57,36 @@ pub struct Recovered {
57
pub arguments: String,
58
}
59
56
-/// Scan a complete text blob for inline tool-call blocks. Returns the text
57
-/// with recovered blocks removed, plus the calls recovered from them. Blocks
58
-/// that don't parse cleanly, and unterminated trailing tags, are left in the
59
-/// returned text untouched.
60
-pub fn extract(text: &str, tools: &[ToolSpec]) -> (String, Vec<Recovered>) {
60
+/// What [`extract`] found in a complete text blob.
61
+#[derive(Debug, Default, PartialEq, Eq)]
62
+pub struct Extracted {
63
+ /// The text with every tool-call block removed, recovered or not.
64
+ pub text: String,
65
+ /// Calls recovered from well-formed blocks, in order.
66
+ pub calls: Vec<Recovered>,
67
+ /// Tool-call blocks that were dropped because they couldn't be parsed.
68
+ pub malformed: usize,
69
+}
70
+
71
+/// Scan a complete text blob for inline tool-call blocks. Recovered blocks
72
+/// become calls; blocks that don't parse cleanly, including an unterminated
73
+/// trailing one, are dropped and counted in [`Extracted::malformed`].
74
+pub fn extract(text: &str, tools: &[ToolSpec]) -> Extracted {
75
let mut scanner = StreamScanner::new(tools);
62
- let mut out = String::with_capacity(text.len());
63
- let mut calls = Vec::new();
76
+ let mut extracted = Extracted {
77
+ text: String::with_capacity(text.len()),
78
+ ..Extracted::default()
79
+ };
80
65
- for event in scanner.push(text) {
81
+ for event in scanner.push(text).into_iter().chain(scanner.finish()) {
82
match event {
67
- ScanEvent::Text(text) => out.push_str(&text),
68
- ScanEvent::ToolCall(call) => calls.push(call),
83
+ ScanEvent::Text(text) => extracted.text.push_str(&text),
84
+ ScanEvent::ToolCall(call) => extracted.calls.push(call),
85
+ ScanEvent::Malformed(_) => extracted.malformed += 1,
86
}
87
}
71
- if let Some(rest) = scanner.take_pending() {
72
- out.push_str(&rest);
73
- }
88
75
- (out, calls)
89
+ extracted
90
}
91
92
/// Parse one legacy XML block's inner text: an offered tool name, then zero or
@@ -314,6 +328,9 @@ pub enum ScanEvent {
328
Text(String),
329
/// A tool call recovered from a completed block.
330
ToolCall(Recovered),
331
+ /// A tool-call block that couldn't be parsed, or never closed. Carries the
332
+ /// raw block for logging; it must not be shown or executed.
333
+ Malformed(String),
334
}
335
336
/// Incremental scanner for a live delta stream.
@@ -342,15 +359,23 @@ impl<'a> StreamScanner<'a> {
359
self.drain()
360
}
361
345
- /// Take whatever is still held back, e.g. a `<tool_call` or Kimi marker
346
- /// prefix that never closed. Text we never saw the end of can't be
347
- /// interpreted safely, so the honest thing is to hand it back as-is.
348
- pub fn take_pending(&mut self) -> Option<String> {
362
+ /// Call once the stream has ended to flush whatever is still held back.
363
+ /// A tool-call block that opened but never closed is [`ScanEvent::Malformed`]:
364
+ /// it can't be executed safely, and it isn't prose either. Anything else,
365
+ /// such as a partial marker prefix or an unclosed Kimi response wrapper, is
366
+ /// handed back as text.
367
+ pub fn finish(&mut self) -> Option<ScanEvent> {
368
if self.pending.is_empty() {
350
- None
351
- } else {
352
- Some(std::mem::take(&mut self.pending))
369
+ return None;
370
}
371
+ let rest = std::mem::take(&mut self.pending);
372
+ Some(
373
+ if rest.starts_with(XML_OPEN_TAG) || rest.starts_with(K3_TOOLS_OPEN) {
374
+ ScanEvent::Malformed(rest)
375
+ } else {
376
+ ScanEvent::Text(rest)
377
+ },
378
+ )
379
}
380
381
fn drain(&mut self) -> Vec<ScanEvent> {
@@ -416,7 +441,7 @@ impl<'a> StreamScanner<'a> {
441
let inner = &block[XML_OPEN_TAG.len()..block.len() - XML_CLOSE_TAG.len()];
442
Some(match parse_xml_block(inner, self.tools) {
443
Some(call) => ScanEvent::ToolCall(call),
419
- None => ScanEvent::Text(block),
444
+ None => ScanEvent::Malformed(block),
445
})
446
}
447
@@ -431,7 +456,7 @@ impl<'a> StreamScanner<'a> {
456
457
Some(match parse_k3_tools_block(inner, self.tools) {
458
Some(calls) => calls.into_iter().map(ScanEvent::ToolCall).collect(),
434
- None => vec![ScanEvent::Text(block)],
459
+ None => vec![ScanEvent::Malformed(block)],
460
})
461
}
462
@@ -473,9 +498,7 @@ impl<'a> StreamScanner<'a> {
498
fn scan_complete_text(text: &str, tools: &[ToolSpec]) -> Vec<ScanEvent> {
499
let mut scanner = StreamScanner::new(tools);
500
let mut events = scanner.push(text);
476
- if let Some(rest) = scanner.take_pending() {
477
- events.push(ScanEvent::Text(rest));
478
- }
501
+ events.extend(scanner.finish());
502
events
503
}
504
@@ -547,7 +570,7 @@ mod tests {
570
#[test]
571
fn an_integer_argument_is_recovered_as_a_json_number() {
572
let tools = vec![command_output_spec()];
550
- let (text, calls) = extract(
573
+ let Extracted { text, calls, .. } = extract(
574
"Checking.<tool_call>command_output<arg_key>task_id</arg_key><arg_value>2</arg_value></tool_call>",
575
&tools,
576
);
@@ -562,7 +585,7 @@ mod tests {
585
#[test]
586
fn recovers_multiple_string_arguments_in_order() {
587
let tools = vec![run_command_spec()];
565
- let (text, calls) = extract(
588
+ let Extracted { text, calls, .. } = extract(
589
"<tool_call>run_command<arg_key>command</arg_key><arg_value>cargo test --locked 2>&1 | tail -20</arg_value><arg_key>cwd</arg_key><arg_value>/Users/x/sigit</arg_value><arg_key>run_in_background</arg_key><arg_value>true</arg_value></tool_call>",
590
&tools,
591
);
@@ -576,7 +599,7 @@ mod tests {
599
#[test]
600
fn recovers_glm_call_with_a_malformed_first_argument_marker() {
601
let tools = vec![run_command_spec()];
579
- let (text, calls) = extract(
602
+ let Extracted { text, calls, .. } = extract(
603
"<tool_call>run_command<tool_call>command</arg_key><arg_value>pwd</arg_value><arg_key>cwd</arg_key><arg_value>/tmp</arg_value></tool_call>",
604
&tools,
605
);
@@ -592,7 +615,7 @@ mod tests {
615
#[test]
616
fn recovers_glm_call_with_check_status_before_real_arguments() {
617
let tools = vec![command_output_spec()];
595
- let (text, calls) = extract(
618
+ let Extracted { text, calls, .. } = extract(
619
"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>",
620
&tools,
621
);
@@ -607,64 +630,101 @@ mod tests {
630
}
631
632
#[test]
610
- fn malformed_check_status_preamble_is_left_as_text() {
633
+ fn malformed_check_status_preamble_is_dropped() {
634
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);
635
+ let Extracted {
636
+ text: out,
637
+ calls,
638
+ malformed,
639
+ } = extract(text, &[command_output_spec()]);
640
+ assert_eq!(out, "");
641
+ assert_eq!(malformed, 1);
642
assert!(calls.is_empty());
643
}
644
645
#[test]
618
- fn check_status_after_a_real_argument_is_left_as_text() {
646
+ fn check_status_after_a_real_argument_is_dropped() {
647
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);
648
+ let Extracted {
649
+ text: out,
650
+ calls,
651
+ malformed,
652
+ } = extract(text, &[command_output_spec()]);
653
+ assert_eq!(out, "");
654
+ assert_eq!(malformed, 1);
655
assert!(calls.is_empty());
656
}
657
658
#[test]
626
- fn check_status_for_an_unknown_tool_is_left_as_text() {
659
+ fn check_status_for_an_unknown_tool_is_dropped() {
660
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);
661
+ let Extracted {
662
+ text: out,
663
+ calls,
664
+ malformed,
665
+ } = extract(text, &[command_output_spec()]);
666
+ assert_eq!(out, "");
667
+ assert_eq!(malformed, 1);
668
assert!(calls.is_empty());
669
}
670
671
#[test]
634
- fn unknown_glm_tool_is_left_as_text() {
672
+ fn unknown_glm_tool_is_dropped() {
673
let text = "<tool_call>not_offered<tool_call>command</arg_key><arg_value>pwd</arg_value></tool_call>";
636
- let (out, calls) = extract(text, &[run_command_spec()]);
637
- assert_eq!(out, text);
674
+ let Extracted {
675
+ text: out,
676
+ calls,
677
+ malformed,
678
+ } = extract(text, &[run_command_spec()]);
679
+ assert_eq!(out, "");
680
+ assert_eq!(malformed, 1);
681
assert!(calls.is_empty());
682
}
683
684
#[test]
685
fn malformed_glm_marker_is_accepted_only_for_the_first_argument() {
686
let text = "<tool_call>run_command<arg_key>command</arg_key><arg_value>pwd</arg_value><tool_call>cwd</arg_key><arg_value>/tmp</arg_value></tool_call>";
644
- let (out, calls) = extract(text, &[run_command_spec()]);
645
- assert_eq!(out, text);
687
+ let Extracted {
688
+ text: out,
689
+ calls,
690
+ malformed,
691
+ } = extract(text, &[run_command_spec()]);
692
+ assert_eq!(out, "");
693
+ assert_eq!(malformed, 1);
694
assert!(calls.is_empty());
695
}
696
697
#[test]
650
- fn a_malformed_block_is_left_alone_rather_than_guessed_at() {
698
+ fn a_malformed_block_is_dropped_rather_than_guessed_at() {
699
let text = "<tool_call>edit_file<arg_value>new_text</arg_key><arg_value>def x; end</arg_value></tool_call>";
652
- let (out, calls) = extract(text, &[run_command_spec()]);
653
- assert_eq!(out, text);
700
+ let Extracted {
701
+ text: out,
702
+ calls,
703
+ malformed,
704
+ } = extract(text, &[run_command_spec()]);
705
+ assert_eq!(out, "");
706
+ assert_eq!(malformed, 1);
707
assert!(calls.is_empty());
708
}
709
710
#[test]
658
- fn an_unterminated_tag_is_left_alone() {
711
+ fn an_unterminated_tag_is_dropped() {
712
let text = "working on it <tool_call>run_command<arg_key>command</arg_key>";
660
- let (out, calls) = extract(text, &[run_command_spec()]);
661
- assert_eq!(out, text);
713
+ let Extracted {
714
+ text: out,
715
+ calls,
716
+ malformed,
717
+ } = extract(text, &[run_command_spec()]);
718
+ assert_eq!(out, "working on it ");
719
+ assert_eq!(malformed, 1);
720
assert!(calls.is_empty());
721
}
722
723
#[test]
724
fn ordinary_text_passes_through_untouched() {
667
- let (out, calls) = extract("if x < y then a<b, nothing to recover", &[]);
725
+ let Extracted {
726
+ text: out, calls, ..
727
+ } = extract("if x < y then a<b, nothing to recover", &[]);
728
assert_eq!(out, "if x < y then a<b, nothing to recover");
729
assert!(calls.is_empty());
730
}
@@ -672,7 +732,7 @@ mod tests {
732
#[test]
733
fn kimi_k3_run_command_block_is_recovered_and_removed() {
734
let tools = vec![run_command_spec()];
675
- let (text, calls) = extract(
735
+ let Extracted { text, calls, .. } = extract(
736
"Working <|open|>tools<|sep|><|open|>call tool=\"run_command\" index=\"1\"<|sep|><|open|>argument key=\"command\" type=\"string\"<|sep|>sleep 180; echo waited<|close|>argument<|sep|><|open|>argument key=\"cwd\" type=\"string\"<|sep|>/Users/setoelkahfi/Repositories/onde-ed<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
737
&tools,
738
);
@@ -687,7 +747,7 @@ mod tests {
747
#[test]
748
fn kimi_k3_non_string_arguments_are_json_decoded() {
749
let tools = vec![command_output_spec()];
690
- let (_, calls) = extract(
750
+ let Extracted { calls, .. } = extract(
751
"<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
752
&tools,
753
);
@@ -699,7 +759,7 @@ mod tests {
759
#[test]
760
fn kimi_k3_missing_argument_type_uses_the_tool_schema() {
761
let tools = vec![command_output_spec()];
702
- let (_, calls) = extract(
762
+ let Extracted { calls, .. } = extract(
763
"<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
764
&tools,
765
);
@@ -711,7 +771,7 @@ mod tests {
771
#[test]
772
fn kimi_k3_multiple_calls_are_recovered_in_order() {
773
let tools = vec![command_output_spec()];
714
- let (_, calls) = extract(
774
+ let Extracted { calls, .. } = extract(
775
"<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>1<|close|>argument<|sep|><|close|>call<|sep|><|open|>call tool=\"command_output\" index=\"2\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
776
&tools,
777
);
@@ -724,7 +784,7 @@ mod tests {
784
785
#[test]
786
fn kimi_k3_response_is_unwrapped_and_think_is_hidden() {
727
- let (text, calls) = extract(
787
+ let Extracted { text, calls, .. } = extract(
788
"a<|open|>think<|sep|>private<|close|>think<|sep|>b<|open|>response<|sep|>visible<|close|>response<|sep|>c",
789
&[],
790
);
@@ -735,7 +795,7 @@ mod tests {
795
#[test]
796
fn kimi_k3_response_content_is_scanned_for_nested_tool_calls() {
797
let tools = vec![command_output_spec()];
738
- let (text, calls) = extract(
798
+ let Extracted { text, calls, .. } = extract(
799
"<|open|>response<|sep|>before <|open|>think<|sep|>private<|close|>think<|sep|><|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|> after<|close|>response<|sep|>",
800
&tools,
801
);
@@ -753,26 +813,41 @@ mod tests {
813
}
814
815
#[test]
756
- fn malformed_kimi_k3_tools_block_is_left_alone() {
816
+ fn unterminated_kimi_k3_tools_block_is_dropped() {
817
let text = "<|open|>tools<|sep|><|open|>call tool=\"run_command\" index=\"1\"<|sep|><|open|>argument key=\"command\" type=\"string\"<|sep|>echo hi<|close|>argument<|sep|>";
758
- let (out, calls) = extract(text, &[run_command_spec()]);
759
- assert_eq!(out, text);
818
+ let Extracted {
819
+ text: out,
820
+ calls,
821
+ malformed,
822
+ } = extract(text, &[run_command_spec()]);
823
+ assert_eq!(out, "");
824
+ assert_eq!(malformed, 1);
825
assert!(calls.is_empty());
826
}
827
828
#[test]
764
- fn unknown_kimi_k3_tool_block_is_left_alone() {
829
+ fn unknown_kimi_k3_tool_block_is_dropped() {
830
let text = "<|open|>tools<|sep|><|open|>call tool=\"delete_everything\" index=\"1\"<|sep|><|close|>call<|sep|><|close|>tools<|sep|>";
766
- let (out, calls) = extract(text, &[run_command_spec()]);
767
- assert_eq!(out, text);
831
+ let Extracted {
832
+ text: out,
833
+ calls,
834
+ malformed,
835
+ } = extract(text, &[run_command_spec()]);
836
+ assert_eq!(out, "");
837
+ assert_eq!(malformed, 1);
838
assert!(calls.is_empty());
839
}
840
841
#[test]
772
- fn invalid_kimi_k3_non_string_argument_is_left_alone() {
842
+ fn invalid_kimi_k3_non_string_argument_is_dropped() {
843
let text = "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>not-json<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>";
774
- let (out, calls) = extract(text, &[command_output_spec()]);
775
- assert_eq!(out, text);
844
+ let Extracted {
845
+ text: out,
846
+ calls,
847
+ malformed,
848
+ } = extract(text, &[command_output_spec()]);
849
+ assert_eq!(out, "");
850
+ assert_eq!(malformed, 1);
851
assert!(calls.is_empty());
852
}
853
@@ -795,10 +870,11 @@ mod tests {
870
match event {
871
ScanEvent::Text(t) => text.push_str(&t),
872
ScanEvent::ToolCall(c) => calls.push(c),
873
+ ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
874
}
875
}
876
}
801
- if let Some(rest) = scanner.take_pending() {
877
+ if let Some(ScanEvent::Text(rest)) = scanner.finish() {
878
text.push_str(&rest);
879
}
880
@@ -824,10 +900,11 @@ mod tests {
900
match event {
901
ScanEvent::Text(value) => text.push_str(&value),
902
ScanEvent::ToolCall(call) => calls.push(call),
903
+ ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
904
}
905
}
906
}
830
- if let Some(rest) = scanner.take_pending() {
907
+ if let Some(ScanEvent::Text(rest)) = scanner.finish() {
908
text.push_str(&rest);
909
}
910
@@ -855,10 +932,11 @@ mod tests {
932
match event {
933
ScanEvent::Text(value) => text.push_str(&value),
934
ScanEvent::ToolCall(call) => calls.push(call),
935
+ ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
936
}
937
}
938
}
861
- if let Some(rest) = scanner.take_pending() {
939
+ if let Some(ScanEvent::Text(rest)) = scanner.finish() {
940
text.push_str(&rest);
941
}
942
@@ -886,10 +964,11 @@ mod tests {
964
match event {
965
ScanEvent::Text(t) => text.push_str(&t),
966
ScanEvent::ToolCall(c) => calls.push(c),
967
+ ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
968
}
969
}
970
}
892
- if let Some(rest) = scanner.take_pending() {
971
+ if let Some(ScanEvent::Text(rest)) = scanner.finish() {
972
text.push_str(&rest);
973
}
974
@@ -913,10 +992,11 @@ mod tests {
992
match event {
993
ScanEvent::Text(chunk) => text.push_str(&chunk),
994
ScanEvent::ToolCall(call) => calls.push(call),
995
+ ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
996
}
997
}
998
}
919
- if let Some(rest) = scanner.take_pending() {
999
+ if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1000
text.push_str(&rest);
1001
}
1002
@@ -928,7 +1008,7 @@ mod tests {
1008
#[test]
1009
fn scanner_recovers_tools_after_an_unclosed_k3_think_wrapper() {
1010
let tools = vec![command_output_spec()];
931
- let (text, calls) = extract(
1011
+ let Extracted { text, calls, .. } = extract(
1012
"<|open|>think<|sep|>broken <|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
1013
&tools,
1014
);
@@ -945,7 +1025,7 @@ mod tests {
1025
events,
1026
vec![ScanEvent::Text("a normal streamed answer".to_string())]
1027
);
948
- assert!(scanner.take_pending().is_none());
1028
+ assert!(scanner.finish().is_none());
1029
}
1030
1031
#[test]
@@ -959,18 +1039,96 @@ mod tests {
1039
scanner.push("box for details"),
1040
vec![ScanEvent::Text("<toolbox for details".to_string())]
1041
);
962
- assert!(scanner.take_pending().is_none());
1042
+ assert!(scanner.finish().is_none());
1043
}
1044
1045
#[test]
966
- fn scanner_hands_back_an_unclosed_tag_at_stream_end() {
1046
+ fn scanner_reports_an_unclosed_tag_at_stream_end_as_malformed() {
1047
let tools = vec![run_command_spec()];
1048
let mut scanner = StreamScanner::new(&tools);
1049
let events = scanner.push("partial <tool_call>run_command<arg_key>cmd");
1050
assert_eq!(events, vec![ScanEvent::Text("partial ".to_string())]);
1051
assert_eq!(
972
- scanner.take_pending().as_deref(),
973
- Some("<tool_call>run_command<arg_key>cmd")
1052
+ scanner.finish(),
1053
+ Some(ScanEvent::Malformed(
1054
+ "<tool_call>run_command<arg_key>cmd".to_string()
1055
+ ))
1056
+ );
1057
+ }
1058
+
1059
+ #[test]
1060
+ fn scanner_hands_back_a_partial_marker_prefix_at_stream_end_as_text() {
1061
+ let mut scanner = StreamScanner::new(&[]);
1062
+ assert_eq!(
1063
+ scanner.push("see <tool_"),
1064
+ vec![ScanEvent::Text("see ".to_string())]
1065
+ );
1066
+ assert_eq!(
1067
+ scanner.finish(),
1068
+ Some(ScanEvent::Text("<tool_".to_string()))
1069
);
1070
}
1071
+
1072
+ /// Issue #105: a GLM-family reply that opened a call, then wrote an
1073
+ /// invented invoke/result transcript instead of closing it. None of that
1074
+ /// may reach the editor, and it must not count as a recovered call.
1075
+ #[test]
1076
+ fn a_hallucinated_invoke_transcript_is_dropped() {
1077
+ let tools = vec![command_output_spec()];
1078
+ let reply = "Checking.<tool_call>command_output\n\n<invokeID>reply_A</invokeID>\n\
1079
+ <parameter_name>task_id</parameter_name>\n<parameter>6</parameter>\n\
1080
+ </invoke>\n\n<function_results>PyPI and NuGet are queued.\n\
1081
+ <tool_call>command_output<arg_key>task_id</arg_key><arg_value>5</task_id>\n\
1082
+ </invoke>\n</function_results>";
1083
+ let Extracted {
1084
+ text,
1085
+ calls,
1086
+ malformed,
1087
+ } = extract(reply, &tools);
1088
+ assert_eq!(text, "Checking.");
1089
+ assert!(calls.is_empty());
1090
+ assert_eq!(malformed, 1);
1091
+ }
1092
+
1093
+ /// Issue #97: the well-formed blocks from that thread are recovered as
1094
+ /// real calls, multi-line argument values included.
1095
+ #[test]
1096
+ fn well_formed_blocks_from_issue_97_are_recovered() {
1097
+ let tools = vec![
1098
+ run_command_spec(),
1099
+ spec(
1100
+ "edit_file",
1101
+ serde_json::json!({
1102
+ "type": "object",
1103
+ "properties": {
1104
+ "path": { "type": "string" },
1105
+ "old_text": { "type": "string" },
1106
+ "new_text": { "type": "string" }
1107
+ }
1108
+ }),
1109
+ ),
1110
+ ];
1111
+ let reply = "Now wire `body_path` into the step:<tool_call>edit_file<arg_key>new_text</arg_key><arg_value> - name: Release\n with:\n body_path: release_notes.md</arg_value><arg_key>old_text</arg_key><arg_value> - name: Release\n with:</arg_value><arg_key>path</arg_key><arg_value>/repo/.github/workflows/release-github.yml</arg_value></tool_call>";
1112
+ let Extracted {
1113
+ text,
1114
+ calls,
1115
+ malformed,
1116
+ } = extract(reply, &tools);
1117
+ assert_eq!(text, "Now wire `body_path` into the step:");
1118
+ assert_eq!(malformed, 0);
1119
+ assert_eq!(calls.len(), 1);
1120
+ let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1121
+ assert_eq!(
1122
+ args["new_text"],
1123
+ " - name: Release\n with:\n body_path: release_notes.md"
1124
+ );
1125
+ assert_eq!(args["path"], "/repo/.github/workflows/release-github.yml");
1126
+
1127
+ let Extracted { calls, .. } = extract(
1128
+ "<tool_call>run_command<arg_key>command</arg_key><arg_value>sleep 90</arg_value><arg_key>cwd</arg_key><arg_value>/repo</arg_value></tool_call>",
1129
+ &tools,
1130
+ );
1131
+ assert_eq!(calls.len(), 1);
1132
+ assert_eq!(calls[0].name, "run_command");
1133
+ }
1134
}
tests/acp_permissions.rs
+90
@@ -943,6 +943,96 @@ fn a_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
943
let _ = std::fs::remove_dir_all(&scratch);
944
}
945
946
+/// A tool-call block that can't be parsed must not reach the editor, and must
947
+/// not stay in history where the model reads it back as a call it made and
948
+/// starts inventing results (getsigit/sigit#97, #105). The model gets one
949
+/// retry with a note that the call didn't run.
950
+#[test]
951
+fn an_unparseable_tool_call_is_hidden_and_retried() {
952
+ let endpoint = start_fake_endpoint(vec![
953
+ sse_body(&[
954
+ json!({"choices": [{"delta": {"content": "Checking. <tool_call>command_output\n\n<invokeID>reply_A"}}]}),
955
+ json!({"choices": [{"delta": {"content": "</invokeID>\n<parameter>6</parameter>\n</invoke>\n<function_results>all green"}}]}),
956
+ ]),
957
+ sse_tool_call(
958
+ "call_1",
959
+ "command_output",
960
+ &json!({"task_id": 6}).to_string(),
961
+ ),
962
+ sse_text("All done."),
963
+ ]);
964
+
965
+ let scratch = std::env::temp_dir().join(format!("sigit_acp_malformed_{}", std::process::id()));
966
+ let config_dir = scratch.join("config");
967
+ let cwd = scratch.join("cwd");
968
+ std::fs::create_dir_all(&config_dir).unwrap();
969
+ std::fs::create_dir_all(&cwd).unwrap();
970
+
971
+ let mut agent = spawn_agent(endpoint.port, &config_dir);
972
+
973
+ let id = agent.request(
974
+ "initialize",
975
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
976
+ );
977
+ agent.wait_for_response(id);
978
+
979
+ let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
980
+ let session_id = agent.wait_for_response(id)["result"]["sessionId"]
981
+ .as_str()
982
+ .expect("session id")
983
+ .to_string();
984
+
985
+ let prompt_id = agent.request(
986
+ "session/prompt",
987
+ json!({
988
+ "sessionId": session_id,
989
+ "prompt": [{"type": "text", "text": "is the build done?"}],
990
+ }),
991
+ );
992
+ let (response, rendered) = agent.wait_for_prompt(prompt_id);
993
+ assert_eq!(response["result"]["stopReason"], "end_turn");
994
+ for markup in [
995
+ "<tool_call>",
996
+ "<invokeID>",
997
+ "<function_results>",
998
+ "all green",
999
+ ] {
1000
+ assert!(
1001
+ !rendered.contains(markup),
1002
+ "{markup} reached the client: {rendered:?}"
1003
+ );
1004
+ }
1005
+ assert!(rendered.contains("Checking."), "{rendered:?}");
1006
+ assert!(rendered.contains("All done."), "{rendered:?}");
1007
+
1008
+ let requests = endpoint.requests.lock().unwrap();
1009
+ assert_eq!(
1010
+ requests.len(),
1011
+ 3,
1012
+ "expected the retry, then the round answering the retried call"
1013
+ );
1014
+ let retry_messages = requests[1]["messages"].as_array().expect("messages");
1015
+ let last = retry_messages.last().expect("retry note");
1016
+ assert_eq!(last["role"], "user");
1017
+ assert!(
1018
+ last["content"]
1019
+ .as_str()
1020
+ .is_some_and(|content| content.starts_with("[siGit Code]")),
1021
+ "{last:?}"
1022
+ );
1023
+ for message in requests[2]["messages"].as_array().expect("messages") {
1024
+ let content = message["content"].as_str().unwrap_or_default();
1025
+ assert!(
1026
+ !content.contains("<tool_call>") && !content.contains("<invokeID>"),
1027
+ "broken markup was kept in history: {message:?}"
1028
+ );
1029
+ }
1030
+ drop(requests);
1031
+
1032
+ drop(agent);
1033
+ let _ = std::fs::remove_dir_all(&scratch);
1034
+}
1035
+
1036
/// Once the repetition guard removes tools from a request, a model may still
1037
/// emit its learned tool syntax. Known calls must be removed from the visible
1038
/// response without being executed or recorded as orphaned history entries.