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:
82
src/sync.rs
82
src/sync.rs
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user