Skip to main content

vv_agent/sdk/resources/
loader.rs

1use std::path::{Path, PathBuf};
2
3use serde_json::Value;
4
5use crate::sdk::types::AgentDefinition;
6
7use super::models::DiscoveredResources;
8use super::parse::{
9    read_bool, read_metadata, read_no_tool_policy, read_percentage_u8, read_positive_u32,
10    read_positive_u64, read_string, read_string_list, read_string_map, read_sub_agents,
11};
12use super::paths::{read_resolved_path_list, resolve_existing_or_absolute_path};
13
14#[derive(Debug, Clone)]
15pub struct AgentResourceLoader {
16    pub workspace: PathBuf,
17    pub project_resource_dir: PathBuf,
18    pub global_resource_dir: PathBuf,
19    cached: Option<DiscoveredResources>,
20}
21
22impl AgentResourceLoader {
23    pub fn new(workspace: impl Into<PathBuf>) -> Self {
24        let workspace = resolve_existing_or_absolute_path(workspace.into());
25        Self {
26            project_resource_dir: resolve_existing_or_absolute_path(workspace.join(".vv-agent")),
27            global_resource_dir: resolve_existing_or_absolute_path(PathBuf::from("~/.vv-agent")),
28            workspace,
29            cached: None,
30        }
31    }
32
33    pub fn with_resource_dirs(
34        workspace: impl Into<PathBuf>,
35        project_resource_dir: impl Into<PathBuf>,
36        global_resource_dir: impl Into<PathBuf>,
37    ) -> Self {
38        Self {
39            workspace: resolve_existing_or_absolute_path(workspace.into()),
40            project_resource_dir: resolve_existing_or_absolute_path(project_resource_dir.into()),
41            global_resource_dir: resolve_existing_or_absolute_path(global_resource_dir.into()),
42            cached: None,
43        }
44    }
45
46    pub fn discover(&mut self) -> DiscoveredResources {
47        self.discover_inner(false)
48    }
49
50    pub fn discover_force_reload(&mut self) -> DiscoveredResources {
51        self.discover_inner(true)
52    }
53
54    fn discover_inner(&mut self, force_reload: bool) -> DiscoveredResources {
55        if let Some(cached) = &self.cached {
56            if !force_reload {
57                return cached.clone();
58            }
59        }
60        let mut discovered = DiscoveredResources::default();
61        for root in [
62            self.global_resource_dir.clone(),
63            self.project_resource_dir.clone(),
64        ] {
65            if root.is_dir() {
66                self.load_agents(&root, &mut discovered);
67                self.load_prompts(&root, &mut discovered);
68                self.load_skills(&root, &mut discovered);
69            }
70        }
71        self.cached = Some(discovered.clone());
72        discovered
73    }
74
75    fn load_agents(&self, root: &Path, discovered: &mut DiscoveredResources) {
76        let config_file = root.join("agents.json");
77        if !config_file.is_file() {
78            return;
79        }
80        let raw = match std::fs::read_to_string(&config_file)
81            .ok()
82            .and_then(|content| serde_json::from_str::<Value>(&content).ok())
83        {
84            Some(raw) => raw,
85            None => {
86                discovered
87                    .diagnostics
88                    .push(format!("Invalid agents.json in {}", root.display()));
89                return;
90            }
91        };
92        let profiles = raw
93            .get("profiles")
94            .and_then(Value::as_object)
95            .or_else(|| raw.as_object());
96        let Some(profiles) = profiles else {
97            discovered.diagnostics.push(format!(
98                "agents.json in {} must be an object or contain `profiles` object.",
99                root.display()
100            ));
101            return;
102        };
103        for (name, payload) in profiles {
104            if name.trim().is_empty() {
105                discovered.diagnostics.push(format!(
106                    "Skip invalid profile name in {}.",
107                    config_file.display()
108                ));
109                continue;
110            }
111            let Some(payload) = payload.as_object() else {
112                discovered.diagnostics.push(format!(
113                    "Skip profile `{name}` in {}: definition must be an object.",
114                    config_file.display()
115                ));
116                continue;
117            };
118            let Some(description) = read_string(payload, "description") else {
119                discovered.diagnostics.push(format!(
120                    "Skip profile `{name}`: `description` must be non-empty string."
121                ));
122                continue;
123            };
124            let Some(model) = read_string(payload, "model") else {
125                discovered.diagnostics.push(format!(
126                    "Skip profile `{name}`: `model` must be non-empty string."
127                ));
128                continue;
129            };
130            let mut definition = AgentDefinition::default_for_model(model);
131            definition.description = description;
132            definition.backend = read_string(payload, "backend");
133            definition.language =
134                read_string(payload, "language").unwrap_or_else(|| "zh-CN".to_string());
135            definition.max_cycles = read_positive_u32(payload, "max_cycles", 10);
136            definition.memory_compact_threshold =
137                read_positive_u64(payload, "memory_compact_threshold", 128_000);
138            definition.memory_threshold_percentage =
139                read_percentage_u8(payload, "memory_threshold_percentage", 90);
140            definition.no_tool_policy = read_no_tool_policy(payload);
141            definition.allow_interruption = read_bool(payload, "allow_interruption", true);
142            definition.use_workspace = read_bool(payload, "use_workspace", true);
143            definition.enable_todo_management = read_bool(payload, "enable_todo_management", true);
144            definition.agent_type = read_string(payload, "agent_type");
145            definition.native_multimodal = read_bool(payload, "native_multimodal", false);
146            definition.enable_sub_agents = read_bool(payload, "enable_sub_agents", false);
147            definition.sub_agents = read_sub_agents(payload);
148            definition.extra_tool_names = read_string_list(payload, "extra_tool_names");
149            definition.exclude_tools = read_string_list(payload, "exclude_tools");
150            definition.bash_shell = read_string(payload, "bash_shell");
151            definition.windows_shell_priority = read_string_list(payload, "windows_shell_priority");
152            definition.bash_env = read_string_map(payload, "bash_env");
153            definition.metadata = read_metadata(payload, "metadata");
154            definition.system_prompt = read_string(payload, "system_prompt");
155            definition.system_prompt_template = read_string(payload, "system_prompt_template");
156            definition.skill_directories =
157                read_resolved_path_list(payload, "skill_directories", root);
158            discovered.agents.insert(name.clone(), definition);
159        }
160    }
161
162    fn load_prompts(&self, root: &Path, discovered: &mut DiscoveredResources) {
163        let prompts_dir = root.join("prompts");
164        if !prompts_dir.is_dir() {
165            return;
166        }
167        let Ok(entries) = std::fs::read_dir(prompts_dir) else {
168            return;
169        };
170        for entry in entries.flatten() {
171            let path = entry.path();
172            if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
173                continue;
174            }
175            if let (Some(stem), Ok(content)) = (
176                path.file_stem().and_then(|stem| stem.to_str()),
177                std::fs::read_to_string(&path),
178            ) {
179                discovered.prompts.insert(stem.to_string(), content);
180            }
181        }
182    }
183
184    fn load_skills(&self, root: &Path, discovered: &mut DiscoveredResources) {
185        let skills_dir = root.join("skills");
186        if !skills_dir.is_dir() {
187            return;
188        }
189        let skills_dir = skills_dir.canonicalize().unwrap_or(skills_dir);
190        let path = skills_dir.to_string_lossy().to_string();
191        if !discovered.skill_directories.contains(&path) {
192            discovered.skill_directories.push(path);
193        }
194    }
195}