1use serde_json::{Map, Value, from_str};
11use std::sync::Arc;
12
13#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
14pub(crate) enum FallbackReason {
15 #[default]
16 None,
17 MalformedStructure,
18 DuplicateParameter,
19 InvalidToolName,
20 UndeclaredTool,
21 TrailingContent,
22 TruncatedTail,
25}
26
27const MAX_TOOL_NAME_LENGTH: usize = 128;
30
31const T_NULL: u8 = 1 << 0;
33const T_BOOLEAN: u8 = 1 << 1;
34const T_INTEGER: u8 = 1 << 2;
35const T_NUMBER: u8 = 1 << 3;
36const T_STRING: u8 = 1 << 4;
37const T_OBJECT: u8 = 1 << 5;
38const T_ARRAY: u8 = 1 << 6;
39
40const TOOL_OPEN: &str = concat!("<", "tool_call>", "");
43const TOOL_CLOSE: &str = concat!("</", "tool_call", ">");
44const FUNCTION_OPEN: &str = concat!("<", "function=", "");
45const FUNCTION_CLOSE: &str = concat!("</", "function", ">");
46const PARAM_OPEN: &str = concat!("<", "parameter=", "");
47const PARAM_CLOSE: &str = concat!("</", "parameter", ">");
48
49fn is_ws(c: char) -> bool {
50 c == ' ' || c == '\t' || c == '\r' || c == '\n'
51}
52
53fn trim_ws(text: &str) -> &str {
54 text.trim_matches(is_ws)
55}
56
57fn valid_function_name(name: &str) -> bool {
58 !name.is_empty()
59 && name.len() <= MAX_TOOL_NAME_LENGTH
60 && name
61 .bytes()
62 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
63}
64
65fn schema_bit(name: &str) -> Option<u8> {
66 Some(match name {
67 "null" => T_NULL,
68 "boolean" => T_BOOLEAN,
69 "integer" => T_INTEGER,
70 "number" => T_NUMBER,
71 "string" => T_STRING,
72 "object" => T_OBJECT,
73 "array" => T_ARRAY,
74 _ => return None,
75 })
76}
77
78fn compile_direct_types(t: &Value) -> Option<u8> {
80 if let Some(name) = t.as_str() {
81 return schema_bit(name);
82 }
83
84 let members = t.as_array()?;
85
86 if members.is_empty() {
87 return None;
88 }
89
90 let mut bits = 0;
91
92 for member in members {
93 bits |= schema_bit(member.as_str()?)?;
94 }
95
96 (bits != 0).then_some(bits)
97}
98
99fn compile_schema_types(schema: &Value) -> Option<u8> {
101 let object = schema.as_object()?;
102
103 if let Some(ty) = object.get("type") {
104 return compile_direct_types(ty);
105 }
106
107 let has_any_of = object.get("anyOf").is_some();
108 let has_one_of = object.get("oneOf").is_some();
109
110 if has_any_of == has_one_of {
111 return None;
112 }
113
114 let alternatives = object
115 .get(if has_any_of { "anyOf" } else { "oneOf" })?
116 .as_array()?;
117
118 if alternatives.is_empty() {
119 return None;
120 }
121
122 let mut combined = 0;
123
124 for alternative in alternatives {
125 combined |= compile_schema_types(alternative)?;
126 }
127
128 (combined != 0).then_some(combined)
129}
130
131#[derive(Debug, Clone)]
132pub(crate) struct Parameter {
133 name: String,
134 types: u8,
136}
137
138#[derive(Debug, Clone)]
139pub(crate) struct Tool {
140 name: String,
141 parameters: Vec<Parameter>,
142 unambiguous: bool,
143}
144
145fn same_tool(a: &Tool, b: &Tool) -> bool {
146 a.parameters.len() == b.parameters.len()
147 && a.parameters
148 .iter()
149 .zip(&b.parameters)
150 .all(|(x, y)| x.name == y.name && x.types == y.types)
151}
152
153#[derive(Debug, Clone, Default)]
156pub(crate) struct ToolCallOutputContract {
157 tools: Vec<Tool>,
158 pub(crate) enforce_declared_names: bool,
161}
162
163impl ToolCallOutputContract {
164 pub(crate) fn from_tools(tools: &[Value]) -> Arc<Self> {
168 let mut contract = ToolCallOutputContract {
169 enforce_declared_names: true,
170 tools: Vec::new(),
171 };
172
173 for definition in tools {
174 let Some(function) = definition.get("function").and_then(Value::as_object) else {
175 continue;
176 };
177 let Some(name) = function.get("name").and_then(Value::as_str) else {
178 continue;
179 };
180
181 let mut tool = Tool {
182 name: name.to_owned(),
183 parameters: Vec::new(),
184 unambiguous: true,
185 };
186
187 let parameters = function
188 .get("parameters")
189 .and_then(Value::as_object)
190 .and_then(|schema| schema.get("properties"))
191 .and_then(Value::as_object);
192
193 if let Some(parameters) = parameters {
194 for (param_name, property) in parameters {
195 tool.parameters.push(Parameter {
196 name: param_name.to_owned(),
197 types: compile_schema_types(property).unwrap_or(0),
198 });
199 }
200 }
201
202 if let Some(existing) = contract.tools.iter_mut().find(|t| t.name == tool.name) {
203 if existing.unambiguous && !same_tool(existing, &tool) {
204 existing.parameters.clear();
205
206 existing.unambiguous = false;
207 }
208 } else {
209 contract.tools.push(tool);
210 }
211 }
212
213 Arc::new(contract)
214 }
215
216 fn find_tool(&self, name: &str) -> Option<&Tool> {
219 self.tools
220 .iter()
221 .find(|tool| tool.name == name && tool.unambiguous)
222 }
223
224 fn is_declared(&self, name: &str) -> bool {
225 self.tools.iter().any(|tool| tool.name == name)
226 }
227}
228
229fn json_number_is_integer(number: &str) -> bool {
233 let bytes = number.as_bytes();
234 let mut pos = if bytes.first() == Some(&b'-') { 1 } else { 0 };
235
236 if pos >= bytes.len() {
237 return false;
238 }
239
240 let integer_begin = pos;
241
242 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
243 pos += 1;
244 }
245
246 let integer_end = pos;
247 let (mut fraction_begin, mut fraction_end) = (pos, pos);
248
249 if pos < bytes.len() && bytes[pos] == b'.' {
250 fraction_begin = pos + 1;
251 pos += 1;
252
253 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
254 pos += 1;
255 }
256
257 fraction_end = pos;
258 }
259
260 let (mut exponent_negative, mut exponent_value) = (false, 0usize);
261
262 if pos < bytes.len() && (bytes[pos] == b'e' || bytes[pos] == b'E') {
263 pos += 1;
264
265 if pos < bytes.len() && (bytes[pos] == b'+' || bytes[pos] == b'-') {
266 exponent_negative = bytes[pos] == b'-';
267 pos += 1;
268 }
269
270 let cap = bytes.len();
271
272 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
273 let digit = (bytes[pos] - b'0') as usize;
274
275 if exponent_value != cap {
276 if exponent_value > cap / 10 || (exponent_value == cap / 10 && digit > cap % 10) {
277 exponent_value = cap;
278 } else {
279 exponent_value = exponent_value * 10 + digit;
280 }
281 }
282
283 pos += 1;
284 }
285 }
286
287 if integer_begin == integer_end || pos != bytes.len() {
288 return false;
289 }
290
291 let mut coefficient_is_zero = true;
292 let mut trailing_zeros = 0;
293
294 for i in integer_begin..integer_end {
295 if bytes[i] == b'0' {
296 trailing_zeros += 1;
297 } else {
298 coefficient_is_zero = false;
299 trailing_zeros = 0;
300 }
301 }
302
303 for i in fraction_begin..fraction_end {
304 if bytes[i] == b'0' {
305 trailing_zeros += 1;
306 } else {
307 coefficient_is_zero = false;
308 trailing_zeros = 0;
309 }
310 }
311
312 if coefficient_is_zero {
313 return true;
314 }
315
316 let fraction_digits = fraction_end - fraction_begin;
317
318 if !exponent_negative {
319 return if exponent_value >= fraction_digits {
320 true
321 } else {
322 fraction_digits - exponent_value <= trailing_zeros
323 };
324 }
325
326 if exponent_value > trailing_zeros {
327 return false;
328 }
329
330 fraction_digits <= trailing_zeros - exponent_value
331}
332
333#[derive(Copy, Clone, PartialEq, Eq)]
334enum JsonValueKind {
335 Null,
336 Boolean,
337 Integer,
338 Number,
339 String,
340 Object,
341 Array,
342}
343
344fn classify_json_value(value: &str, kind: &mut JsonValueKind) -> bool {
345 if value.is_empty() || from_str::<Value>(value).is_err() {
346 return false;
347 }
348
349 match value.as_bytes()[0] {
350 b'n' => *kind = JsonValueKind::Null,
351 b't' | b'f' => *kind = JsonValueKind::Boolean,
352 b'"' => *kind = JsonValueKind::String,
353 b'{' => *kind = JsonValueKind::Object,
354 b'[' => *kind = JsonValueKind::Array,
355 b if b == b'-' || b.is_ascii_digit() => {
356 *kind = if json_number_is_integer(value) {
357 JsonValueKind::Integer
358 } else {
359 JsonValueKind::Number
360 };
361 }
362 _ => return false,
363 }
364
365 true
366}
367
368fn admits_value(types: u8, kind: JsonValueKind) -> bool {
369 match kind {
370 JsonValueKind::Null => types & T_NULL != 0,
371 JsonValueKind::Boolean => types & T_BOOLEAN != 0,
372 JsonValueKind::Integer => types & (T_INTEGER | T_NUMBER) != 0,
373 JsonValueKind::Number => types & T_NUMBER != 0,
374 JsonValueKind::String => types & T_STRING != 0,
375 JsonValueKind::Object => types & T_OBJECT != 0,
376 JsonValueKind::Array => types & T_ARRAY != 0,
377 }
378}
379
380fn encode_json_string(value: &str) -> String {
381 Value::String(value.to_owned()).to_string()
382}
383
384fn strip_parameter_framing(text: &str) -> &str {
387 let start = if text.starts_with("\r\n") {
388 2
389 } else if text.starts_with('\n') {
390 1
391 } else {
392 0
393 };
394 let mut end = text.len();
395
396 if end >= start + 2 && text.ends_with("\r\n") {
397 end -= 2;
398 } else if end > start && text.ends_with('\n') {
399 end -= 1;
400 }
401
402 &text[start..end]
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406enum Disposition {
407 Emitted,
408 Omitted,
409 SchemaMismatch,
410}
411
412fn normalize_declared_parameter(encoded_value: &str, types: u8) -> (Disposition, String) {
413 let framed = strip_parameter_framing(encoded_value);
414
415 if types & T_STRING != 0 {
416 return (Disposition::Emitted, encode_json_string(framed));
417 }
418
419 let value = trim_ws(framed);
420
421 if value.is_empty() {
422 return (Disposition::Omitted, String::new());
423 }
424
425 let mut kind = JsonValueKind::String;
426
427 if classify_json_value(value, &mut kind) {
428 let disposition = if admits_value(types, kind) {
429 Disposition::Emitted
430 } else {
431 Disposition::SchemaMismatch
432 };
433
434 return (disposition, value.to_owned());
435 }
436
437 if types & T_BOOLEAN != 0 {
438 if value.eq_ignore_ascii_case("true") {
439 return (Disposition::Emitted, "true".to_owned());
440 }
441
442 if value.eq_ignore_ascii_case("false") {
443 return (Disposition::Emitted, "false".to_owned());
444 }
445 }
446
447 (Disposition::SchemaMismatch, encode_json_string(framed))
448}
449
450fn normalize_parameter(
451 encoded_value: &str,
452 parameter: Option<&Parameter>,
453) -> (Disposition, String) {
454 if let Some(parameter) = parameter.filter(|p| p.types != 0) {
455 return normalize_declared_parameter(encoded_value, parameter.types);
456 }
457
458 let value = trim_ws(encoded_value);
459
460 if from_str::<Value>(value).is_ok() {
461 (Disposition::Emitted, value.to_owned())
462 } else {
463 (Disposition::Emitted, encode_json_string(value))
464 }
465}
466
467#[derive(Debug)]
470struct RawParameter {
471 name: String,
472 value: String,
473}
474
475#[derive(Debug)]
476struct RawToolCall {
477 name: String,
478 parameters: Vec<RawParameter>,
479}
480
481pub(crate) struct GeneratedToolCall {
482 pub name: String,
483 #[allow(dead_code)]
487 pub arguments: Map<String, Value>,
488 pub arguments_json: String,
490}
491
492pub(crate) struct WireToolCall {
494 pub id: String,
495 pub name: String,
496 pub arguments: String,
497}
498
499#[derive(Debug, Default, Clone, Copy)]
500pub(crate) struct ToolCallParseDiagnostics {
501 #[allow(dead_code)]
503 pub marker_seen: bool,
504 pub structured_call_count: u32,
505 pub empty_arguments_omitted: u32,
506 pub schema_mismatch_arguments: u32,
507 pub fallback_reason: FallbackReason,
508}
509
510pub(crate) struct ParsedToolCallOutput {
511 pub is_tool_call_response: bool,
512 #[allow(dead_code)]
516 pub content: String,
517 pub tool_calls: Vec<GeneratedToolCall>,
518 pub diagnostics: ToolCallParseDiagnostics,
519}
520
521fn fallback(text: &str, diagnostics: ToolCallParseDiagnostics) -> ParsedToolCallOutput {
522 ParsedToolCallOutput {
523 is_tool_call_response: false,
524 content: text.to_owned(),
525 tool_calls: Vec::new(),
526 diagnostics,
527 }
528}
529
530fn normalize_raw_tool_call(
531 raw: &RawToolCall,
532 contract: &ToolCallOutputContract,
533 diagnostics: &mut ToolCallParseDiagnostics,
534) -> GeneratedToolCall {
535 let tool = contract.find_tool(&raw.name);
536 let mut arguments = Map::new();
537 let mut arguments_json = String::from("{");
538 let mut first = true;
539
540 for parameter in &raw.parameters {
541 let declared = tool
542 .map(|tool| tool.parameters.iter().find(|p| p.name == parameter.name))
543 .flatten();
544 let mut normalized = normalize_parameter(¶meter.value, declared);
545
546 if tool.is_some() && declared.is_none() {
549 normalized.0 = Disposition::SchemaMismatch;
550 }
551
552 if normalized.0 == Disposition::Omitted {
553 diagnostics.empty_arguments_omitted += 1;
554
555 continue;
556 }
557
558 if normalized.0 == Disposition::SchemaMismatch {
559 diagnostics.schema_mismatch_arguments += 1;
560 }
561
562 if !first {
563 arguments_json.push(',');
564 }
565
566 first = false;
567 let value = from_str::<Value>(&normalized.1).unwrap_or(Value::Null);
568
569 arguments_json.push_str(&encode_json_string(¶meter.name));
570 arguments_json.push(':');
571 arguments_json.push_str(&value.to_string());
572 arguments.insert(parameter.name.clone(), value);
573 }
574
575 arguments_json.push('}');
576
577 GeneratedToolCall {
578 name: raw.name.clone(),
579 arguments,
580 arguments_json,
581 }
582}
583
584struct RegionParser<'a> {
585 text: &'a str,
586 contract: &'a ToolCallOutputContract,
587 tolerant: bool,
588}
589
590impl<'a> RegionParser<'a> {
591 fn parse(&self, calls: &mut Vec<RawToolCall>) -> FallbackReason {
592 let mut pos = 0;
593
594 loop {
595 skip_ws(self.text, &mut pos);
596
597 if pos == self.text.len() {
598 return if calls.is_empty() {
599 FallbackReason::MalformedStructure
600 } else {
601 FallbackReason::None
602 };
603 }
604
605 if !starts_at(self.text, pos, TOOL_OPEN) {
606 if self.tolerant && !calls.is_empty() {
609 return FallbackReason::TruncatedTail;
610 }
611
612 return if calls.is_empty() {
613 FallbackReason::MalformedStructure
614 } else {
615 FallbackReason::TrailingContent
616 };
617 }
618
619 let mut call = RawToolCall {
620 name: String::new(),
621 parameters: Vec::new(),
622 };
623 let failure = self.parse_tool_call(&mut pos, &mut call);
624
625 if failure == FallbackReason::None {
626 calls.push(call);
627
628 continue;
629 }
630
631 if self.tolerant && !calls.is_empty() {
635 return FallbackReason::TruncatedTail;
636 }
637
638 if failure == FallbackReason::TruncatedTail && calls.is_empty() {
643 calls.push(call);
644
645 return FallbackReason::TruncatedTail;
646 }
647
648 return failure;
649 }
650 }
651
652 fn consume(&self, pos: &mut usize, token: &str) -> bool {
653 if !starts_at(self.text, *pos, token) {
654 return false;
655 }
656
657 *pos += token.len();
658
659 true
660 }
661
662 fn at_region_end(&self, pos: usize) -> bool {
664 let mut at = pos;
665
666 while at < self.text.len() && is_ws(self.text.as_bytes()[at] as char) {
667 at += 1;
668 }
669
670 at == self.text.len()
671 }
672
673 fn parse_tool_call(&self, pos: &mut usize, call: &mut RawToolCall) -> FallbackReason {
674 if !self.consume(pos, TOOL_OPEN) {
675 return FallbackReason::MalformedStructure;
676 }
677
678 skip_ws(self.text, pos);
679
680 let failure = self.parse_function(pos, call);
681
682 if failure != FallbackReason::None {
683 return failure;
684 }
685
686 skip_ws(self.text, pos);
687
688 if self.consume(pos, TOOL_CLOSE) {
689 return FallbackReason::None;
690 }
691
692 if self.tolerant {
696 FallbackReason::TruncatedTail
697 } else {
698 FallbackReason::MalformedStructure
699 }
700 }
701
702 fn parse_function(&self, pos: &mut usize, call: &mut RawToolCall) -> FallbackReason {
703 if !self.consume(pos, FUNCTION_OPEN) {
704 return FallbackReason::MalformedStructure;
705 }
706
707 let name_begin = *pos;
708 let mut name_end = self.text[name_begin..].find('>').map(|r| name_begin + r);
709 let mut ws_boundary = false;
710
711 if self.tolerant {
717 let mut scan = name_begin;
718
719 while scan < self.text.len() && scan - name_begin < MAX_TOOL_NAME_LENGTH {
720 let byte = self.text.as_bytes()[scan];
721
722 if !byte.is_ascii_alphanumeric() && byte != b'_' && byte != b'-' {
723 break;
724 }
725
726 scan += 1;
727 }
728
729 if scan > name_begin
730 && scan < self.text.len()
731 && is_ws(self.text.as_bytes()[scan] as char)
732 && name_end.map(|end| scan < end).unwrap_or(true)
733 {
734 let mut after = scan;
735
736 while after < self.text.len() && is_ws(self.text.as_bytes()[after] as char) {
737 after += 1;
738 }
739
740 if after >= self.text.len() || self.text[after..].starts_with('<') {
741 name_end = Some(scan);
742 ws_boundary = true;
743 }
744 }
745 }
746
747 let Some(end) = name_end else {
748 return FallbackReason::InvalidToolName;
749 };
750
751 if end == name_begin {
752 return FallbackReason::InvalidToolName;
753 }
754
755 let name = &self.text[name_begin..end];
756 call.name = name.to_owned();
757
758 if !valid_function_name(name) {
759 return FallbackReason::InvalidToolName;
760 }
761
762 if self.contract.enforce_declared_names && !self.contract.is_declared(name) {
763 return FallbackReason::UndeclaredTool;
764 }
765
766 *pos = if ws_boundary { end } else { end + 1 };
767
768 loop {
769 skip_ws(self.text, pos);
770
771 if self.consume(pos, FUNCTION_CLOSE) {
772 return FallbackReason::None;
773 }
774
775 if self.tolerant && self.at_region_end(*pos) {
779 return FallbackReason::TruncatedTail;
780 }
781
782 let failure = self.parse_parameter(pos, call);
783
784 if failure != FallbackReason::None {
785 return failure;
786 }
787 }
788 }
789
790 fn parse_parameter(&self, pos: &mut usize, call: &mut RawToolCall) -> FallbackReason {
791 if !self.consume(pos, PARAM_OPEN) {
792 return FallbackReason::MalformedStructure;
793 }
794
795 let name_begin = *pos;
796 let Some(rel) = self.text[name_begin..].find('>') else {
797 return FallbackReason::MalformedStructure;
798 };
799 let name_end = name_begin + rel;
800
801 if name_end == name_begin {
802 return FallbackReason::MalformedStructure;
803 }
804
805 let name = &self.text[name_begin..name_end];
806
807 if call.parameters.iter().any(|p| p.name == name) {
808 return FallbackReason::DuplicateParameter;
809 }
810
811 let value_begin = name_end + 1;
812 let Some(value_end) = self.find_parameter_close(value_begin) else {
813 return FallbackReason::MalformedStructure;
814 };
815
816 call.parameters.push(RawParameter {
817 name: name.to_owned(),
818 value: self.text[value_begin..value_end].to_owned(),
819 });
820
821 *pos = value_end + PARAM_CLOSE.len();
822
823 FallbackReason::None
824 }
825
826 fn find_parameter_open_before(&self, scan: usize, limit: usize) -> Option<usize> {
827 let mut candidate = self.text[scan..].find(PARAM_OPEN).map(|r| scan + r);
828
829 while let Some(at) = candidate {
830 if at >= limit {
831 break;
832 }
833
834 let name_begin = at + PARAM_OPEN.len();
835 let Some(name_end) = self.text[name_begin..].find('>').map(|r| name_begin + r) else {
836 candidate = self.text[at + 1..].find(PARAM_OPEN).map(|r| at + 1 + r);
837
838 continue;
839 };
840
841 if name_end < limit && name_end != name_begin {
842 return Some(name_end + 1);
843 }
844
845 candidate = self.text[at + 1..].find(PARAM_OPEN).map(|r| at + 1 + r);
846 }
847
848 None
849 }
850
851 fn find_parameter_close(&self, value_begin: usize) -> Option<usize> {
854 let mut depth = 1;
855 let mut scan = value_begin;
856
857 loop {
858 let Some(rel) = self.text[scan..].find(PARAM_CLOSE) else {
859 return None;
860 };
861 let close = scan + rel;
862 let open_end = self.find_parameter_open_before(scan, close);
863
864 if let Some(open_end) = open_end {
865 depth += 1;
866 scan = open_end;
867
868 continue;
869 }
870
871 depth -= 1;
872
873 if depth == 0 {
874 return Some(close);
875 }
876
877 scan = close + PARAM_CLOSE.len();
878 }
879 }
880}
881
882fn skip_ws(text: &str, pos: &mut usize) {
883 while *pos < text.len() && is_ws(text.as_bytes()[*pos] as char) {
884 *pos += 1;
885 }
886}
887
888fn starts_at(text: &str, pos: usize, prefix: &str) -> bool {
889 text.get(pos..pos + prefix.len())
890 .is_some_and(|slice| slice == prefix)
891}
892
893pub(crate) fn parse_qwen_tool_call_output(
898 text: &str,
899 contract: &ToolCallOutputContract,
900 tolerant: bool,
901) -> ParsedToolCallOutput {
902 let Some(first) = text.find(TOOL_OPEN) else {
903 return ParsedToolCallOutput {
904 is_tool_call_response: false,
905 content: text.to_owned(),
906 tool_calls: Vec::new(),
907 diagnostics: ToolCallParseDiagnostics::default(),
908 };
909 };
910
911 let mut out = ParsedToolCallOutput {
912 is_tool_call_response: false,
913 content: trim_ws(&text[..first]).to_owned(),
914 tool_calls: Vec::new(),
915 diagnostics: ToolCallParseDiagnostics {
916 marker_seen: true,
917 ..Default::default()
918 },
919 };
920
921 let parser = RegionParser {
922 text: &text[first..],
923 contract,
924 tolerant,
925 };
926 let mut raw_calls = Vec::new();
927 let failure = parser.parse(&mut raw_calls);
928
929 if failure == FallbackReason::TruncatedTail {
930 out.diagnostics.fallback_reason = failure;
933 } else if failure != FallbackReason::None {
934 out.diagnostics.fallback_reason = failure;
935
936 return fallback(text, out.diagnostics);
937 }
938
939 for raw in &raw_calls {
940 out.tool_calls
941 .push(normalize_raw_tool_call(raw, contract, &mut out.diagnostics));
942 }
943
944 out.diagnostics.structured_call_count = out.tool_calls.len() as u32;
945 out.is_tool_call_response = true;
946
947 out
948}
949
950pub(crate) struct Terminal {
953 pub content: String,
955 pub tool_calls: Vec<GeneratedToolCall>,
956 pub diagnostics: ToolCallParseDiagnostics,
957}
958
959pub(crate) struct ToolCallOutputDecoder {
964 contract: Arc<ToolCallOutputContract>,
965 tolerant: bool,
966 trailing_whitespace: String,
967 tool_region: String,
968 marker_prefix: usize,
969 saw_tool_marker: bool,
970}
971
972impl ToolCallOutputDecoder {
973 pub(crate) fn new(contract: Arc<ToolCallOutputContract>, tolerant: bool) -> Self {
974 Self {
975 contract,
976 tolerant,
977 trailing_whitespace: String::new(),
978 tool_region: String::new(),
979 marker_prefix: 0,
980 saw_tool_marker: false,
981 }
982 }
983
984 pub(crate) fn feed(&mut self, text: &str) -> String {
986 if text.is_empty() {
987 return String::new();
988 }
989
990 if self.saw_tool_marker {
991 self.tool_region.push_str(text);
992
993 return String::new();
994 }
995
996 let marker = TOOL_OPEN.as_bytes();
997 let mut visible = String::new();
998 let mut cursor = 0;
999
1000 for c in text.chars() {
1001 if self.marker_prefix > 0 {
1002 if c == marker[self.marker_prefix] as char {
1003 self.marker_prefix += 1;
1004
1005 if self.marker_prefix == marker.len() {
1006 self.tool_region = std::mem::take(&mut self.trailing_whitespace);
1009
1010 self.tool_region.push_str(TOOL_OPEN);
1011 self.tool_region.push_str(&text[cursor + c.len_utf8()..]);
1012
1013 self.marker_prefix = 0;
1014 self.saw_tool_marker = true;
1015
1016 return visible;
1017 }
1018
1019 cursor += c.len_utf8();
1020
1021 continue;
1022 }
1023
1024 visible.push_str(&self.trailing_whitespace);
1026 self.trailing_whitespace.clear();
1027 visible.push_str(&TOOL_OPEN[..self.marker_prefix]);
1028
1029 self.marker_prefix = 0;
1030 }
1031
1032 if c == '<' {
1033 self.marker_prefix = 1;
1034 } else if is_ws(c) {
1035 self.trailing_whitespace.push(c);
1036 } else {
1037 visible.push_str(&self.trailing_whitespace);
1038 self.trailing_whitespace.clear();
1039 visible.push(c);
1040 }
1041
1042 cursor += c.len_utf8();
1043 }
1044
1045 visible
1046 }
1047
1048 pub(crate) fn finish(self) -> Terminal {
1050 let parsed = parse_qwen_tool_call_output(&self.tool_region, &self.contract, self.tolerant);
1051
1052 if self.saw_tool_marker && parsed.is_tool_call_response {
1053 return Terminal {
1054 content: String::new(),
1055 tool_calls: parsed.tool_calls,
1056 diagnostics: parsed.diagnostics,
1057 };
1058 }
1059
1060 let mut content = self.trailing_whitespace;
1061
1062 content.push_str(&TOOL_OPEN[..self.marker_prefix]);
1063 content.push_str(&self.tool_region);
1064
1065 Terminal {
1066 content,
1067 tool_calls: Vec::new(),
1068 diagnostics: parsed.diagnostics,
1069 }
1070 }
1071}
1072
1073#[cfg(test)]
1074#[path = "../../tests/unit/server/tool_call.rs"]
1075mod tests;