Fix malformed GLM inline tool calls

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

Seto Elkahfi committed Sep 13, 2026 at 12:51 UTC 819aa65ad6b0c002afcc93ff1bbd3005e80975a6
1 file changed +94 -5
src/inline_tool_calls.rs
+94 -5
@@ -81,17 +81,31 @@ pub fn extract(text: &str, tools: &[ToolSpec]) -> (String, Vec<Recovered>) {
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(), ""),
84 + let key_idx = inner.find("<arg_key>");
85 + let malformed_key_idx = inner.find(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 };
88 - if name.is_empty() || name.contains(['<', '>']) {
96 + if name.is_empty() || name.contains(['<', '>']) || !tools.iter().any(|tool| tool.name == name) {
97 return None;
98 }
99
100 let mut args = serde_json::Map::new();
101 + let mut first_key = true;
102 while !rest.is_empty() {
94 - let (key, after_key) = split_once(rest.strip_prefix("<arg_key>")?, "</arg_key>")?;
103 + let key_open = if first_key && malformed_first_key {
104 + OPEN_TAG
105 + } else {
106 + "<arg_key>"
107 + };
108 + let (key, after_key) = split_once(rest.strip_prefix(key_open)?, "</arg_key>")?;
109 let (value, after_value) =
110 split_once(after_key.strip_prefix("<arg_value>")?, "</arg_value>")?;
111 let key = key.trim();
@@ -103,6 +117,7 @@ fn parse_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
117 coerce(value, declared_type(tools, name, key).as_deref()),
118 );
119 rest = after_value;
120 + first_key = false;
121 }
122
123 Some(Recovered {
@@ -323,6 +338,43 @@ mod tests {
338 assert_eq!(args["run_in_background"], true);
339 }
340
341 + #[test]
342 + fn recovers_glm_call_with_a_malformed_first_argument_marker() {
343 + let tools = vec![spec(
344 + "run_command",
345 + serde_json::json!({
346 + "type": "object",
347 + "properties": {
348 + "command": { "type": "string" },
349 + "cwd": { "type": "string" },
350 + "run_in_background": { "type": "boolean" }
351 + }
352 + }),
353 + )];
354 + let (text, calls) = extract(
355 + "<tool_call>run_command<tool_call>command</arg_key><arg_value>sleep 240; gh run view --job 103708582541 --repo ondeinference/ed --log 2>&1 | grep -E \"Compiling ed|Finished|error\\[|error:\" | tail -15</arg_value><arg_key>cwd</arg_key><arg_value>/Users/setoelkahfi/Repositories/onde-ed</arg_value><arg_key>run_in_background</arg_key><arg_value>true</arg_value></tool_call>",
356 + &tools,
357 + );
358 + assert_eq!(text, "");
359 + assert_eq!(calls.len(), 1);
360 + assert_eq!(calls[0].name, "run_command");
361 + let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
362 + assert_eq!(
363 + args["command"],
364 + "sleep 240; gh run view --job 103708582541 --repo ondeinference/ed --log 2>&1 | grep -E \"Compiling ed|Finished|error\\[|error:\" | tail -15"
365 + );
366 + assert_eq!(args["cwd"], "/Users/setoelkahfi/Repositories/onde-ed");
367 + assert_eq!(args["run_in_background"], true);
368 + }
369 +
370 + #[test]
371 + fn an_unknown_malformed_call_is_left_alone() {
372 + let text = "<tool_call>not_offered<tool_call>command</arg_key><arg_value>pwd</arg_value></tool_call>";
373 + let (out, calls) = extract(text, &[]);
374 + assert_eq!(out, text);
375 + assert!(calls.is_empty());
376 + }
377 +
378 #[test]
379 fn a_malformed_block_is_left_alone_rather_than_guessed_at() {
380 // The mis-tagged shape seen in practice: an opening `<arg_value>`
@@ -380,6 +432,43 @@ mod tests {
432 assert_eq!(calls[0].name, "command_output");
433 }
434
435 + #[test]
436 + fn scanner_recovers_glm_call_with_malformed_marker_split_across_chunks() {
437 + let tools = vec![spec(
438 + "run_command",
439 + serde_json::json!({
440 + "type": "object",
441 + "properties": { "command": { "type": "string" } }
442 + }),
443 + )];
444 + let mut scanner = StreamScanner::new(&tools);
445 + let mut text = String::new();
446 + let mut calls = Vec::new();
447 +
448 + for chunk in [
449 + "before ",
450 + "<tool_call>run_command<tool_",
451 + "call>command</arg_key><arg_value>pwd</arg_value></tool_call>",
452 + " after",
453 + ] {
454 + for event in scanner.push(chunk) {
455 + match event {
456 + ScanEvent::Text(value) => text.push_str(&value),
457 + ScanEvent::ToolCall(call) => calls.push(call),
458 + }
459 + }
460 + }
461 + if let Some(rest) = scanner.take_pending() {
462 + text.push_str(&rest);
463 + }
464 +
465 + assert_eq!(text, "before after");
466 + assert_eq!(calls.len(), 1);
467 + assert_eq!(calls[0].name, "run_command");
468 + let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
469 + assert_eq!(args["command"], "pwd");
470 + }
471 +
472 #[test]
473 fn scanner_forwards_ordinary_text_without_holding_it_back() {
474 let mut scanner = StreamScanner::new(&[]);