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
165#[must_use]
166pub fn primary_agent_allows_tool(agent: &ActivePrimaryAgent, tool_name: &str) -> bool {
167 let tool_name = normalise_tool_name(tool_name);
168 let allow_list_allows = agent.tools.as_ref().is_none_or(|tools| {
169 tools
170 .iter()
171 .any(|allowed| normalise_tool_name(allowed) == tool_name)
172 });
173 if !allow_list_allows {
174 return false;
175 }
176
177 !agent
178 .disallowed_tools
179 .iter()
180 .any(|denied| normalise_tool_name(denied) == tool_name)
181}
182
183#[must_use]
184pub fn apply_primary_agent_tool_policy(
185 tools: Option<Arc<Vec<ToolDefinition>>>,
186 agent: &ActivePrimaryAgent,
187) -> Option<Arc<Vec<ToolDefinition>>> {
188 let tools = tools?;
189 let filtered = tools
190 .iter()
191 .filter(|tool| primary_agent_allows_tool(agent, tool.function_name()))
192 .cloned()
193 .collect::<Vec<_>>();
194
195 (!filtered.is_empty()).then(|| Arc::new(filtered))
196}
197
198fn permission_rank(mode: PermissionMode) -> u8 {
199 match mode {
200 PermissionMode::DontAsk => 0,
201 PermissionMode::Plan => 1,
202 PermissionMode::Default => 2,
203 PermissionMode::AcceptEdits => 3,
204 PermissionMode::Auto => 4,
205 PermissionMode::BypassPermissions => 5,
206 }
207}
208
209fn normalise_tool_name(tool_name: &str) -> String {
210 tool_name.trim().to_ascii_lowercase()
211}
212
213#[cfg(test)]
214mod tests {
215 use serde_json::json;
216 use tempfile::TempDir;
217 use vtcode_config::{
218 SubagentDiscoveryInput, SubagentMcpServer, SubagentMemoryScope, SubagentSource,
219 discover_subagents,
220 };
221
222 use super::*;
223
224 #[test]
225 fn resolves_existing_spec_by_name() {
226 let spec = test_spec("planner");
227 let active = resolve_primary_agent(&[spec], "planner").expect("resolved");
228
229 assert_eq!(active.identity.name, "planner");
230 assert_eq!(active.display_name, "planner");
231 assert_eq!(active.instructions, "planner instructions");
232 assert_eq!(active.tools, Some(vec!["unified_search".to_string()]));
233 assert_eq!(active.disallowed_tools, vec!["unified_file".to_string()]);
234 assert_eq!(active.permission_mode, Some(PermissionMode::Plan));
235 assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
236 assert_eq!(active.reasoning_effort.as_deref(), Some("high"));
237 }
238
239 #[test]
240 fn unknown_agent_error_preserves_current_active_agent() {
241 let current = test_spec("current");
242 let specs = vec![current.clone()];
243 let mut state = ActivePrimaryAgentState::default();
244 let original = state
245 .select_from_specs(&specs, "current")
246 .expect("initial selection")
247 .clone();
248
249 let error = state
250 .select_from_specs(&specs, "missing")
251 .expect_err("unknown agent");
252
253 assert_eq!(
254 error,
255 PrimaryAgentResolutionError::UnknownAgent {
256 requested: "missing".to_string()
257 }
258 );
259 assert_eq!(state.active(), &original);
260 }
261
262 #[test]
263 fn alias_resolution_uses_existing_matches_name_semantics() {
264 let mut spec = test_spec("reviewer");
265 spec.aliases = vec!["critic".to_string()];
266
267 let active = resolve_primary_agent(&[spec], "CRITIC").expect("resolved by alias");
268
269 assert_eq!(active.identity.name, "reviewer");
270 assert_eq!(active.display_name, "reviewer");
271 }
272
273 #[test]
274 fn exact_name_resolution_wins_over_alias() {
275 let mut build = test_spec("build");
276 build.aliases = vec!["builder".to_string()];
277 let builder = test_spec("builder");
278
279 let active = resolve_primary_agent(&[build, builder], "builder").expect("resolved");
280
281 assert_eq!(active.identity.name, "builder");
282 }
283
284 #[test]
285 fn ignored_subagent_fields_do_not_enter_primary_agent_runtime() {
286 let mut spec = test_spec("worker");
287 spec.aliases = vec!["builder".to_string()];
288 spec.skills = vec!["rust".to_string()];
289 spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
290 spec.background = true;
291 spec.max_turns = Some(12);
292 spec.nickname_candidates = vec!["w".to_string()];
293 spec.initial_prompt = Some("start here".to_string());
294 spec.memory = Some(SubagentMemoryScope::Project);
295 spec.isolation = Some("full".to_string());
296
297 let active = ActivePrimaryAgent::from_spec(&spec);
298
299 assert_eq!(active.identity.name, "worker");
300 assert_eq!(active.display_name, "worker");
301 assert_eq!(active.instructions, "worker instructions");
302 assert_eq!(active.tools, Some(vec!["unified_search".to_string()]));
303 assert_eq!(active.disallowed_tools, vec!["unified_file".to_string()]);
304 assert_eq!(active.permission_mode, Some(PermissionMode::Plan));
305 assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
306 assert_eq!(active.reasoning_effort.as_deref(), Some("high"));
307 }
308
309 #[test]
310 fn default_state_uses_builtin_build_agent() {
311 let mut state = ActivePrimaryAgentState::default();
312
313 assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
314 assert_eq!(state.active().identity.source, SubagentSource::Builtin);
315
316 state
317 .select_from_specs(&[test_spec("worker")], "worker")
318 .expect("selected");
319 assert_eq!(state.active().identity.name, "worker");
320
321 state.reset_to_default_from_specs(&[]);
322
323 assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
324 assert_eq!(state.active().identity.source, SubagentSource::Builtin);
325 }
326
327 #[test]
328 fn discovery_precedence_overrides_builtin_build_agent() {
329 let temp = TempDir::new().expect("tempdir");
330 let discovered = discover_subagents(&SubagentDiscoveryInput {
331 workspace_root: temp.path().to_path_buf(),
332 cli_agents: Some(json!({
333 "build": {
334 "description": "CLI build",
335 "prompt": "cli build instructions",
336 "model": "gpt-cli",
337 "mode": "primary"
338 }
339 })),
340 plugin_agent_files: Vec::new(),
341 })
342 .expect("discovered subagents");
343
344 let active = ActivePrimaryAgentState::from_discovery(&discovered);
345
346 assert_eq!(active.active().identity.name, "build");
347 assert_eq!(active.active().identity.source, SubagentSource::Cli);
348 assert_eq!(active.active().instructions, "cli build instructions");
349 assert_eq!(active.active().model.as_deref(), Some("gpt-cli"));
350 }
351
352 #[test]
353 fn default_build_agent_allows_baseline_tools() {
354 let active = ActivePrimaryAgentState::default();
355
356 assert!(primary_agent_allows_tool(active.active(), "unified_search"));
357 }
358
359 #[test]
360 fn tool_policy_intersects_allow_list_then_applies_deny_list() {
361 let mut spec = test_spec("worker");
362 spec.tools = Some(vec![
363 "unified_search".to_string(),
364 "unified_file".to_string(),
365 ]);
366 spec.disallowed_tools = vec!["UNIFIED_SEARCH".to_string()];
367 let active = ActivePrimaryAgent::from_spec(&spec);
368
369 assert!(!primary_agent_allows_tool(&active, "unified_exec"));
370 assert!(!primary_agent_allows_tool(&active, "unified_search"));
371 assert!(primary_agent_allows_tool(&active, "unified_file"));
372 }
373
374 #[test]
375 fn empty_present_tool_allow_list_exposes_no_tools() {
376 let mut spec = test_spec("worker");
377 spec.tools = Some(Vec::new());
378 spec.disallowed_tools = Vec::new();
379 let active = ActivePrimaryAgent::from_spec(&spec);
380
381 assert!(!primary_agent_allows_tool(&active, "unified_search"));
382 }
383
384 #[test]
385 fn permission_policy_clamps_without_broadening() {
386 assert_eq!(
387 clamp_primary_permission_mode(PermissionMode::Default, Some(PermissionMode::Plan)),
388 PermissionMode::Plan
389 );
390 assert_eq!(
391 clamp_primary_permission_mode(PermissionMode::Default, Some(PermissionMode::Auto)),
392 PermissionMode::Default
393 );
394 assert_eq!(
395 clamp_primary_permission_mode(PermissionMode::Auto, Some(PermissionMode::Plan)),
396 PermissionMode::Plan
397 );
398 assert_eq!(
399 clamp_primary_permission_mode(PermissionMode::Plan, None),
400 PermissionMode::Plan
401 );
402 }
403
404 fn test_spec(name: &str) -> SubagentSpec {
405 SubagentSpec {
406 name: name.to_string(),
407 description: format!("{name} description"),
408 prompt: format!("{name} instructions"),
409 tools: Some(vec!["unified_search".to_string()]),
410 disallowed_tools: vec!["unified_file".to_string()],
411 model: Some("gpt-5.1".to_string()),
412 color: Some("blue".to_string()),
413 reasoning_effort: Some("high".to_string()),
414 permission_mode: Some(PermissionMode::Plan),
415 skills: Vec::new(),
416 mcp_servers: Vec::new(),
417 hooks: None,
418 background: false,
419 mode: vtcode_config::AgentMode::Primary,
420 max_turns: None,
421 nickname_candidates: Vec::new(),
422 initial_prompt: None,
423 memory: None,
424 isolation: None,
425 aliases: Vec::new(),
426 source: SubagentSource::ProjectVtcode,
427 file_path: None,
428 warnings: Vec::new(),
429 }
430 }
431}