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.iter().any(|candidate| normalized == *candidate) {
18 return true;
19 }
20 normalized.split_whitespace().count() == 1
21}
22
23pub 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
185pub 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
208pub 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}