Skip to main content

agentmux_srv/backend/storage/
agent_bundles.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! An agent's Bundles list: the bundles picked for it, in order
5//! (`SPEC_RENAME_KNOWLEDGE_TO_MEMORY_2026_10_06.md` ยง3.6).
6//!
7//! The agent's own bundle (`AgentDefinition::memory_id`) is not stored here:
8//! it is always first and can't be removed, so [`Store::agent_bundle_chain`]
9//! puts it in front of the picks. The table is per channel, like the agent's
10//! own bundle (#3148); bundles themselves live in the identity store, so a
11//! listed id can name a bundle deleted elsewhere, and readers skip it.
12//!
13//! The Stash's old Startup tab stored one bundle as `startup_bundle_id`
14//! agent content. Reading the list folds that pick in and removes it, which
15//! covers existing agents and one an older build writes later.
16
17use rusqlite::params;
18
19use super::bundles::{Bundle, GLOBAL_SECTION_SEPARATOR};
20use super::error::StoreError;
21use super::store::Store;
22
23/// The agent content type the old Startup tab stored its one bundle under.
24pub const STARTUP_BUNDLE_CONTENT_TYPE: &str = "startup_bundle_id";
25
26impl Store {
27    /// The bundles picked for `agent_id`, in order. Not the agent's own.
28    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    /// Append an old Startup-tab pick to the list, then remove it. Best
34    /// effort: on a failure the pick stays, to be folded on a later read.
35    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    /// Replace `agent_id`'s picks with `bundle_ids`, in that order. Blank ids,
63    /// repeats and the agent's own bundle are dropped. Returns what was stored.
64    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    /// Take a deleted bundle out of every agent's list. Returns the rows removed.
93    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    /// The agent's whole list: its own bundle first (when it has one), then
99    /// its picks. What launch unions skills and MCP servers over.
100    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
118/// The picked bundles' part of the startup file: one `# [Bundle] <name>`
119/// section per bundle with instructions, in list order, joined by the rule
120/// Global Memory uses. `bundles` are the picks as found (a deleted one is
121/// already missing). Empty when none has instructions.
122pub 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
131/// The bundles picked for `agent_id` (from `mstore`), looked up in
132/// `bundle_store`, in list order. A bundle that no longer exists is skipped.
133pub 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
152/// The text launch puts after `# Memory` in the startup file: Global Memory,
153/// then the agent's picked bundles, each part only when it has content.
154pub 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        // Replacing, not appending.
207        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        // Removing it afterwards sticks: the pick is gone, not re-folded.
230        s.agent_bundles_set("a1", &ids(&["b1"])).unwrap();
231        assert_eq!(s.agent_bundle_ids("a1").unwrap(), ids(&["b1"]));
232        // Already listed, or the own bundle, or blank: nothing added.
233        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        // The launch path reads through the fold too.
239        set_startup_pick(&s, "b3");
240        assert_eq!(s.agent_bundle_chain("a1"), ids(&["own", "b1", "b3"]));
241    }
242
243    /// `agentdefcreatefromtemplate`'s order: the definition is inserted with
244    /// no own bundle, gets a fresh one, then its picks are stored. A pick
245    /// never becomes the own bundle, and every pick stays listed (#4434).
246    #[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}