Skip to content

Commit a631763

Browse files
committed
Keep prose tool-tag mentions in GLM answers as text
GLM's argument-tag parser had the same defect in answer text: any `<tool_call>` after `</think>` started a call. A quoted tag in prose aborted the response at EOS with "incomplete GLM tool call", and a quoted tag followed by a real call cut the answer short and returned a call whose function name was the intervening prose. Reasoning was unaffected because GLM's `<tool_call>` does not end reasoning. ArgumentTags now implements ToolDialect::starts_call: `<tool_call>` starts a call only when followed by a function name and then `<arg_key>` or `</tool_call>`, matching the GLM 5.3 chat template. Other text keeps the tag in the answer, and an unresolved prefix is held until later tokens decide. An undecided tag at finish is still parsed as a call, so truncated calls keep their EOS error and output-limit handling. This applies to the glm53 recipe; there is no Protobuf or package change. Validation: rebased onto bce032c and rerun on a 32-core build host. Helios library suite for this commit: 1213 passed, 21 ignored, 5 failed, the same five that fail on unmodified main (four engram::reader staging ENOMEM on that host, and a package cache test that cannot fail when the suite runs as root). The new test covers a quoted answer mention, unconfirmed and partial inline tags, and a mention followed by a real call at every two-way split point, and rustfmt --check reports no difference in the file. A probe on the unchanged parser reproduced the abort and the misnamed call for these inputs. Limitations: not run against GLM output or a live Flare service; Helios integration tests, Heliopause, Python, and CUDA tests were not run. A complete call example in prose still parses as a call. Design-Signed-Off-By: mark-yong (AI-assisted Cursor and DeepSeek-V4.1-Flash review); design-review=babb
1 parent e44ac30 commit a631763

1 file changed

Lines changed: 72 additions & 0 deletions

File tree

‎helios/src/output/argument_tags.rs‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,32 @@ impl ToolDialect for ArgumentTags {
6464
})?,
6565
)])
6666
}
67+
fn starts_call(text: &str) -> Option<bool> {
68+
// Calls continue with the function name and an argument key or closing tag;
69+
// quoted tags followed by prose remain text.
70+
let text = text.strip_prefix(Self::OPEN).unwrap_or(text).trim_start();
71+
if text.is_empty() {
72+
return None;
73+
}
74+
let end = text.find(|c: char| c == '<' || c == '>' || c.is_whitespace())?;
75+
if end == 0 {
76+
return Some(false);
77+
}
78+
let next = text[end..].trim_start();
79+
if ["<arg_key>", "</tool_call>"]
80+
.iter()
81+
.any(|tag| next.starts_with(tag))
82+
{
83+
Some(true)
84+
} else if ["<arg_key>", "</tool_call>"]
85+
.iter()
86+
.any(|tag| tag.starts_with(next))
87+
{
88+
None
89+
} else {
90+
Some(false)
91+
}
92+
}
6793
}
6894

6995
#[cfg(test)]
@@ -143,6 +169,52 @@ mod tests {
143169
}
144170
}
145171

172+
#[test]
173+
fn prose_tool_tag_mentions_remain_answer_text() {
174+
let call = "<tool_call>run<arg_key>text</arg_key><arg_value>x</arg_value></tool_call>";
175+
for (text, content, calls) in [
176+
(
177+
"</think>The `<tool_call>` tag wraps calls. END".to_owned(),
178+
"The `<tool_call>` tag wraps calls. END",
179+
0,
180+
),
181+
(
182+
"</think>Write <tool_call> or `<tool_call>run` literally.".to_owned(),
183+
"Write <tool_call> or `<tool_call>run` literally.",
184+
0,
185+
),
186+
(
187+
format!("</think>Quote `<tool_call>` here.\n{call}"),
188+
"Quote `<tool_call>` here.\n",
189+
1,
190+
),
191+
] {
192+
for split in text.char_indices().map(|(at, _)| at) {
193+
let mut parser = ArgumentTagOutput::new(config());
194+
let mut output = Accumulator::new("test".into());
195+
for part in [&text[..split], &text[split..]] {
196+
output
197+
.apply(parser.push(Input::Text(part)).unwrap())
198+
.unwrap();
199+
}
200+
output
201+
.apply(parser.finish(Termination::Eos).unwrap())
202+
.unwrap();
203+
output.finish().unwrap();
204+
let message = output.message();
205+
assert_eq!(message["content"], content, "{text:?} at {split}");
206+
let tools = message["tool_calls"]
207+
.as_array()
208+
.map_or(&[][..], Vec::as_slice);
209+
assert_eq!(tools.len(), calls, "{text:?} at {split}");
210+
if let Some(tool) = tools.first() {
211+
assert_eq!(tool["function"]["name"], "run");
212+
assert_eq!(tool["function"]["arguments"], "{\"text\":\"x\"}");
213+
}
214+
}
215+
}
216+
}
217+
146218
#[test]
147219
fn truncation_and_structured_output_keep_protocol_semantics() {
148220
let mut parser = ArgumentTagOutput::new(config());

0 commit comments

Comments
 (0)