1use super::types::{ContentBlock, Message, ToolCall};
16use serde_json::{Map, Value};
17
18const DSML_NS: &str = "\u{FF5C}DSML\u{FF5C}";
20const DSML_NS_ASCII: &str = "||DSML||";
22
23pub(crate) fn recover_text_tool_calls(message: &mut Message) -> Vec<ToolCall> {
28 if message.role != "assistant" {
29 return message.tool_calls();
30 }
31 let already_structured = message
32 .content
33 .iter()
34 .any(|block| matches!(block, ContentBlock::ToolUse { .. }));
35 let mut recovered = Vec::new();
36 let mut next_content = Vec::with_capacity(message.content.len());
37 for block in message.content.drain(..) {
38 let ContentBlock::Text { text } = block else {
39 next_content.push(block);
40 continue;
41 };
42 let (prose, calls) = split_leaked_tool_calls(&text);
43 if !already_structured {
44 for call in calls {
45 let id = call
46 .id
47 .unwrap_or_else(|| format!("text-tool-{}", recovered.len() + 1));
48 recovered.push(ToolCall {
49 id: id.clone(),
50 name: call.name.clone(),
51 args: call.input.clone(),
52 });
53 next_content.push(ContentBlock::ToolUse {
54 id,
55 name: call.name,
56 input: call.input,
57 });
58 }
59 }
60 if !prose.is_empty() {
61 next_content.push(ContentBlock::Text { text: prose });
62 }
63 }
64 message.content = next_content;
65 if already_structured {
66 message.tool_calls()
67 } else {
68 recovered
69 }
70}
71
72pub fn strip_leaked_tool_protocol(text: &str) -> String {
77 let (prose, _) = split_leaked_tool_calls(text);
78 prose
79}
80
81pub fn recover_leaked_tool_calls(text: &str) -> Vec<(String, String, Value)> {
85 split_leaked_tool_calls(text)
86 .1
87 .into_iter()
88 .enumerate()
89 .map(|(index, call)| {
90 (
91 call.id.unwrap_or_else(|| format!("leaked-{index}")),
92 call.name,
93 call.input,
94 )
95 })
96 .collect()
97}
98
99struct ParsedCall {
100 id: Option<String>,
101 name: String,
102 input: Value,
103}
104
105#[derive(Clone, Copy)]
106enum MarkupKind {
107 Tagged,
108 OpenToolCall,
109 Invoke,
110 DsmlInvoke,
111}
112
113fn split_leaked_tool_calls(text: &str) -> (String, Vec<ParsedCall>) {
114 let mut prose = String::new();
115 let mut calls = Vec::new();
116 let mut rest = text;
117 while let Some((kind, start)) = find_next_markup(rest) {
118 prose.push_str(&rest[..start]);
119 let at = &rest[start..];
120 match take_markup(kind, at) {
121 Some((call, consumed)) => {
122 calls.push(call);
123 rest = &at[consumed..];
124 }
125 None => {
126 let advance = at.chars().next().map(|c| c.len_utf8()).unwrap_or(1);
128 prose.push_str(&at[..advance]);
129 rest = &at[advance..];
130 }
131 }
132 }
133 prose.push_str(rest);
134 let prose = scrub_wrapper_markup(&prose)
135 .lines()
136 .map(str::trim_end)
137 .collect::<Vec<_>>()
138 .join("\n")
139 .trim()
140 .to_string();
141 (prose, calls)
142}
143
144fn find_next_markup(text: &str) -> Option<(MarkupKind, usize)> {
145 let tagged = text.find("<tool_call:").map(|i| (MarkupKind::Tagged, i));
146 let open = text
147 .find("<tool_call>")
148 .map(|i| (MarkupKind::OpenToolCall, i));
149 let invoke = text.find("<invoke ").map(|i| (MarkupKind::Invoke, i));
150 let dsml = find_dsml_invoke(text).map(|i| (MarkupKind::DsmlInvoke, i));
151 [tagged, open, invoke, dsml]
152 .into_iter()
153 .flatten()
154 .min_by_key(|(_, i)| *i)
155}
156
157fn find_dsml_invoke(text: &str) -> Option<usize> {
158 let full = format!("<{DSML_NS}invoke ");
159 let ascii = format!("<{DSML_NS_ASCII}invoke ");
160 [text.find(&full), text.find(&ascii)]
161 .into_iter()
162 .flatten()
163 .min()
164}
165
166fn take_markup(kind: MarkupKind, at: &str) -> Option<(ParsedCall, usize)> {
167 match kind {
168 MarkupKind::Tagged => take_workbuddy_tagged(at),
169 MarkupKind::OpenToolCall => {
170 let after = &at["<tool_call>".len()..];
171 if looks_like_workbuddy_bare(after) {
172 take_workbuddy_bare(at)
173 } else {
174 take_attribute_tool_call(at)
175 }
176 }
177 MarkupKind::Invoke => take_claude_invoke(at),
178 MarkupKind::DsmlInvoke => take_dsml_invoke(at),
179 }
180}
181
182fn take_dsml_invoke(at: &str) -> Option<(ParsedCall, usize)> {
184 let ns = if at.starts_with(&format!("<{DSML_NS}invoke ")) {
185 DSML_NS
186 } else if at.starts_with(&format!("<{DSML_NS_ASCII}invoke ")) {
187 DSML_NS_ASCII
188 } else {
189 return None;
190 };
191 let header_end = at.find('>')?;
192 let header = &at[..=header_end];
193 let name = xml_attr(header, "name")?;
194 if !is_tool_name(&name) {
195 return None;
196 }
197 let body_start = header_end + 1;
198 let after_header = &at[body_start..];
199 let close = format!("</{ns}invoke>");
200 let body_end = after_header.find(&close)?;
201 let body = &after_header[..body_end];
202 let consumed = body_start + body_end + close.len();
203 Some((
204 ParsedCall {
205 id: None,
206 name,
207 input: Value::Object(parse_dsml_parameter_tags(body, ns)),
208 },
209 consumed,
210 ))
211}
212
213fn parse_dsml_parameter_tags(body: &str, ns: &str) -> Map<String, Value> {
214 let mut params = Map::new();
215 let open = format!("<{ns}parameter ");
216 let close = format!("</{ns}parameter>");
217 let mut rest = body;
218 while let Some(start) = rest.find(&open) {
219 rest = &rest[start..];
220 let Some(header_end) = rest.find('>') else {
221 break;
222 };
223 let header = &rest[..=header_end];
224 let value_start = header_end + 1;
225 let Some(value_end) = rest[value_start..].find(&close) else {
226 break;
227 };
228 if let Some(name) = xml_attr(header, "name") {
229 let raw = decode_xml_entities(rest[value_start..value_start + value_end].trim());
230 let as_string = xml_attr(header, "string")
231 .map(|v| v.eq_ignore_ascii_case("true"))
232 .unwrap_or(false);
233 let value = if as_string {
234 Value::String(raw)
235 } else {
236 parse_parameter_value(&raw)
237 };
238 params.insert(name, value);
239 }
240 rest = &rest[value_start + value_end + close.len()..];
241 }
242 if params.is_empty() {
243 let trimmed = body.trim();
244 if trimmed.starts_with('{') {
245 if let Ok(Value::Object(map)) = serde_json::from_str(trimmed) {
246 return map;
247 }
248 }
249 }
250 params
251}
252
253fn take_workbuddy_tagged(at: &str) -> Option<(ParsedCall, usize)> {
255 if !at.starts_with("<tool_call:") {
256 return None;
257 }
258 let after_prefix = &at["<tool_call:".len()..];
259 let id_end = after_prefix.find('>')?;
260 let id = after_prefix[..id_end].trim();
261 if id.is_empty()
262 || id.len() > 128
263 || !id
264 .chars()
265 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':'))
266 {
267 return None;
268 }
269 let after_id = &after_prefix[id_end + 1..];
270 let name_end = after_id.find("\">")?;
271 let name = decode_xml_entities(after_id[..name_end].trim());
272 if !is_tool_name(&name) {
273 return None;
274 }
275 let body_start = name_end + "\">".len();
276 let after_name = &after_id[body_start..];
277 let body_end = after_name.find("</invoke>")?;
278 let body = &after_name[..body_end];
279 let consumed = "<tool_call:".len() + id_end + 1 + body_start + body_end + "</invoke>".len();
280 Some((
281 ParsedCall {
282 id: Some(id.to_string()),
283 name,
284 input: Value::Object(parse_parameter_tags(body)),
285 },
286 consumed,
287 ))
288}
289
290fn take_workbuddy_bare(at: &str) -> Option<(ParsedCall, usize)> {
292 if !at.starts_with("<tool_call>") {
293 return None;
294 }
295 let after = &at["<tool_call>".len()..];
296 let name_end = after.find('>')?;
297 let name = decode_xml_entities(after[..name_end].trim());
298 if !is_tool_name(&name) {
299 return None;
300 }
301 let body_start = name_end + 1;
302 let body_region = &after[body_start..];
303 let close_named = format!("</{name}>");
304 let (body_end, close_len) = if let Some(end) = body_region.find(&close_named) {
305 (end, close_named.len())
306 } else {
307 let end = body_region.find("</tool_call>")?;
308 (end, "</tool_call>".len())
309 };
310 let body = &body_region[..body_end];
311 let consumed = "<tool_call>".len() + body_start + body_end + close_len;
312 Some((
313 ParsedCall {
314 id: None,
315 name,
316 input: Value::Object(parse_parameter_tags(body)),
317 },
318 consumed,
319 ))
320}
321
322fn looks_like_workbuddy_bare(after_open: &str) -> bool {
323 let trimmed = after_open.trim_start();
324 let Some(gt) = trimmed.find('>') else {
325 return false;
326 };
327 let name = trimmed[..gt].trim();
328 is_tool_name(name) && !name.contains('=') && !name.contains(char::is_whitespace)
329}
330
331fn take_attribute_tool_call(at: &str) -> Option<(ParsedCall, usize)> {
332 if !at.starts_with("<tool_call>") {
333 return None;
334 }
335 let after = &at["<tool_call>".len()..];
336 let (body, body_consumed) = take_call_body(after);
337 let call = parse_call_body(body)?;
338 Some((call, "<tool_call>".len() + body_consumed))
339}
340
341fn take_claude_invoke(at: &str) -> Option<(ParsedCall, usize)> {
342 if !at.starts_with("<invoke ") {
343 return None;
344 }
345 let header_end = at.find('>')?;
346 let header = &at[..=header_end];
347 let name = xml_attr(header, "name")?;
348 if !is_tool_name(&name) {
349 return None;
350 }
351 let body_start = header_end + 1;
352 let after_header = &at[body_start..];
353 let body_end = after_header.find("</invoke>")?;
354 let body = &after_header[..body_end];
355 let consumed = body_start + body_end + "</invoke>".len();
356 Some((
357 ParsedCall {
358 id: None,
359 name,
360 input: Value::Object(parse_parameter_tags(body)),
361 },
362 consumed,
363 ))
364}
365
366fn take_call_body(after_open: &str) -> (&str, usize) {
367 if let Some(end) = after_open.find("</tool_call>") {
368 let body = after_open[..end].trim().trim_end_matches('/').trim();
369 return (body, end + "</tool_call>".len());
370 }
371 if let Some(end) = after_open.find("/>") {
372 return (after_open[..end].trim(), end + 2);
373 }
374 if let Some(end) = after_open.find("<tool_call>") {
375 return (after_open[..end].trim(), end);
376 }
377 (after_open.trim(), after_open.len())
378}
379
380fn parse_call_body(body: &str) -> Option<ParsedCall> {
381 let body = body.trim().trim_end_matches('/').trim();
382 if body.is_empty() {
383 return None;
384 }
385 let mut parts = body.split_whitespace();
386 let name = parts.next()?.trim();
387 if !is_tool_name(name) {
388 return None;
389 }
390 let args_src = body[name.len()..].trim();
391 Some(ParsedCall {
392 id: None,
393 name: name.to_string(),
394 input: parse_attributes(args_src),
395 })
396}
397
398fn parse_parameter_tags(body: &str) -> Map<String, Value> {
399 let mut params = Map::new();
400 let mut rest = body;
401 while let Some(start) = rest.find("<parameter ") {
402 rest = &rest[start..];
403 let Some(header_end) = rest.find('>') else {
404 break;
405 };
406 let header = &rest[..=header_end];
407 let value_start = header_end + 1;
408 let Some(value_end) = rest[value_start..].find("</parameter>") else {
409 break;
410 };
411 if let Some(name) = xml_attr(header, "name") {
412 let raw = decode_xml_entities(rest[value_start..value_start + value_end].trim());
413 params.insert(name, parse_parameter_value(&raw));
414 }
415 rest = &rest[value_start + value_end + "</parameter>".len()..];
416 }
417 params
418}
419
420fn xml_attr(tag: &str, attr: &str) -> Option<String> {
421 let needle = format!("{attr}=\"");
422 let start = tag.find(&needle)? + needle.len();
423 let rest = &tag[start..];
424 let end = rest.find('"')?;
425 Some(decode_xml_entities(&rest[..end]))
426}
427
428fn decode_xml_entities(value: &str) -> String {
429 value
430 .replace(""", "\"")
431 .replace("'", "'")
432 .replace("<", "<")
433 .replace(">", ">")
434 .replace("&", "&")
435}
436
437fn parse_parameter_value(raw: &str) -> Value {
438 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
439}
440
441fn scrub_wrapper_markup(prose: &str) -> String {
442 let mut out = prose.to_string();
443 for pattern in [
444 "<function_calls>",
445 "</function_calls>",
446 "</tool_calls>",
447 "</invoke>",
448 "<minimax:tool_call>",
449 "</minimax:tool_call>",
450 ] {
451 out = out.replace(pattern, "");
452 }
453 while let Some(start) = out.find("<tool_calls:") {
455 let Some(end) = out[start..].find('>') else {
456 break;
457 };
458 out.replace_range(start..start + end + 1, "");
459 }
460 for ns in [DSML_NS, DSML_NS_ASCII] {
462 for name in ["tool_calls", "function_calls", "calls"] {
463 let open = format!("<{ns}{name}>");
464 let close = format!("</{ns}{name}>");
465 out = out.replace(&open, "").replace(&close, "");
466 }
467 out = out.replace(&format!("</{ns}invoke>"), "");
468 out = out.replace(&format!("</{ns}parameter>"), "");
469 }
470 out
471}
472
473fn is_tool_name(name: &str) -> bool {
474 let mut chars = name.chars();
475 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
476 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
477}
478
479fn parse_attributes(source: &str) -> Value {
480 let mut map = Map::new();
481 let bytes = source.as_bytes();
482 let mut index = 0;
483 while index < bytes.len() {
484 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
485 index += 1;
486 }
487 if index >= bytes.len() {
488 break;
489 }
490 let key_start = index;
491 while index < bytes.len() && is_attr_name_byte(bytes[index]) {
492 index += 1;
493 }
494 if key_start == index || index >= bytes.len() || bytes[index] != b'=' {
495 break;
496 }
497 let key = source[key_start..index].to_string();
498 index += 1;
499 let (value, next) = parse_attr_value(source, index);
500 map.insert(key, value);
501 index = next;
502 }
503 Value::Object(map)
504}
505
506fn is_attr_name_byte(byte: u8) -> bool {
507 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
508}
509
510fn parse_attr_value(source: &str, start: usize) -> (Value, usize) {
511 let bytes = source.as_bytes();
512 if start >= bytes.len() {
513 return (Value::String(String::new()), start);
514 }
515 let quote = bytes[start];
516 if quote == b'"' || quote == b'\'' {
517 let mut index = start + 1;
518 let mut value = String::new();
519 while index < bytes.len() {
520 let ch = source[index..].chars().next().unwrap_or('\0');
521 if ch == '\\' {
522 let rest = &source[index + 1..];
523 if let Some(next) = rest.chars().next() {
524 value.push(next);
525 index += ch.len_utf8() + next.len_utf8();
526 continue;
527 }
528 }
529 if ch as u8 == quote && ch.is_ascii() {
530 return (Value::String(value), index + 1);
531 }
532 value.push(ch);
533 index += ch.len_utf8();
534 }
535 return (Value::String(value), source.len());
536 }
537 if quote == b'[' || quote == b'{' {
538 if let Some(end) = matching_closer(source, start) {
539 let raw = &source[start..=end];
540 if let Ok(value) = serde_json::from_str(raw) {
541 return (value, end + 1);
542 }
543 return (Value::String(raw.to_string()), end + 1);
544 }
545 }
546 let mut index = start;
547 while index < bytes.len() && !bytes[index].is_ascii_whitespace() {
548 index += 1;
549 }
550 let raw = &source[start..index];
551 (bare_value(raw), index)
552}
553
554fn matching_closer(source: &str, start: usize) -> Option<usize> {
555 let bytes = source.as_bytes();
556 let open = bytes.get(start).copied()?;
557 let close = if open == b'[' { b']' } else { b'}' };
558 let mut depth = 0;
559 let mut index = start;
560 let mut in_string = false;
561 let mut escaped = false;
562 while index < bytes.len() {
563 let byte = bytes[index];
564 if in_string {
565 if escaped {
566 escaped = false;
567 } else if byte == b'\\' {
568 escaped = true;
569 } else if byte == b'"' {
570 in_string = false;
571 }
572 index += 1;
573 continue;
574 }
575 if byte == b'"' {
576 in_string = true;
577 } else if byte == open {
578 depth += 1;
579 } else if byte == close {
580 depth -= 1;
581 if depth == 0 {
582 return Some(index);
583 }
584 }
585 index += 1;
586 }
587 None
588}
589
590fn bare_value(raw: &str) -> Value {
591 if raw == "true" {
592 Value::Bool(true)
593 } else if raw == "false" {
594 Value::Bool(false)
595 } else if raw == "null" {
596 Value::Null
597 } else if let Ok(number) = raw.parse::<i64>() {
598 Value::from(number)
599 } else if let Ok(number) = raw.parse::<f64>() {
600 serde_json::Number::from_f64(number)
601 .map(Value::Number)
602 .unwrap_or_else(|| Value::String(raw.to_string()))
603 } else {
604 Value::String(raw.to_string())
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn recovers_self_closing_markup_and_strips_it_from_prose() {
614 let mut message = Message::assistant(
615 "我来帮你查看今天的热搜。<tool_call>web_search query=\"今天热搜榜\" limit=\"15\" engines=[\"baidu\", \"sogou\"] timeout=\"30\" /></tool_call>",
616 );
617 let calls = recover_text_tool_calls(&mut message);
618 assert_eq!(calls.len(), 1);
619 assert_eq!(calls[0].name, "web_search");
620 assert_eq!(calls[0].args["query"], "今天热搜榜");
621 assert_eq!(calls[0].args["limit"], "15");
622 assert_eq!(calls[0].args["engines"][0], "baidu");
623 assert_eq!(message.text(), "我来帮你查看今天的热搜。");
624 assert!(message
625 .tool_calls()
626 .iter()
627 .any(|call| call.name == "web_search"));
628 }
629
630 #[test]
631 fn does_not_duplicate_structured_tool_calls() {
632 let mut message = Message::assistant("<tool_call>web_search query=\"x\" /></tool_call>");
633 message.content.push(ContentBlock::ToolUse {
634 id: "call-1".to_string(),
635 name: "web_search".to_string(),
636 input: serde_json::json!({"query": "x"}),
637 });
638 let calls = recover_text_tool_calls(&mut message);
639 assert_eq!(calls.len(), 1);
640 assert_eq!(calls[0].id, "call-1");
641 assert!(message.text().is_empty());
642 }
643
644 #[test]
645 fn recovers_workbuddy_tagged_tool_call_xml() {
646 let mut message = Message::assistant(
647 r#"I'll list the workspace.<tool_calls:group_1>
648<tool_call:call_1>ls">
649<parameter name="path">/work/a3s</parameter>
650</invoke>
651</function_calls>"#,
652 );
653 let calls = recover_text_tool_calls(&mut message);
654 assert_eq!(calls.len(), 1);
655 assert_eq!(calls[0].id, "call_1");
656 assert_eq!(calls[0].name, "ls");
657 assert_eq!(calls[0].args["path"], "/work/a3s");
658 assert_eq!(message.text(), "I'll list the workspace.");
659 assert!(!message.text().contains("<tool_call"));
660 assert!(!message.text().contains("</invoke>"));
661 }
662
663 #[test]
664 fn recovers_workbuddy_bare_tool_call_xml_with_named_close() {
665 let mut message = Message::assistant(
666 r#"Let me save this to memory.<tool_call>write>
667<parameter name="file_path">NOTE.md</parameter>
668<parameter name="content">mem_wb_token_violet_91</parameter>
669</write>"#,
670 );
671 let calls = recover_text_tool_calls(&mut message);
672 assert_eq!(calls.len(), 1);
673 assert_eq!(calls[0].name, "write");
674 assert_eq!(calls[0].args["file_path"], "NOTE.md");
675 assert_eq!(calls[0].args["content"], "mem_wb_token_violet_91");
676 assert_eq!(message.text(), "Let me save this to memory.");
677 }
678
679 #[test]
680 fn recovers_claude_invoke_markup() {
681 let mut message = Message::assistant(
682 r#"Checking.<function_calls>
683<invoke name="Read">
684<parameter name="file_path">README.md</parameter>
685</invoke>
686</function_calls>"#,
687 );
688 let calls = recover_text_tool_calls(&mut message);
689 assert_eq!(calls.len(), 1);
690 assert_eq!(calls[0].name, "Read");
691 assert_eq!(calls[0].args["file_path"], "README.md");
692 assert_eq!(message.text(), "Checking.");
693 }
694
695 #[test]
696 fn attribute_form_still_preferred_over_bare_false_positive() {
697 let mut message =
698 Message::assistant(r#"<tool_call>web_search query="today" limit="5" /></tool_call>"#);
699 let calls = recover_text_tool_calls(&mut message);
700 assert_eq!(calls.len(), 1);
701 assert_eq!(calls[0].name, "web_search");
702 assert_eq!(calls[0].args["query"], "today");
703 assert_eq!(calls[0].args["limit"], "5");
704 }
705 #[test]
706 fn recovers_deepseek_dsml_v4_tool_calls() {
707 let mut message = Message::assistant(
708 "正在检索。\n<|DSML|tool_calls>\n<|DSML|invoke name=\"search\">\n<|DSML|parameter name=\"mode\" string=\"true\">glob</|DSML|parameter>\n<|DSML|parameter name=\"query\" string=\"true\">测试一下</|DSML|parameter>\n<|DSML|parameter name=\"path\" string=\"true\">.a3s/kb/wiki/science</|DSML|parameter>\n<|DSML|parameter name=\"limit\" string=\"true\">8</|DSML|parameter>\n</|DSML|invoke>\n</|DSML|tool_calls>",
709 );
710 let calls = recover_text_tool_calls(&mut message);
711 assert_eq!(calls.len(), 1);
712 assert_eq!(calls[0].name, "search");
713 assert_eq!(calls[0].args["mode"], "glob");
714 assert_eq!(calls[0].args["query"], "测试一下");
715 assert_eq!(calls[0].args["path"], ".a3s/kb/wiki/science");
716 assert_eq!(calls[0].args["limit"], "8");
717 assert_eq!(message.text(), "正在检索。");
718 assert!(!message.text().contains("DSML"));
719 }
720
721 #[test]
722 fn recovers_ascii_mangled_dsml_namespace() {
723 let mut message = Message::assistant(
724 r#"<||DSML||calls><||DSML||invoke name="search"><||DSML||parameter name="query" string="true">x</||DSML||parameter></||DSML||invoke></||DSML||calls>"#,
725 );
726 let calls = recover_text_tool_calls(&mut message);
727 assert_eq!(calls.len(), 1);
728 assert_eq!(calls[0].name, "search");
729 assert_eq!(calls[0].args["query"], "x");
730 assert!(message.text().is_empty());
731 }
732
733 #[test]
734 fn strip_leaked_tool_protocol_drops_dsml_only_blobs() {
735 let raw = r#"<|DSML|tool_calls><|DSML|invoke name="search"><|DSML|parameter name="query" string="true">q</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"#;
736 assert!(strip_leaked_tool_protocol(raw).is_empty());
737 assert_eq!(strip_leaked_tool_protocol(&format!("hello {raw}")), "hello");
738 }
739}