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