multi-agent research: parallel LLM-powered investigation

new research tool spawns 3-25 micro-agents (ministral-3b) in
parallel via futures::join_all. each agent gets its own Mistral
conversation with full tool access.

recursive spawning up to depth 4 — agents can spawn sub-agents.
research sessions persisted in SQLite (survive reboots).
thread UX: 🔍 reaction, per-agent progress posts,  when done.

cost: ~$0.03 per research task (20 micro-agents on ministral-3b).
This commit is contained in:
2026-03-23 01:42:40 +00:00
parent 7dbc8a3121
commit de33ddfe33
4 changed files with 588 additions and 0 deletions

View File

@@ -83,6 +83,18 @@ impl Store {
PRIMARY KEY (localpart, service)
);
CREATE TABLE IF NOT EXISTS research_sessions (
session_id TEXT PRIMARY KEY,
room_id TEXT NOT NULL,
event_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
query TEXT NOT NULL,
plan_json TEXT,
findings_json TEXT,
depth INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
);
",
)?;
@@ -272,6 +284,91 @@ impl Store {
}
}
// =========================================================================
// Research Sessions
// =========================================================================
/// Create a new research session.
pub fn create_research_session(
&self,
session_id: &str,
room_id: &str,
event_id: &str,
query: &str,
plan_json: &str,
) {
let conn = self.conn.lock().unwrap();
if let Err(e) = conn.execute(
"INSERT INTO research_sessions (session_id, room_id, event_id, query, plan_json, findings_json)
VALUES (?1, ?2, ?3, ?4, ?5, '[]')",
params![session_id, room_id, event_id, query, plan_json],
) {
warn!("Failed to create research session: {e}");
}
}
/// Append a finding to a research session.
pub fn append_research_finding(&self, session_id: &str, finding_json: &str) {
let conn = self.conn.lock().unwrap();
// Append to the JSON array
if let Err(e) = conn.execute(
"UPDATE research_sessions
SET findings_json = json_insert(findings_json, '$[#]', json(?1))
WHERE session_id = ?2",
params![finding_json, session_id],
) {
warn!("Failed to append research finding: {e}");
}
}
/// Mark a research session as complete.
pub fn complete_research_session(&self, session_id: &str) {
let conn = self.conn.lock().unwrap();
if let Err(e) = conn.execute(
"UPDATE research_sessions SET status = 'complete', completed_at = datetime('now')
WHERE session_id = ?1",
params![session_id],
) {
warn!("Failed to complete research session: {e}");
}
}
/// Mark a research session as failed.
pub fn fail_research_session(&self, session_id: &str) {
let conn = self.conn.lock().unwrap();
if let Err(e) = conn.execute(
"UPDATE research_sessions SET status = 'failed', completed_at = datetime('now')
WHERE session_id = ?1",
params![session_id],
) {
warn!("Failed to mark research session failed: {e}");
}
}
/// Load all running research sessions (for crash recovery on startup).
pub fn load_running_research_sessions(&self) -> Vec<(String, String, String, String)> {
let conn = self.conn.lock().unwrap();
let mut stmt = match conn.prepare(
"SELECT session_id, room_id, query, findings_json
FROM research_sessions WHERE status = 'running'",
) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.ok()
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
/// Load all agent mappings (for startup recovery).
pub fn load_all_agents(&self) -> Vec<(String, String)> {
let conn = self.conn.lock().unwrap();