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