diff --git a/crates/agents/src/prompt.rs b/crates/agents/src/prompt.rs index 00ee9c25..c6b1beef 100644 --- a/crates/agents/src/prompt.rs +++ b/crates/agents/src/prompt.rs @@ -261,8 +261,11 @@ fn append_identity_and_user_sections( (Some(name), None) => parts.push(format!("Your name is {name}.")), _ => {}, } - if let Some(theme) = id.theme.as_deref() { - parts.push(format!("Your theme: {theme}.")); + if let Some(creature) = id.creature.as_deref() { + parts.push(format!("Your creature: {creature}.")); + } + if let Some(vibe) = id.vibe.as_deref() { + parts.push(format!("Your vibe: {vibe}.")); } if !parts.is_empty() { prompt.push_str(&parts.join(" ")); @@ -719,7 +722,8 @@ mod tests { let identity = AgentIdentity { name: Some("Momo".into()), emoji: Some("🦜".into()), - theme: Some("cheerful parrot".into()), + creature: Some("parrot".into()), + vibe: Some("cheerful".into()), }; let user = UserProfile { name: Some("Alice".into()), @@ -740,7 +744,8 @@ mod tests { None, ); assert!(prompt.contains("Your name is Momo 🦜.")); - assert!(prompt.contains("Your theme: cheerful parrot.")); + assert!(prompt.contains("Your creature: parrot.")); + assert!(prompt.contains("Your vibe: cheerful.")); assert!(prompt.contains("The user's name is Alice.")); // Default soul should be injected when soul is None. assert!(prompt.contains("## Soul")); diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 0154362e..cf6b2416 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -1023,8 +1023,11 @@ fn load_prompt_persona() -> PromptPersona { if file_identity.emoji.is_some() { identity.emoji = file_identity.emoji; } - if file_identity.theme.is_some() { - identity.theme = file_identity.theme; + if file_identity.creature.is_some() { + identity.creature = file_identity.creature; + } + if file_identity.vibe.is_some() { + identity.vibe = file_identity.vibe; } } let mut user = config.user.clone(); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 22116e57..04c29eb2 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -14,13 +14,13 @@ pub mod validate; pub use { loader::{ - DEFAULT_SOUL, agents_path, apply_env_overrides, clear_config_dir, clear_data_dir, - config_dir, data_dir, discover_and_load, find_or_default_config_path, - find_user_global_config_file, heartbeat_path, home_dir, identity_path, load_agents_md, - load_heartbeat_md, load_identity, load_memory_md, load_soul, load_tools_md, load_user, - memory_path, save_config, save_identity, save_raw_config, save_soul, save_user, - set_config_dir, set_data_dir, soul_path, tools_path, update_config, user_global_config_dir, - user_global_config_dir_if_different, user_path, + DEFAULT_SOUL, agent_data_dir, agents_path, apply_env_overrides, clear_config_dir, + clear_data_dir, config_dir, data_dir, discover_and_load, extract_yaml_frontmatter, + find_or_default_config_path, find_user_global_config_file, heartbeat_path, home_dir, + identity_path, load_agents_md, load_heartbeat_md, load_identity, load_memory_md, load_soul, + load_tools_md, load_user, memory_path, save_config, save_identity, save_identity_for_agent, + save_raw_config, save_soul, save_user, set_config_dir, set_data_dir, soul_path, tools_path, + update_config, user_global_config_dir, user_global_config_dir_if_different, user_path, }, schema::{ AgentIdentity, AuthConfig, ChatConfig, GeoLocation, MessageQueueMode, MoltisConfig, diff --git a/crates/config/src/loader.rs b/crates/config/src/loader.rs index 9753a9bd..f04bac9a 100644 --- a/crates/config/src/loader.rs +++ b/crates/config/src/loader.rs @@ -291,7 +291,11 @@ pub fn load_identity() -> Option { let content = std::fs::read_to_string(path).ok()?; let frontmatter = extract_yaml_frontmatter(&content)?; let identity = parse_identity_frontmatter(frontmatter); - if identity.name.is_none() && identity.emoji.is_none() && identity.theme.is_none() { + if identity.name.is_none() + && identity.emoji.is_none() + && identity.creature.is_none() + && identity.vibe.is_none() + { None } else { Some(identity) @@ -448,8 +452,10 @@ pub fn save_soul(soul: Option<&str>) -> anyhow::Result { /// Persist identity values to `IDENTITY.md` using YAML frontmatter. pub fn save_identity(identity: &AgentIdentity) -> anyhow::Result { let path = identity_path(); - let has_values = - identity.name.is_some() || identity.emoji.is_some() || identity.theme.is_some(); + let has_values = identity.name.is_some() + || identity.emoji.is_some() + || identity.creature.is_some() + || identity.vibe.is_some(); if !has_values { if path.exists() { @@ -469,8 +475,11 @@ pub fn save_identity(identity: &AgentIdentity) -> anyhow::Result { if let Some(emoji) = identity.emoji.as_deref() { yaml_lines.push(format!("emoji: {}", yaml_scalar(emoji))); } - if let Some(theme) = identity.theme.as_deref() { - yaml_lines.push(format!("theme: {}", yaml_scalar(theme))); + if let Some(creature) = identity.creature.as_deref() { + yaml_lines.push(format!("creature: {}", yaml_scalar(creature))); + } + if let Some(vibe) = identity.vibe.as_deref() { + yaml_lines.push(format!("vibe: {}", yaml_scalar(vibe))); } let yaml = yaml_lines.join("\n"); let content = format!( @@ -481,6 +490,52 @@ pub fn save_identity(identity: &AgentIdentity) -> anyhow::Result { Ok(path) } +/// Return the workspace data directory for a named agent. +/// Non-main agents live under `data_dir()/agents//`. +pub fn agent_data_dir(agent_id: &str) -> PathBuf { + data_dir().join("agents").join(agent_id) +} + +/// Persist identity values for a non-main agent into its workspace. +pub fn save_identity_for_agent( + agent_id: &str, + identity: &AgentIdentity, +) -> anyhow::Result { + let dir = agent_data_dir(agent_id); + std::fs::create_dir_all(&dir)?; + let path = dir.join("IDENTITY.md"); + + let has_values = identity.name.is_some() + || identity.emoji.is_some() + || identity.creature.is_some() + || identity.vibe.is_some(); + + if !has_values { + if path.exists() { + std::fs::remove_file(&path)?; + } + return Ok(path); + } + + let mut yaml_lines = Vec::new(); + if let Some(name) = identity.name.as_deref() { + yaml_lines.push(format!("name: {}", yaml_scalar(name))); + } + if let Some(emoji) = identity.emoji.as_deref() { + yaml_lines.push(format!("emoji: {}", yaml_scalar(emoji))); + } + if let Some(creature) = identity.creature.as_deref() { + yaml_lines.push(format!("creature: {}", yaml_scalar(creature))); + } + if let Some(vibe) = identity.vibe.as_deref() { + yaml_lines.push(format!("vibe: {}", yaml_scalar(vibe))); + } + + let content = format!("---\n{}\n---\n", yaml_lines.join("\n")); + std::fs::write(&path, content)?; + Ok(path) +} + /// Persist user values to `USER.md` using YAML frontmatter. pub fn save_user(user: &UserProfile) -> anyhow::Result { let path = user_path(); @@ -523,7 +578,7 @@ pub fn save_user(user: &UserProfile) -> anyhow::Result { Ok(path) } -fn extract_yaml_frontmatter(content: &str) -> Option<&str> { +pub fn extract_yaml_frontmatter(content: &str) -> Option<&str> { let trimmed = content.trim_start(); if !trimmed.starts_with("---") { return None; @@ -552,8 +607,9 @@ fn parse_identity_frontmatter(frontmatter: &str) -> AgentIdentity { match key { "name" => identity.name = Some(value.to_string()), "emoji" => identity.emoji = Some(value.to_string()), - // Accept "creature" and "vibe" as backward-compat aliases for "theme". - "theme" | "creature" | "vibe" => identity.theme = Some(value.to_string()), + // Accept "theme" as backward-compat alias for "creature". + "creature" | "theme" => identity.creature = Some(value.to_string()), + "vibe" => identity.vibe = Some(value.to_string()), _ => {}, } } @@ -1270,7 +1326,8 @@ name = "Rex" let identity = AgentIdentity { name: Some("Rex".to_string()), emoji: Some("🐶".to_string()), - theme: Some("chill dog".to_string()), + creature: Some("golden retriever".to_string()), + vibe: Some("chill dog".to_string()), }; let path = save_identity(&identity).expect("save identity"); @@ -1280,7 +1337,8 @@ name = "Rex" let loaded = load_identity().expect("load identity"); assert_eq!(loaded.name.as_deref(), Some("Rex")); assert_eq!(loaded.emoji.as_deref(), Some("🐶"), "raw file:\n{raw}"); - assert_eq!(loaded.theme.as_deref(), Some("chill dog")); + assert_eq!(loaded.creature.as_deref(), Some("golden retriever")); + assert_eq!(loaded.vibe.as_deref(), Some("chill dog")); clear_data_dir(); } @@ -1294,7 +1352,8 @@ name = "Rex" let seeded = AgentIdentity { name: Some("Rex".to_string()), emoji: None, - theme: None, + creature: None, + vibe: None, }; let path = save_identity(&seeded).expect("seed identity"); assert!(path.exists()); diff --git a/crates/config/src/schema.rs b/crates/config/src/schema.rs index 820d6e2b..7abd1bd6 100644 --- a/crates/config/src/schema.rs +++ b/crates/config/src/schema.rs @@ -7,13 +7,14 @@ use { serde::{Deserialize, Serialize}, }; -/// Agent identity (name, emoji, theme). +/// Agent identity (name, emoji, creature, vibe). #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct AgentIdentity { pub name: Option, pub emoji: Option, - pub theme: Option, + pub creature: Option, + pub vibe: Option, } /// IANA timezone (e.g. `"Europe/Paris"`). @@ -146,7 +147,8 @@ pub struct UserProfile { pub struct ResolvedIdentity { pub name: String, pub emoji: Option, - pub theme: Option, + pub creature: Option, + pub vibe: Option, pub soul: Option, pub user_name: Option, } @@ -156,7 +158,8 @@ impl ResolvedIdentity { Self { name: cfg.identity.name.clone().unwrap_or_else(|| "moltis".into()), emoji: cfg.identity.emoji.clone(), - theme: cfg.identity.theme.clone(), + creature: cfg.identity.creature.clone(), + vibe: cfg.identity.vibe.clone(), soul: None, user_name: cfg.user.name.clone(), } @@ -168,7 +171,8 @@ impl Default for ResolvedIdentity { Self { name: "moltis".into(), emoji: None, - theme: None, + creature: None, + vibe: None, soul: None, user_name: None, } diff --git a/crates/config/src/validate.rs b/crates/config/src/validate.rs index a0c4200e..a3f8df1b 100644 --- a/crates/config/src/validate.rs +++ b/crates/config/src/validate.rs @@ -354,7 +354,8 @@ fn build_schema_map() -> KnownKeys { Struct(HashMap::from([ ("name", Leaf), ("emoji", Leaf), - ("theme", Leaf), + ("creature", Leaf), + ("vibe", Leaf), ])), ), ( diff --git a/crates/gateway/migrations/20260211110000_agents.sql b/crates/gateway/migrations/20260211110000_agents.sql new file mode 100644 index 00000000..2adad759 --- /dev/null +++ b/crates/gateway/migrations/20260211110000_agents.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + emoji TEXT, + creature TEXT, + vibe TEXT, + description TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/crates/gateway/src/agent_persona.rs b/crates/gateway/src/agent_persona.rs new file mode 100644 index 00000000..4018d72d --- /dev/null +++ b/crates/gateway/src/agent_persona.rs @@ -0,0 +1,540 @@ +//! Agent persona store for multi-agent support. +//! +//! Each agent has its own workspace directory under `data_dir()/agents//` +//! with dedicated `IDENTITY.md`, `SOUL.md`, and memory files. +//! The "main" agent always maps to the root `data_dir()` workspace. + +use { + anyhow::Result, + serde::{Deserialize, Serialize}, + std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }, +}; + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +/// A persisted agent persona. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentPersona { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub emoji: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub creature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub vibe: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub created_at: i64, + pub updated_at: i64, +} + +/// Parameters for creating a new agent. +#[derive(Debug, Deserialize)] +pub struct CreateAgentParams { + pub id: String, + pub name: String, + #[serde(default)] + pub emoji: Option, + #[serde(default)] + pub creature: Option, + #[serde(default)] + pub vibe: Option, + #[serde(default)] + pub description: Option, +} + +/// Parameters for updating an existing agent. +#[derive(Debug, Deserialize)] +pub struct UpdateAgentParams { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub emoji: Option, + #[serde(default)] + pub creature: Option, + #[serde(default)] + pub vibe: Option, + #[serde(default)] + pub description: Option, +} + +#[derive(sqlx::FromRow)] +struct AgentRow { + id: String, + name: String, + emoji: Option, + creature: Option, + vibe: Option, + description: Option, + created_at: i64, + updated_at: i64, +} + +impl From for AgentPersona { + fn from(r: AgentRow) -> Self { + Self { + id: r.id, + name: r.name, + emoji: r.emoji, + creature: r.creature, + vibe: r.vibe, + description: r.description, + created_at: r.created_at, + updated_at: r.updated_at, + } + } +} + +/// Validate an agent ID: lowercase alphanumeric + hyphens, 1-50 chars, not "main". +pub fn validate_agent_id(id: &str) -> Result<(), String> { + if id == "main" { + return Err("cannot use reserved id 'main'".into()); + } + if id.is_empty() || id.len() > 50 { + return Err("id must be 1-50 characters".into()); + } + if !id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err("id must contain only lowercase letters, digits, and hyphens".into()); + } + if id.starts_with('-') || id.ends_with('-') { + return Err("id must not start or end with a hyphen".into()); + } + Ok(()) +} + +/// SQLite-backed agent persona store. +pub struct AgentPersonaStore { + pool: sqlx::SqlitePool, +} + +impl AgentPersonaStore { + pub fn new(pool: sqlx::SqlitePool) -> Self { + Self { pool } + } + + /// List all agents: synthesize "main" from config, then append DB rows. + pub async fn list(&self) -> Result> { + let main = synthesize_main_agent(); + let db_agents: Vec = + sqlx::query_as::<_, AgentRow>("SELECT * FROM agents ORDER BY created_at ASC") + .fetch_all(&self.pool) + .await? + .into_iter() + .map(Into::into) + .collect(); + + let mut result = vec![main]; + result.extend(db_agents); + Ok(result) + } + + /// Get a single agent by ID. + pub async fn get(&self, id: &str) -> Result> { + if id == "main" { + return Ok(Some(synthesize_main_agent())); + } + let row = sqlx::query_as::<_, AgentRow>("SELECT * FROM agents WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(Into::into)) + } + + /// Create a new agent persona and its workspace directory. + pub async fn create(&self, params: CreateAgentParams) -> Result { + validate_agent_id(¶ms.id).map_err(|e| anyhow::anyhow!("{e}"))?; + + let now = now_ms(); + sqlx::query( + r#"INSERT INTO agents (id, name, emoji, creature, vibe, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)"#, + ) + .bind(¶ms.id) + .bind(¶ms.name) + .bind(¶ms.emoji) + .bind(¶ms.creature) + .bind(¶ms.vibe) + .bind(¶ms.description) + .bind(now) + .bind(now) + .execute(&self.pool) + .await?; + + self.ensure_workspace(¶ms.id)?; + + // Write initial IDENTITY.md and SOUL.md if values provided. + let identity = moltis_config::schema::AgentIdentity { + name: Some(params.name.clone()), + emoji: params.emoji.clone(), + creature: params.creature.clone(), + vibe: params.vibe.clone(), + }; + moltis_config::save_identity_for_agent(¶ms.id, &identity)?; + + Ok(AgentPersona { + id: params.id, + name: params.name, + emoji: params.emoji, + creature: params.creature, + vibe: params.vibe, + description: params.description, + created_at: now, + updated_at: now, + }) + } + + /// Update an existing agent persona. + pub async fn update(&self, id: &str, params: UpdateAgentParams) -> Result { + if id == "main" { + anyhow::bail!("cannot modify 'main' agent through this API; use identity settings"); + } + + let existing = self + .get(id) + .await? + .ok_or_else(|| anyhow::anyhow!("agent '{id}' not found"))?; + + let name = params.name.unwrap_or(existing.name); + let emoji = params.emoji.or(existing.emoji); + let creature = params.creature.or(existing.creature); + let vibe = params.vibe.or(existing.vibe); + let description = params.description.or(existing.description); + let now = now_ms(); + + sqlx::query( + "UPDATE agents SET name = ?, emoji = ?, creature = ?, vibe = ?, description = ?, updated_at = ? WHERE id = ?", + ) + .bind(&name) + .bind(&emoji) + .bind(&creature) + .bind(&vibe) + .bind(&description) + .bind(now) + .bind(id) + .execute(&self.pool) + .await?; + + // Update workspace IDENTITY.md. + let identity = moltis_config::schema::AgentIdentity { + name: Some(name.clone()), + emoji: emoji.clone(), + creature: creature.clone(), + vibe: vibe.clone(), + }; + moltis_config::save_identity_for_agent(id, &identity)?; + + Ok(AgentPersona { + id: id.to_string(), + name, + emoji, + creature, + vibe, + description, + created_at: existing.created_at, + updated_at: now, + }) + } + + /// Delete an agent persona. Cannot delete "main". + pub async fn delete(&self, id: &str) -> Result<()> { + if id == "main" { + anyhow::bail!("cannot delete the main agent"); + } + + let result = sqlx::query("DELETE FROM agents WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + anyhow::bail!("agent '{id}' not found"); + } + + // Archive the workspace directory by renaming it. + let workspace = moltis_config::agent_data_dir(id); + if workspace.exists() { + let archived = workspace.with_file_name(format!("{id}.archived")); + if let Err(e) = std::fs::rename(&workspace, &archived) { + tracing::warn!( + agent_id = id, + error = %e, + "failed to archive agent workspace, removing instead" + ); + let _ = std::fs::remove_dir_all(&workspace); + } + } + + Ok(()) + } + + /// Create the workspace directory for an agent. + pub fn ensure_workspace(&self, agent_id: &str) -> Result { + let dir = moltis_config::agent_data_dir(agent_id); + std::fs::create_dir_all(&dir)?; + Ok(dir) + } +} + +/// Synthesize the "main" agent persona from the global identity config. +fn synthesize_main_agent() -> AgentPersona { + let identity = moltis_config::load_identity(); + AgentPersona { + id: "main".to_string(), + name: identity + .as_ref() + .and_then(|i| i.name.clone()) + .unwrap_or_else(|| "moltis".to_string()), + emoji: identity.as_ref().and_then(|i| i.emoji.clone()), + creature: identity.as_ref().and_then(|i| i.creature.clone()), + vibe: identity.as_ref().and_then(|i| i.vibe.clone()), + description: Some("Default agent".to_string()), + created_at: 0, + updated_at: 0, + } +} + +#[allow(clippy::unwrap_used, clippy::expect_used)] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_agent_id() { + assert!(validate_agent_id("research").is_ok()); + assert!(validate_agent_id("my-agent-1").is_ok()); + assert!(validate_agent_id("a").is_ok()); + + assert!(validate_agent_id("main").is_err()); + assert!(validate_agent_id("").is_err()); + assert!(validate_agent_id("UPPER").is_err()); + assert!(validate_agent_id("has space").is_err()); + assert!(validate_agent_id("-leading").is_err()); + assert!(validate_agent_id("trailing-").is_err()); + assert!(validate_agent_id(&"a".repeat(51)).is_err()); + } + + async fn test_pool() -> sqlx::SqlitePool { + let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + emoji TEXT, + creature TEXT, + vibe TEXT, + description TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )"#, + ) + .execute(&pool) + .await + .unwrap(); + pool + } + + #[tokio::test] + async fn test_list_includes_main() { + let pool = test_pool().await; + let store = AgentPersonaStore::new(pool); + let agents = store.list().await.unwrap(); + assert!(!agents.is_empty()); + assert_eq!(agents[0].id, "main"); + } + + #[tokio::test] + async fn test_create_and_get() { + let pool = test_pool().await; + let store = AgentPersonaStore::new(pool); + + let agent = store + .create(CreateAgentParams { + id: "research".to_string(), + name: "Research Assistant".to_string(), + emoji: Some("🔬".to_string()), + creature: None, + vibe: Some("analytical".to_string()), + description: Some("Helps with research tasks".to_string()), + }) + .await + .unwrap(); + + assert_eq!(agent.id, "research"); + assert_eq!(agent.name, "Research Assistant"); + assert_eq!(agent.emoji.as_deref(), Some("🔬")); + + let fetched = store.get("research").await.unwrap().unwrap(); + assert_eq!(fetched.name, "Research Assistant"); + } + + #[tokio::test] + async fn test_create_rejects_main() { + let pool = test_pool().await; + let store = AgentPersonaStore::new(pool); + let result = store + .create(CreateAgentParams { + id: "main".to_string(), + name: "Main".to_string(), + emoji: None, + creature: None, + vibe: None, + description: None, + }) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_create_rejects_invalid_id() { + let pool = test_pool().await; + let store = AgentPersonaStore::new(pool); + let result = store + .create(CreateAgentParams { + id: "INVALID".to_string(), + name: "Test".to_string(), + emoji: None, + creature: None, + vibe: None, + description: None, + }) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_update() { + let pool = test_pool().await; + let store = AgentPersonaStore::new(pool); + store + .create(CreateAgentParams { + id: "writer".to_string(), + name: "Writer".to_string(), + emoji: None, + creature: None, + vibe: None, + description: None, + }) + .await + .unwrap(); + + let updated = store + .update("writer", UpdateAgentParams { + name: Some("Creative Writer".to_string()), + emoji: Some("✍️".to_string()), + creature: None, + vibe: None, + description: None, + }) + .await + .unwrap(); + + assert_eq!(updated.name, "Creative Writer"); + assert_eq!(updated.emoji.as_deref(), Some("✍️")); + } + + #[tokio::test] + async fn test_update_main_rejected() { + let pool = test_pool().await; + let store = AgentPersonaStore::new(pool); + let result = store + .update("main", UpdateAgentParams { + name: Some("Changed".to_string()), + emoji: None, + creature: None, + vibe: None, + description: None, + }) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_delete() { + let pool = test_pool().await; + let store = AgentPersonaStore::new(pool); + store + .create(CreateAgentParams { + id: "temp".to_string(), + name: "Temporary".to_string(), + emoji: None, + creature: None, + vibe: None, + description: None, + }) + .await + .unwrap(); + + store.delete("temp").await.unwrap(); + assert!(store.get("temp").await.unwrap().is_none()); + } + + #[tokio::test] + async fn test_delete_main_rejected() { + let pool = test_pool().await; + let store = AgentPersonaStore::new(pool); + assert!(store.delete("main").await.is_err()); + } + + #[tokio::test] + async fn test_delete_nonexistent() { + let pool = test_pool().await; + let store = AgentPersonaStore::new(pool); + assert!(store.delete("nonexistent").await.is_err()); + } + + #[tokio::test] + async fn test_list_order() { + let pool = test_pool().await; + let store = AgentPersonaStore::new(pool); + + store + .create(CreateAgentParams { + id: "beta".to_string(), + name: "Beta".to_string(), + emoji: None, + creature: None, + vibe: None, + description: None, + }) + .await + .unwrap(); + + store + .create(CreateAgentParams { + id: "alpha".to_string(), + name: "Alpha".to_string(), + emoji: None, + creature: None, + vibe: None, + description: None, + }) + .await + .unwrap(); + + let agents = store.list().await.unwrap(); + assert_eq!(agents.len(), 3); + assert_eq!(agents[0].id, "main"); + assert_eq!(agents[1].id, "beta"); + assert_eq!(agents[2].id, "alpha"); + } + + #[tokio::test] + async fn test_get_main() { + let pool = test_pool().await; + let store = AgentPersonaStore::new(pool); + let main = store.get("main").await.unwrap().unwrap(); + assert_eq!(main.id, "main"); + } +} diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs index c2eb40e9..7a3036d9 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -10,6 +10,7 @@ //! All domain logic (agents, channels, etc.) lives in other crates and is //! invoked through method handlers registered in `methods.rs`. +pub mod agent_persona; pub mod approval; pub mod auth; pub mod auth_middleware; diff --git a/crates/gateway/src/methods/mod.rs b/crates/gateway/src/methods/mod.rs index 38feb285..6ee7689e 100644 --- a/crates/gateway/src/methods/mod.rs +++ b/crates/gateway/src/methods/mod.rs @@ -54,6 +54,8 @@ const READ_METHODS: &[&str] = &[ "models.list", "models.list_all", "agents.list", + "agents.get", + "agents.identity.get", "agent.identity.get", "skills.list", "skills.status", @@ -110,6 +112,12 @@ const WRITE_METHODS: &[&str] = &[ "agent.wait", "agent.identity.update", "agent.identity.update_soul", + "agents.create", + "agents.update", + "agents.delete", + "agents.set_session", + "agents.identity.update", + "agents.identity.update_soul", "wake", "talk.mode", "tts.enable", diff --git a/crates/gateway/src/methods/services.rs b/crates/gateway/src/methods/services.rs index b8439bd8..101065ed 100644 --- a/crates/gateway/src/methods/services.rs +++ b/crates/gateway/src/methods/services.rs @@ -100,12 +100,289 @@ pub(super) fn register(reg: &mut MethodRegistry) { "agents.list", Box::new(|ctx| { Box::pin(async move { - ctx.state - .services - .agent + let Some(ref store) = ctx.state.services.agent_persona_store else { + return ctx + .state + .services + .agent + .list() + .await + .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e)); + }; + store .list() .await - .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e)) + .map(|agents| serde_json::to_value(&agents).unwrap_or_default()) + .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e.to_string())) + }) + }), + ); + reg.register( + "agents.get", + Box::new(|ctx| { + Box::pin(async move { + let id = ctx + .params + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ErrorShape::new(error_codes::INVALID_REQUEST, "missing 'id' parameter") + })?; + let Some(ref store) = ctx.state.services.agent_persona_store else { + return Err(ErrorShape::new( + error_codes::UNAVAILABLE, + "agent personas not available", + )); + }; + match store.get(id).await { + Ok(Some(agent)) => serde_json::to_value(&agent) + .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e.to_string())), + Ok(None) => Err(ErrorShape::new( + error_codes::INVALID_REQUEST, + "agent not found", + )), + Err(e) => Err(ErrorShape::new(error_codes::UNAVAILABLE, e.to_string())), + } + }) + }), + ); + reg.register( + "agents.create", + Box::new(|ctx| { + Box::pin(async move { + let Some(ref store) = ctx.state.services.agent_persona_store else { + return Err(ErrorShape::new( + error_codes::UNAVAILABLE, + "agent personas not available", + )); + }; + let params: crate::agent_persona::CreateAgentParams = + serde_json::from_value(ctx.params).map_err(|e| { + ErrorShape::new(error_codes::INVALID_REQUEST, e.to_string()) + })?; + let agent = store + .create(params) + .await + .map_err(|e| ErrorShape::new(error_codes::INVALID_REQUEST, e.to_string()))?; + serde_json::to_value(&agent) + .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e.to_string())) + }) + }), + ); + reg.register( + "agents.update", + Box::new(|ctx| { + Box::pin(async move { + let id = ctx + .params + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ErrorShape::new(error_codes::INVALID_REQUEST, "missing 'id' parameter") + })? + .to_string(); + let Some(ref store) = ctx.state.services.agent_persona_store else { + return Err(ErrorShape::new( + error_codes::UNAVAILABLE, + "agent personas not available", + )); + }; + let params: crate::agent_persona::UpdateAgentParams = + serde_json::from_value(ctx.params).map_err(|e| { + ErrorShape::new(error_codes::INVALID_REQUEST, e.to_string()) + })?; + let agent = store + .update(&id, params) + .await + .map_err(|e| ErrorShape::new(error_codes::INVALID_REQUEST, e.to_string()))?; + serde_json::to_value(&agent) + .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e.to_string())) + }) + }), + ); + reg.register( + "agents.delete", + Box::new(|ctx| { + Box::pin(async move { + let id = ctx + .params + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ErrorShape::new(error_codes::INVALID_REQUEST, "missing 'id' parameter") + })? + .to_string(); + let Some(ref store) = ctx.state.services.agent_persona_store else { + return Err(ErrorShape::new( + error_codes::UNAVAILABLE, + "agent personas not available", + )); + }; + // Cascade-delete all sessions belonging to this agent. + let mut deleted_sessions = 0_u64; + if let Some(ref meta) = ctx.state.services.session_metadata { + deleted_sessions = meta.delete_by_agent_id(&id).await.unwrap_or(0); + } + store + .delete(&id) + .await + .map_err(|e| ErrorShape::new(error_codes::INVALID_REQUEST, e.to_string()))?; + Ok(serde_json::json!({ + "deleted": true, + "deleted_sessions": deleted_sessions, + })) + }) + }), + ); + reg.register( + "agents.set_session", + Box::new(|ctx| { + Box::pin(async move { + let session_key = ctx + .params + .get("session_key") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ErrorShape::new( + error_codes::INVALID_REQUEST, + "missing 'session_key' parameter", + ) + })?; + let agent_id = ctx.params.get("agent_id").and_then(|v| v.as_str()); + let Some(ref meta) = ctx.state.services.session_metadata else { + return Err(ErrorShape::new( + error_codes::UNAVAILABLE, + "session metadata not available", + )); + }; + meta.set_agent_id(session_key, agent_id) + .await + .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e.to_string()))?; + Ok(serde_json::json!({ "ok": true })) + }) + }), + ); + reg.register( + "agents.identity.get", + Box::new(|ctx| { + Box::pin(async move { + let agent_id = ctx + .params + .get("agent_id") + .and_then(|v| v.as_str()) + .unwrap_or("main"); + if agent_id == "main" { + return ctx + .state + .services + .onboarding + .identity_get() + .await + .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e)); + } + // Non-main agent: read from agent workspace + let dir = moltis_config::agent_data_dir(agent_id); + let identity_path = dir.join("IDENTITY.md"); + let soul_path = dir.join("SOUL.md"); + let identity_content = std::fs::read_to_string(&identity_path).ok(); + let identity = identity_content + .as_deref() + .and_then(moltis_config::extract_yaml_frontmatter) + .map(String::from); + let soul = std::fs::read_to_string(&soul_path).ok(); + Ok(serde_json::json!({ + "identity": identity, + "soul": soul, + })) + }) + }), + ); + reg.register( + "agents.identity.update", + Box::new(|ctx| { + Box::pin(async move { + let agent_id = ctx + .params + .get("agent_id") + .and_then(|v| v.as_str()) + .unwrap_or("main"); + if agent_id == "main" { + return ctx + .state + .services + .onboarding + .identity_update(ctx.params) + .await + .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e)); + } + let identity = moltis_config::schema::AgentIdentity { + name: ctx + .params + .get("name") + .and_then(|v| v.as_str()) + .map(String::from), + emoji: ctx + .params + .get("emoji") + .and_then(|v| v.as_str()) + .map(String::from), + creature: ctx + .params + .get("creature") + .and_then(|v| v.as_str()) + .map(String::from), + vibe: ctx + .params + .get("vibe") + .and_then(|v| v.as_str()) + .map(String::from), + }; + moltis_config::save_identity_for_agent(agent_id, &identity) + .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e.to_string()))?; + Ok(serde_json::json!({ "ok": true })) + }) + }), + ); + reg.register( + "agents.identity.update_soul", + Box::new(|ctx| { + Box::pin(async move { + let agent_id = ctx + .params + .get("agent_id") + .and_then(|v| v.as_str()) + .unwrap_or("main"); + let soul = ctx + .params + .get("soul") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if agent_id == "main" { + return ctx + .state + .services + .onboarding + .identity_update_soul(soul) + .await + .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e)); + } + let dir = moltis_config::agent_data_dir(agent_id); + std::fs::create_dir_all(&dir) + .map_err(|e| ErrorShape::new(error_codes::UNAVAILABLE, e.to_string()))?; + let soul_path = dir.join("SOUL.md"); + match soul.as_deref().map(str::trim) { + Some(content) if !content.is_empty() => { + std::fs::write(&soul_path, content).map_err(|e| { + ErrorShape::new(error_codes::UNAVAILABLE, e.to_string()) + })?; + }, + _ => { + std::fs::write(&soul_path, "").map_err(|e| { + ErrorShape::new(error_codes::UNAVAILABLE, e.to_string()) + })?; + }, + } + Ok(serde_json::json!({ "ok": true })) }) }), ); diff --git a/crates/gateway/src/server.rs b/crates/gateway/src/server.rs index 80e80bad..68c4ecc4 100644 --- a/crates/gateway/src/server.rs +++ b/crates/gateway/src/server.rs @@ -2130,6 +2130,12 @@ pub async fn start_gateway( services = services.with_session_store(Arc::clone(&session_store)); services = services.with_session_share_store(Arc::clone(&session_share_store)); + // Wire agent persona store for multi-agent support. + let agent_persona_store = Arc::new(crate::agent_persona::AgentPersonaStore::new( + db_pool.clone(), + )); + services = services.with_agent_persona_store(agent_persona_store); + // ── Hook discovery & registration ───────────────────────────────────── seed_default_workspace_markdown_files(); seed_example_skill(); diff --git a/crates/gateway/src/services.rs b/crates/gateway/src/services.rs index 1e7b7d28..07c61c0f 100644 --- a/crates/gateway/src/services.rs +++ b/crates/gateway/src/services.rs @@ -1197,6 +1197,8 @@ pub struct GatewayServices { pub session_store: Option>, /// Optional session share store for immutable snapshot links. pub session_share_store: Option>, + /// Optional agent persona store for multi-agent support. + pub agent_persona_store: Option>, } impl GatewayServices { @@ -1276,6 +1278,7 @@ impl GatewayServices { session_metadata: None, session_store: None, session_share_store: None, + agent_persona_store: None, } } @@ -1312,6 +1315,14 @@ impl GatewayServices { self } + pub fn with_agent_persona_store( + mut self, + store: Arc, + ) -> Self { + self.agent_persona_store = Some(store); + self + } + pub fn with_tts(mut self, tts: Arc) -> Self { self.tts = tts; self diff --git a/crates/onboarding/src/service.rs b/crates/onboarding/src/service.rs index e3e0ea1b..294d7797 100644 --- a/crates/onboarding/src/service.rs +++ b/crates/onboarding/src/service.rs @@ -116,7 +116,8 @@ impl LiveOnboardingService { "identity": { "name": config.identity.name, "emoji": config.identity.emoji, - "theme": config.identity.theme, + "creature": config.identity.creature, + "vibe": config.identity.vibe, }, "user": { "name": config.user.name, @@ -199,12 +200,11 @@ impl LiveOnboardingService { if let Some(v) = str_field(¶ms, "emoji") { identity.emoji = v; } - // Accept "theme" directly, or "creature"/"vibe" as backward-compat aliases. - if let Some(v) = str_field(¶ms, "theme") - .or_else(|| str_field(¶ms, "creature")) - .or_else(|| str_field(¶ms, "vibe")) - { - identity.theme = v; + if let Some(v) = str_field(¶ms, "creature") { + identity.creature = v; + } + if let Some(v) = str_field(¶ms, "vibe") { + identity.vibe = v; } if let Some(v) = params.get("soul") { let soul = if v.is_null() { @@ -238,7 +238,8 @@ impl LiveOnboardingService { Ok(json!({ "name": identity.name, "emoji": identity.emoji, - "theme": identity.theme, + "creature": identity.creature, + "vibe": identity.vibe, "soul": moltis_config::load_soul(), "user_name": user.name, "user_timezone": user.timezone.as_ref().map(|tz| tz.name()), @@ -264,8 +265,11 @@ impl LiveOnboardingService { if let Some(emoji) = file_identity.emoji { id.emoji = Some(emoji); } - if let Some(theme) = file_identity.theme { - id.theme = Some(theme); + if let Some(creature) = file_identity.creature { + id.creature = Some(creature); + } + if let Some(vibe) = file_identity.vibe { + id.vibe = Some(vibe); } } if let Some(file_user) = moltis_config::load_user() @@ -282,7 +286,8 @@ impl LiveOnboardingService { id.name = name; } id.emoji = file_identity.emoji; - id.theme = file_identity.theme; + id.creature = file_identity.creature; + id.vibe = file_identity.vibe; } if let Some(file_user) = moltis_config::load_user() { id.user_name = file_user.name; @@ -304,8 +309,11 @@ fn merge_identity(dst: &mut AgentIdentity, src: &AgentIdentity) { if src.emoji.is_some() { dst.emoji = src.emoji.clone(); } - if src.theme.is_some() { - dst.theme = src.theme.clone(); + if src.creature.is_some() { + dst.creature = src.creature.clone(); + } + if src.vibe.is_some() { + dst.vibe = src.vibe.clone(); } } @@ -338,7 +346,7 @@ fn current_value(ws: &WizardState) -> Option<&str> { UserName => ws.user.name.as_deref(), AgentName => ws.identity.name.as_deref(), AgentEmoji => ws.identity.emoji.as_deref(), - AgentTheme => ws.identity.theme.as_deref(), + AgentVibe => ws.identity.vibe.as_deref(), _ => None, } } @@ -451,7 +459,7 @@ mod tests { .identity_update(json!({ "name": "Rex", "emoji": "\u{1f436}", - "theme": "chill dog", + "vibe": "chill dog", "user_name": "Alice", "user_timezone": "America/New_York", })) @@ -460,18 +468,18 @@ mod tests { assert_eq!(res["user_name"], "Alice"); assert_eq!(res["user_timezone"], "America/New_York"); - // Partial update: only change theme + // Partial update: only change vibe let res = svc - .identity_update(json!({ "theme": "playful pup" })) + .identity_update(json!({ "vibe": "playful pup" })) .unwrap(); assert_eq!(res["name"], "Rex"); - assert_eq!(res["theme"], "playful pup"); + assert_eq!(res["vibe"], "playful pup"); assert_eq!(res["emoji"], "\u{1f436}"); // Verify identity_get reflects updates let id = svc.identity_get(); assert_eq!(id.name, "Rex"); - assert_eq!(id.theme.as_deref(), Some("playful pup")); + assert_eq!(id.vibe.as_deref(), Some("playful pup")); assert_eq!(id.user_name.as_deref(), Some("Alice")); let user = moltis_config::load_user().expect("load user"); assert_eq!( diff --git a/crates/onboarding/src/state.rs b/crates/onboarding/src/state.rs index 73c26ae0..78fe45bd 100644 --- a/crates/onboarding/src/state.rs +++ b/crates/onboarding/src/state.rs @@ -10,7 +10,7 @@ pub enum WizardStep { UserName, AgentName, AgentEmoji, - AgentTheme, + AgentVibe, Confirm, Done, } @@ -47,8 +47,8 @@ impl WizardState { WizardStep::UserName => "What's your name?", WizardStep::AgentName => "Pick a name for your agent:", WizardStep::AgentEmoji => "Choose an emoji for your agent (e.g. \u{1f916}):", - WizardStep::AgentTheme => { - "Describe your agent's theme (e.g. wise owl, chill fox, witty robot):" + WizardStep::AgentVibe => { + "Describe your agent's vibe (e.g. wise owl, chill fox, witty robot):" }, WizardStep::Confirm => "All set! Press Enter to save, or type 'back' to go back.", WizardStep::Done => "Onboarding complete!", @@ -78,17 +78,17 @@ impl WizardState { if !input.is_empty() { self.identity.emoji = Some(input.to_string()); } - self.step = WizardStep::AgentTheme; + self.step = WizardStep::AgentVibe; }, - WizardStep::AgentTheme => { + WizardStep::AgentVibe => { if !input.is_empty() { - self.identity.theme = Some(input.to_string()); + self.identity.vibe = Some(input.to_string()); } self.step = WizardStep::Confirm; }, WizardStep::Confirm => { if input.eq_ignore_ascii_case("back") { - self.step = WizardStep::AgentTheme; + self.step = WizardStep::AgentVibe; } else { self.step = WizardStep::Done; } @@ -125,7 +125,7 @@ mod tests { assert_eq!(s.identity.emoji.as_deref(), Some("\u{1f99c}")); s.advance("cheerful parrot"); // → confirm - assert_eq!(s.identity.theme.as_deref(), Some("cheerful parrot")); + assert_eq!(s.identity.vibe.as_deref(), Some("cheerful parrot")); assert_eq!(s.step, WizardStep::Confirm); s.advance(""); // confirm → done @@ -144,7 +144,7 @@ mod tests { assert_eq!(s.step, WizardStep::Confirm); s.advance("back"); - assert_eq!(s.step, WizardStep::AgentTheme); + assert_eq!(s.step, WizardStep::AgentVibe); } #[test] diff --git a/crates/sessions/migrations/20260211100000_session_agent_id.sql b/crates/sessions/migrations/20260211100000_session_agent_id.sql new file mode 100644 index 00000000..f9def2c3 --- /dev/null +++ b/crates/sessions/migrations/20260211100000_session_agent_id.sql @@ -0,0 +1 @@ +ALTER TABLE sessions ADD COLUMN agent_id TEXT; diff --git a/crates/sessions/src/metadata.rs b/crates/sessions/src/metadata.rs index aa852fec..f0e43caa 100644 --- a/crates/sessions/src/metadata.rs +++ b/crates/sessions/src/metadata.rs @@ -43,6 +43,8 @@ pub struct SessionEntry { pub mcp_disabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub preview: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_id: Option, #[serde(default)] pub version: u64, } @@ -120,6 +122,7 @@ impl SessionMetadata { fork_point: None, mcp_disabled: None, preview: None, + agent_id: None, version: 0, }) } @@ -196,6 +199,43 @@ impl SessionMetadata { } } + /// Assign (or unassign) a session to an agent persona. + pub fn set_agent_id(&mut self, key: &str, agent_id: Option) { + if let Some(entry) = self.entries.get_mut(key) { + entry.agent_id = agent_id; + entry.updated_at = now_ms(); + entry.version += 1; + } + } + + /// List all sessions belonging to a given agent. + pub fn list_by_agent_id(&self, agent_id: &str) -> Vec { + let mut entries: Vec<_> = self + .entries + .values() + .filter(|e| e.agent_id.as_deref() == Some(agent_id)) + .cloned() + .collect(); + entries.sort_by_key(|a| a.created_at); + entries + } + + /// Delete all sessions belonging to a given agent. Returns the number of + /// sessions removed. + pub fn delete_by_agent_id(&mut self, agent_id: &str) -> u64 { + let keys: Vec = self + .entries + .iter() + .filter(|(_, e)| e.agent_id.as_deref() == Some(agent_id)) + .map(|(k, _)| k.clone()) + .collect(); + let count = keys.len() as u64; + for key in keys { + self.entries.remove(&key); + } + count + } + /// Remove an entry by key. Returns the removed entry if found. pub fn remove(&mut self, key: &str) -> Option { self.entries.remove(key) @@ -236,6 +276,7 @@ struct SessionRow { fork_point: Option, mcp_disabled: Option, preview: Option, + agent_id: Option, version: i64, } @@ -260,6 +301,7 @@ impl From for SessionEntry { fork_point: r.fork_point.map(|v| v as u32), mcp_disabled: r.mcp_disabled.map(|v| v != 0), preview: r.preview, + agent_id: r.agent_id, version: r.version as u64, } } @@ -296,6 +338,7 @@ impl SqliteSessionMetadata { fork_point INTEGER, mcp_disabled INTEGER, preview TEXT, + agent_id TEXT, version INTEGER NOT NULL DEFAULT 0 )"#, ) @@ -490,6 +533,40 @@ impl SqliteSessionMetadata { .ok(); } + /// Assign (or unassign) a session to an agent persona. + pub async fn set_agent_id(&self, key: &str, agent_id: Option<&str>) -> Result<()> { + let now = now_ms() as i64; + sqlx::query( + "UPDATE sessions SET agent_id = ?, updated_at = ?, version = version + 1 WHERE key = ?", + ) + .bind(agent_id) + .bind(now) + .bind(key) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// List all sessions belonging to a given agent. + pub async fn list_by_agent_id(&self, agent_id: &str) -> Result> { + let rows = sqlx::query_as::<_, SessionRow>( + "SELECT * FROM sessions WHERE agent_id = ? ORDER BY created_at ASC", + ) + .bind(agent_id) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(Into::into).collect()) + } + + /// Delete all sessions belonging to a given agent (cascade). + pub async fn delete_by_agent_id(&self, agent_id: &str) -> Result { + let result = sqlx::query("DELETE FROM sessions WHERE agent_id = ?") + .bind(agent_id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + /// Set the parent session key and fork point for a branched session. pub async fn set_parent(&self, key: &str, parent_key: Option, fork_point: Option) { let now = now_ms() as i64; @@ -1159,6 +1236,9 @@ mod tests { meta.set_preview("main", Some("hello")).await; assert_eq!(meta.get("main").await.unwrap().version, 11); + + meta.set_agent_id("main", Some("agent-1")).await.unwrap(); + assert_eq!(meta.get("main").await.unwrap().version, 12); } #[tokio::test] @@ -1219,4 +1299,174 @@ mod tests { let reloaded = SessionMetadata::load(path).unwrap(); assert_eq!(reloaded.get("main").unwrap().version, 3); } + + #[test] + fn test_agent_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("meta.json"); + let mut meta = SessionMetadata::load(path.clone()).unwrap(); + + meta.upsert("main", None); + assert!(meta.get("main").unwrap().agent_id.is_none()); + + meta.set_agent_id("main", Some("agent-1".to_string())); + assert_eq!( + meta.get("main").unwrap().agent_id.as_deref(), + Some("agent-1") + ); + + meta.set_agent_id("main", None); + assert!(meta.get("main").unwrap().agent_id.is_none()); + + // Round-trip through save/load. + meta.set_agent_id("main", Some("agent-2".to_string())); + meta.save().unwrap(); + let reloaded = SessionMetadata::load(path).unwrap(); + assert_eq!( + reloaded.get("main").unwrap().agent_id.as_deref(), + Some("agent-2") + ); + } + + #[test] + fn test_list_by_agent_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("meta.json"); + let mut meta = SessionMetadata::load(path).unwrap(); + + meta.upsert("s1", Some("Session 1".to_string())); + meta.upsert("s2", Some("Session 2".to_string())); + meta.upsert("s3", Some("Session 3".to_string())); + + meta.set_agent_id("s1", Some("agent-a".to_string())); + meta.set_agent_id("s2", Some("agent-a".to_string())); + meta.set_agent_id("s3", Some("agent-b".to_string())); + + let agent_a = meta.list_by_agent_id("agent-a"); + assert_eq!(agent_a.len(), 2); + let keys: Vec<&str> = agent_a.iter().map(|e| e.key.as_str()).collect(); + assert!(keys.contains(&"s1")); + assert!(keys.contains(&"s2")); + + let agent_b = meta.list_by_agent_id("agent-b"); + assert_eq!(agent_b.len(), 1); + assert_eq!(agent_b[0].key, "s3"); + + let none = meta.list_by_agent_id("agent-missing"); + assert!(none.is_empty()); + } + + #[test] + fn test_delete_by_agent_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("meta.json"); + let mut meta = SessionMetadata::load(path).unwrap(); + + meta.upsert("s1", None); + meta.upsert("s2", None); + meta.upsert("s3", None); + + meta.set_agent_id("s1", Some("agent-a".to_string())); + meta.set_agent_id("s2", Some("agent-a".to_string())); + meta.set_agent_id("s3", Some("agent-b".to_string())); + + let deleted = meta.delete_by_agent_id("agent-a"); + assert_eq!(deleted, 2); + assert!(meta.get("s1").is_none()); + assert!(meta.get("s2").is_none()); + assert!(meta.get("s3").is_some()); + + // Deleting a non-existent agent returns 0. + let deleted = meta.delete_by_agent_id("agent-missing"); + assert_eq!(deleted, 0); + } + + #[tokio::test] + async fn test_sqlite_agent_id() { + let pool = sqlite_pool().await; + let meta = SqliteSessionMetadata::new(pool); + + meta.upsert("main", None).await.unwrap(); + assert!(meta.get("main").await.unwrap().agent_id.is_none()); + + meta.set_agent_id("main", Some("agent-1")).await.unwrap(); + assert_eq!( + meta.get("main").await.unwrap().agent_id.as_deref(), + Some("agent-1") + ); + + meta.set_agent_id("main", None).await.unwrap(); + assert!(meta.get("main").await.unwrap().agent_id.is_none()); + } + + #[tokio::test] + async fn test_sqlite_list_by_agent_id() { + let pool = sqlite_pool().await; + let meta = SqliteSessionMetadata::new(pool); + + meta.upsert("s1", Some("Session 1".to_string())) + .await + .unwrap(); + meta.upsert("s2", Some("Session 2".to_string())) + .await + .unwrap(); + meta.upsert("s3", Some("Session 3".to_string())) + .await + .unwrap(); + + meta.set_agent_id("s1", Some("agent-a")).await.unwrap(); + meta.set_agent_id("s2", Some("agent-a")).await.unwrap(); + meta.set_agent_id("s3", Some("agent-b")).await.unwrap(); + + let agent_a = meta.list_by_agent_id("agent-a").await.unwrap(); + assert_eq!(agent_a.len(), 2); + let keys: Vec<&str> = agent_a.iter().map(|e| e.key.as_str()).collect(); + assert!(keys.contains(&"s1")); + assert!(keys.contains(&"s2")); + + let agent_b = meta.list_by_agent_id("agent-b").await.unwrap(); + assert_eq!(agent_b.len(), 1); + assert_eq!(agent_b[0].key, "s3"); + + let none = meta.list_by_agent_id("agent-missing").await.unwrap(); + assert!(none.is_empty()); + } + + #[tokio::test] + async fn test_sqlite_delete_by_agent_id() { + let pool = sqlite_pool().await; + let meta = SqliteSessionMetadata::new(pool); + + meta.upsert("s1", None).await.unwrap(); + meta.upsert("s2", None).await.unwrap(); + meta.upsert("s3", None).await.unwrap(); + + meta.set_agent_id("s1", Some("agent-a")).await.unwrap(); + meta.set_agent_id("s2", Some("agent-a")).await.unwrap(); + meta.set_agent_id("s3", Some("agent-b")).await.unwrap(); + + let deleted = meta.delete_by_agent_id("agent-a").await.unwrap(); + assert_eq!(deleted, 2); + assert!(meta.get("s1").await.is_none()); + assert!(meta.get("s2").await.is_none()); + assert!(meta.get("s3").await.is_some()); + + // Deleting a non-existent agent returns 0. + let deleted = meta.delete_by_agent_id("agent-missing").await.unwrap(); + assert_eq!(deleted, 0); + } + + #[test] + fn test_agent_id_serde_compat() { + // Existing metadata without agent_id should deserialize fine. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("meta.json"); + fs::write( + &path, + r#"{"main":{"id":"1","key":"main","label":null,"created_at":0,"updated_at":0,"message_count":0}}"#, + ) + .unwrap(); + let meta = SessionMetadata::load(path).unwrap(); + assert!(meta.get("main").unwrap().agent_id.is_none()); + } } diff --git a/crates/web/src/assets/index.html b/crates/web/src/assets/index.html index ca36e32b..82047ced 100644 --- a/crates/web/src/assets/index.html +++ b/crates/web/src/assets/index.html @@ -92,6 +92,7 @@ + diff --git a/crates/web/src/assets/js/page-agents.js b/crates/web/src/assets/js/page-agents.js new file mode 100644 index 00000000..7903d1ba --- /dev/null +++ b/crates/web/src/assets/js/page-agents.js @@ -0,0 +1,346 @@ +// ── Settings > Agents page (Preact + HTM) ───────────────── +// +// CRUD UI for agent personas. "main" agent links to the +// Identity settings section and cannot be deleted. + +import { html } from "htm/preact"; +import { render } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { EmojiPicker } from "./emoji-picker.js"; +import { refresh as refreshGon } from "./gon.js"; +import { sendRpc } from "./helpers.js"; +import { navigate } from "./router.js"; +import { settingsPath } from "./routes.js"; +import { confirmDialog } from "./ui.js"; + +var _mounted = false; +var containerRef = null; + +export function initAgents(container, subPath) { + _mounted = true; + containerRef = container; + render(html`<${AgentsPage} subPath=${subPath} />`, container); +} + +export function teardownAgents() { + _mounted = false; + if (containerRef) render(null, containerRef); + containerRef = null; +} + +// ── Create / Edit form ────────────────────────────────────── + +function AgentForm({ agent, onSave, onCancel }) { + var isEdit = !!agent; + var [id, setId] = useState(agent?.id || ""); + var [name, setName] = useState(agent?.name || ""); + var [emoji, setEmoji] = useState(agent?.emoji || ""); + var [creature, setCreature] = useState(agent?.creature || ""); + var [vibe, setVibe] = useState(agent?.vibe || ""); + var [soul, setSoul] = useState(""); + var [saving, setSaving] = useState(false); + var [error, setError] = useState(null); + + // Load soul: for edits fetch the agent's soul, for new agents fetch main's soul as default + useEffect(() => { + var agentId = isEdit ? agent.id : "main"; + var attempts = 0; + function load() { + sendRpc("agents.identity.get", { agent_id: agentId }).then((res) => { + if (res?.error?.message === "WebSocket not connected" && attempts < 30) { + attempts += 1; + window.setTimeout(load, 200); + return; + } + if (res?.ok && res.payload?.soul) { + setSoul(res.payload.soul); + } + }); + } + load(); + }, [isEdit, agent?.id]); + + function buildParams() { + var base = { + name: name.trim(), + emoji: emoji.trim() || null, + creature: creature.trim() || null, + vibe: vibe.trim() || null, + }; + base.id = isEdit ? agent.id : id.trim(); + return base; + } + + function finishSave(agentId) { + var trimmedSoul = soul.trim(); + if (trimmedSoul) { + sendRpc("agents.identity.update_soul", { agent_id: agentId, soul: trimmedSoul }).then(() => { + setSaving(false); + refreshGon(); + onSave(); + }); + } else { + setSaving(false); + refreshGon(); + onSave(); + } + } + + function onSubmit(e) { + e.preventDefault(); + if (!name.trim()) { + setError("Name is required."); + return; + } + if (!(isEdit || id.trim())) { + setError("ID is required."); + return; + } + setError(null); + setSaving(true); + + var method = isEdit ? "agents.update" : "agents.create"; + sendRpc(method, buildParams()).then((res) => { + if (!res?.ok) { + setSaving(false); + setError(res?.error?.message || "Failed to save"); + return; + } + finishSave(isEdit ? agent.id : id.trim()); + }); + } + + return html` +
+

+ ${isEdit ? `Edit ${agent.name}` : "Create Agent"} +

+ + ${ + !isEdit && + html` + + ` + } + + + +
+ Emoji + <${EmojiPicker} value=${emoji} onChange=${setEmoji} /> +
+ + + + + +