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