From 904ffa2d4de442a289de6e32837d30a5f79ff4c3 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sun, 22 Mar 2026 15:00:23 +0000 Subject: [PATCH] agent recreation on prompt changes, deterministic hash, dynamic delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orchestrator instructions hash (FNV-1a, stable across rust versions) is stored alongside agent ID. on startup, hash mismatch triggers delete of old agent + creation of new one + conversation reset + sneeze. delegation section is now dynamic — only lists domain agents that are actually registered, preventing the model from hallucinating capabilities for agents that don't exist yet. web_search added as a built-in tool on the orchestrator. --- src/agents/definitions.rs | 64 ++++++++++++++++--------- src/agents/registry.rs | 99 ++++++++++++++++++++++++++++----------- 2 files changed, 114 insertions(+), 49 deletions(-) diff --git a/src/agents/definitions.rs b/src/agents/definitions.rs index e39e896..91cde42 100644 --- a/src/agents/definitions.rs +++ b/src/agents/definitions.rs @@ -8,23 +8,27 @@ pub const ORCHESTRATOR_DESCRIPTION: &str = "Sol — virtual librarian for Sunbeam Studios. Routes to domain agents or responds directly."; /// Build the orchestrator agent instructions. -/// The orchestrator carries Sol's personality and sees high-level domain descriptions. -pub fn orchestrator_instructions(system_prompt: &str) -> String { - format!( - "{system_prompt}\n\n\ - ## delegation\n\n\ +/// The orchestrator carries Sol's personality. If domain agents are available, +/// a delegation section is appended describing them. +pub fn orchestrator_instructions( + system_prompt: &str, + active_agents: &[(&str, &str)], // (name, description) +) -> String { + if active_agents.is_empty() { + return system_prompt.to_string(); + } + + let mut delegation = String::from("\n\n## delegation\n\n\ you have access to domain agents for specialized tasks. \ for simple conversation, respond directly. for tasks requiring tools, delegate.\n\n\ - available domains:\n\ - - **observability**: metrics, logs, dashboards, alerts (prometheus, loki, grafana)\n\ - - **data**: full-text search, object storage (opensearch, seaweedfs)\n\ - - **devtools**: git repos, issues, PRs, kanban boards (gitea, planka)\n\ - - **infrastructure**: kubernetes, deployments, certificates, builds\n\ - - **identity**: user accounts, sessions, login, recovery, OAuth2 clients (kratos, hydra)\n\ - - **collaboration**: contacts, documents, meetings, files, email, calendars (la suite)\n\ - - **communication**: chat rooms, messages, members (matrix)\n\ - - **media**: video/audio rooms, recordings, streams (livekit)\n" - ) + available domains:\n"); + + for (name, description) in active_agents { + let domain = name.strip_prefix("sol-").unwrap_or(name); + delegation.push_str(&format!("- **{domain}**: {description}\n")); + } + + format!("{system_prompt}{delegation}") } /// Build a domain agent creation request. @@ -56,15 +60,20 @@ pub fn orchestrator_request( system_prompt: &str, model: &str, tools: Vec, + active_agents: &[(&str, &str)], ) -> CreateAgentRequest { - let instructions = orchestrator_instructions(system_prompt); + let instructions = orchestrator_instructions(system_prompt, active_agents); CreateAgentRequest { model: model.to_string(), name: ORCHESTRATOR_NAME.to_string(), description: Some(ORCHESTRATOR_DESCRIPTION.to_string()), instructions: Some(instructions), - tools: if tools.is_empty() { None } else { Some(tools) }, + tools: { + let mut all_tools = tools; + all_tools.push(AgentTool::web_search()); + Some(all_tools) + }, handoffs: None, completion_args: Some(CompletionArgs { temperature: Some(0.5), @@ -136,17 +145,28 @@ mod tests { use super::*; #[test] - fn test_orchestrator_instructions_includes_prompt() { - let prompt = "you are sol."; - let instructions = orchestrator_instructions(prompt); + fn test_orchestrator_instructions_no_agents() { + let instructions = orchestrator_instructions("you are sol.", &[]); + assert_eq!(instructions, "you are sol."); + assert!(!instructions.contains("delegation")); + } + + #[test] + fn test_orchestrator_instructions_with_agents() { + let agents = vec![ + ("sol-devtools", "Git repos, issues, PRs"), + ("sol-identity", "User accounts, sessions"), + ]; + let instructions = orchestrator_instructions("you are sol.", &agents); assert!(instructions.starts_with("you are sol.")); - assert!(instructions.contains("observability")); assert!(instructions.contains("delegation")); + assert!(instructions.contains("**devtools**: Git repos")); + assert!(instructions.contains("**identity**: User accounts")); } #[test] fn test_orchestrator_request() { - let req = orchestrator_request("test prompt", "mistral-medium-latest", vec![]); + let req = orchestrator_request("test prompt", "mistral-medium-latest", vec![], &[]); assert_eq!(req.name, "sol-orchestrator"); assert_eq!(req.model, "mistral-medium-latest"); assert!(req.instructions.unwrap().contains("test prompt")); diff --git a/src/agents/registry.rs b/src/agents/registry.rs index 3078ec1..b58c45f 100644 --- a/src/agents/registry.rs +++ b/src/agents/registry.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use mistralai_client::v1::agents::{Agent, CreateAgentRequest}; use mistralai_client::v1::client::Client as MistralClient; use tokio::sync::Mutex; -use tracing::{info, warn, error}; +use tracing::{info, warn}; use super::definitions; use crate::persistence::Store; @@ -19,6 +19,17 @@ pub struct AgentRegistry { store: Arc, } +/// Compute a deterministic hash of the instructions string for staleness detection. +/// Uses FNV-1a (stable across Rust versions, unlike DefaultHasher). +fn instructions_hash(instructions: &str) -> String { + let mut hash: u64 = 0xcbf29ce484222325; // FNV offset basis + for byte in instructions.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); // FNV prime + } + format!("{:016x}", hash) +} + impl AgentRegistry { pub fn new(store: Arc) -> Self { Self { @@ -27,50 +38,70 @@ impl AgentRegistry { } } - /// Ensure the orchestrator agent exists. Creates or verifies it. - /// Returns the agent ID. + /// Ensure the orchestrator agent exists with current instructions. + /// + /// If the instructions have changed since the agent was created, the old agent + /// is deleted and a new one is created. Returns `(agent_id, recreated)` where + /// `recreated` is true if the agent was recreated due to stale instructions — + /// callers should reset conversations and sneeze. pub async fn ensure_orchestrator( &self, system_prompt: &str, model: &str, tools: Vec, mistral: &MistralClient, - ) -> Result { + active_agents: &[(&str, &str)], + ) -> Result<(String, bool), String> { let mut agents = self.agents.lock().await; + let current_instructions = definitions::orchestrator_instructions(system_prompt, active_agents); + let current_hash = instructions_hash(¤t_instructions); + // Check in-memory cache if let Some(agent) = agents.get(definitions::ORCHESTRATOR_NAME) { - return Ok(agent.id.clone()); + return Ok((agent.id.clone(), false)); } // Check SQLite for persisted agent ID - if let Some(agent_id) = self.store.get_agent(definitions::ORCHESTRATOR_NAME) { - // Verify it still exists on the server - match mistral.get_agent_async(&agent_id).await { - Ok(agent) => { - info!(agent_id = agent.id.as_str(), "Restored orchestrator agent from database"); - agents.insert(definitions::ORCHESTRATOR_NAME.to_string(), agent); - return Ok(agent_id); + if let Some((agent_id, stored_hash)) = self.store.get_agent(definitions::ORCHESTRATOR_NAME) { + if stored_hash == current_hash { + // Instructions haven't changed — verify agent still exists on server + match mistral.get_agent_async(&agent_id).await { + Ok(agent) => { + info!(agent_id = agent.id.as_str(), "Restored orchestrator agent from database"); + agents.insert(definitions::ORCHESTRATOR_NAME.to_string(), agent); + return Ok((agent_id, false)); + } + Err(_) => { + warn!("Persisted orchestrator agent {agent_id} no longer exists on server"); + self.store.delete_agent(definitions::ORCHESTRATOR_NAME); + } } - Err(_) => { - warn!("Persisted orchestrator agent {agent_id} no longer exists on server"); - self.store.delete_agent(definitions::ORCHESTRATOR_NAME); + } else { + // Instructions changed — delete old agent, will create new below + info!( + old_hash = stored_hash.as_str(), + new_hash = current_hash.as_str(), + "System prompt changed — recreating orchestrator agent" + ); + // Try to delete old agent from Mistral (best-effort) + if let Err(e) = mistral.delete_agent_async(&agent_id).await { + warn!("Failed to delete old orchestrator agent: {}", e.message); } + self.store.delete_agent(definitions::ORCHESTRATOR_NAME); } } - // Check if it exists on the server by name + // Check if it exists on the server by name (but skip reuse if hash changed) let existing = self.find_by_name(definitions::ORCHESTRATOR_NAME, mistral).await; if let Some(agent) = existing { - let id = agent.id.clone(); - info!(agent_id = id.as_str(), "Found existing orchestrator agent on server"); - self.store.upsert_agent(definitions::ORCHESTRATOR_NAME, &id, model); - agents.insert(definitions::ORCHESTRATOR_NAME.to_string(), agent); - return Ok(id); + // Delete it — we need a fresh one with current instructions + info!(agent_id = agent.id.as_str(), "Deleting stale orchestrator agent from server"); + let _ = mistral.delete_agent_async(&agent.id).await; } // Create new - let req = definitions::orchestrator_request(system_prompt, model, tools); + let req = definitions::orchestrator_request(system_prompt, model, tools, active_agents); let agent = mistral .create_agent_async(&req) .await @@ -78,9 +109,9 @@ impl AgentRegistry { let id = agent.id.clone(); info!(agent_id = id.as_str(), "Created orchestrator agent"); - self.store.upsert_agent(definitions::ORCHESTRATOR_NAME, &id, model); + self.store.upsert_agent(definitions::ORCHESTRATOR_NAME, &id, model, ¤t_hash); agents.insert(definitions::ORCHESTRATOR_NAME.to_string(), agent); - Ok(id) + Ok((id, true)) } /// Ensure a domain agent exists. Returns the agent ID. @@ -97,7 +128,7 @@ impl AgentRegistry { } // Check SQLite - if let Some(agent_id) = self.store.get_agent(name) { + if let Some((agent_id, _hash)) = self.store.get_agent(name) { match mistral.get_agent_async(&agent_id).await { Ok(agent) => { info!(name, agent_id = agent.id.as_str(), "Restored domain agent from database"); @@ -115,7 +146,7 @@ impl AgentRegistry { if let Some(agent) = existing { let id = agent.id.clone(); info!(name, agent_id = id.as_str(), "Found existing domain agent on server"); - self.store.upsert_agent(name, &id, &request.model); + self.store.upsert_agent(name, &id, &request.model, ""); agents.insert(name.to_string(), agent); return Ok(id); } @@ -127,7 +158,7 @@ impl AgentRegistry { let id = agent.id.clone(); info!(name, agent_id = id.as_str(), "Created domain agent"); - self.store.upsert_agent(name, &id, &request.model); + self.store.upsert_agent(name, &id, &request.model, ""); agents.insert(name.to_string(), agent); Ok(id) } @@ -172,4 +203,18 @@ mod tests { let store = Arc::new(Store::open_memory().unwrap()); let _reg = AgentRegistry::new(store); } + + #[test] + fn test_instructions_hash_deterministic() { + let h1 = instructions_hash("you are sol."); + let h2 = instructions_hash("you are sol."); + assert_eq!(h1, h2); + } + + #[test] + fn test_instructions_hash_changes() { + let h1 = instructions_hash("you are sol."); + let h2 = instructions_hash("you are sol. updated."); + assert_ne!(h1, h2); + } }