vv_agent/sdk/resources/
loader.rs1use 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}