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

View File

@@ -14,6 +14,7 @@ use tracing::{debug, error, info, warn};
use opensearch::OpenSearch;
use crate::agents::registry::AgentRegistry;
use crate::archive::indexer::Indexer;
use crate::archive::schema::ArchiveDocument;
use crate::brain::conversation::{ContextMessage, ConversationManager};
@@ -21,6 +22,7 @@ use crate::brain::evaluator::{Engagement, Evaluator};
use crate::brain::responder::Responder;
use crate::config::Config;
use crate::context::{self, ResponseContext};
use crate::conversations::ConversationRegistry;
use crate::matrix_utils;
use crate::memory;
@@ -32,6 +34,10 @@ pub struct AppState {
pub conversations: Arc<Mutex<ConversationManager>>,
pub mistral: Arc<mistralai_client::v1::client::Client>,
pub opensearch: OpenSearch,
/// Agent registry — manages Mistral agent lifecycle.
pub agent_registry: Arc<AgentRegistry>,
/// Conversation registry for Mistral Conversations API.
pub conversation_registry: Arc<ConversationRegistry>,
/// Tracks when Sol last responded in each room (for cooldown)
pub last_response: Arc<Mutex<HashMap<String, Instant>>>,
/// Tracks rooms where a response is currently being generated (in-flight guard)
@@ -104,10 +110,31 @@ async fn handle_message(
return Ok(());
}
let Some(body) = matrix_utils::extract_body(&event) else {
return Ok(());
// Extract text body — or image caption for m.image events
let image_data_uri = matrix_utils::download_image_as_data_uri(
&room.client(),
&event,
)
.await;
let body = if let Some(ref _uri) = image_data_uri {
// For images, use the caption/filename as the text body
matrix_utils::extract_image(&event)
.map(|(_, _, caption)| caption)
.or_else(|| matrix_utils::extract_body(&event))
.unwrap_or_default()
} else {
match matrix_utils::extract_body(&event) {
Some(b) => b,
None => return Ok(()),
}
};
// Skip if we have neither text nor image
if body.is_empty() && image_data_uri.is_none() {
return Ok(());
}
let room_name = matrix_utils::room_display_name(&room);
let sender_name = room
.get_member_no_sync(&event.sender)
@@ -131,7 +158,9 @@ async fn handle_message(
content: body.clone(),
reply_to,
thread_id,
media_urls: Vec::new(),
media_urls: matrix_utils::extract_image(&event)
.map(|(url, _, _)| vec![url])
.unwrap_or_default(),
event_type: "m.room.message".into(),
edited: false,
redacted: false,
@@ -242,20 +271,39 @@ async fn handle_message(
let members = matrix_utils::room_member_names(&room).await;
let display_sender = sender_name.as_deref().unwrap_or(&sender);
let response = state
.responder
.generate_response(
&context,
&body,
display_sender,
&room_name,
&members,
is_spontaneous,
&state.mistral,
&room,
&response_ctx,
)
.await;
let response = if state.config.agents.use_conversations_api {
state
.responder
.generate_response_conversations(
&body,
display_sender,
&room_id,
is_dm,
is_spontaneous,
&state.mistral,
&room,
&response_ctx,
&state.conversation_registry,
image_data_uri.as_deref(),
)
.await
} else {
state
.responder
.generate_response(
&context,
&body,
display_sender,
&room_name,
&members,
is_spontaneous,
&state.mistral,
&room,
&response_ctx,
image_data_uri.as_deref(),
)
.await
};
if let Some(text) = response {
// Reply with reference only when directly addressed. Spontaneous