agent recreation on prompt changes, deterministic hash, dynamic delegation

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.
This commit is contained in:
2026-03-22 15:00:23 +00:00
parent c9d4f7400d
commit 904ffa2d4d
2 changed files with 114 additions and 49 deletions

View File

@@ -8,23 +8,27 @@ pub const ORCHESTRATOR_DESCRIPTION: &str =
"Sol — virtual librarian for Sunbeam Studios. Routes to domain agents or responds directly."; "Sol — virtual librarian for Sunbeam Studios. Routes to domain agents or responds directly.";
/// Build the orchestrator agent instructions. /// Build the orchestrator agent instructions.
/// The orchestrator carries Sol's personality and sees high-level domain descriptions. /// The orchestrator carries Sol's personality. If domain agents are available,
pub fn orchestrator_instructions(system_prompt: &str) -> String { /// a delegation section is appended describing them.
format!( pub fn orchestrator_instructions(
"{system_prompt}\n\n\ system_prompt: &str,
## delegation\n\n\ 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. \ you have access to domain agents for specialized tasks. \
for simple conversation, respond directly. for tasks requiring tools, delegate.\n\n\ for simple conversation, respond directly. for tasks requiring tools, delegate.\n\n\
available domains:\n\ available domains:\n");
- **observability**: metrics, logs, dashboards, alerts (prometheus, loki, grafana)\n\
- **data**: full-text search, object storage (opensearch, seaweedfs)\n\ for (name, description) in active_agents {
- **devtools**: git repos, issues, PRs, kanban boards (gitea, planka)\n\ let domain = name.strip_prefix("sol-").unwrap_or(name);
- **infrastructure**: kubernetes, deployments, certificates, builds\n\ delegation.push_str(&format!("- **{domain}**: {description}\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\ format!("{system_prompt}{delegation}")
- **media**: video/audio rooms, recordings, streams (livekit)\n"
)
} }
/// Build a domain agent creation request. /// Build a domain agent creation request.
@@ -56,15 +60,20 @@ pub fn orchestrator_request(
system_prompt: &str, system_prompt: &str,
model: &str, model: &str,
tools: Vec<AgentTool>, tools: Vec<AgentTool>,
active_agents: &[(&str, &str)],
) -> CreateAgentRequest { ) -> CreateAgentRequest {
let instructions = orchestrator_instructions(system_prompt); let instructions = orchestrator_instructions(system_prompt, active_agents);
CreateAgentRequest { CreateAgentRequest {
model: model.to_string(), model: model.to_string(),
name: ORCHESTRATOR_NAME.to_string(), name: ORCHESTRATOR_NAME.to_string(),
description: Some(ORCHESTRATOR_DESCRIPTION.to_string()), description: Some(ORCHESTRATOR_DESCRIPTION.to_string()),
instructions: Some(instructions), 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, handoffs: None,
completion_args: Some(CompletionArgs { completion_args: Some(CompletionArgs {
temperature: Some(0.5), temperature: Some(0.5),
@@ -136,17 +145,28 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_orchestrator_instructions_includes_prompt() { fn test_orchestrator_instructions_no_agents() {
let prompt = "you are sol."; let instructions = orchestrator_instructions("you are sol.", &[]);
let instructions = orchestrator_instructions(prompt); 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.starts_with("you are sol."));
assert!(instructions.contains("observability"));
assert!(instructions.contains("delegation")); assert!(instructions.contains("delegation"));
assert!(instructions.contains("**devtools**: Git repos"));
assert!(instructions.contains("**identity**: User accounts"));
} }
#[test] #[test]
fn test_orchestrator_request() { 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.name, "sol-orchestrator");
assert_eq!(req.model, "mistral-medium-latest"); assert_eq!(req.model, "mistral-medium-latest");
assert!(req.instructions.unwrap().contains("test prompt")); assert!(req.instructions.unwrap().contains("test prompt"));

View File

@@ -4,7 +4,7 @@ use std::sync::Arc;
use mistralai_client::v1::agents::{Agent, CreateAgentRequest}; use mistralai_client::v1::agents::{Agent, CreateAgentRequest};
use mistralai_client::v1::client::Client as MistralClient; use mistralai_client::v1::client::Client as MistralClient;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{info, warn, error}; use tracing::{info, warn};
use super::definitions; use super::definitions;
use crate::persistence::Store; use crate::persistence::Store;
@@ -19,6 +19,17 @@ pub struct AgentRegistry {
store: Arc<Store>, store: Arc<Store>,
} }
/// 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 { impl AgentRegistry {
pub fn new(store: Arc<Store>) -> Self { pub fn new(store: Arc<Store>) -> Self {
Self { Self {
@@ -27,50 +38,70 @@ impl AgentRegistry {
} }
} }
/// Ensure the orchestrator agent exists. Creates or verifies it. /// Ensure the orchestrator agent exists with current instructions.
/// Returns the agent ID. ///
/// 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( pub async fn ensure_orchestrator(
&self, &self,
system_prompt: &str, system_prompt: &str,
model: &str, model: &str,
tools: Vec<mistralai_client::v1::agents::AgentTool>, tools: Vec<mistralai_client::v1::agents::AgentTool>,
mistral: &MistralClient, mistral: &MistralClient,
) -> Result<String, String> { active_agents: &[(&str, &str)],
) -> Result<(String, bool), String> {
let mut agents = self.agents.lock().await; let mut agents = self.agents.lock().await;
let current_instructions = definitions::orchestrator_instructions(system_prompt, active_agents);
let current_hash = instructions_hash(&current_instructions);
// Check in-memory cache // Check in-memory cache
if let Some(agent) = agents.get(definitions::ORCHESTRATOR_NAME) { 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 // Check SQLite for persisted agent ID
if let Some(agent_id) = self.store.get_agent(definitions::ORCHESTRATOR_NAME) { if let Some((agent_id, stored_hash)) = self.store.get_agent(definitions::ORCHESTRATOR_NAME) {
// Verify it still exists on the server if stored_hash == current_hash {
match mistral.get_agent_async(&agent_id).await { // Instructions haven't changed — verify agent still exists on server
Ok(agent) => { match mistral.get_agent_async(&agent_id).await {
info!(agent_id = agent.id.as_str(), "Restored orchestrator agent from database"); Ok(agent) => {
agents.insert(definitions::ORCHESTRATOR_NAME.to_string(), agent); info!(agent_id = agent.id.as_str(), "Restored orchestrator agent from database");
return Ok(agent_id); 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(_) => { } else {
warn!("Persisted orchestrator agent {agent_id} no longer exists on server"); // Instructions changed — delete old agent, will create new below
self.store.delete_agent(definitions::ORCHESTRATOR_NAME); 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; let existing = self.find_by_name(definitions::ORCHESTRATOR_NAME, mistral).await;
if let Some(agent) = existing { if let Some(agent) = existing {
let id = agent.id.clone(); // Delete it — we need a fresh one with current instructions
info!(agent_id = id.as_str(), "Found existing orchestrator agent on server"); info!(agent_id = agent.id.as_str(), "Deleting stale orchestrator agent from server");
self.store.upsert_agent(definitions::ORCHESTRATOR_NAME, &id, model); let _ = mistral.delete_agent_async(&agent.id).await;
agents.insert(definitions::ORCHESTRATOR_NAME.to_string(), agent);
return Ok(id);
} }
// Create new // 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 let agent = mistral
.create_agent_async(&req) .create_agent_async(&req)
.await .await
@@ -78,9 +109,9 @@ impl AgentRegistry {
let id = agent.id.clone(); let id = agent.id.clone();
info!(agent_id = id.as_str(), "Created orchestrator agent"); 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, &current_hash);
agents.insert(definitions::ORCHESTRATOR_NAME.to_string(), agent); agents.insert(definitions::ORCHESTRATOR_NAME.to_string(), agent);
Ok(id) Ok((id, true))
} }
/// Ensure a domain agent exists. Returns the agent ID. /// Ensure a domain agent exists. Returns the agent ID.
@@ -97,7 +128,7 @@ impl AgentRegistry {
} }
// Check SQLite // 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 { match mistral.get_agent_async(&agent_id).await {
Ok(agent) => { Ok(agent) => {
info!(name, agent_id = agent.id.as_str(), "Restored domain agent from database"); info!(name, agent_id = agent.id.as_str(), "Restored domain agent from database");
@@ -115,7 +146,7 @@ impl AgentRegistry {
if let Some(agent) = existing { if let Some(agent) = existing {
let id = agent.id.clone(); let id = agent.id.clone();
info!(name, agent_id = id.as_str(), "Found existing domain agent on server"); 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); agents.insert(name.to_string(), agent);
return Ok(id); return Ok(id);
} }
@@ -127,7 +158,7 @@ impl AgentRegistry {
let id = agent.id.clone(); let id = agent.id.clone();
info!(name, agent_id = id.as_str(), "Created domain agent"); 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); agents.insert(name.to_string(), agent);
Ok(id) Ok(id)
} }
@@ -172,4 +203,18 @@ mod tests {
let store = Arc::new(Store::open_memory().unwrap()); let store = Arc::new(Store::open_memory().unwrap());
let _reg = AgentRegistry::new(store); 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);
}
} }