//! `@path` mentions in user prompts: the mentioned files (or folder listings) //! are attached to the prompt, read with the regular read/list tools. use crate::app::App; use crate::tools::{Tool, ToolCtx}; use crate::types::Part; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; use tokio_util::sync::CancellationToken; const MAX_MENTIONS: usize = 10; const MAX_CHARS: usize = 60_000; /// `@token`s at the start of the text or after whitespace (not e-mail addresses). pub fn find(text: &str) -> Vec { let re = regex::Regex::new(r"(?:^|\s)@([\w./~+\-]+)").unwrap(); let mut out: Vec = Vec::new(); for c in re.captures_iter(text) { let token = c[1].trim_end_matches(['.', ',', ';', ':', '!', '?', ')']).to_string(); if !token.is_empty() && !out.contains(&token) { out.push(token); } } out } /// Parts to append to a user prompt for its mentions. pub async fn expand(app: &Arc, session_id: &str, text: &str) -> Vec { let tokens = find(text); if tokens.is_empty() { return vec![]; } let Ok(session) = app.session(session_id) else { return vec![] }; let cwd = PathBuf::from(&session.cwd); let ctx = ToolCtx { app: app.clone(), session_id: session_id.to_string(), call_id: String::new(), cwd: cwd.clone(), cancel: CancellationToken::new(), agent: session.agent.clone(), roots: app.user_roots(session.owner_id.as_deref()), sandbox: crate::sandbox::spec_for(app, session.owner_id.as_deref()), clean_room: session.clean_room(app.config().craft.clean_room), owner_id: None, }; let mut parts = Vec::new(); for token in tokens.into_iter().take(MAX_MENTIONS) { let Ok(path) = ctx.resolve_read(&token) else { continue }; if !path.exists() { continue; } let res = if path.is_dir() { crate::tools::fs::List.run(json!({"path": token}), &ctx).await } else { crate::tools::fs::Read.run(json!({"path": token}), &ctx).await }; let Ok(out) = res else { continue }; let mut content = out.content; if content.len() > MAX_CHARS { let mut end = MAX_CHARS; while !content.is_char_boundary(end) { end -= 1; } content.truncate(end); content.push_str("\n[… truncated – read the rest with the read tool]"); } let kind = if path.is_dir() { "folder" } else { "file" }; parts.push(Part::Text { text: format!("\n{content}\n") }); parts.extend(out.images.into_iter().map(|image| Part::Image { image })); } parts } #[cfg(test)] mod tests { #[test] fn finds_mentions() { assert_eq!(super::find("look at @src/main.rs, and @README.md."), vec!["src/main.rs", "README.md"]); assert!(super::find("mail me at a@b.de").is_empty()); assert_eq!(super::find("@docs/ please"), vec!["docs/"]); } }