Skip to main content

vtcode_core/subagents/
prompt.rs

1use vtcode_config::SubagentSpec;
2
3use super::constants::VAGUE_SUBAGENT_PROMPTS;
4use super::types::SubagentInputItem;
5
6// ─── Delegation Detection ───────────────────────────────────────────────────
7
8#[must_use]
9pub fn delegated_task_requires_clarification(prompt: &str) -> bool {
10    let normalized = prompt
11        .trim()
12        .trim_matches(|ch: char| matches!(ch, '"' | '\'' | '.' | ',' | '!' | '?' | ':' | ';'))
13        .to_ascii_lowercase();
14    if normalized.is_empty() {
15        return true;
16    }
17    if VAGUE_SUBAGENT_PROMPTS
18        .iter()
19        .any(|candidate| normalized == *candidate)
20    {
21        return true;
22    }
23    normalized.split_whitespace().count() == 1
24}
25
26// ─── Agent Mention Extraction ──────────────────────────────────────────────
27
28pub fn extract_explicit_agent_mentions(input: &str, specs: &[SubagentSpec]) -> Vec<String> {
29    let mut mentions = Vec::new();
30    for direct in extract_direct_agent_mentions(input) {
31        if let Some(matching_child) = specs
32            .iter()
33            .find(|spec| spec.is_subagent() && spec.matches_name(direct.as_str()))
34        {
35            push_unique_agent_mention(&mut mentions, &matching_child.name);
36            continue;
37        }
38
39        if specs
40            .iter()
41            .any(|spec| spec.is_primary() && spec.matches_name(direct.as_str()))
42        {
43            continue;
44        }
45
46        push_unique_agent_mention(&mut mentions, &direct);
47    }
48
49    let lower = input.to_ascii_lowercase();
50    for spec in specs.iter().filter(|spec| spec.is_subagent()) {
51        if !matches_explicit_named_agent_selection(lower.as_str(), spec) {
52            continue;
53        }
54        push_unique_agent_mention(&mut mentions, &spec.name);
55    }
56
57    mentions
58}
59
60fn extract_direct_agent_mentions(input: &str) -> Vec<String> {
61    input
62        .split_whitespace()
63        .filter_map(|token| {
64            let trimmed = token.trim_matches(|ch: char| {
65                matches!(
66                    ch,
67                    '"' | '\'' | ',' | '.' | ':' | ';' | '!' | '?' | ')' | '('
68                )
69            });
70            trimmed
71                .strip_prefix("@agent-")
72                .map(str::trim)
73                .filter(|value| !value.is_empty())
74                .map(ToOwned::to_owned)
75        })
76        .collect()
77}
78
79fn push_unique_agent_mention(mentions: &mut Vec<String>, candidate: &str) {
80    if mentions
81        .iter()
82        .any(|existing| existing.eq_ignore_ascii_case(candidate))
83    {
84        return;
85    }
86    mentions.push(candidate.to_string());
87}
88
89fn matches_explicit_named_agent_selection(input: &str, spec: &SubagentSpec) -> bool {
90    std::iter::once(spec.name.as_str())
91        .chain(spec.aliases.iter().map(String::as_str))
92        .any(|candidate| contains_explicit_named_agent_selection(input, candidate))
93}
94
95fn contains_explicit_named_agent_selection(input: &str, candidate: &str) -> bool {
96    let candidate = candidate.trim().to_ascii_lowercase();
97    if candidate.is_empty() {
98        return false;
99    }
100
101    let direct_match = [
102        format!("use {candidate} agent"),
103        format!("use the {candidate} agent"),
104        format!("use {candidate} subagent"),
105        format!("use the {candidate} subagent"),
106        format!("run {candidate} agent"),
107        format!("run the {candidate} agent"),
108        format!("run {candidate} subagent"),
109        format!("run the {candidate} subagent"),
110        format!("delegate to {candidate}"),
111        format!("delegate this to {candidate}"),
112        format!("delegate the task to {candidate}"),
113        format!("spawn {candidate}"),
114        format!("spawn the {candidate}"),
115        format!("ask {candidate} to"),
116    ]
117    .iter()
118    .any(|pattern| input.contains(pattern.as_str()));
119    if direct_match {
120        return true;
121    }
122
123    [
124        format!("use {candidate} and"),
125        format!("use the {candidate} and"),
126    ]
127    .iter()
128    .any(|pattern| input.contains(pattern.as_str()))
129        && (input.contains(" agent") || input.contains(" subagent"))
130}
131
132pub fn contains_explicit_delegation_request(input: &str, explicit_mentions: &[String]) -> bool {
133    let lower = input.to_ascii_lowercase();
134    !explicit_mentions.is_empty()
135        || lower.contains(" run in parallel")
136        || lower.contains(" spawn ")
137        || lower.starts_with("spawn ")
138        || lower.contains(" delegate ")
139        || lower.starts_with("delegate ")
140        || lower.contains(" background subagent")
141        || lower.contains(" background agent")
142        || (lower.contains(" use the ")
143            && (lower.contains(" agent") || lower.contains(" subagent")))
144        || (lower.starts_with("use ") && (lower.contains(" agent") || lower.contains(" subagent")))
145}
146
147pub fn contains_explicit_model_request(input: &str, requested_model: &str) -> bool {
148    let requested = requested_model.trim();
149    if requested.is_empty() {
150        return false;
151    }
152
153    let lower_input = input.to_ascii_lowercase();
154    let lower_requested = requested.to_ascii_lowercase();
155
156    match lower_requested.as_str() {
157        "small" => {
158            lower_input.contains("small model")
159                || lower_input.contains("smaller model")
160                || lower_input.contains("lightweight model")
161                || lower_input.contains("cheap model")
162        }
163        "haiku" | "sonnet" | "opus" | "inherit" => {
164            contains_bounded_term(&lower_input, &lower_requested)
165                || lower_input.contains(&format!("use {lower_requested}"))
166                || lower_input.contains(&format!("using {lower_requested}"))
167                || lower_input.contains(&format!("with {lower_requested}"))
168                || lower_input.contains(&format!("run on {lower_requested}"))
169                || lower_input.contains(&format!("{lower_requested} model"))
170                || lower_input.contains(&format!("model {lower_requested}"))
171        }
172        _ => contains_bounded_term(&lower_input, &lower_requested),
173    }
174}
175
176pub fn normalize_requested_model_override(
177    raw: Option<String>,
178    current_input: &str,
179) -> Option<String> {
180    let requested = raw?.trim().to_string();
181    if requested.is_empty() || requested.eq_ignore_ascii_case("default") {
182        return None;
183    }
184    if requested.eq_ignore_ascii_case("inherit")
185        && !contains_explicit_model_request(current_input, requested.as_str())
186    {
187        return None;
188    }
189    Some(requested)
190}
191
192fn contains_bounded_term(input: &str, needle: &str) -> bool {
193    if needle.is_empty() {
194        return false;
195    }
196
197    input.match_indices(needle).any(|(start, matched)| {
198        let end = start + matched.len();
199        let leading_ok = start == 0
200            || !input[..start]
201                .chars()
202                .next_back()
203                .is_some_and(|ch| ch.is_ascii_alphanumeric());
204        let trailing_ok = end == input.len()
205            || !input[end..]
206                .chars()
207                .next()
208                .is_some_and(|ch| ch.is_ascii_alphanumeric());
209        leading_ok && trailing_ok
210    })
211}
212
213// ─── Input Sanitization ─────────────────────────────────────────────────────
214
215pub fn sanitize_subagent_input_items(items: &mut Vec<SubagentInputItem>) {
216    let mut sanitized = Vec::with_capacity(items.len());
217    for mut item in items.drain(..) {
218        item.item_type = trim_optional_field(item.item_type.take());
219        item.text = trim_optional_field(item.text.take());
220        item.path = trim_optional_field(item.path.take());
221        item.name = trim_optional_field(item.name.take());
222        item.image_url = trim_optional_field(item.image_url.take());
223        if item.text.is_none()
224            && item.path.is_none()
225            && item.name.is_none()
226            && item.image_url.is_none()
227        {
228            continue;
229        }
230        sanitized.push(item);
231    }
232    *items = sanitized;
233}
234
235fn trim_optional_field(value: Option<String>) -> Option<String> {
236    let trimmed = value?.trim().to_string();
237    (!trimmed.is_empty()).then_some(trimmed)
238}
239
240// ─── Request Prompt Extraction ──────────────────────────────────────────────
241
242pub fn request_prompt(message: &Option<String>, items: &[SubagentInputItem]) -> Option<String> {
243    if let Some(message) = message
244        && !message.trim().is_empty()
245    {
246        return Some(message.trim().to_string());
247    }
248
249    let segments = items
250        .iter()
251        .filter_map(item_prompt_segment)
252        .collect::<Vec<_>>();
253    if segments.is_empty() {
254        None
255    } else {
256        Some(segments.join("\n"))
257    }
258}
259
260fn item_prompt_segment(item: &SubagentInputItem) -> Option<String> {
261    if let Some(text) = item.text.as_ref()
262        && !text.trim().is_empty()
263    {
264        return Some(text.trim().to_string());
265    }
266    if let Some(path) = item.path.as_ref()
267        && !path.trim().is_empty()
268    {
269        return Some(format!("Reference: {}", path.trim()));
270    }
271    if let Some(name) = item.name.as_ref()
272        && !name.trim().is_empty()
273    {
274        return Some(name.trim().to_string());
275    }
276    if let Some(image_url) = item.image_url.as_ref()
277        && !image_url.trim().is_empty()
278    {
279        return Some(format!("Image: {}", image_url.trim()));
280    }
281    None
282}
283
284#[cfg(test)]
285mod tests {
286    use vtcode_config::{AgentMode, SubagentSource, SubagentSpec};
287
288    use super::extract_explicit_agent_mentions;
289
290    fn test_spec(name: &str, mode: AgentMode) -> SubagentSpec {
291        SubagentSpec {
292            name: name.to_string(),
293            description: "test".to_string(),
294            prompt: String::new(),
295            tools: None,
296            disallowed_tools: Vec::new(),
297            model: None,
298            color: None,
299            reasoning_effort: None,
300            permission_mode: None,
301            skills: Vec::new(),
302            mcp_servers: Vec::new(),
303            hooks: None,
304            background: false,
305            mode,
306            max_turns: None,
307            nickname_candidates: Vec::new(),
308            initial_prompt: None,
309            memory: None,
310            isolation: None,
311            aliases: Vec::new(),
312            source: SubagentSource::Builtin,
313            file_path: None,
314            warnings: Vec::new(),
315        }
316    }
317
318    #[test]
319    fn explicit_mentions_use_delegated_agent_namespace() {
320        let primary_plan = test_spec("plan", AgentMode::Primary);
321        let child_plan = test_spec("plan", AgentMode::Subagent);
322
323        let mentions = extract_explicit_agent_mentions(
324            "@agent-plan inspect this",
325            &[primary_plan, child_plan],
326        );
327
328        assert_eq!(mentions, vec!["plan".to_string()]);
329    }
330
331    #[test]
332    fn explicit_mentions_ignore_primary_only_agents() {
333        let primary_plan = test_spec("plan", AgentMode::Primary);
334
335        let mentions = extract_explicit_agent_mentions("@agent-plan inspect this", &[primary_plan]);
336
337        assert!(mentions.is_empty());
338    }
339
340    #[test]
341    fn explicit_mentions_allow_all_mode_agents() {
342        let plan = test_spec("plan", AgentMode::All);
343
344        let mentions = extract_explicit_agent_mentions("@agent-plan inspect this", &[plan]);
345
346        assert_eq!(mentions, vec!["plan".to_string()]);
347    }
348}