Skip to main content

tool_parser/parsers/
hy_v4.rs

1//! Hunyuan v4 tagged tool calls. Checkpoint suffixes are learned from the
2//! outer marker rather than hard-coded (e.g. `:6124c78e`).
3use async_trait::async_trait;
4use openai_protocol::common::Tool;
5use serde_json::{json, Map, Value};
6
7use crate::{
8    errors::{ParserError, ParserResult},
9    traits::ToolParser,
10    types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
11};
12
13mod streaming;
14
15const LIMIT: usize = 4 * 1024 * 1024;
16const START: &str = "<tool_calls";
17
18#[derive(Default)]
19pub struct HyV4Parser {
20    buffer: String,
21    suffix: Option<String>,
22    index: usize,
23    opening: String,
24    stream: streaming::StreamParser,
25}
26
27impl HyV4Parser {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    fn call(body: &str, suffix: &str, tools: &[Tool]) -> Option<ToolCall> {
33        let key_start = format!("<arg_key{suffix}>");
34        let key_end = format!("</arg_key{suffix}>");
35        let val_start = format!("<arg_value{suffix}>");
36        let val_end = format!("</arg_value{suffix}>");
37        let (name, mut rest) = body
38            .find(&key_start)
39            .map_or_else(|| (body.trim(), ""), |i| (body[..i].trim(), &body[i..]));
40        if name.is_empty() || name.contains(['<', '>']) {
41            return None;
42        }
43        let props = tools
44            .iter()
45            .find(|t| t.function.name == name)
46            .and_then(|t| t.function.parameters.get("properties"));
47        let mut args = Map::new();
48        while !rest.trim().is_empty() {
49            rest = rest.trim_start().strip_prefix(&key_start)?;
50            let (key, tail) = rest.split_once(&key_end)?;
51            let key = key.trim();
52            if key.is_empty() || args.contains_key(key) {
53                return None;
54            }
55            rest = tail.trim_start().strip_prefix(&val_start)?;
56            let (value, tail) = rest.split_once(&val_end)?;
57            args.insert(
58                key.to_owned(),
59                coerce(value, props.and_then(|p| p.get(key))),
60            );
61            rest = tail;
62        }
63        Some(ToolCall {
64            function: FunctionCall {
65                name: name.to_owned(),
66                arguments: Value::Object(args).to_string(),
67            },
68        })
69    }
70
71    fn drain(&mut self, tools: &[Tool]) -> StreamingParseResult {
72        let mut out = StreamingParseResult::default();
73        loop {
74            let Some(suffix) = self.suffix.clone() else {
75                if let Some(i) = self.buffer.find(START) {
76                    out.normal_text.push_str(&self.buffer[..i]);
77                    self.buffer.drain(..i);
78                    let Some(end) = self.buffer.find('>') else {
79                        break;
80                    };
81                    let suffix = &self.buffer[START.len()..end];
82                    if !suffix.is_empty()
83                        && (!suffix.starts_with(':')
84                            || suffix.len() == 1
85                            || suffix.chars().any(|c| c.is_whitespace() || c == '<'))
86                    {
87                        out.normal_text.push('<');
88                        self.buffer.drain(..1);
89                        continue;
90                    }
91                    self.suffix = Some(suffix.to_owned());
92                    self.opening = self.buffer[..end + 1].to_owned();
93                    self.buffer.drain(..end + 1);
94                    continue;
95                }
96                let keep = (1..START.len())
97                    .rev()
98                    .find(|&n| self.buffer.ends_with(&START[..n]))
99                    .unwrap_or(0);
100                let end = self.buffer.len() - keep;
101                out.normal_text.push_str(&self.buffer[..end]);
102                self.buffer.drain(..end);
103                break;
104            };
105            let close = format!("</tool_calls{suffix}>");
106            let begin = format!("<tool_call{suffix}>");
107            let end = format!("</tool_call{suffix}>");
108            let leading = self.buffer.len() - self.buffer.trim_start().len();
109            if self.buffer[leading..].starts_with(&close) {
110                self.buffer.drain(..leading + close.len());
111                self.opening.clear();
112                self.suffix = None;
113                continue;
114            }
115            if !self.buffer[leading..].starts_with(&begin) {
116                if begin.starts_with(&self.buffer[leading..])
117                    || close.starts_with(&self.buffer[leading..])
118                {
119                    break;
120                }
121                out.normal_text.push_str(&std::mem::take(&mut self.opening));
122                out.normal_text.push_str(&self.buffer);
123                self.buffer.clear();
124                self.suffix = None;
125                break;
126            }
127            let body_start = leading + begin.len();
128            // A closing-call literal inside an argument value is data, not framing.
129            let value_begin = format!("<arg_value{suffix}>");
130            let value_end = format!("</arg_value{suffix}>");
131            let mut cursor = body_start;
132            let end_index = loop {
133                let Some(rel) = self.buffer[cursor..].find(&end) else {
134                    break None;
135                };
136                let candidate = cursor + rel;
137                if let Some(v) = self.buffer[cursor..candidate].find(&value_begin) {
138                    let value_pos = cursor + v + value_begin.len();
139                    let Some(vend) = self.buffer[value_pos..].find(&value_end) else {
140                        break None;
141                    };
142                    cursor = value_pos + vend + value_end.len();
143                } else {
144                    break Some(candidate);
145                }
146            };
147            let Some(i) = end_index else {
148                break;
149            };
150            if let Some(call) = Self::call(&self.buffer[body_start..i], &suffix, tools) {
151                self.opening.clear();
152                out.calls.push(ToolCallItem {
153                    tool_index: self.index,
154                    name: Some(call.function.name),
155                    parameters: call.function.arguments,
156                });
157                self.index += 1;
158            } else {
159                out.normal_text.push_str(&std::mem::take(&mut self.opening));
160                out.normal_text.push_str(&self.buffer[..i + end.len()]);
161                self.suffix = None;
162            }
163            self.buffer.drain(..i + end.len());
164        }
165        out
166    }
167
168    /// Constrain a `<tool_calls>` block containing one or more calls. Arguments
169    /// use xgrammar's `glm_xml` style: raw strings and schema-typed JSON values,
170    /// with required/optional properties handled by xgrammar as for GLM-4.7.
171    /// Uses suffix-less markers; this builder has no tokenizer information
172    /// from which to select checkpoint-specific suffixes.
173    pub fn build_structural_tag(tools: &[Tool], at_least_one: bool) -> Value {
174        let calls: Vec<Value> = tools
175            .iter()
176            .filter(|tool| !tool.function.name.is_empty())
177            .map(|tool| {
178                json!({
179                    "type": "tag",
180                    "begin": format!("<tool_call>{}", tool.function.name),
181                    "content": {
182                        "type": "json_schema",
183                        "json_schema": tool.function.parameters,
184                        "style": "glm_xml",
185                    },
186                    "end": "</tool_call>",
187                })
188            })
189            .collect();
190        // An empty `or` is unsatisfiable; with no named tool, admit only an
191        // empty block, which parses to no calls.
192        let content = if calls.is_empty() {
193            json!({ "type": "const_string", "value": "" })
194        } else {
195            json!({ "type": "plus", "content": { "type": "or", "elements": calls } })
196        };
197
198        json!({
199            "format": {
200                "type": "triggered_tags",
201                "triggers": ["<tool_calls>"],
202                "tags": [{
203                    "begin": "<tool_calls>",
204                    "content": content,
205                    "end": "</tool_calls>",
206                }],
207                "at_least_one": at_least_one,
208            }
209        })
210    }
211
212    /// Close prefilled reasoning before the forced call. Prefix exclusions also
213    /// prevent checkpoint-suffixed control markers from appearing in reasoning.
214    pub fn reasoning_prefix() -> Value {
215        json!({
216            "type": "tag",
217            "begin": "",
218            "content": {
219                "type": "any_text",
220                "excludes": [
221                    "<think", "</think",
222                    "<tool_call", "</tool_call",
223                    "<arg_key", "</arg_key", "<arg_value", "</arg_value",
224                ],
225            },
226            "end": "</think>",
227        })
228    }
229}
230
231fn types(schema: Option<&Value>) -> Vec<&str> {
232    let Some(s) = schema else {
233        return vec!["string"];
234    };
235    if let Some(t) = s.get("type") {
236        if let Some(t) = t.as_str() {
237            return vec![t];
238        }
239        if let Some(ts) = t.as_array() {
240            return ts.iter().filter_map(Value::as_str).collect();
241        }
242    }
243    if let Some(v) = s
244        .get("anyOf")
245        .or_else(|| s.get("oneOf"))
246        .and_then(Value::as_array)
247    {
248        return v.iter().flat_map(|s| types(Some(s))).collect();
249    }
250    vec!["string"]
251}
252
253fn coerce(raw: &str, schema: Option<&Value>) -> Value {
254    let ts = types(schema);
255    if ts.contains(&"boolean") {
256        match raw.trim().to_ascii_lowercase().as_str() {
257            "true" => return Value::Bool(true),
258            "false" => return Value::Bool(false),
259            _ => {}
260        }
261    }
262    if let Ok(v) = serde_json::from_str::<Value>(raw) {
263        let matches = ts.iter().any(|t| match *t {
264            "integer" | "int" => v.is_i64() || v.is_u64(),
265            "number" | "float" | "double" => v.is_number(),
266            "object" | "dict" | "map" => v.is_object(),
267            "array" | "list" => v.is_array(),
268            "null" => v.is_null(),
269            _ => false,
270        });
271        if matches {
272            return v;
273        }
274    }
275    // Strings remain literal: preserve whitespace, quotes and XML-like text.
276    Value::String(raw.to_owned())
277}
278
279#[async_trait]
280impl ToolParser for HyV4Parser {
281    async fn parse_complete(&self, output: &str) -> ParserResult<(String, Vec<ToolCall>)> {
282        self.parse_complete_with_tools(output, &[]).await
283    }
284    async fn parse_complete_with_tools(
285        &self,
286        output: &str,
287        tools: &[Tool],
288    ) -> ParserResult<(String, Vec<ToolCall>)> {
289        let mut p = Self::new();
290        if output.len() > LIMIT {
291            return Err(ParserError::ParsingFailed(
292                "Hy4 tool buffer exceeds 4 MiB".into(),
293            ));
294        }
295        p.buffer.push_str(output);
296        let mut result = p.drain(tools);
297        result
298            .normal_text
299            .push_str(&format!("{}{}", p.opening, p.buffer));
300        Ok((
301            result.normal_text,
302            result
303                .calls
304                .into_iter()
305                .map(|c| ToolCall {
306                    function: FunctionCall {
307                        name: c.name.unwrap_or_default(),
308                        arguments: c.parameters,
309                    },
310                })
311                .collect(),
312        ))
313    }
314    async fn parse_incremental(
315        &mut self,
316        chunk: &str,
317        tools: &[Tool],
318    ) -> ParserResult<StreamingParseResult> {
319        self.stream.parse(chunk, tools)
320    }
321
322    fn has_tool_markers(&self, text: &str) -> bool {
323        text.contains(START)
324    }
325    fn take_unstreamed_normal_text(&mut self) -> String {
326        self.stream.flush_text()
327    }
328
329    fn reset(&mut self) {
330        *self = Self::new();
331    }
332}