vtcode_core/subagents/
prompt.rs1use vtcode_config::SubagentSpec;
2
3use super::constants::VAGUE_SUBAGENT_PROMPTS;
4use super::types::SubagentInputItem;
5
6#[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
26pub 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
213pub 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
240pub 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}