1
+//! Recovery for tool calls a model emits as literal text instead of the
2
+//! endpoint's structured `tool_calls` field.
3
+//!
4
+//! Several open-weight families siGit Code can drive — Qwen 3, GLM, DeepSeek — are
5
+//! fine-tuned on a chat template that renders a tool call as
6
+//! `<tool_call>NAME<arg_key>K</arg_key><arg_value>V</arg_value>...</tool_call>`.
7
+//! A serving stack is supposed to parse that back into the OpenAI-shaped
8
+//! `tool_calls` field before it reaches us. When it doesn't — seen against the
9
+//! hosted GLM tiers once the model has just been told a call was rejected as a
10
+//! repeat (getsigit/sigit#73) — the raw tag arrives as ordinary content.
11
+//!
12
+//! Nothing then executes it. Worse, `consume_stream` forwards content to the
13
+//! UI as it arrives, so the tag is rendered verbatim in the editor and the
14
+//! turn ends with no tool calls, which reads to the agent loop as "the model
15
+//! chose to answer in prose". Whatever it was actually trying to do is
16
+//! dropped.
17
+//!
18
+//! This lives here rather than only in the gateway because siGit Code talks to
19
+//! more than one kind of endpoint: siGit Code Cloud, an arbitrary
20
+//! OpenAI-compatible base URL from `providers.toml` or `OPENAI_BASE_URL`, and
21
+//! on-device models. A fix in any single upstream leaves the others exposed.
22
+//!
23
+//! Only the well-formed shape is recovered: a flat sequence of key/value pairs
24
+//! with no nesting. A block that doesn't match is left in the text untouched,
25
+//! deliberately. Reissuing a `run_command` is cheap, but guessing wrong at a
26
+//! half-parsed `edit_file` would write the wrong change to a file, so a
27
+//! malformed block stays visible rather than being silently misinterpreted.
28
+
29
+use crate::backend::ToolSpec;
30
+
31
+const OPEN_TAG: &str = "<tool_call>";
32
+const CLOSE_TAG: &str = "</tool_call>";
33
+
34
+/// One tool call recovered from inline text.
35
+#[derive(Debug, Clone, PartialEq, Eq)]
36
+pub struct Recovered {
37
+ pub name: String,
38
+ /// Arguments as a JSON-encoded string, matching `ToolCall::arguments`.
39
+ pub arguments: String,
40
+}
41
+
42
+/// Scan a complete text blob for inline tool-call blocks. Returns the text
43
+/// with recovered blocks removed, plus the calls recovered from them. Blocks
44
+/// that don't parse cleanly, and an unterminated trailing tag, are left in the
45
+/// returned text untouched.
46
+///
47
+/// `tools` are the specs offered for this turn, used to type each argument
48
+/// value: a field the schema declares as an integer has to arrive as a JSON
49
+/// number or the tool's own argument parsing rejects it (`command_output`
50
+/// reads `task_id` with `as_u64`, which refuses the string `"2"`). Anything
51
+/// the schema doesn't cover stays a string.
52
+pub fn extract(text: &str, tools: &[ToolSpec]) -> (String, Vec<Recovered>) {
53
+ let mut out = String::with_capacity(text.len());
54
+ let mut calls = Vec::new();
55
+ let mut cursor = text;
56
+
57
+ while let Some(open_idx) = cursor.find(OPEN_TAG) {
58
+ let Some(close_rel) = cursor[open_idx..].find(CLOSE_TAG) else {
59
+ // Unterminated tag: leave the rest alone rather than guess at a
60
+ // block we never saw the end of.
61
+ break;
62
+ };
63
+ let close_idx = open_idx + close_rel;
64
+ let inner = &cursor[open_idx + OPEN_TAG.len()..close_idx];
65
+
66
+ match parse_block(inner, tools) {
67
+ Some(call) => {
68
+ out.push_str(&cursor[..open_idx]);
69
+ calls.push(call);
70
+ }
71
+ // Didn't parse cleanly: keep the whole tag as visible text.
72
+ None => out.push_str(&cursor[..close_idx + CLOSE_TAG.len()]),
73
+ }
74
+ cursor = &cursor[close_idx + CLOSE_TAG.len()..];
75
+ }
76
+ out.push_str(cursor);
77
+ (out, calls)
78
+}
79
+
80
+/// Parse one block's inner text: a name, then zero or more
81
+/// `<arg_key>K</arg_key><arg_value>V</arg_value>` pairs. Returns `None` the
82
+/// moment anything departs from that shape.
83
+fn parse_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
84
+ let (name, mut rest) = match inner.find("<arg_key>") {
85
+ Some(idx) => (inner[..idx].trim(), &inner[idx..]),
86
+ None => (inner.trim(), ""),
87
+ };
88
+ if name.is_empty() || name.contains(['<', '>']) {
89
+ return None;
90
+ }
91
+
92
+ let mut args = serde_json::Map::new();
93
+ while !rest.is_empty() {
94
+ let (key, after_key) = split_once(rest.strip_prefix("<arg_key>")?, "</arg_key>")?;
95
+ let (value, after_value) =
96
+ split_once(after_key.strip_prefix("<arg_value>")?, "</arg_value>")?;
97
+ let key = key.trim();
98
+ if key.is_empty() {
99
+ return None;
100
+ }
101
+ args.insert(
102
+ key.to_string(),
103
+ coerce(value, declared_type(tools, name, key).as_deref()),
104
+ );
105
+ rest = after_value;
106
+ }
107
+
108
+ Some(Recovered {
109
+ name: name.to_string(),
110
+ arguments: serde_json::Value::Object(args).to_string(),
111
+ })
112
+}
113
+
114
+fn split_once<'a>(s: &'a str, delim: &str) -> Option<(&'a str, &'a str)> {
115
+ let idx = s.find(delim)?;
116
+ Some((&s[..idx], &s[idx + delim.len()..]))
117
+}
118
+
119
+/// The JSON Schema `type` declared for `tool_name`'s `key` parameter — e.g.
120
+/// `"integer"` for `command_output`'s `task_id`. [`ToolSpec`] carries the
121
+/// schema as an unparsed string, so this parses it per lookup; the argument
122
+/// counts involved are tiny and this only runs on the recovery path.
123
+fn declared_type(tools: &[ToolSpec], tool_name: &str, key: &str) -> Option<String> {
124
+ let spec = tools.iter().find(|spec| spec.name == tool_name)?;
125
+ let schema: serde_json::Value = serde_json::from_str(&spec.parameters_schema).ok()?;
126
+ Some(
127
+ schema
128
+ .get("properties")?
129
+ .get(key)?
130
+ .get("type")?
131
+ .as_str()?
132
+ .to_string(),
133
+ )
134
+}
135
+
136
+/// Convert a raw (unescaped) text value to JSON per its declared schema type.
137
+/// Falls back to a plain string for `"string"`, an unknown type, or a value
138
+/// that doesn't parse as what it claims to be. This only ever narrows a value
139
+/// to what the schema already promises — it never invents structure the text
140
+/// doesn't have, since a bad coercion would corrupt a call that was otherwise
141
+/// recoverable.
142
+fn coerce(raw: &str, declared: Option<&str>) -> serde_json::Value {
143
+ match declared {
144
+ Some("integer") => raw
145
+ .trim()
146
+ .parse::<i64>()
147
+ .map(serde_json::Value::from)
148
+ .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
149
+ Some("number") => raw
150
+ .trim()
151
+ .parse::<f64>()
152
+ .ok()
153
+ .and_then(serde_json::Number::from_f64)
154
+ .map(serde_json::Value::Number)
155
+ .unwrap_or_else(|| serde_json::Value::String(raw.to_string())),
156
+ Some("boolean") => match raw.trim() {
157
+ "true" => serde_json::Value::Bool(true),
158
+ "false" => serde_json::Value::Bool(false),
159
+ _ => serde_json::Value::String(raw.to_string()),
160
+ },
161
+ _ => serde_json::Value::String(raw.to_string()),
162
+ }
163
+}
164
+
165
+/// One event from incrementally scanning a stream of content deltas.
166
+#[derive(Debug, Clone, PartialEq, Eq)]
167
+pub enum ScanEvent {
168
+ /// Plain text, safe to forward to the UI.
169
+ Text(String),
170
+ /// A tool call recovered from a completed block.
171
+ ToolCall(Recovered),
172
+}
173
+
174
+/// Incremental scanner for a live delta stream.
175
+///
176
+/// Holds back only the text that could still turn into a `<tool_call>` tag, so
177
+/// ordinary answers — the overwhelming majority of turns, which never contain
178
+/// one — still reach the UI token by token. Buffering starts only once an
179
+/// opening tag actually appears, and lasts only until it closes.
180
+pub struct StreamScanner<'a> {
181
+ tools: &'a [ToolSpec],
182
+ pending: String,
183
+}
184
+
185
+impl<'a> StreamScanner<'a> {
186
+ pub fn new(tools: &'a [ToolSpec]) -> Self {
187
+ Self {
188
+ tools,
189
+ pending: String::new(),
190
+ }
191
+ }
192
+
193
+ /// Feed the next content delta. Returns events to emit now, in order; may
194
+ /// be empty if the chunk was absorbed into a tag that hasn't closed yet.
195
+ pub fn push(&mut self, chunk: &str) -> Vec<ScanEvent> {
196
+ self.pending.push_str(chunk);
197
+ self.drain()
198
+ }
199
+
200
+ /// Take whatever is still held back — e.g. a `<tool_call` prefix that
201
+ /// never closed. Text we never saw the end of can't be interpreted
202
+ /// safely, so the honest thing is to hand it back as-is, exactly as if
203
+ /// recovery had never run. Call this wherever the stream can end, or
204
+ /// held-back text is lost.
205
+ pub fn take_pending(&mut self) -> Option<String> {
206
+ if self.pending.is_empty() {
207
+ None
208
+ } else {
209
+ Some(std::mem::take(&mut self.pending))
210
+ }
211
+ }
212
+
213
+ fn drain(&mut self) -> Vec<ScanEvent> {
214
+ let mut events = Vec::new();
215
+ loop {
216
+ let Some(open_idx) = self.pending.find(OPEN_TAG) else {
217
+ // No tag yet: flush all but a suffix that could still grow
218
+ // into "<tool_call>" once the next chunk lands.
219
+ let keep = partial_prefix_len(&self.pending, OPEN_TAG);
220
+ let flush_len = self.pending.len() - keep;
221
+ if flush_len > 0 {
222
+ events.push(ScanEvent::Text(
223
+ self.pending.drain(..flush_len).collect::<String>(),
224
+ ));
225
+ }
226
+ break;
227
+ };
228
+ if open_idx > 0 {
229
+ events.push(ScanEvent::Text(
230
+ self.pending.drain(..open_idx).collect::<String>(),
231
+ ));
232
+ }
233
+ let Some(close_rel) = self.pending[OPEN_TAG.len()..].find(CLOSE_TAG) else {
234
+ break; // opened but not closed: wait for more
235
+ };
236
+ let close_idx = OPEN_TAG.len() + close_rel;
237
+ let block: String = self.pending.drain(..close_idx + CLOSE_TAG.len()).collect();
238
+ let inner = &block[OPEN_TAG.len()..block.len() - CLOSE_TAG.len()];
239
+ match parse_block(inner, self.tools) {
240
+ Some(call) => events.push(ScanEvent::ToolCall(call)),
241
+ None => events.push(ScanEvent::Text(block)),
242
+ }
243
+ }
244
+ events
245
+ }
246
+}
247
+
248
+/// Length of the longest suffix of `buf` that is a prefix of `needle` — how
249
+/// much of the tail could still become `needle`. Lets a split opening tag
250
+/// (one chunk ending `"<tool_c"`, the next starting `"all>"`) be caught
251
+/// without delaying text that can't possibly be part of one.
252
+fn partial_prefix_len(buf: &str, needle: &str) -> usize {
253
+ let max = buf.len().min(needle.len() - 1);
254
+ for len in (1..=max).rev() {
255
+ let start = buf.len() - len;
256
+ if buf.is_char_boundary(start) && needle.starts_with(&buf[start..]) {
257
+ return len;
258
+ }
259
+ }
260
+ 0
261
+}
262
+
263
+#[cfg(test)]
264
+mod tests {
265
+ use super::*;
266
+
267
+ fn spec(name: &str, schema: serde_json::Value) -> ToolSpec {
268
+ ToolSpec {
269
+ name: name.to_string(),
270
+ description: String::new(),
271
+ parameters_schema: schema.to_string(),
272
+ }
273
+ }
274
+
275
+ fn command_output_spec() -> ToolSpec {
276
+ spec(
277
+ "command_output",
278
+ serde_json::json!({
279
+ "type": "object",
280
+ "properties": { "task_id": { "type": "integer" } }
281
+ }),
282
+ )
283
+ }
284
+
285
+ #[test]
286
+ fn an_integer_argument_is_recovered_as_a_json_number() {
287
+ // `exec_command_output` reads task_id with `as_u64`, so a string "2"
288
+ // here would parse as JSON and then fail at the tool.
289
+ let tools = vec![command_output_spec()];
290
+ let (text, calls) = extract(
291
+ "Checking.<tool_call>command_output<arg_key>task_id</arg_key><arg_value>2</arg_value></tool_call>",
292
+ &tools,
293
+ );
294
+ assert_eq!(text, "Checking.");
295
+ assert_eq!(calls.len(), 1);
296
+ assert_eq!(calls[0].name, "command_output");
297
+ let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
298
+ assert_eq!(args["task_id"], 2);
299
+ assert!(args["task_id"].is_number());
300
+ }
301
+
302
+ #[test]
303
+ fn recovers_multiple_string_arguments_in_order() {
304
+ let tools = vec![spec(
305
+ "run_command",
306
+ serde_json::json!({
307
+ "type": "object",
308
+ "properties": {
309
+ "command": { "type": "string" },
310
+ "cwd": { "type": "string" },
311
+ "run_in_background": { "type": "boolean" }
312
+ }
313
+ }),
314
+ )];
315
+ let (text, calls) = extract(
316
+ "<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>",
317
+ &tools,
318
+ );
319
+ assert_eq!(text, "");
320
+ let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
321
+ assert_eq!(args["command"], "cargo test --locked 2>&1 | tail -20");
322
+ assert_eq!(args["cwd"], "/Users/x/sigit");
323
+ assert_eq!(args["run_in_background"], true);
324
+ }
325
+
326
+ #[test]
327
+ fn a_malformed_block_is_left_alone_rather_than_guessed_at() {
328
+ // The mis-tagged shape seen in practice: an opening `<arg_value>`
329
+ // where `<arg_key>` was meant. Guessing at an edit_file call is worse
330
+ // than leaving it visible.
331
+ let text = "<tool_call>edit_file<arg_value>new_text</arg_key><arg_value>def x; end</arg_value></tool_call>";
332
+ let (out, calls) = extract(text, &[]);
333
+ assert_eq!(out, text);
334
+ assert!(calls.is_empty());
335
+ }
336
+
337
+ #[test]
338
+ fn an_unterminated_tag_is_left_alone() {
339
+ let text = "working on it <tool_call>run_command<arg_key>command</arg_key>";
340
+ let (out, calls) = extract(text, &[]);
341
+ assert_eq!(out, text);
342
+ assert!(calls.is_empty());
343
+ }
344
+
345
+ #[test]
346
+ fn ordinary_text_passes_through_untouched() {
347
+ let (out, calls) = extract("if x < y then a<b, nothing to recover", &[]);
348
+ assert_eq!(out, "if x < y then a<b, nothing to recover");
349
+ assert!(calls.is_empty());
350
+ }
351
+
352
+ #[test]
353
+ fn scanner_recovers_a_tag_split_across_chunk_boundaries() {
354
+ let tools = vec![command_output_spec()];
355
+ let mut scanner = StreamScanner::new(&tools);
356
+ let mut text = String::new();
357
+ let mut calls = Vec::new();
358
+
359
+ for chunk in [
360
+ "polling ",
361
+ "<tool_c",
362
+ "all>command_output<arg_",
363
+ "key>task_id</arg_key><arg_value>2</arg_v",
364
+ "alue></tool_call>",
365
+ " done",
366
+ ] {
367
+ for event in scanner.push(chunk) {
368
+ match event {
369
+ ScanEvent::Text(t) => text.push_str(&t),
370
+ ScanEvent::ToolCall(c) => calls.push(c),
371
+ }
372
+ }
373
+ }
374
+ if let Some(rest) = scanner.take_pending() {
375
+ text.push_str(&rest);
376
+ }
377
+
378
+ assert_eq!(text, "polling done", "no tag fragment may reach the UI");
379
+ assert_eq!(calls.len(), 1);
380
+ assert_eq!(calls[0].name, "command_output");
381
+ }
382
+
383
+ #[test]
384
+ fn scanner_forwards_ordinary_text_without_holding_it_back() {
385
+ let mut scanner = StreamScanner::new(&[]);
386
+ let events = scanner.push("a normal streamed answer");
387
+ assert_eq!(
388
+ events,
389
+ vec![ScanEvent::Text("a normal streamed answer".to_string())]
390
+ );
391
+ assert!(scanner.take_pending().is_none());
392
+ }
393
+
394
+ #[test]
395
+ fn scanner_releases_a_near_miss_prefix_once_it_cannot_match() {
396
+ let mut scanner = StreamScanner::new(&[]);
397
+ // "<tool" could still become "<tool_call>", so it is held back...
398
+ assert_eq!(
399
+ scanner.push("see <tool"),
400
+ vec![ScanEvent::Text("see ".to_string())]
401
+ );
402
+ // ...and released as soon as the next chunk rules that out.
403
+ assert_eq!(
404
+ scanner.push("box for details"),
405
+ vec![ScanEvent::Text("<toolbox for details".to_string())]
406
+ );
407
+ assert!(scanner.take_pending().is_none());
408
+ }
409
+
410
+ #[test]
411
+ fn scanner_hands_back_an_unclosed_tag_at_stream_end() {
412
+ let mut scanner = StreamScanner::new(&[]);
413
+ let events = scanner.push("partial <tool_call>run_command<arg_key>cmd");
414
+ assert_eq!(events, vec![ScanEvent::Text("partial ".to_string())]);
415
+ assert_eq!(
416
+ scanner.take_pending().as_deref(),
417
+ Some("<tool_call>run_command<arg_key>cmd")
418
+ );
419
+ }
420
+}