1use std::collections::HashSet;
2
3use async_trait::async_trait;
4use openai_protocol::common::Tool;
5use serde_json::Value;
6
7use crate::{
8 errors::ParserResult,
9 partial_json::PartialJson,
10 traits::ToolParser,
11 types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
12};
13
14const TOOL_CALL_JSON_START: &str = "<|content_invoke_tool_json|>";
15const TOOL_CALL_TEXT_START: &str = "<|content_invoke_tool_text|>";
16const END_MESSAGE: &str = "<|end_message|>";
17const MESSAGE_MODEL: &str = "<|message_model|>";
18const CONTENT_TEXT: &str = "<|content_text|>";
19const CONTENT_THINKING: &str = "<|content_thinking|>";
20const MODEL_END_SAMPLING: &str = "<|content_model_end_sampling|>";
21
22const STREAM_CONTROL_TOKENS: [&str; 7] = [
23 TOOL_CALL_JSON_START,
24 TOOL_CALL_TEXT_START,
25 MESSAGE_MODEL,
26 CONTENT_TEXT,
27 CONTENT_THINKING,
28 END_MESSAGE,
29 MODEL_END_SAMPLING,
30];
31
32const HEADER_CONTROL_TOKENS: [&str; 7] = [
33 TOOL_CALL_JSON_START,
34 TOOL_CALL_TEXT_START,
35 MESSAGE_MODEL,
36 CONTENT_TEXT,
37 CONTENT_THINKING,
38 END_MESSAGE,
39 MODEL_END_SAMPLING,
40];
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum StreamingState {
44 Text,
45 MessageHeader,
46 ToolCallJson,
47 ToolCallEnd,
48 DiscardToolCall,
49}
50
51pub struct InklingParser {
61 partial_json: PartialJson,
62 buffer: String,
63 state: StreamingState,
64 current_tool_index: usize,
65 current_tool_name: Option<String>,
66}
67
68impl InklingParser {
69 pub fn new() -> Self {
70 Self {
71 partial_json: PartialJson::default(),
72 buffer: String::new(),
73 state: StreamingState::Text,
74 current_tool_index: 0,
75 current_tool_name: None,
76 }
77 }
78
79 pub fn build_structural_tag(tools: &[Tool], at_least_one: bool) -> Value {
82 let tags = tools
83 .iter()
84 .filter(|tool| !tool.function.name.is_empty())
85 .map(|tool| {
86 let name = serde_json::to_string(&tool.function.name).unwrap_or_default();
87 serde_json::json!({
88 "begin": format!(
89 "{TOOL_CALL_JSON_START}{{\"name\":{name},\"args\":"
90 ),
91 "content": {
92 "type": "json_schema",
93 "json_schema": tool.function.parameters_schema(),
94 },
95 "end": format!("}}{END_MESSAGE}"),
96 })
97 })
98 .collect::<Vec<_>>();
99
100 serde_json::json!({
101 "format": {
102 "type": "triggered_tags",
103 "triggers": [TOOL_CALL_JSON_START],
104 "tags": tags,
105 "at_least_one": at_least_one,
106 }
107 })
108 }
109
110 fn clean_normal_text(text: &str) -> String {
111 let controls = [
112 MESSAGE_MODEL,
113 CONTENT_TEXT,
114 CONTENT_THINKING,
115 END_MESSAGE,
116 MODEL_END_SAMPLING,
117 ];
118 let mut output = String::new();
119 let mut cursor = 0;
120 let mut in_header = false;
121
122 while cursor < text.len() {
123 let remaining = &text[cursor..];
124 let Some((start, token)) = find_earliest_token(remaining, &controls) else {
125 if !in_header {
126 output.push_str(remaining);
127 }
128 break;
129 };
130
131 if !in_header {
132 output.push_str(&remaining[..start]);
133 }
134 cursor += start + token.len();
135 in_header = token == MESSAGE_MODEL;
136 }
137
138 output
139 }
140
141 fn parse_complete_impl(
142 text: &str,
143 allowed_tools: Option<&HashSet<&str>>,
144 ) -> (String, Vec<ToolCall>) {
145 let tool_markers = [TOOL_CALL_JSON_START, TOOL_CALL_TEXT_START];
146 let mut normal_text = String::new();
147 let mut calls = Vec::new();
148 let mut cursor = 0;
149
150 while let Some((relative_start, marker)) =
151 find_earliest_token(&text[cursor..], &tool_markers)
152 {
153 let marker_start = cursor + relative_start;
154 normal_text.push_str(&Self::clean_normal_text(&text[cursor..marker_start]));
155 let payload_start = marker_start + marker.len();
156
157 if marker == TOOL_CALL_TEXT_START {
158 tracing::warn!(
159 "Ignoring TML text-mode tool invocation; OpenAI tool calls require structured JSON"
160 );
161 cursor = after_discarded_tool_frame(text, payload_start);
162 continue;
163 }
164
165 let payload = &text[payload_start..];
166 let whitespace = payload.len() - payload.trim_start().len();
167 let json_start = payload_start + whitespace;
168 let Some(json_len) = complete_json_object_len(&text[json_start..]) else {
169 tracing::warn!("Ignoring malformed TML JSON tool invocation");
170 cursor = after_discarded_tool_frame(text, payload_start);
171 continue;
172 };
173 let json_end = json_start + json_len;
174 let after_json = &text[json_end..];
175 let whitespace = after_json.len() - after_json.trim_start().len();
176 let end_start = json_end + whitespace;
177
178 let Some(call) = parse_tool_call_json(&text[json_start..json_end], allowed_tools)
179 else {
180 cursor = after_discarded_tool_frame(text, json_end);
183 continue;
184 };
185 calls.push(call);
186
187 if text[end_start..].starts_with(END_MESSAGE) {
188 cursor = end_start + END_MESSAGE.len();
189 } else if text[end_start..].starts_with(MODEL_END_SAMPLING) {
190 cursor = end_start + MODEL_END_SAMPLING.len();
191 } else {
192 cursor = end_start;
196 }
197 }
198
199 normal_text.push_str(&Self::clean_normal_text(&text[cursor..]));
200 (normal_text, calls)
201 }
202
203 fn process_text(&mut self, result: &mut StreamingParseResult) -> bool {
204 let state_markers = [MESSAGE_MODEL, TOOL_CALL_JSON_START, TOOL_CALL_TEXT_START];
205 if let Some((start, marker)) = find_earliest_token(&self.buffer, &state_markers) {
206 let normal_text = Self::clean_normal_text(&self.buffer[..start]);
207 result.normal_text.push_str(&normal_text);
208 self.buffer.drain(..start + marker.len());
209 self.state = if marker == MESSAGE_MODEL {
210 StreamingState::MessageHeader
211 } else if marker == TOOL_CALL_JSON_START {
212 StreamingState::ToolCallJson
213 } else {
214 tracing::warn!(
215 "Ignoring TML text-mode tool invocation; OpenAI tool calls require structured JSON"
216 );
217 StreamingState::DiscardToolCall
218 };
219 return true;
220 }
221
222 let keep = longest_partial_token_suffix(&self.buffer, &STREAM_CONTROL_TOKENS);
223 let safe_len = self.buffer.len() - keep;
224 if safe_len > 0 {
225 let safe_text = self.buffer[..safe_len].to_string();
226 self.buffer.drain(..safe_len);
227 result
228 .normal_text
229 .push_str(&Self::clean_normal_text(&safe_text));
230 }
231 false
232 }
233
234 fn process_message_header(&mut self) -> bool {
235 if let Some((start, token)) = find_earliest_token(&self.buffer, &HEADER_CONTROL_TOKENS) {
236 self.buffer.drain(..start + token.len());
240 self.state = if token == MESSAGE_MODEL {
241 StreamingState::MessageHeader
242 } else if token == TOOL_CALL_JSON_START {
243 StreamingState::ToolCallJson
244 } else if token == TOOL_CALL_TEXT_START {
245 tracing::warn!(
246 "Ignoring TML text-mode tool invocation; OpenAI tool calls require structured JSON"
247 );
248 StreamingState::DiscardToolCall
249 } else {
250 StreamingState::Text
251 };
252 return true;
253 }
254
255 let keep = longest_partial_token_suffix(&self.buffer, &HEADER_CONTROL_TOKENS);
258 let safe_len = self.buffer.len() - keep;
259 if safe_len > 0 {
260 self.buffer.drain(..safe_len);
261 }
262 false
263 }
264
265 fn process_tool_call(
266 &mut self,
267 allowed_tools: &HashSet<&str>,
268 result: &mut StreamingParseResult,
269 ) -> bool {
270 let whitespace = self.buffer.len() - self.buffer.trim_start().len();
271 if whitespace > 0 {
272 self.buffer.drain(..whitespace);
273 }
274 if self.buffer.is_empty() {
275 return false;
276 }
277
278 if !self.buffer.starts_with('{') {
279 self.current_tool_name = None;
280 self.state = StreamingState::DiscardToolCall;
281 return true;
282 }
283
284 if self.current_tool_name.is_none() {
285 if let Ok((Value::Object(object), _)) =
286 self.partial_json.parse_value(&self.buffer, false)
287 {
288 if let Some(name) = object.get("name").and_then(Value::as_str) {
289 if allowed_tools.contains(name) {
290 self.current_tool_name = Some(name.to_string());
291 result.calls.push(ToolCallItem {
292 tool_index: self.current_tool_index,
293 name: Some(name.to_string()),
294 parameters: String::new(),
295 });
296 } else {
297 tracing::debug!("Inkling attempted to call undefined tool: {}", name);
298 self.state = StreamingState::DiscardToolCall;
299 return true;
300 }
301 }
302 }
303 }
304
305 let Some(json_len) = complete_json_object_len(&self.buffer) else {
306 return false;
307 };
308 let json = &self.buffer[..json_len];
309 let Some(call) = parse_tool_call_json(json, Some(allowed_tools)) else {
310 self.current_tool_name = None;
311 self.state = StreamingState::DiscardToolCall;
312 return true;
313 };
314
315 if self.current_tool_name.is_none() {
318 result.calls.push(ToolCallItem {
319 tool_index: self.current_tool_index,
320 name: Some(call.function.name.clone()),
321 parameters: String::new(),
322 });
323 }
324 result.calls.push(ToolCallItem {
325 tool_index: self.current_tool_index,
326 name: None,
327 parameters: call.function.arguments,
328 });
329
330 self.buffer.drain(..json_len);
331 self.current_tool_index += 1;
332 self.current_tool_name = None;
333 self.state = StreamingState::ToolCallEnd;
334 true
335 }
336
337 fn process_tool_call_end(&mut self) -> bool {
338 let whitespace = self.buffer.len() - self.buffer.trim_start().len();
339 if whitespace > 0 {
340 self.buffer.drain(..whitespace);
341 }
342 if self.buffer.is_empty() {
343 return false;
344 }
345 if self.buffer.starts_with(END_MESSAGE) {
346 self.buffer.drain(..END_MESSAGE.len());
347 self.state = StreamingState::Text;
348 return true;
349 }
350 if END_MESSAGE.starts_with(&self.buffer) {
351 return false;
352 }
353
354 self.state = StreamingState::Text;
357 true
358 }
359
360 fn discard_tool_call(&mut self) -> bool {
361 let end_tokens = [END_MESSAGE, MODEL_END_SAMPLING];
362 if let Some((end, token)) = find_earliest_token(&self.buffer, &end_tokens) {
363 self.buffer.drain(..end + token.len());
364 self.current_tool_name = None;
365 self.state = StreamingState::Text;
366 return true;
367 }
368
369 let keep = longest_partial_token_suffix(&self.buffer, &end_tokens);
370 let safe_len = self.buffer.len() - keep;
371 if safe_len > 0 {
372 self.buffer.drain(..safe_len);
373 }
374 false
375 }
376}
377
378impl Default for InklingParser {
379 fn default() -> Self {
380 Self::new()
381 }
382}
383
384#[async_trait]
385impl ToolParser for InklingParser {
386 async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
387 Ok(Self::parse_complete_impl(text, None))
388 }
389
390 async fn parse_complete_with_tools(
391 &self,
392 text: &str,
393 tools: &[Tool],
394 ) -> ParserResult<(String, Vec<ToolCall>)> {
395 let allowed_tools = tools
396 .iter()
397 .map(|tool| tool.function.name.as_str())
398 .collect::<HashSet<_>>();
399 Ok(Self::parse_complete_impl(text, Some(&allowed_tools)))
400 }
401
402 async fn parse_incremental(
403 &mut self,
404 chunk: &str,
405 tools: &[Tool],
406 ) -> ParserResult<StreamingParseResult> {
407 self.buffer.push_str(chunk);
408 let allowed_tools = tools
409 .iter()
410 .map(|tool| tool.function.name.as_str())
411 .collect::<HashSet<_>>();
412 let mut result = StreamingParseResult::default();
413
414 loop {
415 let progressed = match self.state {
416 StreamingState::Text => self.process_text(&mut result),
417 StreamingState::MessageHeader => self.process_message_header(),
418 StreamingState::ToolCallJson => self.process_tool_call(&allowed_tools, &mut result),
419 StreamingState::ToolCallEnd => self.process_tool_call_end(),
420 StreamingState::DiscardToolCall => self.discard_tool_call(),
421 };
422 if !progressed {
423 break;
424 }
425 }
426
427 Ok(result)
428 }
429
430 fn has_tool_markers(&self, text: &str) -> bool {
431 text.contains(TOOL_CALL_JSON_START) || text.contains(TOOL_CALL_TEXT_START)
432 }
433
434 fn reset(&mut self) {
435 self.buffer.clear();
436 self.state = StreamingState::Text;
437 self.current_tool_index = 0;
438 self.current_tool_name = None;
439 }
440}
441
442fn parse_tool_call_json(json: &str, allowed_tools: Option<&HashSet<&str>>) -> Option<ToolCall> {
443 let value = serde_json::from_str::<Value>(json).ok()?;
444 let object = value.as_object()?;
445 let name = object.get("name")?.as_str()?;
446 let args = object.get("args")?.as_object()?;
447
448 if allowed_tools.is_some_and(|tools| !tools.contains(name)) {
449 tracing::debug!("Inkling attempted to call undefined tool: {}", name);
450 return None;
451 }
452
453 Some(ToolCall {
454 function: FunctionCall {
455 name: name.to_string(),
456 arguments: serde_json::to_string(args).ok()?,
457 },
458 })
459}
460
461fn find_earliest_token<'a>(text: &str, tokens: &'a [&'a str]) -> Option<(usize, &'a str)> {
464 tokens
465 .iter()
466 .filter_map(|token| text.find(token).map(|start| (start, *token)))
467 .min_by_key(|(start, _)| *start)
468}
469
470fn after_discarded_tool_frame(text: &str, payload_start: usize) -> usize {
474 let end_tokens = [END_MESSAGE, MODEL_END_SAMPLING];
475 find_earliest_token(&text[payload_start..], &end_tokens)
476 .map_or(text.len(), |(relative_end, token)| {
477 payload_start + relative_end + token.len()
478 })
479}
480
481fn complete_json_object_len(text: &str) -> Option<usize> {
484 if !text.starts_with('{') {
485 return None;
486 }
487
488 let mut depth = 0usize;
489 let mut in_string = false;
490 let mut escaped = false;
491
492 for (index, byte) in text.bytes().enumerate() {
493 if in_string {
494 if escaped {
495 escaped = false;
496 } else if byte == b'\\' {
497 escaped = true;
498 } else if byte == b'"' {
499 in_string = false;
500 }
501 continue;
502 }
503
504 match byte {
505 b'"' => in_string = true,
506 b'{' => depth += 1,
507 b'}' => {
508 depth = depth.checked_sub(1)?;
509 if depth == 0 {
510 return Some(index + 1);
511 }
512 }
513 _ => {}
514 }
515 }
516 None
517}
518
519fn longest_partial_token_suffix(buffer: &str, tokens: &[&str]) -> usize {
520 tokens
521 .iter()
522 .flat_map(|token| {
523 token
524 .char_indices()
525 .skip(1)
526 .map(move |(index, _)| &token[..index])
527 })
528 .filter(|prefix| buffer.ends_with(prefix))
529 .map(str::len)
530 .max()
531 .unwrap_or(0)
532}