use super::builder::AgentService;
use super::types::{MessageQueueCallback, ProgressCallback, ProgressEvent};
use crate::brain::provider::{ContentBlock, LLMRequest, LLMResponse, Message, StopReason};
use serde_json::Value;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
pub(crate) const MIMO_TOOL_CALL_HINT: &str = "## Tool calls — required format\n\
When you need to run a tool, emit it ONLY as a real structured tool call. Never \
write a tool call as text or JSON in your visible message or in your reasoning \
(e.g. `<tool_call>`, `<tool_call_list>`, or a raw `{\"tool_name\": …}` object) — \
text like that is NOT executed and the action silently does nothing. Do not \
announce that you are about to act (\"Running the tests now.\", \"Let me check \
the logs.\") and then stop: in the same turn, actually call the tool. Only write \
a plain-text reply once the work is genuinely done.";
pub(crate) fn is_mimo_model(model: &str) -> bool {
model.to_ascii_lowercase().contains("mimo")
}
pub(crate) fn empty_reasoning_nudge(no_tools_yet: bool, attempt: u32) -> &'static str {
if no_tools_yet {
match attempt {
1 => {
"[System: Your last turn produced only internal reasoning — no tool call and no \
reply. If you need to DO something (read a file, run a command, check git, fetch \
data), CALL the correct tool NOW through the structured tool-call API — do NOT \
describe the tool in text, that does nothing. If you already have everything you \
need, write the answer as plain text instead. Pick one and act on this turn.]"
}
2 => {
"[System: Again only reasoning — no action. Decide NOW: either call the tool you \
need via the structured tool-call API (not text, not JSON in your message), or \
write the final answer as plain text. Do exactly one of them this turn.]"
}
_ => {
"[System: Still no tool call and no reply after reasoning. Invoke the required \
tool through the structured API now, or write the answer. Another reasoning-only \
turn will switch the conversation to a fallback provider automatically.]"
}
}
} else {
match attempt {
1 => {
"[System: Your previous turn produced only internal reasoning and no visible \
reply. The tool results above are sufficient — write the answer now as plain \
text (tables, prose, or whatever the user asked for). Do not re-reason; only \
call another tool if you genuinely still need more data.]"
}
2 => {
"[System: Second nudge — you again produced only reasoning. Output the answer as \
plain text on this turn using the tool results you already have.]"
}
3 => {
"[System: Third nudge. Stop reasoning. Reply now in plain prose, one or two short \
paragraphs, from the results above. No <thinking>, no internal monologue.]"
}
4 => {
"[System: Fourth nudge — final warning before fallback. Emit a visible text reply \
NOW. If you produce another reasoning-only turn the conversation will switch to \
a different provider automatically.]"
}
_ => {
"[System: Fifth and last nudge. Reply in plain text on this turn or the system \
will hand the conversation to a fallback provider on the next turn.]"
}
}
}
}
pub(crate) fn assistant_reasoning_stub(reasoning: Option<&str>) -> Message {
match reasoning {
Some(r) if !r.trim().is_empty() => Message {
role: crate::brain::provider::Role::Assistant,
content: vec![ContentBlock::Thinking {
thinking: r.to_string(),
signature: None,
}],
},
_ => Message::assistant(String::new()),
}
}
pub(crate) fn retain_react_directive(text: &str) -> String {
match crate::utils::extract_react_marker(text) {
(_, Some(emoji)) => format!("<<react:{emoji}>>"),
(_, None) => String::new(),
}
}
pub(crate) fn handshake_timeout_for(cli_handles_tools: bool, base_url: Option<&str>) -> Duration {
if cli_handles_tools {
Duration::from_secs(600)
} else if base_url.is_some_and(crate::brain::provider::factory::is_local_base_url) {
Duration::from_secs(90)
} else {
Duration::from_secs(60)
}
}
impl AgentService {
pub(super) fn actual_tool_schema_tokens(&self) -> usize {
crate::brain::tokenizer::count_tokens(
&serde_json::to_string(&self.tool_registry.get_tool_definitions()).unwrap_or_default(),
)
}
pub(super) fn tool_schemas_for_session(
&self,
session_id: uuid::Uuid,
) -> Vec<crate::brain::provider::Tool> {
if self.lazy_tools {
let active = self.tool_registry.active_tools(session_id);
self.tool_registry.get_tool_definitions_filtered(&active)
} else {
self.tool_registry.get_tool_definitions()
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn stream_complete(
&self,
session_id: Uuid,
request: LLMRequest,
cancel_token: Option<&CancellationToken>,
override_cb: Option<&ProgressCallback>,
queue_cb: Option<&MessageQueueCallback>,
queued_out: Option<&tokio::sync::Mutex<Option<super::types::QueuedUserMessage>>>,
suppress_callback: bool,
) -> std::result::Result<(LLMResponse, Option<String>), crate::brain::provider::ProviderError>
{
use crate::brain::provider::{ContentDelta, StreamEvent, TokenUsage};
use futures::StreamExt;
let effective_cb: Option<&ProgressCallback> = if suppress_callback {
None
} else {
override_cb.or(self.progress_callback.as_ref())
};
let provider = self.provider_for_session(session_id);
let mut request = request;
let supported = provider.supported_models();
if !supported.is_empty() && !supported.iter().any(|m| m == &request.model) {
let remapped = provider.default_model().to_string();
tracing::warn!(
"stream_complete: provider '{}' does not support model '{}' — remapping to '{}' (never send a pair the user never configured)",
provider.name(),
request.model,
remapped,
);
request.model = remapped;
}
let request_model = request.model.clone();
let handshake_timeout =
handshake_timeout_for(provider.cli_handles_tools(), provider.base_url());
let mut stream =
match tokio::time::timeout(handshake_timeout, provider.stream(request)).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
crate::config::health::record_failure(provider.name(), &e.to_string());
return Err(e);
}
Err(_elapsed) => {
let secs = handshake_timeout.as_secs();
tracing::warn!(
"⏱️ stream handshake timeout after {}s ({}); retry chain will fire",
secs,
provider.base_url().unwrap_or("<no-base-url>"),
);
crate::config::health::record_failure(
provider.name(),
&format!("handshake timeout after {}s", secs),
);
return Err(crate::brain::provider::ProviderError::Timeout(secs));
}
};
let mut id = String::new();
let mut model = String::new();
let mut stop_reason: Option<StopReason> = None;
let mut input_tokens = 0u32;
let mut output_tokens = 0u32;
let mut cache_creation_tokens = 0u32;
let mut cache_read_tokens = 0u32;
let mut billing_cache_creation = 0u32;
let mut billing_cache_read = 0u32;
const IDLE_GAP_SECS: f64 = 1.0;
let mut active_secs: f64 = 0.0;
let mut window_start: Option<std::time::Instant> = None;
let mut last_delta_at: Option<std::time::Instant> = None;
let note_delta = |now: std::time::Instant,
active_secs: &mut f64,
window_start: &mut Option<std::time::Instant>,
last_delta_at: &mut Option<std::time::Instant>| {
match (*window_start, *last_delta_at) {
(None, _) => {
*window_start = Some(now);
}
(Some(start), Some(last)) => {
if (now - last).as_secs_f64() > IDLE_GAP_SECS {
*active_secs += (last - start).as_secs_f64();
*window_start = Some(now);
}
}
(Some(_), None) => {
*window_start = Some(now);
}
}
*last_delta_at = Some(now);
};
let mut total_text_len: usize = 0;
let mut text_window = String::new(); const REPEAT_WINDOW: usize = 2048; const REPEAT_MIN_MATCH: usize = 200;
struct BlockState {
block: ContentBlock,
json_buf: String, }
let mut block_states: Vec<BlockState> = Vec::new();
let mut reasoning_buf = String::new();
let mut reasoning_window = String::new(); const REASONING_REPEAT_WINDOW: usize = 8192; const REASONING_REPEAT_MIN_MATCH: usize = 300; let is_cli = provider.cli_handles_tools();
let mut cli_unflushed_text = String::new();
let is_local = provider
.base_url()
.map(crate::brain::provider::factory::is_local_base_url)
.unwrap_or(false);
let stream_idle_timeout = if is_cli || is_local {
std::time::Duration::from_secs(3600)
} else {
std::time::Duration::from_secs(90)
};
loop {
let next = tokio::select! {
biased;
_ = async {
if let Some(token) = cancel_token {
token.cancelled().await;
} else {
std::future::pending::<()>().await;
}
} => {
tracing::info!("Stream cancelled by user");
break;
}
result = tokio::time::timeout(stream_idle_timeout, stream.next()) => {
match result {
Ok(Some(item)) => item,
Ok(None) => break, Err(_elapsed) => {
tracing::warn!(
"⏱️ Stream idle timeout after {}s — no event received from provider. \
Treating as dropped stream (stop_reason=None → will retry).",
stream_idle_timeout.as_secs()
);
break; }
}
}
};
let event = match next {
Ok(e) => e,
Err(e) => {
tracing::warn!("Stream error: {}", e);
return Err(e);
}
};
match event {
StreamEvent::MessageStart { message } => {
id = message.id;
model = message.model;
input_tokens = message.usage.input_tokens;
}
StreamEvent::ContentBlockStart {
index,
content_block,
} => {
while block_states.len() <= index {
block_states.push(BlockState {
block: ContentBlock::Text {
text: String::new(),
},
json_buf: String::new(),
});
}
if matches!(content_block, ContentBlock::Thinking { .. })
&& !reasoning_buf.is_empty()
{
reasoning_buf.push_str("\n\n");
if let Some(cb) = effective_cb {
cb(
session_id,
ProgressEvent::ReasoningChunk {
text: "\n\n".to_string(),
},
);
}
}
block_states[index] = BlockState {
block: content_block,
json_buf: String::new(),
};
}
StreamEvent::ContentBlockDelta { index, delta } => {
if index < block_states.len() {
note_delta(
std::time::Instant::now(),
&mut active_secs,
&mut window_start,
&mut last_delta_at,
);
match delta {
ContentDelta::TextDelta { text } => {
if let Some(cb) = effective_cb {
cb(
session_id,
ProgressEvent::StreamingChunk { text: text.clone() },
);
}
if is_cli {
cli_unflushed_text.push_str(&text);
}
if let ContentBlock::Text { text: ref mut t } =
block_states[index].block
{
t.push_str(&text);
}
total_text_len += text.len();
text_window.push_str(&text);
if text_window.len() > REPEAT_WINDOW {
let mut drain = text_window.len() - REPEAT_WINDOW;
while !text_window.is_char_boundary(drain)
&& drain < text_window.len()
{
drain += 1;
}
text_window.drain(..drain);
}
if detect_text_repetition(
&strip_fenced_code(&text_window),
REPEAT_MIN_MATCH,
) {
tracing::warn!(
"🔁 Repetition detected in streaming response after {} bytes. \
Provider appears to be looping. Terminating stream.",
total_text_len,
);
stop_reason = Some(StopReason::EndTurn);
break;
}
}
ContentDelta::InputJsonDelta { partial_json } => {
block_states[index].json_buf.push_str(&partial_json);
}
ContentDelta::ReasoningDelta { text } => {
if let Some(cb) = effective_cb {
cb(
session_id,
ProgressEvent::ReasoningChunk { text: text.clone() },
);
}
reasoning_buf.push_str(&text);
reasoning_window.push_str(&text);
if reasoning_window.len() > REASONING_REPEAT_WINDOW {
let mut drain =
reasoning_window.len() - REASONING_REPEAT_WINDOW;
while !reasoning_window.is_char_boundary(drain)
&& drain < reasoning_window.len()
{
drain += 1;
}
reasoning_window.drain(..drain);
}
if detect_text_repetition(
&reasoning_window,
REASONING_REPEAT_MIN_MATCH,
) {
tracing::warn!(
"🔁 Repetition detected in reasoning after {} bytes. \
Model appears to be looping in its thinking. \
Terminating stream.",
reasoning_buf.len(),
);
stop_reason = Some(StopReason::EndTurn);
break;
}
}
ContentDelta::ThinkingDelta { thinking } => {
if let Some(cb) = effective_cb {
cb(
session_id,
ProgressEvent::ReasoningChunk {
text: thinking.clone(),
},
);
}
reasoning_buf.push_str(&thinking);
reasoning_window.push_str(&thinking);
if reasoning_window.len() > REASONING_REPEAT_WINDOW {
let mut drain =
reasoning_window.len() - REASONING_REPEAT_WINDOW;
while !reasoning_window.is_char_boundary(drain)
&& drain < reasoning_window.len()
{
drain += 1;
}
reasoning_window.drain(..drain);
}
if detect_text_repetition(
&reasoning_window,
REASONING_REPEAT_MIN_MATCH,
) {
tracing::warn!(
"🔁 Repetition detected in thinking after {} bytes. \
Model appears to be looping in its thinking. \
Terminating stream.",
reasoning_buf.len(),
);
stop_reason = Some(StopReason::EndTurn);
break;
}
}
}
}
}
StreamEvent::ContentBlockStop { index } => {
if index < block_states.len() {
{
let state = &mut block_states[index];
if let ContentBlock::ToolUse { ref mut input, .. } = state.block
&& !state.json_buf.is_empty()
{
*input = crate::brain::provider::json_repair::parse_or_repair(
&state.json_buf,
);
}
}
let is_tool =
matches!(block_states[index].block, ContentBlock::ToolUse { .. });
if is_cli
&& is_tool
&& !cli_unflushed_text.is_empty()
&& let Some(cb) = effective_cb
{
cb(
session_id,
ProgressEvent::IntermediateText {
text: cli_unflushed_text.clone(),
reasoning: None,
},
);
cli_unflushed_text.clear();
for bs in block_states.iter_mut() {
if let ContentBlock::Text { text: ref mut t } = bs.block {
t.clear();
}
}
}
if is_cli {
let state = &mut block_states[index];
if let ContentBlock::ToolUse {
ref name,
ref input,
..
} = state.block
&& let Some(cb) = effective_cb
{
let emit_name = name.to_lowercase();
cb(
session_id,
ProgressEvent::ToolStarted {
tool_name: emit_name.clone(),
tool_input: input.clone(),
},
);
cb(
session_id,
ProgressEvent::ToolCompleted {
tool_name: emit_name,
tool_input: input.clone(),
success: true,
summary: String::new(),
},
);
if let Some(qcb) = queue_cb
&& let Some(queued) = qcb(session_id).await
{
tracing::info!(
"Queued user message at CLI tool boundary — storing for tool_loop"
);
if let Some(buf) = queued_out {
*buf.lock().await = Some(queued);
}
stop_reason = Some(StopReason::EndTurn);
break;
}
}
}
}
}
StreamEvent::MessageDelta { delta, usage } => {
if delta.stop_reason.is_some() {
stop_reason = delta.stop_reason;
}
if usage.input_tokens > input_tokens {
input_tokens = usage.input_tokens;
}
if usage.output_tokens > output_tokens {
output_tokens = usage.output_tokens;
}
if usage.cache_creation_tokens > cache_creation_tokens {
cache_creation_tokens = usage.cache_creation_tokens;
}
if usage.cache_read_tokens > cache_read_tokens {
cache_read_tokens = usage.cache_read_tokens;
}
if usage.billing_cache_creation > billing_cache_creation {
billing_cache_creation = usage.billing_cache_creation;
}
if usage.billing_cache_read > billing_cache_read {
billing_cache_read = usage.billing_cache_read;
}
}
StreamEvent::MessageStop => break,
StreamEvent::Ping => {
if is_cli
&& !cli_unflushed_text.is_empty()
&& let Some(cb) = effective_cb
{
cb(
session_id,
ProgressEvent::IntermediateText {
text: std::mem::take(&mut cli_unflushed_text),
reasoning: None,
},
);
for bs in block_states.iter_mut() {
if let ContentBlock::Text { text: ref mut t } = bs.block {
*t = retain_react_directive(t);
}
}
}
}
StreamEvent::Error { error } => {
crate::config::health::record_failure(provider.name(), &error);
return Err(crate::brain::provider::ProviderError::StreamError(error));
}
}
}
if is_cli
&& !cli_unflushed_text.is_empty()
&& let Some(cb) = effective_cb
{
cb(
session_id,
ProgressEvent::IntermediateText {
text: cli_unflushed_text,
reasoning: None,
},
);
}
if stop_reason.is_none() && !block_states.is_empty() {
let has_tool_use = block_states
.iter()
.any(|bs| matches!(&bs.block, ContentBlock::ToolUse { .. }));
let text: String = block_states
.iter()
.filter_map(|bs| match &bs.block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
if !has_tool_use && crate::utils::text_complete::text_looks_complete(&text) {
tracing::info!(
"Stream ended without [DONE] but text looks complete \
({} blocks, {} output tokens, last 40 chars: {:?}) — \
synthesising EndTurn instead of retrying",
block_states.len(),
output_tokens,
text.chars()
.rev()
.take(40)
.collect::<String>()
.chars()
.rev()
.collect::<String>(),
);
stop_reason = Some(StopReason::EndTurn);
} else {
let msg = format!(
"Stream ended without [DONE]: {} content blocks, {} output tokens — connection likely dropped",
block_states.len(),
output_tokens,
);
tracing::warn!("⚠️ {}", msg);
return Err(crate::brain::provider::ProviderError::StreamError(msg));
}
}
if stop_reason == Some(StopReason::EndTurn) && output_tokens > 0 && output_tokens < 100 {
let has_tool_use = block_states
.iter()
.any(|bs| matches!(&bs.block, ContentBlock::ToolUse { .. }));
if !has_tool_use {
let text: String = block_states
.iter()
.filter_map(|bs| match &bs.block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
let trimmed = text.trim();
if trimmed.ends_with(':') || trimmed.ends_with("...") {
let has_prior_sentence = trimmed[..trimmed.len().saturating_sub(1)]
.contains('.')
|| trimmed[..trimmed.len().saturating_sub(1)].contains('!');
if has_prior_sentence {
tracing::debug!(
"Self-heal: skipping truncation check — text contains \
prior sentences (likely deliberate short response)"
);
} else {
let preview = if trimmed.len() > 80 {
&trimmed[trimmed.len() - 80..]
} else {
trimmed
};
let msg = format!(
"Self-heal: provider sent stop after only {} output tokens — \
response appears truncated: \"{}\"",
output_tokens, preview,
);
tracing::warn!("⚠️ {}", msg);
if let Some(cb) = effective_cb {
cb(
session_id,
ProgressEvent::SelfHealingAlert {
message: msg.clone(),
},
);
}
return Err(crate::brain::provider::ProviderError::StreamError(msg));
}
}
}
}
let content_blocks: Vec<ContentBlock> = block_states
.into_iter()
.map(|s| s.block)
.filter(|b| !matches!(b, ContentBlock::Text { text } if text.is_empty()))
.collect();
crate::config::health::record_success(provider.name());
let reasoning = if reasoning_buf.is_empty() {
None
} else {
Some(reasoning_buf)
};
let final_active_secs = match (window_start, last_delta_at) {
(Some(start), Some(last)) => active_secs + (last - start).as_secs_f64().max(0.0),
_ => active_secs,
};
let streaming_active_secs = if final_active_secs > 0.0 {
Some(final_active_secs)
} else {
None
};
Ok((
LLMResponse {
id,
model: if model.is_empty() {
request_model
} else {
model
},
content: content_blocks,
stop_reason,
usage: TokenUsage {
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
billing_cache_creation,
billing_cache_read,
},
streaming_active_secs,
},
reasoning,
))
}
pub(crate) fn build_user_message(text: &str) -> Message {
let mut clean_text = text.to_string();
while let Some(start) = clean_text.find("<<IMG:") {
let Some(end) = clean_text[start..].find(">>") else {
break; };
let marker_end = start + end + 2;
let img_path = clean_text[start + 6..start + end].to_string();
let hint = format!("[image attached: {img_path}]");
clean_text = format!(
"{}{}{}",
&clean_text[..start],
hint,
&clean_text[marker_end..]
);
}
Message::user(clean_text.trim().to_string())
}
pub(super) fn format_tool_summary(tool_name: &str, tool_input: &Value) -> String {
use crate::utils::string::tilde_home;
let raw = match tool_name {
"bash" => {
let cmd = tool_input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("?");
let label = crate::utils::command_label::command_label(cmd);
let label = if label.is_empty() { "?" } else { &label };
format!("bash: {}", tilde_home(label))
}
"read_file" | "read" => {
let path = tool_input
.get("path")
.or_else(|| tool_input.get("file_path"))
.or_else(|| tool_input.get("filePath"))
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("Read {}", tilde_home(path))
}
"write_file" | "write" => {
let path = tool_input
.get("path")
.or_else(|| tool_input.get("file_path"))
.or_else(|| tool_input.get("filePath"))
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("Write {}", tilde_home(path))
}
"edit_file" | "edit" => {
let path = tool_input
.get("path")
.or_else(|| tool_input.get("file_path"))
.or_else(|| tool_input.get("filePath"))
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("Edit {}", tilde_home(path))
}
"ls" => {
let path = tool_input
.get("path")
.and_then(|v| v.as_str())
.unwrap_or(".");
format!("ls {}", tilde_home(path))
}
"glob" => {
let p = tool_input
.get("pattern")
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("Glob {}", p)
}
"grep" => {
let p = tool_input
.get("pattern")
.and_then(|v| v.as_str())
.unwrap_or("?");
let path = tool_input
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("");
if path.is_empty() {
format!("Grep '{}'", p)
} else {
format!("Grep '{}' in {}", p, tilde_home(path))
}
}
"web_search" | "exa_search" | "brave_search" => {
let q = tool_input
.get("query")
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("Search: {}", q)
}
"plan" => {
let op = tool_input
.get("operation")
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("Plan: {}", op)
}
"task_manager" => {
let op = tool_input
.get("operation")
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("Task: {}", op)
}
"memory_search" => {
let q = tool_input
.get("query")
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("Memory: {}", q)
}
other => other.to_string(),
};
crate::utils::sanitize::redact_command(&raw)
}
pub(crate) fn normalize_tool_call(
name: String,
mut input: serde_json::Value,
) -> (String, serde_json::Value) {
if let Some(op) = name
.strip_prefix("Plan: ")
.or_else(|| name.strip_prefix("plan: "))
.or_else(|| name.strip_prefix("Plan:"))
.or_else(|| name.strip_prefix("plan:"))
{
let op = op.trim().replace(' ', "_");
if !op.is_empty() {
if let Some(obj) = input.as_object_mut() {
obj.entry("operation")
.or_insert_with(|| serde_json::Value::String(op));
}
tracing::info!(
"[TOOL_NORM] Normalized '{}' → tool='plan', input={:?}",
name,
input
);
return ("plan".to_string(), input);
}
}
if name.contains(": ") {
let parts: Vec<&str> = name.splitn(2, ": ").collect();
if parts.len() == 2 {
let candidate = parts[0].to_lowercase().replace(' ', "_");
let suffix = parts[1].trim().replace(' ', "_");
if !suffix.is_empty() {
if let Some(obj) = input.as_object_mut() {
obj.entry("operation")
.or_insert_with(|| serde_json::Value::String(suffix));
}
tracing::info!(
"[TOOL_NORM] Normalized '{}' → tool='{}', input={:?}",
name,
candidate,
input
);
return (candidate, input);
}
}
}
let mapped = match name.as_str() {
"Bash" => Some("bash"),
"Read" => Some("read_file"),
"Write" => Some("write_file"),
"Edit" => Some("edit_file"),
"Glob" => Some("glob"),
"Grep" => Some("grep"),
"WebSearch" => Some("web_search"),
"WebFetch" => Some("http_request"),
"NotebookEdit" => Some("notebook_edit"),
_ => None,
};
if let Some(canonical) = mapped {
tracing::info!(
"[TOOL_NORM] Mapped Claude Code tool '{}' → '{}'",
name,
canonical
);
return (canonical.to_string(), input);
}
let lowered = name.to_lowercase();
if lowered != name {
tracing::info!("[TOOL_NORM] Lowercased tool '{}' → '{}'", name, lowered);
return (lowered, input);
}
(name, input)
}
pub(crate) fn has_xml_tool_block(text: &str) -> bool {
(text.contains("<tool_call>") && text.contains("</tool_call>"))
|| (text.contains("<tool_code>") && text.contains("</tool_code>"))
|| (text.contains("<StartToolCall>") && text.contains("</StartToolCall>"))
|| (text.contains("<minimax:tool_call>") && text.contains("</minimax:tool_call>"))
|| (text.contains("<invoke") && text.contains("</invoke>"))
|| (text.contains("<tool_use>") && text.contains("</tool_use>"))
}
pub(crate) fn parse_xml_tool_calls(text: &str) -> Vec<(String, serde_json::Value)> {
use regex::Regex;
use std::sync::LazyLock;
static XML_BLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?s)<(?:tool_call|tool_code|tool_use|minimax:tool_call|StartToolCall)>(.*?)</(?:tool_call|tool_code|tool_use|minimax:tool_call|StartToolCall)>"#).unwrap()
});
let mut results = Vec::new();
for cap in XML_BLOCK_RE.captures_iter(text) {
let inner = cap[1].trim();
if let Ok(obj) = serde_json::from_str::<serde_json::Value>(inner) {
let name = obj
.get("tool_name")
.or_else(|| obj.get("name"))
.or_else(|| obj.get("function"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
continue;
}
let input = obj
.get("args")
.or_else(|| obj.get("arguments"))
.or_else(|| obj.get("input"))
.or_else(|| obj.get("parameters"))
.cloned()
.unwrap_or(serde_json::json!({}));
tracing::info!(
"[XML_TOOL_PARSE] Recovered tool call: name={}, input_keys={:?}",
name,
input.as_object().map(|o| o.keys().collect::<Vec<_>>())
);
results.push((name, input));
}
}
results
}
pub(crate) fn strip_xml_tool_calls(text: &str) -> String {
use regex::Regex;
use std::sync::LazyLock;
static TOOL_CALL_BLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?s)(<tool_call>.*?</tool_call>|<tool_code>.*?</tool_code>|<StartToolCall>.*?</StartToolCall>|<minimax:tool_call>.*?</minimax:tool_call>|<qwen:tool_call>.*?</qwen:tool_call>|<function_calls>.*?</function_calls>|<invoke\b.*?</invoke>|<param(?:eter)?\b[^>]*>.*?</param(?:eter)?>|<tool_use>.*?</tool_use>|<tool_result>.*?</tool_result>|<result>.*?</result>)"#).unwrap()
});
let result = TOOL_CALL_BLOCK_RE.replace_all(text, "");
static ORPHAN_CLOSE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?im)^\s*</(?:tool_result|tool_call|tool_code|tool_use|invoke|function_calls|qwen:tool_call|minimax:tool_call|StartToolCall|param(?:eter)?|result)>\s*$"#).unwrap()
});
let result = ORPHAN_CLOSE_RE.replace_all(&result, "");
static INLINE_ORPHAN_CLOSE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?im)\s*</(?:tool_result|tool_call|tool_code|tool_use|invoke|function_calls|qwen:tool_call|minimax:tool_call)>\s*$"#).unwrap()
});
let result = INLINE_ORPHAN_CLOSE_RE.replace_all(&result, "");
result.trim().to_string()
}
pub(crate) fn strip_html_comments(text: &str) -> String {
use regex::Regex;
use std::sync::LazyLock;
let mut stripped = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("<!-- tools-v2:") {
stripped.push_str(&rest[..start]);
let after_prefix = &rest[start + "<!-- tools-v2:".len()..];
let array_start = match after_prefix.find('[') {
Some(i) => i,
None => {
rest = after_prefix;
break;
}
};
let scan = &after_prefix[array_start..];
let Some(array_end_rel) = find_balanced_json_end(scan) else {
rest = "";
break;
};
let tail = &scan[array_end_rel..];
let tail_trim_len = tail.len() - tail.trim_start().len();
let post = &tail[tail_trim_len..];
if let Some(stripped_end) = post.strip_prefix("-->") {
rest = stripped_end;
} else {
rest = tail;
}
}
stripped.push_str(rest);
static HTML_COMMENT_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?s)<!--.*?-->"#).unwrap());
let result = HTML_COMMENT_RE.replace_all(&stripped, "");
let collapsed = result.lines().collect::<Vec<_>>().join("\n");
let trimmed = collapsed.trim().to_string();
static MULTI_BLANK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\n{3,}").unwrap());
MULTI_BLANK.replace_all(&trimmed, "\n\n").to_string()
}
pub(crate) fn strip_compaction_banner(content: &mut String) {
if !content.starts_with("[CONTEXT COMPACTION") {
return;
}
if let Some(idx) = content.find("\n\n") {
*content = content[idx + 2..].to_string();
}
}
}
fn find_balanced_json_end(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
if bytes.first() != Some(&b'[') {
return None;
}
let mut depth: i32 = 0;
let mut in_string = false;
let mut escape = false;
for (idx, &b) in bytes.iter().enumerate() {
if escape {
escape = false;
continue;
}
if in_string {
match b {
b'\\' => escape = true,
b'"' => in_string = false,
_ => {}
}
continue;
}
match b {
b'"' => in_string = true,
b'[' | b'{' => depth += 1,
b']' | b'}' => {
depth -= 1;
if depth == 0 {
return Some(idx + 1);
}
}
_ => {}
}
}
None
}
pub fn detect_text_repetition(window: &str, min_match: usize) -> bool {
if min_match == 0 || window.len() < min_match * 2 {
return false;
}
let mut half = window.len() / 2;
while !window.is_char_boundary(half) && half < window.len() {
half += 1;
}
let second_half = &window[half..];
let mut check_len = min_match.min(second_half.len());
while !second_half.is_char_boundary(check_len) && check_len < second_half.len() {
check_len += 1;
}
if let Some(needle) = second_half.get(..check_len) {
window[..half].contains(needle)
} else {
false
}
}
pub fn provider_matches_session(saved_provider: Option<&str>, active_provider: &str) -> bool {
saved_provider.is_none_or(|saved| saved == active_provider)
}
pub fn strip_fenced_code(window: &str) -> String {
let mut out = String::with_capacity(window.len());
let mut in_fence = false;
for line in window.split_inclusive('\n') {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
out.push('\n');
continue;
}
if in_fence {
if line.ends_with('\n') {
out.push('\n');
}
} else {
out.push_str(line);
}
}
out
}