1use rusqlite::params;
18
19use super::bundles::{Bundle, GLOBAL_SECTION_SEPARATOR};
20use super::error::StoreError;
21use super::store::Store;
22
23pub const STARTUP_BUNDLE_CONTENT_TYPE: &str = "startup_bundle_id";
25
26impl Store {
27 pub fn agent_bundle_ids(&self, agent_id: &str) -> Result<Vec<String>, StoreError> {
29 self.agent_bundles_fold_startup_pick(agent_id);
30 self.agent_bundle_ids_stored(agent_id)
31 }
32
33 fn agent_bundles_fold_startup_pick(&self, agent_id: &str) {
36 let Ok(Some(content)) = self.agent_content_get(agent_id, STARTUP_BUNDLE_CONTENT_TYPE) else {
37 return;
38 };
39 let pick = content.content.trim().to_string();
40 if !pick.is_empty() {
41 let mut ids = self.agent_bundle_ids_stored(agent_id).unwrap_or_default();
42 if !ids.contains(&pick) {
43 ids.push(pick);
44 if let Err(e) = self.agent_bundles_set(agent_id, &ids) {
45 tracing::warn!(agent_id, error = %e, "agent bundles: Startup pick not folded in");
46 return;
47 }
48 }
49 }
50 let _ = self.agent_content_delete(agent_id, STARTUP_BUNDLE_CONTENT_TYPE);
51 }
52
53 fn agent_bundle_ids_stored(&self, agent_id: &str) -> Result<Vec<String>, StoreError> {
54 let conn = self.conn.lock().unwrap();
55 let mut stmt = conn.prepare(
56 "SELECT bundle_id FROM db_agent_bundles WHERE agent_id = ?1 ORDER BY position",
57 )?;
58 let rows = stmt.query_map(params![agent_id], |row| row.get::<_, String>(0))?;
59 Ok(rows.collect::<Result<_, _>>()?)
60 }
61
62 pub fn agent_bundles_set(
65 &self,
66 agent_id: &str,
67 bundle_ids: &[String],
68 ) -> Result<Vec<String>, StoreError> {
69 let own = self
70 .agent_def_get(agent_id)?
71 .map(|def| def.memory_id)
72 .unwrap_or_default();
73 let mut picks: Vec<String> = Vec::new();
74 for id in bundle_ids.iter().map(|id| id.trim()) {
75 if !id.is_empty() && id != own && !picks.iter().any(|p| p == id) {
76 picks.push(id.to_string());
77 }
78 }
79 let mut conn = self.conn.lock().unwrap();
80 let tx = conn.transaction()?;
81 tx.execute("DELETE FROM db_agent_bundles WHERE agent_id = ?1", params![agent_id])?;
82 for (position, id) in picks.iter().enumerate() {
83 tx.execute(
84 "INSERT INTO db_agent_bundles (agent_id, bundle_id, position) VALUES (?1, ?2, ?3)",
85 params![agent_id, id, position as i64],
86 )?;
87 }
88 tx.commit()?;
89 Ok(picks)
90 }
91
92 pub fn agent_bundles_forget_bundle(&self, bundle_id: &str) -> Result<usize, StoreError> {
94 let conn = self.conn.lock().unwrap();
95 Ok(conn.execute("DELETE FROM db_agent_bundles WHERE bundle_id = ?1", params![bundle_id])?)
96 }
97
98 pub fn agent_bundle_chain(&self, agent_id: &str) -> Vec<String> {
101 let own = match self.agent_def_get(agent_id) {
102 Ok(Some(def)) => def.memory_id,
103 _ => String::new(),
104 };
105 let mut chain: Vec<String> = Vec::new();
106 if !own.is_empty() {
107 chain.push(own);
108 }
109 for id in self.agent_bundle_ids(agent_id).unwrap_or_default() {
110 if !chain.contains(&id) {
111 chain.push(id);
112 }
113 }
114 chain
115 }
116}
117
118pub fn format_agent_bundle_block(bundles: &[Bundle]) -> String {
123 bundles
124 .iter()
125 .filter(|b| !b.instructions.trim().is_empty())
126 .map(|b| format!("# [Bundle] {}\n\n{}", b.name, b.instructions))
127 .collect::<Vec<_>>()
128 .join(GLOBAL_SECTION_SEPARATOR)
129}
130
131pub fn agent_picked_bundles(mstore: &Store, bundle_store: &Store, agent_id: &str) -> Vec<Bundle> {
134 mstore
135 .agent_bundle_ids(agent_id)
136 .unwrap_or_default()
137 .iter()
138 .filter_map(|id| match bundle_store.bundle_get(id) {
139 Ok(Some(bundle)) => Some(bundle),
140 Ok(None) => {
141 tracing::debug!(agent_id, bundle_id = %id, "agent bundles: skipping a bundle that no longer exists");
142 None
143 }
144 Err(e) => {
145 tracing::warn!(agent_id, bundle_id = %id, error = %e, "agent bundles: bundle lookup failed");
146 None
147 }
148 })
149 .collect()
150}
151
152pub fn join_startup_blocks(global_block: &str, agent_block: &str) -> String {
155 [global_block, agent_block]
156 .into_iter()
157 .filter(|b| !b.is_empty())
158 .collect::<Vec<_>>()
159 .join(GLOBAL_SECTION_SEPARATOR)
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::backend::storage::agents::test_agent_def;
166
167 fn bundle(id: &str, name: &str, instructions: &str) -> Bundle {
168 Bundle {
169 id: id.into(),
170 name: name.into(),
171 description: String::new(),
172 is_blank: false,
173 is_global: false,
174 provider: String::new(),
175 model: String::new(),
176 instructions: instructions.into(),
177 instructions_by_provider: "{}".into(),
178 context_files: "[]".into(),
179 mcp_servers: "[]".into(),
180 skills: "[]".into(),
181 sort_order: 0,
182 created_at: 0,
183 updated_at: 0,
184 is_system: false,
185 }
186 }
187
188 fn store_with_agent(own_bundle: &str) -> Store {
189 let s = Store::open_in_memory().unwrap();
190 let mut def = test_agent_def("a1", "a1", "claude", "agent", 1, "");
191 def.memory_id = own_bundle.into();
192 s.agent_def_insert(&mut def).unwrap();
193 s
194 }
195
196 fn ids(v: &[&str]) -> Vec<String> {
197 v.iter().map(|s| s.to_string()).collect()
198 }
199
200 #[test]
201 fn set_keeps_order_and_drops_blanks_repeats_and_the_own_bundle() {
202 let s = store_with_agent("own");
203 let stored = s.agent_bundles_set("a1", &ids(&["b2", "", "own", "b1", "b2", " b3 "])).unwrap();
204 assert_eq!(stored, ids(&["b2", "b1", "b3"]));
205 assert_eq!(s.agent_bundle_ids("a1").unwrap(), stored);
206 s.agent_bundles_set("a1", &ids(&["b1"])).unwrap();
208 assert_eq!(s.agent_bundle_ids("a1").unwrap(), ids(&["b1"]));
209 assert!(s.agent_bundle_ids("nobody").unwrap().is_empty());
210 }
211
212 fn set_startup_pick(s: &Store, id: &str) {
213 s.agent_content_set(&crate::backend::storage::content::AgentContent {
214 agent_id: "a1".into(),
215 content_type: STARTUP_BUNDLE_CONTENT_TYPE.into(),
216 content: id.into(),
217 updated_at: 1,
218 })
219 .unwrap();
220 }
221
222 #[test]
223 fn an_old_startup_pick_is_folded_into_the_list_once() {
224 let s = store_with_agent("own");
225 s.agent_bundles_set("a1", &ids(&["b1"])).unwrap();
226 set_startup_pick(&s, "b2");
227 assert_eq!(s.agent_bundle_ids("a1").unwrap(), ids(&["b1", "b2"]));
228 assert!(s.agent_content_get("a1", STARTUP_BUNDLE_CONTENT_TYPE).unwrap().is_none());
229 s.agent_bundles_set("a1", &ids(&["b1"])).unwrap();
231 assert_eq!(s.agent_bundle_ids("a1").unwrap(), ids(&["b1"]));
232 for pick in ["b1", "own", " "] {
234 set_startup_pick(&s, pick);
235 assert_eq!(s.agent_bundle_ids("a1").unwrap(), ids(&["b1"]), "pick {pick:?}");
236 assert!(s.agent_content_get("a1", STARTUP_BUNDLE_CONTENT_TYPE).unwrap().is_none());
237 }
238 set_startup_pick(&s, "b3");
240 assert_eq!(s.agent_bundle_chain("a1"), ids(&["own", "b1", "b3"]));
241 }
242
243 #[test]
247 fn a_new_agents_picks_never_become_its_own_bundle() {
248 let s = Store::open_in_memory().unwrap();
249 s.bundle_upsert(&bundle("shared", "Shared", "shared rules")).unwrap();
250 let mut def = test_agent_def("a1", "a1", "claude", "agent", 1, "");
251 s.agent_def_insert(&mut def).unwrap();
252 s.agent_def_provision_and_bind_bundle(&s, &mut def, 1);
253 assert!(!def.memory_id.is_empty() && def.memory_id != "shared", "own: {}", def.memory_id);
254 s.agent_bundles_set("a1", &ids(&["shared"])).unwrap();
255 assert_eq!(s.agent_bundle_chain("a1"), vec![def.memory_id.clone(), "shared".to_string()]);
256 }
257
258 #[test]
259 fn the_chain_puts_the_own_bundle_first() {
260 let s = store_with_agent("own");
261 assert_eq!(s.agent_bundle_chain("a1"), ids(&["own"]));
262 s.agent_bundles_set("a1", &ids(&["b1", "b2"])).unwrap();
263 assert_eq!(s.agent_bundle_chain("a1"), ids(&["own", "b1", "b2"]));
264 let no_own = store_with_agent("");
265 no_own.agent_bundles_set("a1", &ids(&["b1"])).unwrap();
266 assert_eq!(no_own.agent_bundle_chain("a1"), ids(&["b1"]));
267 }
268
269 #[test]
270 fn a_deleted_bundle_leaves_every_list() {
271 let s = store_with_agent("own");
272 let mut other = test_agent_def("a2", "a2", "claude", "agent", 1, "");
273 s.agent_def_insert(&mut other).unwrap();
274 s.agent_bundles_set("a1", &ids(&["gone", "b1"])).unwrap();
275 s.agent_bundles_set("a2", &ids(&["gone"])).unwrap();
276 assert_eq!(s.agent_bundles_forget_bundle("gone").unwrap(), 2);
277 assert_eq!(s.agent_bundle_ids("a1").unwrap(), ids(&["b1"]));
278 assert!(s.agent_bundle_ids("a2").unwrap().is_empty());
279 }
280
281 #[test]
282 fn deleting_the_agent_deletes_its_list() {
283 let s = store_with_agent("own");
284 s.agent_bundles_set("a1", &ids(&["b1"])).unwrap();
285 assert!(s.agent_def_delete("a1").unwrap());
286 assert!(s.agent_bundle_ids("a1").unwrap().is_empty());
287 }
288
289 #[test]
290 fn picked_bundles_skip_a_missing_one_and_keep_order() {
291 let s = store_with_agent("own");
292 s.bundle_upsert(&bundle("b1", "One", "first")).unwrap();
293 s.bundle_upsert(&bundle("b2", "Two", "second")).unwrap();
294 s.agent_bundles_set("a1", &ids(&["b2", "gone", "b1"])).unwrap();
295 let names: Vec<String> = agent_picked_bundles(&s, &s, "a1").into_iter().map(|b| b.name).collect();
296 assert_eq!(names, ids(&["Two", "One"]));
297 }
298
299 #[test]
300 fn the_block_has_one_section_per_bundle_with_instructions() {
301 let block = format_agent_bundle_block(&[
302 bundle("b1", "One", "first"),
303 bundle("b2", "Empty", " "),
304 bundle("b3", "Three", "third"),
305 ]);
306 assert_eq!(block, "# [Bundle] One\n\nfirst\n\n---\n\n# [Bundle] Three\n\nthird");
307 assert!(format_agent_bundle_block(&[bundle("b2", "Empty", "")]).is_empty());
308 }
309
310 #[test]
311 fn startup_blocks_join_only_what_has_content() {
312 assert_eq!(join_startup_blocks("G", "B"), "G\n\n---\n\nB");
313 assert_eq!(join_startup_blocks("", "B"), "B");
314 assert_eq!(join_startup_blocks("G", ""), "G");
315 assert_eq!(join_startup_blocks("", ""), "");
316 }
317}