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.";
/// 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<AgentTool>,
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"));

View File

@@ -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<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 {
pub fn new(store: Arc<Store>) -> 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<mistralai_client::v1::agents::AgentTool>,
mistral: &MistralClient,
) -> Result<String, String> {
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(&current_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
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);
return Ok((agent_id, false));
}
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, &current_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);
}
}