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(&[]);