use super::builder::AgentService;
use super::types::{MessageQueueCallback, ProgressCallback, ProgressEvent};
use crate::brain::provider::{
ContentBlock, ImageSource, LLMRequest, LLMResponse, Message, Role, StopReason,
};
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
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(),
)
}
#[allow(clippy::too_many_arguments)]
pub(super) 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<String>>>,
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 = if provider.cli_handles_tools() {
std::time::Duration::from_secs(600)
} else {
std::time::Duration::from_secs(90)
};
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;
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 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() {
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(&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);
}
ContentDelta::ThinkingDelta { thinking } => {
if let Some(cb) = effective_cb {
cb(
session_id,
ProgressEvent::ReasoningChunk {
text: thinking.clone(),
},
);
}
reasoning_buf.push_str(&thinking);
}
}
}
}
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()
&& let Ok(parsed) = serde_json::from_str(&state.json_buf)
{
*input = parsed;
}
}
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().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.clear();
}
}
}
}
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 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());
{
use std::sync::atomic::{AtomicBool, Ordering};
static SAVED: AtomicBool = AtomicBool::new(false);
if !SAVED.swap(true, Ordering::Relaxed) {
crate::config::save_last_good_config();
}
}
let reasoning = if reasoning_buf.is_empty() {
None
} else {
Some(reasoning_buf)
};
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,
},
},
reasoning,
))
}
pub(super) async fn build_user_message(text: &str) -> Message {
let mut image_blocks: Vec<ContentBlock> = Vec::new();
let mut clean_text = text.to_string();
while let Some(start) = clean_text.find("<<IMG:") {
if let Some(end) = clean_text[start..].find(">>") {
let marker_end = start + end + 2;
let img_path = &clean_text[start + 6..start + end];
if img_path.starts_with("http://") || img_path.starts_with("https://") {
image_blocks.push(ContentBlock::Image {
source: ImageSource::Url {
url: img_path.to_string(),
},
});
tracing::info!("Auto-attached image URL: {}", img_path);
}
else {
let path = std::path::Path::new(img_path);
if let Ok(data) = tokio::fs::read(path).await {
let lower = img_path.to_lowercase();
let media_type = match lower.rsplit('.').next().unwrap_or("") {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"svg" => "image/svg+xml",
_ => "application/octet-stream",
};
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
image_blocks.push(ContentBlock::Image {
source: ImageSource::Base64 {
media_type: media_type.to_string(),
data: b64,
},
});
tracing::info!(
"Auto-attached image: {} ({}, {} bytes)",
img_path,
media_type,
data.len()
);
} else {
tracing::warn!("Could not read image file: {}", img_path);
}
}
let hint = format!("[image attached: {}]", img_path);
clean_text = format!(
"{}{}{}",
&clean_text[..start],
hint,
&clean_text[marker_end..]
);
} else {
break; }
}
let clean_text = clean_text.trim().to_string();
if image_blocks.is_empty() {
Message::user(clean_text)
} else {
let mut blocks = vec![ContentBlock::Text { text: clean_text }];
blocks.extend(image_blocks);
Message {
role: Role::User,
content: blocks,
}
}
}
pub(super) fn format_tool_summary(tool_name: &str, tool_input: &Value) -> String {
use crate::utils::string::tilde_home;
match tool_name {
"bash" => {
let cmd = tool_input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("bash: {}", tilde_home(cmd))
}
"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(),
}
}
pub(super) 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>|<invoke\b.*?</invoke>|<param(?:eter)?\b[^>]*>.*?</param(?:eter)?>|<tool_use>.*?</tool_use>|<result>.*?</result>)"#).unwrap()
});
let result = TOOL_CALL_BLOCK_RE.replace_all(text, "");
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()
}
}
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
}
}
const GASLIGHTING_REFUSAL_PHRASES: &[&str] = &[
"tools aren't responding",
"tools are not responding",
"tools are flaky",
"tools are still flaky",
"tools appear to be",
"tools appear broken",
"tools appear unavailable",
"appear to be unavailable",
"tools are unavailable",
"tools are currently unavailable",
"tools are disabled",
"tools are not loading",
"tools are not available",
"isn't currently available",
"is not currently available",
"not currently available",
"tool isn't currently",
"tool is not currently",
"vision tool isn't",
"vision tool is not",
"vision integration",
"despite being in my tool list",
"despite being in the tool list",
"despite appearing in",
"even though it appears in",
"isn't actually registered",
"is not actually registered",
"not actually registered",
"isn't registered",
"isn't loaded",
"is not loaded",
"isn't in the registry",
"not in the registry",
"mismatch between the advertised",
"advertised capabilities",
"runtime hiccup",
"might be a runtime",
"might be a configuration issue",
"configuration issue",
"runtime issue",
"runtime glitch",
"underlying system disruption",
"system disruption",
"provider glitch",
"provider hiccup",
"can't execute the tool",
"cannot execute the tool",
"unable to execute the tool",
"unable to invoke",
"unable to call the tool",
"unable to retrieve",
"unable to analyze",
"unable to analyse",
"unable to process",
"unable to view",
"unable to see",
"unable to read",
"tool execution failed before it started",
"try uploading it again",
"try uploading the image again",
"upload it again",
"upload the image again",
"just tell me what's in",
"just describe what's in",
"or just tell me",
"or just describe",
"paste it as",
"paste the image",
"drop the path",
"if you need image analysis",
"for image analysis you could",
"don't have access to a working",
"do not have access to a working",
"don't have a working",
"tool isn't available in my",
"tool is not available in my",
"isn't available in my current environment",
"not available in my current environment",
"in my current environment",
"working image analysis tool",
"image analysis tool for local files",
"upload the screenshot to a public",
"upload the image to a public",
"try to analyze it via url",
"analyze it via a url",
"analyze it via url",
"public url (imgur",
"(imgur, github",
"tools are acting up",
"tools are acting weird",
"tool layer is acting",
"does not exists errors",
"does not exist errors",
"errors across the board",
"getting errors across",
"getting \"does not exist",
"getting 'does not exist",
"tools seem to be down",
"tools seem broken",
"tool system is down",
"running in a sandbox",
"running in a sandboxed",
"sandboxed environment",
"sandboxed container",
"docker container with no",
"no tool access in",
"tools are still glitching",
"tools still glitching",
"tools are glitching",
"tool layer is glitching",
"session state got weird",
"session state is weird",
"from the model switch earlier",
"from the earlier model switch",
"from the earlier session state",
"turbulence from rapid model",
"turbulence from the model",
"some turbulence from",
"session had some turbulence",
"tools temporarily failing",
"temporarily failing due to",
"session state issues from model",
"had the issue context from the earlier fetch",
"have the issue context from the earlier",
"from the earlier fetch, so let me break",
"so let me break this down directly",
"tools are completely offline",
"tools completely offline",
"every call returns \"does not exists\"",
"every call returns 'does not exists'",
"every call returns \"does not exist\"",
"this isn't just session state",
"this is not just session state",
"tool registry itself isn't loading",
"tool registry itself is not loading",
"tool registry isn't loading post-restart",
"tool registry is not loading post-restart",
"isn't loading post-restart",
"not loading post-restart",
"ping me when you're back in",
"ping me when you are back in",
"once tools are back",
];
pub fn is_gaslighting_preamble(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.len() > 1500 {
return false;
}
let lower = trimmed.to_lowercase();
if GASLIGHTING_REFUSAL_PHRASES
.iter()
.any(|phrase| lower.contains(phrase))
{
return true;
}
const REFUSAL_OPENINGS: &[&str] = &[
"i can't",
"i cannot",
"i can not",
"i don't have",
"i do not have",
"i'm unable",
"i am unable",
"i'm not able",
"i am not able",
"i lack ",
"unfortunately, i can't",
"unfortunately i can't",
"unfortunately, i cannot",
"unfortunately i cannot",
"unfortunately, i don't",
"unfortunately i don't",
"sorry, i can't",
"sorry i can't",
"sorry, i cannot",
"sorry i cannot",
];
let starts_with_refusal = REFUSAL_OPENINGS.iter().any(|o| lower.starts_with(o));
if !starts_with_refusal {
return false;
}
const IMAGE_CONTEXT: &[&str] = &[
"image",
"images",
"screenshot",
"photo",
"picture",
"vision",
"visual",
"analyze_image",
"analyse_image",
];
IMAGE_CONTEXT.iter().any(|w| lower.contains(w))
}
pub fn strip_gaslighting_preamble(text: &str) -> Option<String> {
let paragraphs: Vec<&str> = text.split("\n\n").collect();
if paragraphs.is_empty() {
return None;
}
let mut first_kept = 0usize;
for (idx, p) in paragraphs.iter().enumerate() {
if is_gaslighting_preamble(p) {
first_kept = idx + 1;
} else {
break;
}
}
if first_kept == 0 {
return None;
}
let remainder = paragraphs[first_kept..].join("\n\n");
Some(remainder.trim_start().to_string())
}
const INTENT_PHRASES: &[&str] = &[
"now let me ",
"now update ",
"now fix ",
"now add ",
"now bump ",
"now run ",
"now check ",
"now read ",
"now commit",
"now amend",
"i'll update",
"i'll fix",
"i'll modify",
"i'll create",
"i'll write",
"i'll edit",
"i'll add",
"i'll change",
"i'll replace",
"i'll commit",
"i'll amend",
"i'll proceed",
"i'll start",
"i'll finish",
"i'll run",
"i'll check",
"i'll see",
"i'll look",
"i'll prepare",
"i'll take a look",
"i will proceed",
"let me update",
"let me fix",
"let me modify",
"let me create",
"let me write",
"let me edit",
"let me add",
"let me change",
"let me commit",
"let me amend",
"let me see",
"let me check",
"let me look",
"let me read",
"let me examine",
"let me verify",
"let me inspect",
"let me review",
"let me take", "let me actually", "let me prepare",
"let me proceed",
"let me start",
"let me first", "let me finish",
"let me finalize",
"let me run",
"let me dig",
"let me investigate",
"let me explore",
"let me search",
"let me find",
"let me gather",
"let me pull",
"let me grab",
"let me get",
"let me fetch",
"let me query",
"let me scan",
"i'll dig",
"i'll investigate",
"i'll explore",
"i'll search",
"i'll find",
"i'll gather",
"i'll pull",
"i'll grab",
"i'll get",
"i'll fetch",
"i'll query",
"i'll scan",
];
pub fn has_phantom_tool_intent_no_tools(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.len() < 20 {
return false;
}
let lead = prose_lead_in(trimmed);
if lead.is_empty() {
return false;
}
let lower = lead.to_lowercase();
INTENT_PHRASES.iter().any(|p| lower.contains(p))
}
fn prose_lead_in(text: &str) -> &str {
let mut byte_offset: usize = 0;
for (idx, line) in text.lines().enumerate() {
let trimmed_line = line.trim_start();
let is_structural = trimmed_line.starts_with("```")
|| (trimmed_line.starts_with('|') && trimmed_line.contains('|'))
|| trimmed_line.starts_with("- ")
|| trimmed_line.starts_with("* ")
|| trimmed_line.starts_with("• ")
|| (trimmed_line
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
&& trimmed_line.contains(". "));
if is_structural {
return text[..byte_offset].trim_end();
}
if idx >= 6 {
break;
}
byte_offset += line.len() + 1; }
text
}
pub fn looks_truncated_mid_sentence(text: &str) -> bool {
let trimmed = text.trim_end();
if trimmed.chars().count() < 40 {
return false;
}
if trimmed.ends_with("```") {
return false;
}
if trimmed.ends_with('|') {
return false;
}
if ends_with_url(trimmed) {
return false;
}
let last = match trimmed.chars().next_back() {
Some(c) => c,
None => return false,
};
if last.is_alphanumeric() {
return true;
}
matches!(
last,
',' | ';' | ':' | '-' | '(' | '[' | '{' | '<' | '/' | '\\' | '&' | '@' | '#'
)
}
fn ends_with_url(text: &str) -> bool {
let trimmed = text.trim_end();
let boundary = trimmed
.rfind(|c: char| c.is_whitespace() || matches!(c, '(' | '[' | '{' | '<' | '"' | '\''))
.map(|i| i + 1)
.unwrap_or(0);
let tail = &trimmed[boundary..];
tail.contains("://")
}
pub fn has_phantom_tool_intent(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.len() < 40 {
return false;
}
let lower = trimmed.to_lowercase();
use regex::Regex;
let now_imperative =
Regex::new(r"(?m)^[\s\-*]*(?:now\s+(?:let\s+me\s+)?|let\s+me\s+)\w").unwrap();
if now_imperative.find_iter(&lower).count() >= 2 {
return true;
}
let numbered_steps =
Regex::new(r"(?m)^\s*\d+\.\s+(?:update|fix|modify|create|write|edit|add|change|remove|delete|check|read|run|bump|amend|verify|test|deploy|install)")
.unwrap();
if numbered_steps.find_iter(&lower).count() >= 2 {
return true;
}
let past_tense_standalone = Regex::new(
r"(?m)^[\s\-*]*(?:amended|updated|fixed|modified|created|written|saved|deleted|removed|replaced|bumped|deployed|committed)[.!]"
).unwrap();
if past_tense_standalone.find_iter(&lower).count() >= 2 {
return true;
}
const COMPLETION_CLAIMS: &[&str] = &[
"here's what changed",
"here's what's changed",
"here are the changes",
"here's what i did",
"here is what i did",
"changes applied",
"updated the file",
"updated the code",
"updated src/",
"modified the file",
"modified src/",
"fixed the file",
"fixed the bug",
"fixed the issue",
"fixed src/",
"created the file",
"wrote the file",
"everything is updated",
"i've made the changes",
"i've completed",
"i've finished",
"i've updated",
"i've written",
"i've created",
"i've saved",
"i've modified",
"i've fixed",
"i've replaced",
"i've amended",
"i've committed",
"i've bumped",
"i've made all",
"all changes have been",
"all files have been",
"the changes have been applied",
"changes are now in place",
"the file now contains",
"the file has been",
"file updated",
"file created",
"file saved",
"changes saved",
"amended.",
"committed.",
"amended the commit",
"bumped the version",
"version bumped",
];
if COMPLETION_CLAIMS.iter().any(|c| lower.contains(c)) {
return true;
}
let has_intent = INTENT_PHRASES.iter().any(|v| lower.contains(v));
let trailing_colon_intent = Regex::new(
r"(?im)(?:^|\n)\s*(?:let\s+me|i'll|i\s+will|now\s+let\s+me|now\s+i'll)\s+\w[^:\n]{0,80}:\s*$",
)
.unwrap();
if trailing_colon_intent.is_match(trimmed) {
return true;
}
if has_intent {
let path_re =
Regex::new(r"(?:^|[\s`(])(?:\./)?[a-zA-Z_][\w\-]*/[\w\-/]*\.\w{1,6}(?:[\s`),:;]|$)")
.unwrap();
let ext_re = Regex::new(
r"(?:^|[\s`(])[\w\-]+\.(?:rs|py|ts|tsx|js|jsx|go|sh|toml|yaml|yml|json|md)(?:[\s`),:;]|$)",
)
.unwrap();
let backtick_code_re = Regex::new(r"`[a-zA-Z_]\w+`").unwrap();
if path_re.is_match(trimmed)
|| ext_re.is_match(trimmed)
|| backtick_code_re.is_match(trimmed)
{
return true;
}
}
false
}