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:
2026-03-21 22:21:14 +00:00
parent 5e2186f324
commit 7580c10dda
20 changed files with 1723 additions and 655 deletions

173
src/agents/definitions.rs Normal file
View 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
View File

@@ -0,0 +1,2 @@
pub mod definitions;
pub mod registry;

175
src/agents/registry.rs Normal file
View 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);
}
}