1use std::error::Error;
2use std::fmt;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use vtcode_config::constants::defaults::DEFAULT_PRIMARY_AGENT_NAME;
7use vtcode_config::{
8 DiscoveredSubagents, PermissionMode, SubagentSource, SubagentSpec, builtin_primary_build_agent,
9};
10
11use crate::llm::provider::ToolDefinition;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ActivePrimaryAgentSpecIdentity {
15 pub name: String,
16 pub source: SubagentSource,
17 pub file_path: Option<PathBuf>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ActivePrimaryAgent {
22 pub identity: ActivePrimaryAgentSpecIdentity,
23 pub display_name: String,
24 pub instructions: String,
25 pub tools: Option<Vec<String>>,
26 pub disallowed_tools: Vec<String>,
27 pub permission_mode: Option<PermissionMode>,
28 pub model: Option<String>,
29 pub reasoning_effort: Option<String>,
30}
31
32impl ActivePrimaryAgent {
33 #[must_use]
34 pub fn from_spec(spec: &SubagentSpec) -> Self {
35 Self {
36 identity: ActivePrimaryAgentSpecIdentity {
37 name: spec.name.clone(),
38 source: spec.source.clone(),
39 file_path: spec.file_path.clone(),
40 },
41 display_name: spec.name.clone(),
42 instructions: spec.prompt.clone(),
43 tools: spec.tools.clone(),
44 disallowed_tools: spec.disallowed_tools.clone(),
45 permission_mode: spec.permission_mode,
46 model: spec.model.clone(),
47 reasoning_effort: spec.reasoning_effort.clone(),
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ActivePrimaryAgentState {
54 active: ActivePrimaryAgent,
55}
56
57impl Default for ActivePrimaryAgentState {
58 fn default() -> Self {
59 Self {
60 active: ActivePrimaryAgent::from_spec(&builtin_primary_build_agent()),
61 }
62 }
63}
64
65impl ActivePrimaryAgentState {
66 #[must_use]
67 pub const fn active(&self) -> &ActivePrimaryAgent {
68 &self.active
69 }
70
71 #[must_use]
72 pub fn from_discovery(discovered: &DiscoveredSubagents) -> Self {
73 Self::from_specs(&discovered.effective)
74 }
75
76 #[must_use]
77 pub fn from_specs(specs: &[SubagentSpec]) -> Self {
78 let active = resolve_primary_agent(specs, DEFAULT_PRIMARY_AGENT_NAME)
79 .unwrap_or_else(|_| ActivePrimaryAgent::from_spec(&builtin_primary_build_agent()));
80 Self { active }
81 }
82
83 pub fn reset_to_default_from_specs(&mut self, specs: &[SubagentSpec]) -> &ActivePrimaryAgent {
84 self.active = Self::from_specs(specs).active;
85 &self.active
86 }
87
88 pub fn select_from_discovery(
89 &mut self,
90 discovered: &DiscoveredSubagents,
91 requested: &str,
92 ) -> PrimaryAgentResolutionResult<&ActivePrimaryAgent> {
93 self.select_from_specs(&discovered.effective, requested)
94 }
95
96 pub fn select_from_specs(
97 &mut self,
98 specs: &[SubagentSpec],
99 requested: &str,
100 ) -> PrimaryAgentResolutionResult<&ActivePrimaryAgent> {
101 let active = resolve_primary_agent(specs, requested)?;
102 self.active = active;
103 Ok(&self.active)
104 }
105}
106
107pub type PrimaryAgentResolutionResult<T> = Result<T, PrimaryAgentResolutionError>;
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum PrimaryAgentResolutionError {
111 UnknownAgent { requested: String },
112}
113
114impl fmt::Display for PrimaryAgentResolutionError {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 match self {
117 Self::UnknownAgent { requested } => write!(f, "Unknown primary agent {requested}"),
118 }
119 }
120}
121
122impl Error for PrimaryAgentResolutionError {}
123
124pub fn resolve_discovered_primary_agent(
125 discovered: &DiscoveredSubagents,
126 requested: &str,
127) -> PrimaryAgentResolutionResult<ActivePrimaryAgent> {
128 resolve_primary_agent(&discovered.effective, requested)
129}
130
131pub fn resolve_primary_agent(
132 specs: &[SubagentSpec],
133 requested: &str,
134) -> PrimaryAgentResolutionResult<ActivePrimaryAgent> {
135 specs
136 .iter()
137 .find(|spec| spec.is_primary() && spec.name.eq_ignore_ascii_case(requested))
138 .or_else(|| {
139 specs
140 .iter()
141 .find(|spec| spec.is_primary() && spec.matches_name(requested))
142 })
143 .map(ActivePrimaryAgent::from_spec)
144 .ok_or_else(|| PrimaryAgentResolutionError::UnknownAgent {
145 requested: requested.to_string(),
146 })
147}
148
149#[must_use]
150pub fn clamp_primary_permission_mode(
151 base: PermissionMode,
152 requested: Option<PermissionMode>,
153) -> PermissionMode {
154 let Some(requested) = requested else {
155 return base;
156 };
157
158 if permission_rank(requested) <= permission_rank(base) {
159 requested
160 } else {
161 base
162 }
163}
164
165fn is_subagent_lifecycle_tool(tool_name: &str) -> bool {
171 matches!(
172 tool_name,
173 "spawn_agent"
174 | "spawn_background_subprocess"
175 | "send_input"
176 | "wait_agent"
177 | "resume_agent"
178 | "close_agent"
179 )
180}
181
182#[must_use]
183pub fn primary_agent_allows_tool(agent: &ActivePrimaryAgent, tool_name: &str) -> bool {
184 let tool_name = normalise_tool_name(tool_name);
185
186 if is_subagent_lifecycle_tool(&tool_name) {
189 return true;
190 }
191
192 let allow_list_allows = agent.tools.as_ref().is_none_or(|tools| {
193 tools
194 .iter()
195 .any(|allowed| normalise_tool_name(allowed) == tool_name)
196 });
197 if !allow_list_allows {
198 return false;
199 }
200
201 !agent
202 .disallowed_tools
203 .iter()
204 .any(|denied| normalise_tool_name(denied) == tool_name)
205}
206
207#[must_use]
208pub fn apply_primary_agent_tool_policy(
209 tools: Option<Arc<Vec<ToolDefinition>>>,
210 agent: &ActivePrimaryAgent,
211) -> Option<Arc<Vec<ToolDefinition>>> {
212 let tools = tools?;
213 let filtered = tools
214 .iter()
215 .filter(|tool| primary_agent_allows_tool(agent, tool.function_name()))
216 .cloned()
217 .collect::<Vec<_>>();
218
219 (!filtered.is_empty()).then(|| Arc::new(filtered))
220}
221
222fn permission_rank(mode: PermissionMode) -> u8 {
223 match mode {
224 PermissionMode::DontAsk => 0,
225 PermissionMode::Plan => 1,
226 PermissionMode::Default => 2,
227 PermissionMode::AcceptEdits => 3,
228 PermissionMode::Auto => 4,
229 PermissionMode::BypassPermissions => 5,
230 }
231}
232
233fn normalise_tool_name(tool_name: &str) -> String {
234 tool_name.trim().to_ascii_lowercase()
235}
236
237#[cfg(test)]
238mod tests {
239 use serde_json::json;
240 use tempfile::TempDir;
241 use vtcode_config::{
242 SubagentDiscoveryInput, SubagentMcpServer, SubagentMemoryScope, SubagentSource,
243 discover_subagents,
244 };
245
246 use super::*;
247
248 #[test]
249 fn resolves_existing_spec_by_name() {
250 let spec = test_spec("planner");
251 let active = resolve_primary_agent(&[spec], "planner").expect("resolved");
252
253 assert_eq!(active.identity.name, "planner");
254 assert_eq!(active.display_name, "planner");
255 assert_eq!(active.instructions, "planner instructions");
256 assert_eq!(active.tools, Some(vec!["unified_search".to_string()]));
257 assert_eq!(active.disallowed_tools, vec!["unified_file".to_string()]);
258 assert_eq!(active.permission_mode, Some(PermissionMode::Plan));
259 assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
260 assert_eq!(active.reasoning_effort.as_deref(), Some("high"));
261 }
262
263 #[test]
264 fn unknown_agent_error_preserves_current_active_agent() {
265 let current = test_spec("current");
266 let specs = vec![current.clone()];
267 let mut state = ActivePrimaryAgentState::default();
268 let original = state
269 .select_from_specs(&specs, "current")
270 .expect("initial selection")
271 .clone();
272
273 let error = state
274 .select_from_specs(&specs, "missing")
275 .expect_err("unknown agent");
276
277 assert_eq!(
278 error,
279 PrimaryAgentResolutionError::UnknownAgent {
280 requested: "missing".to_string()
281 }
282 );
283 assert_eq!(state.active(), &original);
284 }
285
286 #[test]
287 fn alias_resolution_uses_existing_matches_name_semantics() {
288 let mut spec = test_spec("reviewer");
289 spec.aliases = vec!["critic".to_string()];
290
291 let active = resolve_primary_agent(&[spec], "CRITIC").expect("resolved by alias");
292
293 assert_eq!(active.identity.name, "reviewer");
294 assert_eq!(active.display_name, "reviewer");
295 }
296
297 #[test]
298 fn exact_name_resolution_wins_over_alias() {
299 let mut build = test_spec("build");
300 build.aliases = vec!["builder".to_string()];
301 let builder = test_spec("builder");
302
303 let active = resolve_primary_agent(&[build, builder], "builder").expect("resolved");
304
305 assert_eq!(active.identity.name, "builder");
306 }
307
308 #[test]
309 fn ignored_subagent_fields_do_not_enter_primary_agent_runtime() {
310 let mut spec = test_spec("worker");
311 spec.aliases = vec!["builder".to_string()];
312 spec.skills = vec!["rust".to_string()];
313 spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
314 spec.background = true;
315 spec.max_turns = Some(12);
316 spec.nickname_candidates = vec!["w".to_string()];
317 spec.initial_prompt = Some("start here".to_string());
318 spec.memory = Some(SubagentMemoryScope::Project);
319 spec.isolation = Some("full".to_string());
320
321 let active = ActivePrimaryAgent::from_spec(&spec);
322
323 assert_eq!(active.identity.name, "worker");
324 assert_eq!(active.display_name, "worker");
325 assert_eq!(active.instructions, "worker instructions");
326 assert_eq!(active.tools, Some(vec!["unified_search".to_string()]));
327 assert_eq!(active.disallowed_tools, vec!["unified_file".to_string()]);
328 assert_eq!(active.permission_mode, Some(PermissionMode::Plan));
329 assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
330 assert_eq!(active.reasoning_effort.as_deref(), Some("high"));
331 }
332
333 #[test]
334 fn default_state_uses_builtin_build_agent() {
335 let mut state = ActivePrimaryAgentState::default();
336
337 assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
338 assert_eq!(state.active().identity.source, SubagentSource::Builtin);
339
340 state
341 .select_from_specs(&[test_spec("worker")], "worker")
342 .expect("selected");
343 assert_eq!(state.active().identity.name, "worker");
344
345 state.reset_to_default_from_specs(&[]);
346
347 assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
348 assert_eq!(state.active().identity.source, SubagentSource::Builtin);
349 }
350
351 #[test]
352 fn discovery_precedence_overrides_builtin_build_agent() {
353 let temp = TempDir::new().expect("tempdir");
354 let discovered = discover_subagents(&SubagentDiscoveryInput {
355 workspace_root: temp.path().to_path_buf(),
356 cli_agents: Some(json!({
357 "build": {
358 "description": "CLI build",
359 "prompt": "cli build instructions",
360 "model": "gpt-cli",
361 "mode": "primary"
362 }
363 })),
364 plugin_agent_files: Vec::new(),
365 })
366 .expect("discovered subagents");
367
368 let active = ActivePrimaryAgentState::from_discovery(&discovered);
369
370 assert_eq!(active.active().identity.name, "build");
371 assert_eq!(active.active().identity.source, SubagentSource::Cli);
372 assert_eq!(active.active().instructions, "cli build instructions");
373 assert_eq!(active.active().model.as_deref(), Some("gpt-cli"));
374 }
375
376 #[test]
377 fn default_build_agent_allows_baseline_tools() {
378 let active = ActivePrimaryAgentState::default();
379
380 assert!(primary_agent_allows_tool(active.active(), "unified_search"));
381 }
382
383 #[test]
384 fn tool_policy_intersects_allow_list_then_applies_deny_list() {
385 let mut spec = test_spec("worker");
386 spec.tools = Some(vec![
387 "unified_search".to_string(),
388 "unified_file".to_string(),
389 ]);
390 spec.disallowed_tools = vec!["UNIFIED_SEARCH".to_string()];
391 let active = ActivePrimaryAgent::from_spec(&spec);
392
393 assert!(!primary_agent_allows_tool(&active, "unified_exec"));
394 assert!(!primary_agent_allows_tool(&active, "unified_search"));
395 assert!(primary_agent_allows_tool(&active, "unified_file"));
396 }
397
398 #[test]
399 fn empty_present_tool_allow_list_exposes_no_tools() {
400 let mut spec = test_spec("worker");
401 spec.tools = Some(Vec::new());
402 spec.disallowed_tools = Vec::new();
403 let active = ActivePrimaryAgent::from_spec(&spec);
404
405 assert!(!primary_agent_allows_tool(&active, "unified_search"));
406 }
407
408 #[test]
409 fn subagent_lifecycle_tools_bypass_tool_policy() {
410 let mut spec = test_spec("restricted");
411 spec.tools = Some(vec!["unified_search".to_string()]);
412 spec.disallowed_tools = vec![
413 "spawn_agent".to_string(),
414 "wait_agent".to_string(),
415 "close_agent".to_string(),
416 "send_input".to_string(),
417 "resume_agent".to_string(),
418 "spawn_background_subprocess".to_string(),
419 ];
420 let active = ActivePrimaryAgent::from_spec(&spec);
421
422 assert!(!primary_agent_allows_tool(&active, "unified_exec"));
424 assert!(!primary_agent_allows_tool(&active, "unified_file"));
425
426 assert!(primary_agent_allows_tool(&active, "spawn_agent"));
429 assert!(primary_agent_allows_tool(&active, "wait_agent"));
430 assert!(primary_agent_allows_tool(&active, "close_agent"));
431 assert!(primary_agent_allows_tool(&active, "send_input"));
432 assert!(primary_agent_allows_tool(&active, "resume_agent"));
433 assert!(primary_agent_allows_tool(
434 &active,
435 "spawn_background_subprocess"
436 ));
437 }
438
439 #[test]
440 fn permission_policy_clamps_without_broadening() {
441 assert_eq!(
442 clamp_primary_permission_mode(PermissionMode::Default, Some(PermissionMode::Plan)),
443 PermissionMode::Plan
444 );
445 assert_eq!(
446 clamp_primary_permission_mode(PermissionMode::Default, Some(PermissionMode::Auto)),
447 PermissionMode::Default
448 );
449 assert_eq!(
450 clamp_primary_permission_mode(PermissionMode::Auto, Some(PermissionMode::Plan)),
451 PermissionMode::Plan
452 );
453 assert_eq!(
454 clamp_primary_permission_mode(PermissionMode::Plan, None),
455 PermissionMode::Plan
456 );
457 }
458
459 fn test_spec(name: &str) -> SubagentSpec {
460 SubagentSpec {
461 name: name.to_string(),
462 description: format!("{name} description"),
463 prompt: format!("{name} instructions"),
464 tools: Some(vec!["unified_search".to_string()]),
465 disallowed_tools: vec!["unified_file".to_string()],
466 model: Some("gpt-5.1".to_string()),
467 color: Some("blue".to_string()),
468 reasoning_effort: Some("high".to_string()),
469 permission_mode: Some(PermissionMode::Plan),
470 skills: Vec::new(),
471 mcp_servers: Vec::new(),
472 hooks: None,
473 background: false,
474 mode: vtcode_config::AgentMode::Primary,
475 max_turns: None,
476 nickname_candidates: Vec::new(),
477 initial_prompt: None,
478 memory: None,
479 isolation: None,
480 aliases: Vec::new(),
481 source: SubagentSource::ProjectVtcode,
482 file_path: None,
483 warnings: Vec::new(),
484 }
485 }
486}