use crate::reasoning::MARKER_PAIRS;
use crate::tools::ToolCallFormat;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
Text,
Reasoning,
ToolCall,
}
const TAIL_CAP: usize = 32;
pub struct StreamClassifier {
tool_open: &'static str,
tool_close: &'static str,
state: TokenKind,
reasoning_close: &'static str,
tail: String,
last_marker: Option<&'static str>,
}
impl StreamClassifier {
pub fn new(tool_format: ToolCallFormat) -> Self {
let (tool_open, tool_close) = match tool_format {
ToolCallFormat::Hermes => ("<tool_call>", "</tool_call>"),
ToolCallFormat::Gemma => ("<|tool_call>", "<tool_call|>"),
ToolCallFormat::None => ("", ""),
};
StreamClassifier {
tool_open,
tool_close,
state: TokenKind::Text,
reasoning_close: "",
tail: String::new(),
last_marker: None,
}
}
pub fn last_marker(&self) -> Option<&'static str> {
self.last_marker
}
pub(crate) fn seed_reasoning(&mut self, close: &'static str) {
self.state = TokenKind::Reasoning;
self.reasoning_close = close;
self.tail.clear();
}
pub fn classify(&mut self, piece: &str) -> TokenKind {
self.last_marker = None;
self.tail.push_str(piece);
if self.tail.len() > TAIL_CAP {
let mut cut = self.tail.len() - TAIL_CAP;
while !self.tail.is_char_boundary(cut) {
cut += 1;
}
self.tail.drain(..cut);
}
match self.state {
TokenKind::Text => {
if let Some((open, close)) = MARKER_PAIRS
.iter()
.find(|(open, _)| self.tail.contains(open))
{
self.state = TokenKind::Reasoning;
self.reasoning_close = close;
self.last_marker = Some(open);
self.tail.clear();
return TokenKind::Reasoning;
}
if !self.tool_open.is_empty() && self.tail.contains(self.tool_open) {
self.state = TokenKind::ToolCall;
self.last_marker = Some(self.tool_open);
self.tail.clear();
return TokenKind::ToolCall;
}
TokenKind::Text
}
TokenKind::Reasoning => {
if self.tail.contains(self.reasoning_close) {
self.state = TokenKind::Text;
self.last_marker = Some(self.reasoning_close);
self.tail.clear();
}
TokenKind::Reasoning
}
TokenKind::ToolCall => {
if self.tail.contains(self.tool_close) {
self.state = TokenKind::Text;
self.last_marker = Some(self.tool_close);
self.tail.clear();
}
TokenKind::ToolCall
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn classify_all(format: ToolCallFormat, pieces: &[&str]) -> Vec<TokenKind> {
let mut c = StreamClassifier::new(format);
pieces.iter().map(|p| c.classify(p)).collect()
}
#[test]
fn plain_text_stays_text() {
let kinds = classify_all(ToolCallFormat::None, &["hello", " ", "world"]);
assert!(kinds.iter().all(|k| *k == TokenKind::Text));
}
#[test]
fn reasoning_span_is_tagged_and_reverts_to_text() {
let kinds = classify_all(
ToolCallFormat::None,
&["<think>", "hmm", "</think>", "the answer", " is 4"],
);
assert_eq!(
kinds,
vec![
TokenKind::Reasoning,
TokenKind::Reasoning,
TokenKind::Reasoning,
TokenKind::Text,
TokenKind::Text,
]
);
}
#[test]
fn marker_split_across_two_tokens_is_still_detected() {
let kinds = classify_all(
ToolCallFormat::None,
&["<thi", "nk>", "reasoning here", "</think>", "answer"],
);
assert_eq!(kinds[1], TokenKind::Reasoning);
assert_eq!(kinds[2], TokenKind::Reasoning);
assert_eq!(kinds[3], TokenKind::Reasoning);
assert_eq!(kinds[4], TokenKind::Text);
}
#[test]
fn seed_reasoning_tags_generation_from_the_first_token() {
let mut c = StreamClassifier::new(ToolCallFormat::None);
c.seed_reasoning("</think>");
let kinds: Vec<_> = ["let", " me", " think", "</think>", "answer"]
.iter()
.map(|p| c.classify(p))
.collect();
assert_eq!(
kinds,
vec![
TokenKind::Reasoning,
TokenKind::Reasoning,
TokenKind::Reasoning,
TokenKind::Reasoning,
TokenKind::Text,
]
);
}
#[test]
fn hermes_tool_call_span_is_tagged() {
let kinds = classify_all(
ToolCallFormat::Hermes,
&[
"let me check.",
"<tool_call>",
r#"{"name": "x"}"#,
"</tool_call>",
"done",
],
);
assert_eq!(
kinds,
vec![
TokenKind::Text,
TokenKind::ToolCall,
TokenKind::ToolCall,
TokenKind::ToolCall,
TokenKind::Text
]
);
}
#[test]
fn gemma_tool_call_span_is_tagged() {
let kinds = classify_all(
ToolCallFormat::Gemma,
&[
"<|tool_call>call:",
"get_weather{city:\"Paris\"}",
"<tool_call|>",
"ok",
],
);
assert_eq!(
kinds,
vec![
TokenKind::ToolCall,
TokenKind::ToolCall,
TokenKind::ToolCall,
TokenKind::Text
]
);
}
#[test]
fn none_format_never_enters_tool_call_state() {
let kinds = classify_all(
ToolCallFormat::None,
&["<tool_call>", "still just text", "</tool_call>"],
);
assert!(kinds.iter().all(|k| *k == TokenKind::Text));
}
}