Skip to main content

vtcode_core/prompts/
context.rs

1use crate::config::ConfigManager;
2use crate::config::types::CapabilityLevel;
3use crate::skills::command_skills::is_model_catalog_eligible;
4use crate::skills::manager::SkillsManager;
5use crate::skills::model::SkillMetadata;
6use crate::tools::search_runtime::snapshot_for_workspace;
7use std::path::Path;
8use std::path::PathBuf;
9
10/// Context information for prompt generation
11#[derive(Debug, Clone, Default)]
12pub struct PromptContext {
13    /// Current workspace path
14    pub workspace: Option<PathBuf>,
15    /// Detected programming languages
16    pub languages: Vec<String>,
17    /// Project type (if detected)
18    pub project_type: Option<String>,
19    /// Available tools
20    pub available_tools: Vec<String>,
21    /// Available skills (name: description)
22    pub available_skills: Vec<(String, String)>,
23    /// Available skill metadata for lean prompt rendering
24    pub available_skill_metadata: Vec<SkillMetadata>,
25    /// User preferences
26    pub user_preferences: Option<UserPreferences>,
27    /// Capability level (inferred from tools or explicitly set)
28    pub capability_level: Option<CapabilityLevel>,
29    /// Current working directory (different from workspace root)
30    pub current_directory: Option<PathBuf>,
31}
32
33/// User preferences for prompt customization
34#[derive(Debug, Clone)]
35pub struct UserPreferences {
36    /// Preferred programming languages
37    pub preferred_languages: Vec<String>,
38    /// Coding style preferences
39    pub coding_style: Option<String>,
40    /// Framework preferences
41    pub preferred_frameworks: Vec<String>,
42}
43
44impl PromptContext {
45    /// Create context from workspace
46    pub fn from_workspace(workspace: PathBuf) -> Self {
47        Self { workspace: Some(workspace), ..Default::default() }
48    }
49
50    /// Add detected language
51    pub fn add_language(&mut self, language: String) {
52        if !self.languages.contains(&language) {
53            self.languages.push(language);
54        }
55    }
56
57    /// Set project type
58    pub fn set_project_type(&mut self, project_type: String) {
59        self.project_type = Some(project_type);
60    }
61
62    /// Add available tool
63    pub fn add_tool(&mut self, tool: String) {
64        if !self.available_tools.contains(&tool) {
65            self.available_tools.push(tool);
66        }
67    }
68
69    /// Add available skill
70    pub fn add_skill(&mut self, name: String, description: String) {
71        if !self.available_skills.iter().any(|(n, _)| n == &name) {
72            self.available_skills.push((name, description));
73        }
74    }
75
76    /// Add available skill metadata
77    pub fn add_skill_metadata(&mut self, metadata: SkillMetadata) {
78        self.add_skill(metadata.name.clone(), metadata.description.clone());
79        if !self.available_skill_metadata.iter().any(|skill| skill.name == metadata.name) {
80            self.available_skill_metadata.push(metadata);
81        }
82    }
83
84    /// Add multiple skill metadata entries
85    pub fn add_skill_metadata_entries(&mut self, skills: Vec<SkillMetadata>) {
86        for skill in skills {
87            self.add_skill_metadata(skill);
88        }
89    }
90
91    /// Add multiple skills
92    pub fn add_skills(&mut self, skills: Vec<(String, String)>) {
93        for (name, description) in skills {
94            self.add_skill(name, description);
95        }
96    }
97
98    /// Set capability level explicitly
99    pub fn set_capability_level(&mut self, level: CapabilityLevel) {
100        self.capability_level = Some(level);
101    }
102
103    /// Infer capability level from available tools
104    pub fn infer_capability_level(&mut self) {
105        self.capability_level = Some(crate::prompts::guidelines::infer_capability_level(&self.available_tools));
106    }
107
108    /// Set current working directory
109    pub fn set_current_directory(&mut self, dir: PathBuf) {
110        self.current_directory = Some(dir);
111    }
112
113    pub fn load_available_skills(&mut self) {
114        let home_dir = default_codex_home_dir();
115        self.load_available_skills_with_home_dir(home_dir.as_deref());
116    }
117
118    /// Load skill metadata without blocking the Tokio worker running prompt setup.
119    ///
120    /// The synchronous API remains available for callers outside an async
121    /// runtime; active agent setup should use this method because lightweight
122    /// discovery still traverses workspace and home-directory files.
123    pub async fn load_available_skills_async(&mut self) {
124        let Some(workspace) = self.workspace.clone() else {
125            return;
126        };
127        let Some(home_dir) = default_codex_home_dir() else {
128            return;
129        };
130
131        let result = tokio::task::spawn_blocking(move || collect_available_skill_metadata(&workspace, &home_dir)).await;
132        match result {
133            Ok(skills) => self.add_skill_metadata_entries(skills),
134            Err(error) => tracing::warn!(%error, "skill metadata discovery task failed"),
135        }
136    }
137
138    pub fn replace_available_skills_with_named(&mut self, names: &[String]) {
139        let home_dir = default_codex_home_dir();
140        self.replace_available_skills_with_named_and_home_dir(names, home_dir.as_deref());
141    }
142
143    fn load_available_skills_with_home_dir(&mut self, home_dir: Option<&Path>) {
144        let Some(workspace) = self.workspace.as_deref() else {
145            return;
146        };
147        let Some(home_dir) = home_dir else {
148            return;
149        };
150
151        self.add_skill_metadata_entries(collect_available_skill_metadata(workspace, home_dir));
152    }
153
154    fn replace_available_skills_with_named_and_home_dir(&mut self, names: &[String], home_dir: Option<&Path>) {
155        self.available_skills.clear();
156        self.available_skill_metadata.clear();
157
158        if names.is_empty() {
159            return;
160        }
161
162        let Some(workspace) = self.workspace.as_deref() else {
163            return;
164        };
165        let Some(home_dir) = home_dir else {
166            return;
167        };
168
169        let requested = names
170            .iter()
171            .map(|name| name.trim().to_ascii_lowercase())
172            .filter(|name| !name.is_empty())
173            .collect::<std::collections::HashSet<_>>();
174        if requested.is_empty() {
175            return;
176        }
177
178        let bundled_skills_enabled = ConfigManager::load_from_workspace(workspace)
179            .map(|manager| manager.config().skills.bundled.enabled)
180            .unwrap_or(true);
181        let manager = SkillsManager::new_with_bundled_skills_enabled(home_dir.to_path_buf(), bundled_skills_enabled);
182        let outcome = manager.skills_metadata_lightweight(workspace);
183        self.add_skill_metadata_entries(
184            outcome
185                .skills
186                .into_iter()
187                .filter(is_model_catalog_eligible)
188                .filter(|skill| requested.contains(&skill.name.to_ascii_lowercase()))
189                .collect(),
190        );
191    }
192
193    /// Build prompt context from a workspace and the tool names exposed to the model.
194    pub fn from_workspace_tools(
195        workspace: impl AsRef<Path>,
196        available_tools: impl IntoIterator<Item = impl Into<String>>,
197    ) -> Self {
198        let mut context = Self {
199            workspace: Some(workspace.as_ref().to_path_buf()),
200            ..Default::default()
201        };
202
203        if let Ok(cwd) = std::env::current_dir() {
204            context.set_current_directory(cwd);
205        }
206
207        for language in snapshot_for_workspace(workspace.as_ref()).workspace_languages {
208            context.add_language(language);
209        }
210
211        for tool in available_tools {
212            context.add_tool(tool.into());
213        }
214
215        if !context.available_tools.is_empty() {
216            context.infer_capability_level();
217        }
218
219        context
220    }
221}
222
223fn collect_available_skill_metadata(workspace: &Path, home_dir: &Path) -> Vec<SkillMetadata> {
224    let bundled_skills_enabled = ConfigManager::load_from_workspace(workspace)
225        .map(|manager| manager.config().skills.bundled.enabled)
226        .unwrap_or(true);
227    let manager = SkillsManager::new_with_bundled_skills_enabled(home_dir.to_path_buf(), bundled_skills_enabled);
228    manager
229        .skills_metadata_lightweight(workspace)
230        .skills
231        .into_iter()
232        .filter(is_model_catalog_eligible)
233        .collect()
234}
235
236fn default_codex_home_dir() -> Option<PathBuf> {
237    std::env::var_os("CODEX_HOME")
238        .filter(|value| !value.is_empty())
239        .map(PathBuf::from)
240        .or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
241}
242
243#[cfg(test)]
244mod tests {
245    use super::PromptContext;
246    use crate::config::constants::tools;
247    use std::fs;
248    use std::path::PathBuf;
249    use tempfile::TempDir;
250
251    fn write_skill(skill_dir: &std::path::Path, name: &str, description: &str) {
252        fs::create_dir_all(skill_dir).expect("create skill dir");
253        fs::write(
254            skill_dir.join("SKILL.md"),
255            format!("---\nname: {name}\ndescription: {description}\n---\n# {name}\n"),
256        )
257        .expect("write skill");
258    }
259
260    #[test]
261    fn from_workspace_tools_populates_workspace_and_tools() {
262        let context = PromptContext::from_workspace_tools(
263            PathBuf::from("/tmp/vtcode"),
264            [tools::CODE_SEARCH, tools::EXEC_COMMAND],
265        );
266
267        assert_eq!(context.workspace, Some(PathBuf::from("/tmp/vtcode")));
268        assert_eq!(context.available_tools.len(), 2);
269        assert!(context.capability_level.is_some());
270        assert!(context.current_directory.is_some());
271    }
272
273    #[test]
274    fn from_workspace_tools_populates_detected_languages() {
275        let workspace = TempDir::new().expect("workspace tempdir");
276        fs::create_dir_all(workspace.path().join("src")).expect("create src");
277        fs::create_dir_all(workspace.path().join("web")).expect("create web");
278        fs::write(workspace.path().join("src/lib.rs"), "fn alpha() {}\n").expect("write rust");
279        fs::write(workspace.path().join("web/app.ts"), "const app = 1;\n").expect("write ts");
280
281        let context = PromptContext::from_workspace_tools(workspace.path(), [tools::CODE_SEARCH]);
282
283        assert_eq!(context.languages, vec!["Rust".to_string(), "TypeScript".to_string()]);
284    }
285
286    #[test]
287    fn load_available_skills_discovers_repo_and_system_skills() {
288        let workspace = TempDir::new().expect("workspace tempdir");
289        fs::create_dir(workspace.path().join(".git")).expect("create git dir");
290        write_skill(&workspace.path().join(".agents/skills/repo-skill"), "repo-skill", "Repo-local skill");
291
292        let home = TempDir::new().expect("home tempdir");
293        let mut context = PromptContext::from_workspace_tools(workspace.path(), [tools::CODE_SEARCH]);
294
295        assert!(context.available_skill_metadata.is_empty());
296
297        context.load_available_skills_with_home_dir(Some(home.path()));
298
299        let skill_names = context
300            .available_skill_metadata
301            .iter()
302            .map(|skill| skill.name.as_str())
303            .collect::<Vec<_>>();
304
305        assert!(skill_names.contains(&"ast-grep"));
306        assert!(skill_names.contains(&"repo-skill"));
307        assert!(skill_names.contains(&"skill-creator"));
308        assert!(!skill_names.contains(&"cmd-review"));
309    }
310
311    #[test]
312    fn replace_available_skills_with_named_recomputes_scope() {
313        let workspace = TempDir::new().expect("workspace tempdir");
314        fs::create_dir(workspace.path().join(".git")).expect("create git dir");
315        write_skill(&workspace.path().join(".agents/skills/alpha"), "alpha", "Alpha skill");
316        write_skill(&workspace.path().join(".agents/skills/beta"), "beta", "Beta skill");
317
318        let home = TempDir::new().expect("home tempdir");
319        let mut context = PromptContext::from_workspace_tools(workspace.path(), [tools::CODE_SEARCH]);
320
321        context.replace_available_skills_with_named_and_home_dir(&["alpha".to_string()], Some(home.path()));
322        assert_eq!(
323            context
324                .available_skill_metadata
325                .iter()
326                .map(|skill| skill.name.as_str())
327                .collect::<Vec<_>>(),
328            vec!["alpha"]
329        );
330
331        context.replace_available_skills_with_named_and_home_dir(&["beta".to_string()], Some(home.path()));
332        assert_eq!(
333            context
334                .available_skill_metadata
335                .iter()
336                .map(|skill| skill.name.as_str())
337                .collect::<Vec<_>>(),
338            vec!["beta"]
339        );
340
341        context.replace_available_skills_with_named_and_home_dir(&[], Some(home.path()));
342        assert!(context.available_skill_metadata.is_empty());
343        assert!(context.available_skills.is_empty());
344    }
345}