1use std::sync::Arc;
11
12use serde::Deserialize;
13
14use super::storage::bundles::Bundle;
15use super::storage::store::{AgentDefinition, AgentContent, AgentSkill, Store};
16use super::storage::StoreError;
17
18pub struct SeedReport {
20 pub created: usize,
21 pub skipped: usize,
22}
23
24#[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#[derive(Debug, Deserialize)]
36struct SeedMemory {
37 id: String,
38 name: String,
39 #[serde(default)]
40 description: String,
41 #[serde(default)]
45 is_global: bool,
46 #[serde(default)]
47 instructions: String,
48 #[serde(default)]
52 replaces_instructions_sha256: Vec<String>,
53}
54
55#[derive(Debug, Deserialize)]
57struct SeedAgent {
58 id: String,
59 name: String,
60 #[serde(default = "default_icon")]
61 icon: String,
62 #[serde(default = "default_provider")]
64 provider: String,
65 #[serde(default = "default_agent_type")]
67 agent_type: String,
68 #[serde(default = "default_environment")]
70 environment: String,
71 #[serde(default)]
72 description: String,
73 #[serde(default)]
74 working_directory: String,
75 #[serde(default)]
76 shell: String,
77 #[serde(default)]
78 agent_bus_id: String,
79 #[serde(default)]
80 auto_start: bool,
81 #[serde(default)]
82 restart_on_crash: bool,
83 #[serde(default)]
86 container_image: String,
87 #[serde(default)]
88 content: SeedContent,
89 #[serde(default)]
90 skills: Vec<SeedSkill>,
91}
92
93fn default_icon() -> String {
94 "\u{2726}".to_string()
95}
96
97fn default_provider() -> String {
98 "claude".to_string()
99}
100
101fn default_agent_type() -> String {
102 "host".to_string()
103}
104
105fn default_environment() -> String {
106 std::env::consts::OS.to_string()
107}
108
109#[derive(Debug, Default, Deserialize)]
111struct SeedContent {
112 #[serde(default)]
113 agentmd: Option<String>,
114 #[serde(default)]
115 mcp: Option<String>,
116 #[serde(default)]
117 env: Option<String>,
118 #[serde(default)]
119 soul: Option<String>,
120 #[serde(default)]
121 startup: Option<String>,
122}
123
124#[derive(Debug, Deserialize)]
126struct SeedSkill {
127 name: String,
128 #[serde(default)]
129 trigger: String,
130 #[serde(default = "default_skill_type")]
131 skill_type: String,
132 #[serde(default)]
133 description: String,
134 #[serde(default)]
135 content: String,
136}
137
138fn default_skill_type() -> String {
139 "prompt".to_string()
140}
141
142const SEED_MANIFEST: &str = include_str!("../../agent-seed.json");
144
145pub fn seed_agents(mstore: &Arc<Store>) -> Result<SeedReport, StoreError> {
148 let manifest: SeedManifest = serde_json::from_str(SEED_MANIFEST)
149 .map_err(|e| StoreError::Other(format!("agent seed: parse manifest: {e}")))?;
150
151 let existing = mstore.agent_def_list()?;
152 let existing_ids: std::collections::HashSet<String> =
153 existing.iter().map(|a| a.id.clone()).collect();
154
155 let now = agentmux_common::time::now_ms();
156
157 let mut created = 0usize;
158 let mut skipped = 0usize;
159
160 for agent_def in &manifest.agents {
161 if existing_ids.contains(&agent_def.id) {
162 skipped += 1;
163 continue;
164 }
165
166 let mut agent = AgentDefinition {
171 id: agent_def.id.clone(),
172 slug: agent_def.id.clone(),
173 name: agent_def.name.clone(),
174 icon: agent_def.icon.clone(),
175 provider: agent_def.provider.clone(),
176 description: agent_def.description.clone(),
177 working_directory: agent_def.working_directory.clone(),
178 shell: agent_def.shell.clone(),
179 provider_flags: String::new(),
180 auto_start: if agent_def.auto_start { 1 } else { 0 },
181 restart_on_crash: if agent_def.restart_on_crash { 1 } else { 0 },
182 idle_timeout_minutes: 0,
183 created_at: now,
184 agent_type: agent_def.agent_type.clone(),
185 environment: agent_def.environment.clone(),
186 agent_bus_id: agent_def.agent_bus_id.clone(),
187 is_seeded: 1,
188 accounts: String::new(),
189 parent_id: String::new(),
190 branch_label: String::new(),
191 updated_at: now,
192 user_hidden: 0,
197 container_image: agent_def.container_image.clone(),
198 container_volumes: "[]".to_string(),
199 container_name: String::new(),
200 use_ambient_login: 0,
201 model_vendor_base_url: String::new(), auto_continue_enabled: 0,
203 memory_id: String::new(),
204 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
205 };
206 mstore.agent_def_insert(&mut agent)?;
207
208 let content_pairs = [
210 ("agentmd", &agent_def.content.agentmd),
211 ("mcp", &agent_def.content.mcp),
212 ("env", &agent_def.content.env),
213 ("soul", &agent_def.content.soul),
214 ("startup", &agent_def.content.startup),
215 ];
216 for (content_type, maybe_content) in &content_pairs {
217 if let Some(content) = maybe_content {
218 if !content.is_empty() {
219 mstore.agent_content_set(&AgentContent {
220 agent_id: agent_def.id.clone(),
221 content_type: content_type.to_string(),
222 content: content.clone(),
223 updated_at: now,
224 })?;
225 }
226 }
227 }
228
229 for skill_def in &agent_def.skills {
231 let skill = AgentSkill {
232 id: uuid::Uuid::new_v4().to_string(),
233 agent_id: agent_def.id.clone(),
234 name: skill_def.name.clone(),
235 trigger: skill_def.trigger.clone(),
236 skill_type: skill_def.skill_type.clone(),
237 description: skill_def.description.clone(),
238 content: skill_def.content.clone(),
239 created_at: now,
240 };
241 mstore.agent_skill_insert(&skill)?;
242 }
243
244 created += 1;
245 }
246
247 Ok(SeedReport { created, skipped })
248}
249
250fn seed_memories(mstore: &Arc<Store>, manifest: &SeedManifest) -> Result<usize, StoreError> {
253 let existing = mstore.bundle_list()?;
254 let existing_by_id: std::collections::HashMap<String, Bundle> =
255 existing.into_iter().map(|m| (m.id.clone(), m)).collect();
256
257 let now = agentmux_common::time::now_ms();
258
259 let mut created = 0usize;
260 for (idx, mem_def) in manifest.memories.iter().enumerate() {
261 if let Some(current) = existing_by_id.get(&mem_def.id) {
262 if is_untouched_earlier_seed(current, mem_def) {
263 let mut refreshed = current.clone();
264 refreshed.instructions = mem_def.instructions.clone();
265 refreshed.updated_at = now;
266 match mstore.bundle_upsert(&refreshed) {
267 Ok(()) => created += 1,
268 Err(e) => tracing::warn!(id = %mem_def.id, error = %e, "agent seed: refreshing an untouched bundle failed"),
269 }
270 }
271 continue;
272 }
273 let memory = Bundle {
274 id: mem_def.id.clone(),
275 name: mem_def.name.clone(),
276 description: mem_def.description.clone(),
277 is_blank: false,
278 is_global: mem_def.is_global,
279 provider: String::new(),
280 model: String::new(),
281 instructions: mem_def.instructions.clone(),
282 instructions_by_provider: "{}".to_string(),
283 context_files: "[]".to_string(),
284 mcp_servers: "[]".to_string(),
285 skills: "[]".to_string(),
286 sort_order: idx as i64,
289 created_at: now,
290 updated_at: now,
291 is_system: false,
292 };
293 match mstore.bundle_upsert(&memory) {
297 Ok(()) => { created += 1; }
298 Err(e) => {
299 tracing::warn!(
300 id = %mem_def.id,
301 name = %mem_def.name,
302 error = %e,
303 "agent seed: skipping bundle due to upsert error (name collision?)"
304 );
305 }
306 }
307 }
308
309 Ok(created)
310}
311
312fn is_untouched_earlier_seed(current: &Bundle, mem_def: &SeedMemory) -> bool {
314 use sha2::{Digest, Sha256};
315 if mem_def.replaces_instructions_sha256.is_empty() || current.instructions == mem_def.instructions {
316 return false;
317 }
318 let digest = hex::encode(Sha256::digest(current.instructions.as_bytes()));
319 mem_def.replaces_instructions_sha256.iter().any(|h| h.eq_ignore_ascii_case(&digest))
320}
321
322pub fn auto_seed_on_startup(mstore: &Arc<Store>) {
325 let manifest: SeedManifest = match serde_json::from_str(SEED_MANIFEST) {
326 Ok(m) => m,
327 Err(e) => {
328 tracing::error!("agent seed: failed to parse seed manifest: {e}");
329 return;
330 }
331 };
332
333 match mstore.agent_def_count() {
334 Ok(0) => {
335 tracing::info!("agent seed: no agents found, seeding from manifest v{}...", manifest.version);
336 match seed_agents(mstore) {
337 Ok(report) => {
338 tracing::info!(
339 "agent seed: seeded {} agents ({} skipped)",
340 report.created,
341 report.skipped
342 );
343 }
344 Err(e) => tracing::error!("agent seed: failed: {e}"),
345 }
346 }
347 Ok(count) => {
348 match reseed_if_needed(mstore, &manifest) {
350 Ok(Some(report)) => {
351 tracing::info!(
352 "agent seed: re-seeded from manifest v{}: {} created, {} updated, {} removed",
353 manifest.version, report.created, report.updated, report.removed,
354 );
355 }
356 Ok(None) => {
357 tracing::info!("agent seed: {} agents exist, manifest up to date", count);
358 }
359 Err(e) => tracing::error!("agent seed: re-seed failed: {e}"),
360 }
361 }
362 Err(e) => tracing::error!("agent seed: failed to count agents: {e}"),
363 }
364
365 if !manifest.memories.is_empty() {
369 match seed_memories(mstore, &manifest) {
370 Ok(0) => {}
371 Ok(n) => tracing::info!("agent seed: seeded or refreshed {n} bundles"),
372 Err(e) => tracing::error!("agent seed: failed to seed memories: {e}"),
373 }
374 }
375}
376
377pub struct ReseedReport {
379 pub created: usize,
380 pub updated: usize,
381 pub removed: usize,
382}
383
384fn reseed_if_needed(
387 mstore: &Arc<Store>,
388 manifest: &SeedManifest,
389) -> Result<Option<ReseedReport>, StoreError> {
390 let existing = mstore.agent_def_list()?;
391
392 let manifest_ids: std::collections::HashSet<&str> =
394 manifest.agents.iter().map(|a| a.id.as_str()).collect();
395 let existing_map: std::collections::HashMap<&str, &AgentDefinition> =
396 existing.iter().map(|a| (a.id.as_str(), a)).collect();
397
398 let mut needs_reseed = false;
399
400 for agent_def in &manifest.agents {
402 match existing_map.get(agent_def.id.as_str()) {
403 None => { needs_reseed = true; break; }
404 Some(existing_agent) => {
405 if existing_agent.description != agent_def.description {
408 needs_reseed = true;
409 break;
410 }
411 }
412 }
413 }
414
415 for agent in &existing {
417 if agent.is_seeded == 1 && !manifest_ids.contains(agent.id.as_str()) {
418 needs_reseed = true;
419 break;
420 }
421 }
422
423 if !needs_reseed {
424 return Ok(None);
425 }
426
427 let now = agentmux_common::time::now_ms();
428
429 let mut created = 0usize;
430 let mut updated = 0usize;
431 let mut removed = 0usize;
432
433 for agent_def in &manifest.agents {
435 let mut agent = AgentDefinition {
436 id: agent_def.id.clone(),
437 slug: agent_def.id.clone(),
438 name: agent_def.name.clone(),
439 icon: agent_def.icon.clone(),
440 provider: agent_def.provider.clone(),
441 description: agent_def.description.clone(),
442 working_directory: agent_def.working_directory.clone(),
443 shell: agent_def.shell.clone(),
444 provider_flags: String::new(),
445 auto_start: if agent_def.auto_start { 1 } else { 0 },
446 restart_on_crash: if agent_def.restart_on_crash { 1 } else { 0 },
447 idle_timeout_minutes: 0,
448 created_at: now,
449 agent_type: agent_def.agent_type.clone(),
450 environment: agent_def.environment.clone(),
451 agent_bus_id: agent_def.agent_bus_id.clone(),
452 is_seeded: 1,
453 accounts: String::new(),
454 parent_id: String::new(),
455 branch_label: String::new(),
456 updated_at: now,
457 user_hidden: 0,
464 container_image: agent_def.container_image.clone(),
465 container_volumes: "[]".to_string(),
466 container_name: String::new(),
467 use_ambient_login: 0,
468 model_vendor_base_url: String::new(), auto_continue_enabled: 0,
470 memory_id: String::new(),
471 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
472 };
473
474 if let Some(existing_agent) = existing_map.get(agent_def.id.as_str()) {
475 agent.provider = existing_agent.provider.clone();
479 agent.agent_type = existing_agent.agent_type.clone();
480 agent.environment = existing_agent.environment.clone();
481 agent.model_vendor_base_url = existing_agent.model_vendor_base_url.clone();
482 agent.shell = if existing_agent.shell.is_empty() {
483 agent_def.shell.clone()
484 } else {
485 existing_agent.shell.clone()
486 };
487 agent.auto_start = existing_agent.auto_start;
488 agent.restart_on_crash = existing_agent.restart_on_crash;
489 agent.created_at = existing_agent.created_at;
490 agent.accounts = existing_agent.accounts.clone();
491 agent.user_hidden = existing_agent.user_hidden;
497 mstore.agent_def_update(&mut agent)?;
498 updated += 1;
499 } else {
500 mstore.agent_def_insert(&mut agent)?;
501 created += 1;
502 }
503 }
504
505 for agent in &existing {
507 if agent.is_seeded == 1 && !manifest_ids.contains(agent.id.as_str()) {
508 mstore.agent_def_delete(&agent.id)?;
509 removed += 1;
510 tracing::info!("agent seed: removed seeded agent '{}'", agent.id);
511 }
512 }
513
514 Ok(Some(ReseedReport { created, updated, removed }))
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::backend::storage::store::{AgentDefinition, Store};
521
522 fn manifest_with(ids_and_descriptions: &[(&str, &str)]) -> SeedManifest {
526 SeedManifest {
527 version: 999,
528 agents: ids_and_descriptions
529 .iter()
530 .map(|(id, desc)| SeedAgent {
531 id: id.to_string(),
532 name: id.to_string(),
533 icon: default_icon(),
534 provider: default_provider(),
535 agent_type: default_agent_type(),
536 environment: default_environment(),
537 description: desc.to_string(),
538 working_directory: String::new(),
539 shell: String::new(),
540 agent_bus_id: String::new(),
541 container_image: String::new(),
542 auto_start: false,
543 restart_on_crash: false,
544 content: SeedContent::default(),
545 skills: Vec::new(),
546 })
547 .collect(),
548 memories: Vec::new(),
549 }
550 }
551
552 fn insert_tpl(mstore: &Arc<Store>, id: &str, name: &str, hidden: i64) {
553 let mut def = AgentDefinition {
554 id: id.to_string(),
555 slug: id.to_string(),
556 name: name.to_string(),
557 icon: "✦".to_string(),
558 provider: "claude".to_string(),
559 description: "v1 desc".to_string(),
560 working_directory: String::new(),
561 shell: String::new(),
562 provider_flags: String::new(),
563 auto_start: 0,
564 restart_on_crash: 0,
565 idle_timeout_minutes: 0,
566 created_at: 1_700_000_000_000,
567 agent_type: "host".to_string(),
568 environment: String::new(),
569 agent_bus_id: String::new(),
570 is_seeded: 1,
571 accounts: String::new(),
572 parent_id: String::new(),
573 branch_label: String::new(),
574 updated_at: 1_700_000_000_000,
575 user_hidden: hidden,
576 container_image: String::new(),
577 container_volumes: "[]".to_string(),
578 container_name: String::new(),
579 use_ambient_login: 0,
580 auto_continue_enabled: 0,
581 model_vendor_base_url: String::new(),
582 memory_id: String::new(),
583 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
584 };
585 mstore.agent_def_insert(&mut def).unwrap();
586 }
587
588 #[test]
589 fn reseed_preserves_user_hidden_on_existing_templates() {
590 let mstore = Arc::new(Store::open_in_memory().unwrap());
594 insert_tpl(&mstore, "tpl-claude", "Claude", 1);
595 let manifest = manifest_with(&[("tpl-claude", "v2 desc")]);
598
599 let report = reseed_if_needed(&mstore, &manifest)
600 .expect("reseed succeeds")
601 .expect("reseed runs because description changed");
602 assert_eq!(report.created, 0);
603 assert_eq!(report.updated, 1);
604
605 let after = mstore.agent_def_list().unwrap();
606 let tpl = after.iter().find(|a| a.id == "tpl-claude").unwrap();
607 assert_eq!(tpl.user_hidden, 1, "hide preference must survive reseed");
608 assert_eq!(tpl.description, "v2 desc", "description must update");
609 }
610
611 #[test]
612 fn reseed_resets_user_hidden_on_newly_added_template_id() {
613 let mstore = Arc::new(Store::open_in_memory().unwrap());
618 insert_tpl(&mstore, "tpl-claude", "Claude", 1);
619 let manifest = manifest_with(&[
620 ("tpl-claude", "v1 desc"), ("tpl-codex", "Codex CLI"), ]);
623
624 let report = reseed_if_needed(&mstore, &manifest)
625 .expect("reseed succeeds")
626 .expect("reseed runs because tpl-codex is new");
627 assert!(report.created >= 1, "tpl-codex should be inserted");
628
629 let after = mstore.agent_def_list().unwrap();
630 let codex = after
631 .iter()
632 .find(|a| a.id == "tpl-codex")
633 .expect("tpl-codex should now exist");
634 assert_eq!(
635 codex.user_hidden, 0,
636 "newly-added template must start visible (Phase 2 spec invariant)",
637 );
638 let claude = after.iter().find(|a| a.id == "tpl-claude").unwrap();
640 assert_eq!(claude.user_hidden, 1);
641 }
642
643 fn sha_hex(text: &str) -> String {
646 use sha2::{Digest, Sha256};
647 hex::encode(Sha256::digest(text.as_bytes()))
648 }
649
650 fn bundle_manifest(new_text: &str, replaces: &[String]) -> SeedManifest {
651 serde_json::from_value(serde_json::json!({
652 "version": 1,
653 "agents": [],
654 "memories": [{
655 "id": "seed-test-bundle",
656 "name": "Test Bundle",
657 "instructions": new_text,
658 "replaces_instructions_sha256": replaces,
659 }],
660 }))
661 .unwrap()
662 }
663
664 fn instructions_of(mstore: &Arc<Store>) -> String {
665 mstore.bundle_get("seed-test-bundle").unwrap().unwrap().instructions
666 }
667
668 #[test]
669 fn an_untouched_earlier_seed_text_is_replaced() {
670 let mstore = Arc::new(Store::open_in_memory().unwrap());
671 seed_memories(&mstore, &bundle_manifest("old text", &[])).unwrap();
672 let n = seed_memories(&mstore, &bundle_manifest("new text", &[sha_hex("old text")])).unwrap();
673 assert_eq!(n, 1);
674 assert_eq!(instructions_of(&mstore), "new text");
675 }
676
677 #[test]
678 fn an_edited_bundle_is_left_alone() {
679 let mstore = Arc::new(Store::open_in_memory().unwrap());
680 seed_memories(&mstore, &bundle_manifest("old text, edited by the user", &[])).unwrap();
681 let n = seed_memories(&mstore, &bundle_manifest("new text", &[sha_hex("old text")])).unwrap();
682 assert_eq!(n, 0);
683 assert_eq!(instructions_of(&mstore), "old text, edited by the user");
684 }
685
686 #[test]
687 fn without_earlier_hashes_an_existing_bundle_is_never_touched() {
688 let mstore = Arc::new(Store::open_in_memory().unwrap());
689 seed_memories(&mstore, &bundle_manifest("old text", &[])).unwrap();
690 assert_eq!(seed_memories(&mstore, &bundle_manifest("new text", &[])).unwrap(), 0);
691 assert_eq!(instructions_of(&mstore), "old text");
692 }
693
694 #[test]
695 fn the_shipped_manifest_refreshes_the_old_reagent_text_of_the_dev_bundle() {
696 let manifest: SeedManifest = serde_json::from_str(SEED_MANIFEST).unwrap();
697 let dev = manifest.memories.iter().find(|m| m.id == "seed-agentmux-dev").unwrap();
698 assert!(!dev.replaces_instructions_sha256.is_empty());
699 assert!(dev.instructions.contains("### muxreview Bot"));
700 assert!(!dev.replaces_instructions_sha256.contains(&sha_hex(&dev.instructions)),
701 "the current text must not be listed as an earlier one");
702 }
703}