use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::Value;
use tokio::sync::mpsc;
use crate::skills;
use crate::types::{ChannelVisibility, UserRole};
use super::execution_state::StepExecutionOutcome;
use super::{contains_keyword_as_words, StatusUpdate};
pub fn send_status(tx: &Option<mpsc::Sender<StatusUpdate>>, update: StatusUpdate) {
if let Some(ref tx) = tx {
let _ = tx.try_send(update);
}
}
pub fn touch_heartbeat(hb: &Option<Arc<AtomicU64>>) {
if let Some(ref hb) = hb {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
hb.store(now, Ordering::Relaxed);
}
}
fn truncate_summary(s: &str, max: usize) -> String {
let truncated: String = s.chars().take(max).collect();
if s.chars().count() > max {
format!("{}...", truncated)
} else {
truncated
}
}
fn short_path(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
pub(in crate::agent) fn summarize_tool_args(name: &str, arguments: &str) -> String {
let val: Value = match serde_json::from_str(arguments) {
Ok(v) => v,
Err(_) => return String::new(),
};
let get_str = |key: &str| val.get(key).and_then(|v| v.as_str());
match name {
"terminal" | "run_command" => get_str("command")
.map(|cmd| format!("`{}`", truncate_summary(cmd, 60)))
.unwrap_or_default(),
"read_file" => get_str("path")
.map(|p| short_path(p).to_string())
.unwrap_or_default(),
"write_file" => get_str("path")
.map(|p| short_path(p).to_string())
.unwrap_or_default(),
"edit_file" => get_str("path")
.map(|p| short_path(p).to_string())
.unwrap_or_default(),
"search_files" => {
let pattern = get_str("pattern").or_else(|| get_str("glob")).unwrap_or("");
if pattern.is_empty() {
String::new()
} else {
truncate_summary(pattern, 40)
}
}
"list_files" => get_str("path")
.map(|p| short_path(p).to_string())
.unwrap_or_default(),
"web_search" => get_str("query")
.map(|q| truncate_summary(q, 50))
.unwrap_or_default(),
"web_fetch" => get_str("url")
.map(|u| truncate_summary(u, 60))
.unwrap_or_default(),
"http_request" => {
let method = get_str("method").unwrap_or("GET");
let url = get_str("url").unwrap_or("");
if url.is_empty() {
method.to_string()
} else {
format!("{} {}", method, truncate_summary(url, 50))
}
}
"browser" => {
let action = get_str("action").unwrap_or("");
let url = get_str("url").unwrap_or("");
if !url.is_empty() {
format!("{} {}", action, truncate_summary(url, 50))
} else {
action.to_string()
}
}
"git_info" => {
let include = val.get("include").and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
});
include.unwrap_or_default()
}
"git_commit" => get_str("message")
.map(|m| truncate_summary(m, 40))
.unwrap_or_default(),
"remember_fact" => {
let fact = get_str("fact").or_else(|| get_str("value")).unwrap_or("");
if fact.is_empty() {
"saving to memory".to_string()
} else {
truncate_summary(fact, 40)
}
}
"manage_memories" => get_str("action").unwrap_or("").to_string(),
"use_skill" => get_str("skill_name").unwrap_or("").to_string(),
"manage_skills" => {
let action = get_str("action").unwrap_or("");
let name_val = get_str("name").unwrap_or("");
if name_val.is_empty() {
action.to_string()
} else {
format!("{} {}", action, name_val)
}
}
"manage_people" => {
let action = get_str("action").unwrap_or("");
let name_val = get_str("name").unwrap_or("");
if name_val.is_empty() {
action.to_string()
} else {
format!("{} {}", action, name_val)
}
}
"spawn_agent" => get_str("mission")
.map(|m| truncate_summary(m, 50))
.unwrap_or_default(),
"cli_agent" => {
let action = get_str("action").unwrap_or("run");
if action != "run" {
return format!("action={}", action);
}
let tool = get_str("tool").unwrap_or("auto");
let prompt = get_str("prompt")
.or_else(|| get_str("task"))
.or_else(|| get_str("mission"))
.or_else(|| get_str("description"))
.or_else(|| get_str("command"))
.unwrap_or("");
let task_desc = truncate_summary(prompt, 50);
if task_desc.is_empty() {
format!("→ {}", tool)
} else {
format!("→ {}: {}", tool, task_desc)
}
}
"manage_cli_agents" => get_str("action").unwrap_or("").to_string(),
"manage_config" => get_str("action").unwrap_or("").to_string(),
"manage_mcp" => {
let action = get_str("action").unwrap_or("");
let name_val = get_str("name").unwrap_or("");
if name_val.is_empty() {
action.to_string()
} else {
format!("{} {}", action, name_val)
}
}
"project_inspect" => {
if let Some(path) = get_str("path") {
short_path(path).to_string()
} else if let Some(paths) = val.get("paths").and_then(|v| v.as_array()) {
let mut summarized: Vec<String> = paths
.iter()
.filter_map(|v| v.as_str())
.map(short_path)
.map(str::to_string)
.take(3)
.collect();
if summarized.is_empty() {
String::new()
} else {
let total = paths.iter().filter_map(|v| v.as_str()).count();
if total > summarized.len() {
summarized.push(format!("+{} more", total - summarized.len()));
}
summarized.join(", ")
}
} else {
String::new()
}
}
"read_channel_history" => {
let channel = get_str("channel_id").unwrap_or("");
if channel.is_empty() {
String::new()
} else {
truncate_summary(channel, 30)
}
}
"send_file" => get_str("file_path")
.map(|p| short_path(p).to_string())
.unwrap_or_default(),
_ if name.starts_with("mcp__") => {
let without_prefix = &name[5..]; if let Some(idx) = without_prefix.find("__") {
let server = &without_prefix[..idx];
let tool = &without_prefix[idx + 2..];
let arg_info = match tool {
"navigate_page" => get_str("url")
.map(|u| format!(" {}", truncate_summary(u, 40)))
.unwrap_or_default(),
"click" | "hover" | "fill" => get_str("uid")
.map(|u| format!(" #{}", u))
.unwrap_or_default(),
"evaluate_script" => get_str("function")
.map(|f| format!(" {}", truncate_summary(f, 30)))
.unwrap_or_default(),
_ => String::new(),
};
format!("{}: {}{}", server, tool.replace('_', " "), arg_info)
} else {
without_prefix.replace('_', " ")
}
}
_ => String::new(),
}
}
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub(in crate::agent) struct IntentGateDecision {
pub(in crate::agent) can_answer_now: Option<bool>,
pub(in crate::agent) needs_tools: Option<bool>,
pub(in crate::agent) needs_clarification: Option<bool>,
pub(in crate::agent) clarifying_question: Option<String>,
pub(in crate::agent) missing_info: Vec<String>,
pub(in crate::agent) complexity: Option<String>,
pub(in crate::agent) cancel_intent: Option<bool>,
pub(in crate::agent) cancel_scope: Option<String>,
pub(in crate::agent) is_acknowledgment: Option<bool>,
pub(in crate::agent) schedule: Option<String>,
pub(in crate::agent) schedule_type: Option<String>,
pub(in crate::agent) schedule_cron: Option<String>,
pub(in crate::agent) domains: Vec<String>,
}
#[derive(Debug, Clone)]
pub(in crate::agent) struct ResumeCheckpoint {
pub(in crate::agent) task_id: String,
pub(in crate::agent) description: String,
pub(in crate::agent) original_user_message: Option<String>,
pub(in crate::agent) elapsed_secs: u64,
pub(in crate::agent) last_iteration: u32,
pub(in crate::agent) tool_results_count: u32,
pub(in crate::agent) pending_tool_call_ids: Vec<String>,
pub(in crate::agent) last_assistant_summary: Option<String>,
pub(in crate::agent) last_tool_summary: Option<String>,
pub(in crate::agent) last_error: Option<String>,
pub(in crate::agent) execution_snapshot: Option<ResumeExecutionSnapshot>,
}
#[derive(Debug, Clone)]
pub(in crate::agent) struct ResumeExecutionSnapshot {
pub(in crate::agent) execution_id: String,
pub(in crate::agent) current_step_id: Option<String>,
pub(in crate::agent) current_tool: Option<String>,
pub(in crate::agent) current_target: Option<String>,
pub(in crate::agent) last_outcome: Option<StepExecutionOutcome>,
pub(in crate::agent) background_handoff_active: bool,
pub(in crate::agent) idempotency_key: Option<String>,
}
impl ResumeCheckpoint {
pub(in crate::agent) fn render_prompt_section(&self) -> String {
let mut lines = vec![
"## Resume Checkpoint".to_string(),
"The user explicitly asked to continue prior in-progress work. Resume from this checkpoint and avoid restarting completed steps."
.to_string(),
format!("- Previous task_id: {}", self.task_id),
format!("- Original task: {}", self.description),
format!("- Elapsed before interruption: {}s", self.elapsed_secs),
format!("- Last completed iteration: {}", self.last_iteration),
format!("- Completed tool results: {}", self.tool_results_count),
format!(
"- Pending unresolved tool calls: {}",
self.pending_tool_call_ids.len()
),
];
if !self.pending_tool_call_ids.is_empty() {
let pending = self
.pending_tool_call_ids
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(", ");
lines.push(format!("- Pending tool call IDs: {}", pending));
}
if let Some(msg) = &self.original_user_message {
lines.push(format!(
"- Original user request: {}",
truncate_for_resume(msg, 180)
));
}
if let Some(summary) = &self.last_assistant_summary {
lines.push(format!("- Last assistant output: {}", summary));
}
if let Some(summary) = &self.last_tool_summary {
lines.push(format!("- Last tool result: {}", summary));
}
if let Some(err) = &self.last_error {
lines.push(format!("- Last error: {}", err));
}
if let Some(snapshot) = &self.execution_snapshot {
lines.push(format!("- Execution id: {}", snapshot.execution_id));
if let Some(step_id) = &snapshot.current_step_id {
lines.push(format!("- Last execution step: {}", step_id));
}
if let Some(tool) = &snapshot.current_tool {
lines.push(format!("- Last execution tool: {}", tool));
}
if let Some(target) = &snapshot.current_target {
lines.push(format!("- Last execution target: {}", target));
}
if let Some(outcome) = snapshot.last_outcome {
lines.push(format!("- Last execution outcome: {:?}", outcome));
}
if snapshot.background_handoff_active {
lines.push("- Background execution was active before interruption.".to_string());
}
if let Some(key) = &snapshot.idempotency_key {
lines.push(format!(
"- Replay/idempotency key: {}",
truncate_for_resume(key, 120)
));
}
}
lines.push(
"Resume from the next concrete step immediately. Re-run tools only if needed to verify or recover."
.to_string(),
);
lines.join("\n")
}
}
pub(in crate::agent) fn truncate_for_resume(text: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let mut out = String::new();
for (count, ch) in text.chars().enumerate() {
if count >= max_chars {
out.push_str("...");
return out;
}
out.push(ch);
}
out
}
pub(in crate::agent) fn build_empty_response_fallback(response_note: Option<&str>) -> String {
let base = "I wasn't able to process that request.";
let generic = format!("{base} Could you try rephrasing?");
let Some(note) = response_note.map(str::trim).filter(|s| !s.is_empty()) else {
return generic;
};
let flattened = note.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = flattened.trim_matches(|c: char| c == '"' || c == '\'');
let trimmed = trimmed.trim_end_matches(['.', '!', '?']);
if trimmed.is_empty() {
return generic;
}
let note_preview = truncate_for_resume(trimmed, 180);
format!(
"{base} The model returned no usable output ({note_preview}). Could you try rephrasing?"
)
}
fn normalize_for_resume_intent(text: &str) -> String {
text.split_whitespace()
.map(|part| part.trim_matches(|c: char| c.is_ascii_punctuation()))
.filter(|part| !part.is_empty())
.map(|part| part.to_lowercase())
.collect::<Vec<_>>()
.join(" ")
}
pub(in crate::agent) fn is_resume_request(text: &str) -> bool {
let normalized = normalize_for_resume_intent(text);
if normalized.is_empty() {
return false;
}
const EXACT: &[&str] = &[
"continue",
"resume",
"keep going",
"go on",
"carry on",
"next phase",
"next step",
];
if EXACT.contains(&normalized.as_str()) {
return true;
}
normalized.starts_with("continue ")
|| normalized.starts_with("resume ")
|| normalized.starts_with("keep going ")
|| normalized.starts_with("go on ")
|| normalized.starts_with("carry on ")
|| normalized.starts_with("next phase ")
|| normalized.starts_with("next step ")
}
pub(in crate::agent) fn user_text_references_filesystem_path(user_text: &str) -> bool {
if user_text.trim().is_empty() {
return false;
}
const NON_PATH_SLASH_PHRASES: &[&str] = &["yes/no", "no/yes", "and/or", "w/o", "on/off"];
const FILE_EXTS: &[&str] = &[
"rs", "py", "js", "ts", "tsx", "json", "toml", "yaml", "yml", "md", "txt", "log", "env",
"sql", "csv", "go", "java", "c", "cc", "cpp", "h", "hpp", "sh", "zsh", "bash",
];
const COMMON_RELATIVE_DIRS: &[&str] = &[
"src", "tests", "test", "target", "crates", "apps", "packages", "scripts", "bin", "lib",
"include", "cmd", "internal", "docs",
];
for raw in user_text.split_whitespace() {
let token = raw.trim_matches(|c: char| c.is_ascii_punctuation());
if token.is_empty() {
continue;
}
let lower = token.to_ascii_lowercase();
if lower.contains("://") {
continue;
}
if lower.starts_with("\\\\") {
return true;
}
if lower.len() >= 3 {
let bytes = lower.as_bytes();
let drive = bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
let sep = bytes[2] == b'\\' || bytes[2] == b'/';
if drive && sep {
return true;
}
}
if lower.contains('\\') {
return true;
}
if lower.starts_with("~/") || lower.starts_with("./") || lower.starts_with("../") {
return true;
}
if lower.starts_with('/') {
return true;
}
if let Some((_, ext)) = lower.rsplit_once('.') {
if FILE_EXTS.contains(&ext) {
return true;
}
}
if !lower.contains('/') {
continue;
}
if NON_PATH_SLASH_PHRASES.contains(&lower.as_str()) {
continue;
}
let is_simple_fraction_or_date = {
let parts: Vec<&str> = lower.split('/').collect();
(parts.len() == 2 || parts.len() == 3)
&& parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
};
if is_simple_fraction_or_date {
continue;
}
if lower.matches('/').count() >= 2 {
return true;
}
if lower.contains('.') {
return true;
}
if let Some((first, _rest)) = lower.split_once('/') {
if COMMON_RELATIVE_DIRS.contains(&first) {
return true;
}
}
}
false
}
fn text_contains_any_phrase_as_words(text: &str, phrases: &[&str]) -> bool {
phrases
.iter()
.any(|phrase| contains_keyword_as_words(text, phrase))
}
pub(in crate::agent) fn text_has_explicit_project_scope_cues(text: &str) -> bool {
text_contains_any_phrase_as_words(
text,
&[
"project",
"repo",
"repository",
"workspace",
"directory",
"folder",
"codebase",
"code base",
"work in",
"inside",
"under",
],
)
}
fn text_has_local_project_command_cues(text: &str, token: &str) -> bool {
let lower = text.trim().to_ascii_lowercase();
if lower.is_empty() || lower.ends_with('?') {
return false;
}
let words: Vec<&str> = lower.split_whitespace().collect();
let strong_local_verbs = [
"run", "build", "deploy", "publish", "restart", "reload", "commit", "push", "lint",
"format", "fmt", "compile", "test", "debug", "fix", "refactor", "edit",
];
const COMMAND_PREFIXES: &[&str] = &["now", "also", "please", "just", "quickly", "go"];
let starts_like_local_command = words
.iter()
.take(2)
.map(|word| word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_'))
.enumerate()
.any(|(i, word)| {
strong_local_verbs
.iter()
.any(|verb| word.eq_ignore_ascii_case(verb))
&& (i == 0 || words.first().is_some_and(|w| COMMAND_PREFIXES.contains(w)))
});
if !starts_like_local_command {
return false;
}
let normalized_token = token
.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.');
!normalized_token.is_empty() && contains_keyword_as_words(&lower, normalized_token)
}
pub(in crate::agent) fn should_allow_contextual_project_nickname_scope(
text: &str,
token: &str,
) -> bool {
let lower = text.trim().to_ascii_lowercase();
user_text_references_filesystem_path(text)
|| text_has_explicit_project_scope_cues(&lower)
|| text_has_local_project_command_cues(text, token)
}
pub(in crate::agent) fn user_explicitly_requests_local_file_inspection(user_text: &str) -> bool {
if user_text_references_filesystem_path(user_text) {
return true;
}
let lower = user_text.to_ascii_lowercase();
let mentions_local_subject = [
"file",
"files",
"repo",
"repository",
"codebase",
"directory",
"folder",
"workspace",
"local file",
"local files",
"current repo",
"this repo",
"the repo",
]
.iter()
.any(|kw| contains_keyword_as_words(&lower, kw));
let mentions_inspection_verb = [
"read", "open", "inspect", "look in", "look at", "search", "scan", "check", "review",
"show", "list", "find", "grep", "compare",
]
.iter()
.any(|kw| contains_keyword_as_words(&lower, kw));
mentions_local_subject && mentions_inspection_verb
}
pub(in crate::agent) fn matched_untrusted_external_reference_skill_names(
skills_snapshot: &[skills::Skill],
user_text: &str,
user_role: UserRole,
visibility: ChannelVisibility,
) -> Vec<String> {
skills::match_skills(skills_snapshot, user_text, user_role, visibility)
.skills
.into_iter()
.filter(|skill| skills::is_untrusted_external_reference_skill(skill))
.map(|skill| skill.name.clone())
.collect()
}
pub(in crate::agent) fn is_untrusted_external_reference_blocked_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"read_file"
| "search_files"
| "project_inspect"
| "check_environment"
| "web_fetch"
| "web_search"
| "browser"
| "send_file"
| "skill_resources"
)
}
pub(in crate::agent) fn filter_tool_defs_for_untrusted_external_reference(
defs: &[Value],
) -> Vec<Value> {
defs.iter()
.filter(|def| {
let name = def
.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str());
!name.is_some_and(is_untrusted_external_reference_blocked_tool)
})
.cloned()
.collect()
}
#[allow(dead_code)]
fn merge_intent_gate_decision(
model_decision: Option<IntentGateDecision>,
inferred: IntentGateDecision,
) -> IntentGateDecision {
let Some(model) = model_decision else {
return inferred;
};
IntentGateDecision {
can_answer_now: model.can_answer_now.or(inferred.can_answer_now),
needs_tools: model.needs_tools.or(inferred.needs_tools),
needs_clarification: model.needs_clarification.or(inferred.needs_clarification),
clarifying_question: model.clarifying_question.or(inferred.clarifying_question),
missing_info: if model.missing_info.is_empty() {
inferred.missing_info
} else {
model.missing_info
},
complexity: model.complexity.or(inferred.complexity),
cancel_intent: model.cancel_intent.or(inferred.cancel_intent),
cancel_scope: model.cancel_scope.or(inferred.cancel_scope),
is_acknowledgment: model.is_acknowledgment.or(inferred.is_acknowledgment),
schedule: model.schedule.or(inferred.schedule),
schedule_type: model.schedule_type.or(inferred.schedule_type),
schedule_cron: model.schedule_cron.or(inferred.schedule_cron),
domains: if model.domains.is_empty() {
inferred.domains
} else {
model.domains
},
}
}