feat: multi-agent architecture with Conversations API and persistent state
Mistral Agents + Conversations API integration:
- Orchestrator agent created on startup with Sol's personality + tools
- ConversationRegistry routes messages through persistent conversations
- Per-room conversation state (room_id → conversation_id + token counts)
- Function call handling within conversation responses
- Configurable via [agents] section in sol.toml (use_conversations_api flag)
Multimodal support:
- m.image detection and Matrix media download (mxc:// → base64 data URI)
- ContentPart-based messages sent to Mistral vision models
- Archive stores media_urls for image messages
System prompt rewrite:
- 687 → 150 lines — dense, few-shot examples, hard rules
- {room_context_rules} placeholder for group vs DM behavior
- Sender prefixing (<@user:server>) for multi-user turns in group rooms
SQLite persistence (/data/sol.db):
- Conversation mappings and agent IDs survive reboots
- WAL mode for concurrent reads
- Falls back to in-memory on failure (sneezes into all rooms to signal)
- PVC already mounted at /data alongside Matrix SDK state store
New modules:
- src/persistence.rs — SQLite state store
- src/conversations.rs — ConversationRegistry + message merging
- src/agents/{mod,definitions,registry}.rs — agent lifecycle
- src/agent_ux.rs — reaction + thread progress UX
- src/tools/bridge.rs — tool dispatch for domain agents
102 tests passing.
This commit is contained in:
173
src/agents/definitions.rs
Normal file
173
src/agents/definitions.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use mistralai_client::v1::agents::{AgentTool, CompletionArgs, CreateAgentRequest};
|
||||
|
||||
/// Domain agent definitions — each scoped to a subset of sunbeam-sdk tools.
|
||||
/// These are created on startup via the Agents API and cached by the registry.
|
||||
|
||||
pub const ORCHESTRATOR_NAME: &str = "sol-orchestrator";
|
||||
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\
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a domain agent creation request.
|
||||
pub fn domain_agent_request(
|
||||
name: &str,
|
||||
description: &str,
|
||||
instructions: &str,
|
||||
tools: Vec<AgentTool>,
|
||||
model: &str,
|
||||
) -> CreateAgentRequest {
|
||||
CreateAgentRequest {
|
||||
model: model.to_string(),
|
||||
name: name.to_string(),
|
||||
description: Some(description.to_string()),
|
||||
instructions: Some(instructions.to_string()),
|
||||
tools: Some(tools),
|
||||
handoffs: None,
|
||||
completion_args: Some(CompletionArgs {
|
||||
temperature: Some(0.3),
|
||||
..Default::default()
|
||||
}),
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the orchestrator agent creation request.
|
||||
/// Includes Sol's existing tools as function calling tools.
|
||||
pub fn orchestrator_request(
|
||||
system_prompt: &str,
|
||||
model: &str,
|
||||
tools: Vec<AgentTool>,
|
||||
) -> CreateAgentRequest {
|
||||
let instructions = orchestrator_instructions(system_prompt);
|
||||
|
||||
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) },
|
||||
handoffs: None,
|
||||
completion_args: Some(CompletionArgs {
|
||||
temperature: Some(0.5),
|
||||
..Default::default()
|
||||
}),
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Known domain agent configurations.
|
||||
/// Each entry: (name, description, instructions_snippet)
|
||||
pub const DOMAIN_AGENTS: &[(&str, &str, &str)] = &[
|
||||
(
|
||||
"sol-observability",
|
||||
"Metrics, logs, dashboards, and alerts",
|
||||
"you handle observability tasks for sunbeam infrastructure. \
|
||||
you can query prometheus metrics, search loki logs, manage grafana dashboards, \
|
||||
and check alert status. respond with data, not opinions.",
|
||||
),
|
||||
(
|
||||
"sol-data",
|
||||
"Full-text search and object storage",
|
||||
"you handle data operations. you can search the opensearch archive for past conversations, \
|
||||
manage seaweedfs object storage buckets and files. present search results clearly.",
|
||||
),
|
||||
(
|
||||
"sol-devtools",
|
||||
"Git repos, issues, PRs, and kanban boards",
|
||||
"you handle development tools. you can manage gitea repositories, issues, pull requests, \
|
||||
and planka kanban boards. be precise about repo names and issue numbers.",
|
||||
),
|
||||
(
|
||||
"sol-infrastructure",
|
||||
"Kubernetes, deployments, certificates, and builds",
|
||||
"you handle infrastructure operations. you can inspect kubernetes resources, \
|
||||
trigger deployments, check certificate status, and manage builds. \
|
||||
always confirm destructive actions.",
|
||||
),
|
||||
(
|
||||
"sol-identity",
|
||||
"User accounts, sessions, and OAuth2",
|
||||
"you handle identity management. you can create and manage user accounts via kratos, \
|
||||
manage OAuth2 clients via hydra, and handle recovery flows. \
|
||||
be careful with credentials — never expose secrets.",
|
||||
),
|
||||
(
|
||||
"sol-collaboration",
|
||||
"Contacts, documents, meetings, files, email, calendars",
|
||||
"you handle collaboration services from la suite numérique. \
|
||||
you can manage contacts (people), documents (docs), meetings (meet), \
|
||||
files (drive), email, and calendars. help users find and organize their work.",
|
||||
),
|
||||
(
|
||||
"sol-communication",
|
||||
"Chat rooms, messages, and members",
|
||||
"you handle matrix communication. you can manage rooms, look up members, \
|
||||
search message history, and help with room administration.",
|
||||
),
|
||||
(
|
||||
"sol-media",
|
||||
"Video/audio rooms, recordings, and streams",
|
||||
"you handle media services via livekit. you can manage video/audio rooms, \
|
||||
start/stop recordings, and check stream status.",
|
||||
),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_orchestrator_instructions_includes_prompt() {
|
||||
let prompt = "you are sol.";
|
||||
let instructions = orchestrator_instructions(prompt);
|
||||
assert!(instructions.starts_with("you are sol."));
|
||||
assert!(instructions.contains("observability"));
|
||||
assert!(instructions.contains("delegation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_orchestrator_request() {
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_agent_request() {
|
||||
let req = domain_agent_request(
|
||||
"sol-test",
|
||||
"Test agent",
|
||||
"You test things.",
|
||||
vec![AgentTool::web_search()],
|
||||
"mistral-medium-latest",
|
||||
);
|
||||
assert_eq!(req.name, "sol-test");
|
||||
assert_eq!(req.tools.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_agents_defined() {
|
||||
assert_eq!(DOMAIN_AGENTS.len(), 8);
|
||||
assert_eq!(DOMAIN_AGENTS[0].0, "sol-observability");
|
||||
}
|
||||
}
|
||||
2
src/agents/mod.rs
Normal file
2
src/agents/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod definitions;
|
||||
pub mod registry;
|
||||
175
src/agents/registry.rs
Normal file
175
src/agents/registry.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use std::collections::HashMap;
|
||||
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 super::definitions;
|
||||
use crate::persistence::Store;
|
||||
|
||||
/// Manages the lifecycle of Mistral agents — creates on startup, caches IDs,
|
||||
/// handles instruction updates by re-creating agents.
|
||||
/// Agent ID mappings are persisted to SQLite so they survive reboots.
|
||||
pub struct AgentRegistry {
|
||||
/// agent_name → Agent
|
||||
agents: Mutex<HashMap<String, Agent>>,
|
||||
/// SQLite persistence.
|
||||
store: Arc<Store>,
|
||||
}
|
||||
|
||||
impl AgentRegistry {
|
||||
pub fn new(store: Arc<Store>) -> Self {
|
||||
Self {
|
||||
agents: Mutex::new(HashMap::new()),
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the orchestrator agent exists. Creates or verifies it.
|
||||
/// Returns the agent ID.
|
||||
pub async fn ensure_orchestrator(
|
||||
&self,
|
||||
system_prompt: &str,
|
||||
model: &str,
|
||||
tools: Vec<mistralai_client::v1::agents::AgentTool>,
|
||||
mistral: &MistralClient,
|
||||
) -> Result<String, String> {
|
||||
let mut agents = self.agents.lock().await;
|
||||
|
||||
// Check in-memory cache
|
||||
if let Some(agent) = agents.get(definitions::ORCHESTRATOR_NAME) {
|
||||
return Ok(agent.id.clone());
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Persisted orchestrator agent {agent_id} no longer exists on server");
|
||||
self.store.delete_agent(definitions::ORCHESTRATOR_NAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it exists on the server by name
|
||||
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);
|
||||
}
|
||||
|
||||
// Create new
|
||||
let req = definitions::orchestrator_request(system_prompt, model, tools);
|
||||
let agent = mistral
|
||||
.create_agent_async(&req)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create orchestrator agent: {}", e.message))?;
|
||||
|
||||
let id = agent.id.clone();
|
||||
info!(agent_id = id.as_str(), "Created orchestrator agent");
|
||||
self.store.upsert_agent(definitions::ORCHESTRATOR_NAME, &id, model);
|
||||
agents.insert(definitions::ORCHESTRATOR_NAME.to_string(), agent);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Ensure a domain agent exists. Returns the agent ID.
|
||||
pub async fn ensure_domain_agent(
|
||||
&self,
|
||||
name: &str,
|
||||
request: &CreateAgentRequest,
|
||||
mistral: &MistralClient,
|
||||
) -> Result<String, String> {
|
||||
let mut agents = self.agents.lock().await;
|
||||
|
||||
if let Some(agent) = agents.get(name) {
|
||||
return Ok(agent.id.clone());
|
||||
}
|
||||
|
||||
// Check SQLite
|
||||
if let Some(agent_id) = 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");
|
||||
agents.insert(name.to_string(), agent);
|
||||
return Ok(agent_id);
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(name, "Persisted agent {agent_id} gone from server");
|
||||
self.store.delete_agent(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let existing = self.find_by_name(name, mistral).await;
|
||||
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);
|
||||
agents.insert(name.to_string(), agent);
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
let agent = mistral
|
||||
.create_agent_async(request)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create agent {name}: {}", e.message))?;
|
||||
|
||||
let id = agent.id.clone();
|
||||
info!(name, agent_id = id.as_str(), "Created domain agent");
|
||||
self.store.upsert_agent(name, &id, &request.model);
|
||||
agents.insert(name.to_string(), agent);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Get the agent ID for a given name.
|
||||
pub async fn get_id(&self, name: &str) -> Option<String> {
|
||||
self.agents
|
||||
.lock()
|
||||
.await
|
||||
.get(name)
|
||||
.map(|a| a.id.clone())
|
||||
}
|
||||
|
||||
/// List all registered agent names and IDs.
|
||||
pub async fn list(&self) -> Vec<(String, String)> {
|
||||
self.agents
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(name, agent)| (name.clone(), agent.id.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find an agent by name on the Mistral server.
|
||||
async fn find_by_name(&self, name: &str, mistral: &MistralClient) -> Option<Agent> {
|
||||
match mistral.list_agents_async().await {
|
||||
Ok(list) => list.data.into_iter().find(|a| a.name == name),
|
||||
Err(e) => {
|
||||
warn!("Failed to list agents: {}", e.message);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_registry_creation() {
|
||||
let store = Arc::new(Store::open_memory().unwrap());
|
||||
let _reg = AgentRegistry::new(store);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user