Skip to main content

vtcode_core/
primary_agent.rs

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/// Returns `true` when `tool_name` is a subagent lifecycle tool that must
166/// remain available regardless of the active primary agent's tool policy.
167/// These tools manage running subagents (spawn, wait, close, send input,
168/// resume, background subprocess). Blocking them would orphan active
169/// subagents when a restricted primary agent (e.g. `plan`) is selected.
170fn 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    // Subagent lifecycle tools are always allowed -- they manage running
187    // subagents regardless of which primary agent is active.
188    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        // Non-lifecycle tools respect the policy.
423        assert!(!primary_agent_allows_tool(&active, "unified_exec"));
424        assert!(!primary_agent_allows_tool(&active, "unified_file"));
425
426        // Subagent lifecycle tools are always allowed even when listed in
427        // disallowed_tools and absent from the allow list.
428        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}