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:
@@ -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"));
|
||||
|
||||
@@ -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(¤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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user