bamboo_server/subagent_profiles/
loader.rs1use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use bamboo_domain::subagent::{
18 SubagentProfileFile, SubagentProfileRegistry, SubagentProfileRegistryError,
19};
20use thiserror::Error;
21
22use super::builtin::builtin_profiles;
23
24pub const ENV_OVERRIDE_FILE: &str = "BAMBOO_SUBAGENT_PROFILES_FILE";
26
27pub const CONFIG_FILE_NAME: &str = "subagent_profiles.json";
29
30#[derive(Debug, Error)]
31pub enum LoaderError {
32 #[error("failed to read subagent profile file `{path}`: {source}")]
33 Io {
34 path: PathBuf,
35 #[source]
36 source: std::io::Error,
37 },
38 #[error("failed to parse subagent profile file `{path}`: {source}")]
39 Parse {
40 path: PathBuf,
41 #[source]
42 source: serde_json::Error,
43 },
44 #[error(transparent)]
45 Registry(#[from] SubagentProfileRegistryError),
46}
47
48pub fn load_registry(
55 bamboo_data_dir: &Path,
56 workspace_dir: Option<&Path>,
57) -> Result<Arc<SubagentProfileRegistry>, LoaderError> {
58 let mut builder = SubagentProfileRegistry::builder().extend(builtin_profiles());
59
60 let user_global = bamboo_data_dir.join(CONFIG_FILE_NAME);
62 if let Some(file) = read_optional(&user_global)? {
63 builder = builder.extend_from_file(file);
64 }
65
66 if let Some(ws) = workspace_dir {
68 let project_path = ws.join(".bamboo").join(CONFIG_FILE_NAME);
69 if let Some(file) = read_optional(&project_path)? {
70 builder = builder.extend_from_file(file);
71 }
72 }
73
74 if let Some(env_path) = env_override_path() {
76 let file = read_required(&env_path)?;
77 builder = builder.extend_from_file(file);
78 }
79
80 Ok(Arc::new(builder.build()?))
81}
82
83fn env_override_path() -> Option<PathBuf> {
84 std::env::var_os(ENV_OVERRIDE_FILE)
85 .filter(|v| !v.is_empty())
86 .map(PathBuf::from)
87}
88
89fn read_optional(path: &Path) -> Result<Option<SubagentProfileFile>, LoaderError> {
90 match std::fs::read_to_string(path) {
91 Ok(text) => Ok(Some(parse(path, &text)?)),
92 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
93 Err(err) => Err(LoaderError::Io {
94 path: path.to_path_buf(),
95 source: err,
96 }),
97 }
98}
99
100fn read_required(path: &Path) -> Result<SubagentProfileFile, LoaderError> {
101 let text = std::fs::read_to_string(path).map_err(|source| LoaderError::Io {
102 path: path.to_path_buf(),
103 source,
104 })?;
105 parse(path, &text)
106}
107
108fn parse(path: &Path, text: &str) -> Result<SubagentProfileFile, LoaderError> {
109 serde_json::from_str(text).map_err(|source| LoaderError::Parse {
110 path: path.to_path_buf(),
111 source,
112 })
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use std::fs;
119 use tempfile::TempDir;
120
121 fn write_file(path: &Path, body: &str) {
122 if let Some(parent) = path.parent() {
123 fs::create_dir_all(parent).unwrap();
124 }
125 fs::write(path, body).unwrap();
126 }
127
128 #[test]
129 fn builtins_only_when_no_files_present() {
130 let dir = TempDir::new().unwrap();
131 let reg = load_registry(dir.path(), None).unwrap();
132 assert_eq!(reg.len(), 6);
134 assert_eq!(reg.resolve("researcher").id, "researcher");
135 assert_eq!(reg.resolve("does-not-exist").id, "general-purpose");
137 }
138
139 #[test]
140 fn user_global_overrides_builtin() {
141 let dir = TempDir::new().unwrap();
142 write_file(
143 &dir.path().join(CONFIG_FILE_NAME),
144 r#"{"profiles":[{
145 "id":"researcher",
146 "display_name":"Custom",
147 "system_prompt":"OVERRIDDEN",
148 "tools":{"mode":"inherit"}
149 }]}"#,
150 );
151 let reg = load_registry(dir.path(), None).unwrap();
152 let r = reg.resolve("researcher");
153 assert_eq!(r.display_name, "Custom");
154 assert_eq!(r.system_prompt, "OVERRIDDEN");
155 assert_eq!(reg.resolve("plan").display_name, "Planner");
157 }
158
159 #[test]
160 fn project_layer_overrides_user_global() {
161 let bamboo_dir = TempDir::new().unwrap();
162 let ws_dir = TempDir::new().unwrap();
163
164 write_file(
165 &bamboo_dir.path().join(CONFIG_FILE_NAME),
166 r#"{"profiles":[{
167 "id":"researcher","display_name":"User-Global",
168 "system_prompt":"global","tools":{"mode":"inherit"}
169 }]}"#,
170 );
171 write_file(
172 &ws_dir.path().join(".bamboo").join(CONFIG_FILE_NAME),
173 r#"{"profiles":[{
174 "id":"researcher","display_name":"Project",
175 "system_prompt":"project","tools":{"mode":"inherit"}
176 }]}"#,
177 );
178
179 let reg = load_registry(bamboo_dir.path(), Some(ws_dir.path())).unwrap();
180 let r = reg.resolve("researcher");
181 assert_eq!(r.display_name, "Project");
182 assert_eq!(r.system_prompt, "project");
183 }
184
185 #[test]
186 fn env_override_takes_highest_precedence() {
187 let bamboo_dir = TempDir::new().unwrap();
190 let env_file_dir = TempDir::new().unwrap();
191 let env_file = env_file_dir.path().join("env_overrides.json");
192 write_file(
193 &env_file,
194 r#"{"profiles":[{
195 "id":"coder","display_name":"EnvCoder",
196 "system_prompt":"env","tools":{"mode":"inherit"}
197 }]}"#,
198 );
199
200 let prev = std::env::var_os(ENV_OVERRIDE_FILE);
203 std::env::set_var(ENV_OVERRIDE_FILE, &env_file);
204
205 let reg = load_registry(bamboo_dir.path(), None).unwrap();
206
207 match prev {
208 Some(v) => std::env::set_var(ENV_OVERRIDE_FILE, v),
209 None => std::env::remove_var(ENV_OVERRIDE_FILE),
210 }
211
212 assert_eq!(reg.resolve("coder").display_name, "EnvCoder");
213 }
214
215 #[test]
216 fn malformed_json_surfaces_parse_error() {
217 let dir = TempDir::new().unwrap();
218 write_file(&dir.path().join(CONFIG_FILE_NAME), "{ not json");
219 let err = load_registry(dir.path(), None).unwrap_err();
220 assert!(matches!(err, LoaderError::Parse { .. }));
221 }
222
223 #[test]
224 fn missing_user_global_file_is_silent() {
225 let dir = TempDir::new().unwrap();
226 let reg = load_registry(dir.path(), None).unwrap();
228 assert_eq!(reg.len(), 6);
229 }
230}