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#[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}