agentmux_srv\backend/
agent_seed.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent seed engine: preloads agents from an embedded manifest on first launch.
5//! Seeds agents with identity + content. Provider, agent_type, and environment
6//! are NOT baked into the manifest — they default to sensible values and are
7//! user-configurable via the Agent settings UI after seeding.
8
9use std::sync::Arc;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use serde::Deserialize;
13
14use super::storage::wstore::{AgentDefinition, AgentContent, AgentSkill, WaveStore};
15use super::storage::StoreError;
16
17/// Report returned after seeding.
18pub struct SeedReport {
19    pub created: usize,
20    pub skipped: usize,
21}
22
23/// Top-level seed manifest structure.
24#[derive(Debug, Deserialize)]
25struct SeedManifest {
26    #[allow(dead_code)]
27    version: u32,
28    agents: Vec<SeedAgent>,
29}
30
31/// An agent definition in the seed manifest.
32#[derive(Debug, Deserialize)]
33struct SeedAgent {
34    id: String,
35    name: String,
36    #[serde(default = "default_icon")]
37    icon: String,
38    /// Defaults to "claude" when absent. User can change in Agent settings UI.
39    #[serde(default = "default_provider")]
40    provider: String,
41    /// Defaults to "host" when absent. User can change in Agent settings UI.
42    #[serde(default = "default_agent_type")]
43    agent_type: String,
44    /// Defaults to the current OS when absent.
45    #[serde(default = "default_environment")]
46    environment: String,
47    #[serde(default)]
48    description: String,
49    #[serde(default)]
50    working_directory: String,
51    #[serde(default)]
52    shell: String,
53    #[serde(default)]
54    agent_bus_id: String,
55    #[serde(default)]
56    auto_start: bool,
57    #[serde(default)]
58    restart_on_crash: bool,
59    #[serde(default)]
60    content: SeedContent,
61    #[serde(default)]
62    skills: Vec<SeedSkill>,
63}
64
65fn default_icon() -> String {
66    "\u{2726}".to_string()
67}
68
69fn default_provider() -> String {
70    "claude".to_string()
71}
72
73fn default_agent_type() -> String {
74    "host".to_string()
75}
76
77fn default_environment() -> String {
78    std::env::consts::OS.to_string()
79}
80
81/// Content blobs to seed for an agent.
82#[derive(Debug, Default, Deserialize)]
83struct SeedContent {
84    #[serde(default)]
85    agentmd: Option<String>,
86    #[serde(default)]
87    mcp: Option<String>,
88    #[serde(default)]
89    env: Option<String>,
90    #[serde(default)]
91    soul: Option<String>,
92    #[serde(default)]
93    startup: Option<String>,
94}
95
96/// A skill definition in the seed manifest.
97#[derive(Debug, Deserialize)]
98struct SeedSkill {
99    name: String,
100    #[serde(default)]
101    trigger: String,
102    #[serde(default = "default_skill_type")]
103    skill_type: String,
104    #[serde(default)]
105    description: String,
106    #[serde(default)]
107    content: String,
108}
109
110fn default_skill_type() -> String {
111    "prompt".to_string()
112}
113
114/// The embedded seed manifest JSON.
115const SEED_MANIFEST: &str = include_str!("../../agent-seed.json");
116
117/// Seed agent definitions from the embedded manifest.
118/// Skips agents whose ID already exists in the database.
119pub fn seed_agents(wstore: &Arc<WaveStore>) -> Result<SeedReport, StoreError> {
120    let manifest: SeedManifest = serde_json::from_str(SEED_MANIFEST)
121        .map_err(|e| StoreError::Other(format!("agent seed: parse manifest: {e}")))?;
122
123    let existing = wstore.agent_def_list()?;
124    let existing_ids: std::collections::HashSet<String> =
125        existing.iter().map(|a| a.id.clone()).collect();
126
127    let now = SystemTime::now()
128        .duration_since(UNIX_EPOCH)
129        .unwrap_or_default()
130        .as_millis() as i64;
131
132    let mut created = 0usize;
133    let mut skipped = 0usize;
134
135    for agent_def in &manifest.agents {
136        if existing_ids.contains(&agent_def.id) {
137            skipped += 1;
138            continue;
139        }
140
141        // Insert agent. For seeded agents, the manifest `id` is already
142        // a human-readable slug-form string (agentx, agent1, etc.), so
143        // reuse it as the slug. agent_def_insert collision-resolves if
144        // needed and mutates the slug field in place.
145        let mut agent = AgentDefinition {
146            id: agent_def.id.clone(),
147            slug: agent_def.id.clone(),
148            name: agent_def.name.clone(),
149            icon: agent_def.icon.clone(),
150            provider: agent_def.provider.clone(),
151            description: agent_def.description.clone(),
152            working_directory: agent_def.working_directory.clone(),
153            shell: agent_def.shell.clone(),
154            provider_flags: String::new(),
155            auto_start: if agent_def.auto_start { 1 } else { 0 },
156            restart_on_crash: if agent_def.restart_on_crash { 1 } else { 0 },
157            idle_timeout_minutes: 0,
158            created_at: now,
159            agent_type: agent_def.agent_type.clone(),
160            environment: agent_def.environment.clone(),
161            agent_bus_id: agent_def.agent_bus_id.clone(),
162            is_seeded: 1,
163            accounts: String::new(),
164            parent_id: String::new(),
165            branch_label: String::new(),
166            updated_at: now,
167            // First seeding always lands templates visible. Phase 2
168            // user_hidden is set by `agent_def_set_hidden` after the
169            // user explicitly hides; new template ids in re-seed are
170            // force-reset to 0 below (see `reseed_if_needed`).
171            user_hidden: 0,
172        };
173        wstore.agent_def_insert(&mut agent)?;
174
175        // Insert content blobs
176        let content_pairs = [
177            ("agentmd", &agent_def.content.agentmd),
178            ("mcp", &agent_def.content.mcp),
179            ("env", &agent_def.content.env),
180            ("soul", &agent_def.content.soul),
181            ("startup", &agent_def.content.startup),
182        ];
183        for (content_type, maybe_content) in &content_pairs {
184            if let Some(content) = maybe_content {
185                if !content.is_empty() {
186                    wstore.agent_content_set(&AgentContent {
187                        agent_id: agent_def.id.clone(),
188                        content_type: content_type.to_string(),
189                        content: content.clone(),
190                        updated_at: now,
191                    })?;
192                }
193            }
194        }
195
196        // Insert skills
197        for skill_def in &agent_def.skills {
198            let skill = AgentSkill {
199                id: uuid::Uuid::new_v4().to_string(),
200                agent_id: agent_def.id.clone(),
201                name: skill_def.name.clone(),
202                trigger: skill_def.trigger.clone(),
203                skill_type: skill_def.skill_type.clone(),
204                description: skill_def.description.clone(),
205                content: skill_def.content.clone(),
206                created_at: now,
207            };
208            wstore.agent_skill_insert(&skill)?;
209        }
210
211        created += 1;
212    }
213
214    Ok(SeedReport { created, skipped })
215}
216
217/// Run auto-seed on startup. Seeds if empty, or re-seeds if manifest version changed.
218/// Re-seeding updates existing seeded agents and removes seeded agents not in the manifest.
219pub fn auto_seed_on_startup(wstore: &Arc<WaveStore>) {
220    let manifest: SeedManifest = match serde_json::from_str(SEED_MANIFEST) {
221        Ok(m) => m,
222        Err(e) => {
223            tracing::error!("agent seed: failed to parse seed manifest: {e}");
224            return;
225        }
226    };
227
228    match wstore.agent_def_count() {
229        Ok(0) => {
230            tracing::info!("agent seed: no agents found, seeding from manifest v{}...", manifest.version);
231            match seed_agents(wstore) {
232                Ok(report) => {
233                    tracing::info!(
234                        "agent seed: seeded {} agents ({} skipped)",
235                        report.created,
236                        report.skipped
237                    );
238                }
239                Err(e) => tracing::error!("agent seed: failed: {e}"),
240            }
241        }
242        Ok(count) => {
243            // Check if we need to re-seed (manifest version changed)
244            match reseed_if_needed(wstore, &manifest) {
245                Ok(Some(report)) => {
246                    tracing::info!(
247                        "agent seed: re-seeded from manifest v{}: {} created, {} updated, {} removed",
248                        manifest.version, report.created, report.updated, report.removed,
249                    );
250                }
251                Ok(None) => {
252                    tracing::info!("agent seed: {} agents exist, manifest up to date", count);
253                }
254                Err(e) => tracing::error!("agent seed: re-seed failed: {e}"),
255            }
256        }
257        Err(e) => tracing::error!("agent seed: failed to count agents: {e}"),
258    }
259}
260
261/// Report from a re-seed operation.
262pub struct ReseedReport {
263    pub created: usize,
264    pub updated: usize,
265    pub removed: usize,
266}
267
268/// Re-seed if the manifest version is newer than what's in the DB.
269/// Updates seeded agents, adds new ones, removes seeded agents not in the manifest.
270fn reseed_if_needed(
271    wstore: &Arc<WaveStore>,
272    manifest: &SeedManifest,
273) -> Result<Option<ReseedReport>, StoreError> {
274    let existing = wstore.agent_def_list()?;
275
276    // Check if any seeded agent needs updating by comparing providers/descriptions
277    let manifest_ids: std::collections::HashSet<&str> =
278        manifest.agents.iter().map(|a| a.id.as_str()).collect();
279    let existing_map: std::collections::HashMap<&str, &AgentDefinition> =
280        existing.iter().map(|a| (a.id.as_str(), a)).collect();
281
282    let mut needs_reseed = false;
283
284    // Check for new agents or changed providers
285    for agent_def in &manifest.agents {
286        match existing_map.get(agent_def.id.as_str()) {
287            None => { needs_reseed = true; break; }
288            Some(existing_agent) => {
289                // Only compare identity fields, NOT provider/agent_type/environment
290                // which the user may have changed via the Agent settings UI.
291                if existing_agent.description != agent_def.description {
292                    needs_reseed = true;
293                    break;
294                }
295            }
296        }
297    }
298
299    // Check for agents to remove (seeded agents not in manifest)
300    for agent in &existing {
301        if agent.is_seeded == 1 && !manifest_ids.contains(agent.id.as_str()) {
302            needs_reseed = true;
303            break;
304        }
305    }
306
307    if !needs_reseed {
308        return Ok(None);
309    }
310
311    let now = std::time::SystemTime::now()
312        .duration_since(std::time::UNIX_EPOCH)
313        .unwrap_or_default()
314        .as_millis() as i64;
315
316    let mut created = 0usize;
317    let mut updated = 0usize;
318    let mut removed = 0usize;
319
320    // Upsert agents from manifest
321    for agent_def in &manifest.agents {
322        let mut agent = AgentDefinition {
323            id: agent_def.id.clone(),
324            slug: agent_def.id.clone(),
325            name: agent_def.name.clone(),
326            icon: agent_def.icon.clone(),
327            provider: agent_def.provider.clone(),
328            description: agent_def.description.clone(),
329            working_directory: agent_def.working_directory.clone(),
330            shell: agent_def.shell.clone(),
331            provider_flags: String::new(),
332            auto_start: if agent_def.auto_start { 1 } else { 0 },
333            restart_on_crash: if agent_def.restart_on_crash { 1 } else { 0 },
334            idle_timeout_minutes: 0,
335            created_at: now,
336            agent_type: agent_def.agent_type.clone(),
337            environment: agent_def.environment.clone(),
338            agent_bus_id: agent_def.agent_bus_id.clone(),
339            is_seeded: 1,
340            accounts: String::new(),
341            parent_id: String::new(),
342            branch_label: String::new(),
343            updated_at: now,
344            // Newly-added template ids start visible (overwritten below
345            // when the row already exists). Phase 2 of the two-tier
346            // picker spec (Q2 Decision Y) requires NEW template ids
347            // surface once even if a same-named template was previously
348            // hidden — the `else` branch below honours that by lining
349            // up against existing ids only.
350            user_hidden: 0,
351        };
352
353        if let Some(existing_agent) = existing_map.get(agent_def.id.as_str()) {
354            // Preserve user-modified runtime config — only update identity
355            // fields (name, icon, description). Everything the user can
356            // change in the Agent settings UI stays as-is.
357            agent.provider = existing_agent.provider.clone();
358            agent.agent_type = existing_agent.agent_type.clone();
359            agent.environment = existing_agent.environment.clone();
360            agent.shell = if existing_agent.shell.is_empty() {
361                agent_def.shell.clone()
362            } else {
363                existing_agent.shell.clone()
364            };
365            agent.auto_start = existing_agent.auto_start;
366            agent.restart_on_crash = existing_agent.restart_on_crash;
367            agent.created_at = existing_agent.created_at;
368            agent.accounts = existing_agent.accounts.clone();
369            // Phase 2: preserve the user's hide preference across a
370            // manifest re-sync for templates that already exist on disk
371            // (the user may have explicitly hidden this one). The
372            // newly-added branch below keeps user_hidden = 0 so a never-
373            // before-seen template id always surfaces at least once.
374            agent.user_hidden = existing_agent.user_hidden;
375            wstore.agent_def_update(&mut agent)?;
376            updated += 1;
377        } else {
378            wstore.agent_def_insert(&mut agent)?;
379            created += 1;
380        }
381    }
382
383    // Remove seeded agents not in manifest (e.g., agent4, agent5)
384    for agent in &existing {
385        if agent.is_seeded == 1 && !manifest_ids.contains(agent.id.as_str()) {
386            wstore.agent_def_delete(&agent.id)?;
387            removed += 1;
388            tracing::info!("agent seed: removed seeded agent '{}'", agent.id);
389        }
390    }
391
392    Ok(Some(ReseedReport { created, updated, removed }))
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::backend::storage::wstore::{AgentDefinition, WaveStore};
399
400    /// Helper to build a manifest in-memory with a fixed set of agents.
401    /// `reseed_if_needed` reads `manifest.agents`; we construct the
402    /// struct directly so the test doesn't have to round-trip JSON.
403    fn manifest_with(ids_and_descriptions: &[(&str, &str)]) -> SeedManifest {
404        SeedManifest {
405            version: 999,
406            agents: ids_and_descriptions
407                .iter()
408                .map(|(id, desc)| SeedAgent {
409                    id: id.to_string(),
410                    name: id.to_string(),
411                    icon: default_icon(),
412                    provider: default_provider(),
413                    agent_type: default_agent_type(),
414                    environment: default_environment(),
415                    description: desc.to_string(),
416                    working_directory: String::new(),
417                    shell: String::new(),
418                    agent_bus_id: String::new(),
419                    auto_start: false,
420                    restart_on_crash: false,
421                    content: SeedContent::default(),
422                    skills: Vec::new(),
423                })
424                .collect(),
425        }
426    }
427
428    fn insert_tpl(wstore: &Arc<WaveStore>, id: &str, name: &str, hidden: i64) {
429        let mut def = AgentDefinition {
430            id: id.to_string(),
431            slug: id.to_string(),
432            name: name.to_string(),
433            icon: "✦".to_string(),
434            provider: "claude".to_string(),
435            description: "v1 desc".to_string(),
436            working_directory: String::new(),
437            shell: String::new(),
438            provider_flags: String::new(),
439            auto_start: 0,
440            restart_on_crash: 0,
441            idle_timeout_minutes: 0,
442            created_at: 1_700_000_000_000,
443            agent_type: "host".to_string(),
444            environment: String::new(),
445            agent_bus_id: String::new(),
446            is_seeded: 1,
447            accounts: String::new(),
448            parent_id: String::new(),
449            branch_label: String::new(),
450            updated_at: 1_700_000_000_000,
451            user_hidden: hidden,
452        };
453        wstore.agent_def_insert(&mut def).unwrap();
454    }
455
456    #[test]
457    fn reseed_preserves_user_hidden_on_existing_templates() {
458        // The user previously hid `tpl-claude`. A description-only
459        // manifest change triggers a re-seed; the user's hide preference
460        // MUST survive (it's a per-user UI flag, not manifest-managed).
461        let wstore = Arc::new(WaveStore::open_in_memory().unwrap());
462        insert_tpl(&wstore, "tpl-claude", "Claude", 1);
463        // Manifest carries a *different* description so reseed_if_needed
464        // sees a change and runs the upsert path.
465        let manifest = manifest_with(&[("tpl-claude", "v2 desc")]);
466
467        let report = reseed_if_needed(&wstore, &manifest)
468            .expect("reseed succeeds")
469            .expect("reseed runs because description changed");
470        assert_eq!(report.created, 0);
471        assert_eq!(report.updated, 1);
472
473        let after = wstore.agent_def_list().unwrap();
474        let tpl = after.iter().find(|a| a.id == "tpl-claude").unwrap();
475        assert_eq!(tpl.user_hidden, 1, "hide preference must survive reseed");
476        assert_eq!(tpl.description, "v2 desc", "description must update");
477    }
478
479    #[test]
480    fn reseed_resets_user_hidden_on_newly_added_template_id() {
481        // The user previously hid `tpl-claude`. A manifest update
482        // introduces a brand-new id `tpl-codex`. The new id MUST land
483        // with user_hidden = 0 — Phase 2 spec invariant so users always
484        // see new templates at least once.
485        let wstore = Arc::new(WaveStore::open_in_memory().unwrap());
486        insert_tpl(&wstore, "tpl-claude", "Claude", 1);
487        let manifest = manifest_with(&[
488            ("tpl-claude", "v1 desc"), // unchanged — won't fire upsert on its own
489            ("tpl-codex", "Codex CLI"),  // NEW id — forces reseed
490        ]);
491
492        let report = reseed_if_needed(&wstore, &manifest)
493            .expect("reseed succeeds")
494            .expect("reseed runs because tpl-codex is new");
495        assert!(report.created >= 1, "tpl-codex should be inserted");
496
497        let after = wstore.agent_def_list().unwrap();
498        let codex = after
499            .iter()
500            .find(|a| a.id == "tpl-codex")
501            .expect("tpl-codex should now exist");
502        assert_eq!(
503            codex.user_hidden, 0,
504            "newly-added template must start visible (Phase 2 spec invariant)",
505        );
506        // And the previously-hidden one stays hidden.
507        let claude = after.iter().find(|a| a.id == "tpl-claude").unwrap();
508        assert_eq!(claude.user_hidden, 1);
509    }
510}