feat(orchestrator): Phase 1 — event types + broadcast channel foundation
Introduces the orchestrator module with: - OrchestratorEvent enum: 11 event variants covering lifecycle, tools, progress, and side effects - RequestId (UUID per generation), ResponseMode (Chat/Code), ToolSide - ChatRequest/CodeRequest structs for transport-agnostic request input - Orchestrator struct with tokio::broadcast channel (capacity 256) - subscribe() for transport bridges, emit() for the engine - Client-side tool dispatch: pending_client_tools map with oneshot channels - submit_tool_result() to unblock engine from gRPC client responses Additive only — no behavior change. Existing responder + gRPC session paths are untouched. Phase 2 will migrate the Conversations API path.
This commit is contained in:
@@ -171,7 +171,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_orchestrator_request() {
|
fn test_orchestrator_request() {
|
||||||
let req = orchestrator_request("test prompt", "mistral-medium-latest", vec![], &[]);
|
let req = orchestrator_request("test prompt", "mistral-medium-latest", vec![], &[], "sol-orchestrator");
|
||||||
assert_eq!(req.name, "sol-orchestrator");
|
assert_eq!(req.name, "sol-orchestrator");
|
||||||
assert_eq!(req.model, "mistral-medium-latest");
|
assert_eq!(req.model, "mistral-medium-latest");
|
||||||
assert!(req.instructions.unwrap().contains("test prompt"));
|
assert!(req.instructions.unwrap().contains("test prompt"));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ mod matrix_utils;
|
|||||||
mod memory;
|
mod memory;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
mod grpc;
|
mod grpc;
|
||||||
|
mod orchestrator;
|
||||||
mod sdk;
|
mod sdk;
|
||||||
mod sync;
|
mod sync;
|
||||||
mod time_context;
|
mod time_context;
|
||||||
|
|||||||
288
src/orchestrator/event.rs
Normal file
288
src/orchestrator/event.rs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
//! Orchestrator events — the contract between Sol's response pipeline
|
||||||
|
//! and its presentation layers (Matrix, gRPC, future web/API).
|
||||||
|
//!
|
||||||
|
//! Every state transition in the response lifecycle is an event.
|
||||||
|
//! Transport bridges subscribe to these and translate to their protocol.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Unique identifier for a response generation request.
|
||||||
|
/// One per user message → response cycle (including all tool iterations).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct RequestId(pub String);
|
||||||
|
|
||||||
|
impl RequestId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(uuid::Uuid::new_v4().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RequestId {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which transport initiated this request.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ResponseMode {
|
||||||
|
/// Standard Matrix chat (DM, room, thread).
|
||||||
|
Chat {
|
||||||
|
room_id: String,
|
||||||
|
is_spontaneous: bool,
|
||||||
|
use_thread: bool,
|
||||||
|
trigger_event_id: String,
|
||||||
|
},
|
||||||
|
/// Coding session via gRPC (`sunbeam code`).
|
||||||
|
Code {
|
||||||
|
session_id: String,
|
||||||
|
room_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a tool executes on the server or on a connected client.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ToolSide {
|
||||||
|
Server,
|
||||||
|
Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An event emitted by the orchestrator during response generation.
|
||||||
|
/// Transport bridges subscribe to these and translate to their protocol.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum OrchestratorEvent {
|
||||||
|
// ── Lifecycle ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Response generation has begun.
|
||||||
|
ResponseStarted {
|
||||||
|
request_id: RequestId,
|
||||||
|
mode: ResponseMode,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// The model is generating (typing indicator equivalent).
|
||||||
|
Thinking {
|
||||||
|
request_id: RequestId,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Final response text is ready.
|
||||||
|
ResponseReady {
|
||||||
|
request_id: RequestId,
|
||||||
|
text: String,
|
||||||
|
prompt_tokens: u32,
|
||||||
|
completion_tokens: u32,
|
||||||
|
tool_iterations: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Response generation failed.
|
||||||
|
ResponseFailed {
|
||||||
|
request_id: RequestId,
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Tool execution ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A tool call was detected in the model's output.
|
||||||
|
ToolCallDetected {
|
||||||
|
request_id: RequestId,
|
||||||
|
call_id: String,
|
||||||
|
name: String,
|
||||||
|
args: String,
|
||||||
|
side: ToolSide,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A tool started executing.
|
||||||
|
ToolExecutionStarted {
|
||||||
|
request_id: RequestId,
|
||||||
|
call_id: String,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A tool finished executing.
|
||||||
|
ToolExecutionCompleted {
|
||||||
|
request_id: RequestId,
|
||||||
|
call_id: String,
|
||||||
|
name: String,
|
||||||
|
result: String,
|
||||||
|
success: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Progress (presentation hints) ──────────────────────────
|
||||||
|
|
||||||
|
/// Agentic progress started (first tool call in a response).
|
||||||
|
AgentProgressStarted {
|
||||||
|
request_id: RequestId,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A progress step (tool call summary for display).
|
||||||
|
AgentProgressStep {
|
||||||
|
request_id: RequestId,
|
||||||
|
summary: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Agentic progress complete (all tool iterations done).
|
||||||
|
AgentProgressDone {
|
||||||
|
request_id: RequestId,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Side effects ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Memory extraction should run for this exchange.
|
||||||
|
MemoryExtractionScheduled {
|
||||||
|
request_id: RequestId,
|
||||||
|
user_msg: String,
|
||||||
|
response: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrchestratorEvent {
|
||||||
|
/// Get the request ID for any event variant.
|
||||||
|
pub fn request_id(&self) -> &RequestId {
|
||||||
|
match self {
|
||||||
|
Self::ResponseStarted { request_id, .. }
|
||||||
|
| Self::Thinking { request_id }
|
||||||
|
| Self::ResponseReady { request_id, .. }
|
||||||
|
| Self::ResponseFailed { request_id, .. }
|
||||||
|
| Self::ToolCallDetected { request_id, .. }
|
||||||
|
| Self::ToolExecutionStarted { request_id, .. }
|
||||||
|
| Self::ToolExecutionCompleted { request_id, .. }
|
||||||
|
| Self::AgentProgressStarted { request_id }
|
||||||
|
| Self::AgentProgressStep { request_id, .. }
|
||||||
|
| Self::AgentProgressDone { request_id }
|
||||||
|
| Self::MemoryExtractionScheduled { request_id, .. } => request_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to generate a chat response (Matrix path).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
pub request_id: RequestId,
|
||||||
|
pub trigger_body: String,
|
||||||
|
pub trigger_sender: String,
|
||||||
|
pub room_id: String,
|
||||||
|
pub room_name: String,
|
||||||
|
pub is_dm: bool,
|
||||||
|
pub is_spontaneous: bool,
|
||||||
|
pub use_thread: bool,
|
||||||
|
pub trigger_event_id: String,
|
||||||
|
pub image_data_uri: Option<String>,
|
||||||
|
pub context_hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to generate a coding response (gRPC path).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CodeRequest {
|
||||||
|
pub request_id: RequestId,
|
||||||
|
pub session_id: String,
|
||||||
|
pub text: String,
|
||||||
|
pub project_name: String,
|
||||||
|
pub project_path: String,
|
||||||
|
pub prompt_md: String,
|
||||||
|
pub model: String,
|
||||||
|
pub room_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_request_id_unique() {
|
||||||
|
let a = RequestId::new();
|
||||||
|
let b = RequestId::new();
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_event_request_id_accessor() {
|
||||||
|
let id = RequestId::new();
|
||||||
|
let event = OrchestratorEvent::Thinking {
|
||||||
|
request_id: id.clone(),
|
||||||
|
};
|
||||||
|
assert_eq!(event.request_id(), &id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_response_mode_variants() {
|
||||||
|
let chat = ResponseMode::Chat {
|
||||||
|
room_id: "!room:test".into(),
|
||||||
|
is_spontaneous: false,
|
||||||
|
use_thread: true,
|
||||||
|
trigger_event_id: "$event".into(),
|
||||||
|
};
|
||||||
|
assert!(matches!(chat, ResponseMode::Chat { .. }));
|
||||||
|
|
||||||
|
let code = ResponseMode::Code {
|
||||||
|
session_id: "sess-1".into(),
|
||||||
|
room_id: "!room:test".into(),
|
||||||
|
};
|
||||||
|
assert!(matches!(code, ResponseMode::Code { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tool_side() {
|
||||||
|
assert_ne!(ToolSide::Server, ToolSide::Client);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_event_variants_have_request_id() {
|
||||||
|
let id = RequestId::new();
|
||||||
|
let events = vec![
|
||||||
|
OrchestratorEvent::ResponseStarted {
|
||||||
|
request_id: id.clone(),
|
||||||
|
mode: ResponseMode::Chat {
|
||||||
|
room_id: "!r:t".into(),
|
||||||
|
is_spontaneous: false,
|
||||||
|
use_thread: false,
|
||||||
|
trigger_event_id: "$e".into(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OrchestratorEvent::Thinking { request_id: id.clone() },
|
||||||
|
OrchestratorEvent::ResponseReady {
|
||||||
|
request_id: id.clone(),
|
||||||
|
text: "hi".into(),
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
tool_iterations: 0,
|
||||||
|
},
|
||||||
|
OrchestratorEvent::ResponseFailed {
|
||||||
|
request_id: id.clone(),
|
||||||
|
error: "err".into(),
|
||||||
|
},
|
||||||
|
OrchestratorEvent::ToolCallDetected {
|
||||||
|
request_id: id.clone(),
|
||||||
|
call_id: "c1".into(),
|
||||||
|
name: "bash".into(),
|
||||||
|
args: "{}".into(),
|
||||||
|
side: ToolSide::Server,
|
||||||
|
},
|
||||||
|
OrchestratorEvent::ToolExecutionStarted {
|
||||||
|
request_id: id.clone(),
|
||||||
|
call_id: "c1".into(),
|
||||||
|
name: "bash".into(),
|
||||||
|
},
|
||||||
|
OrchestratorEvent::ToolExecutionCompleted {
|
||||||
|
request_id: id.clone(),
|
||||||
|
call_id: "c1".into(),
|
||||||
|
name: "bash".into(),
|
||||||
|
result: "ok".into(),
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
OrchestratorEvent::AgentProgressStarted { request_id: id.clone() },
|
||||||
|
OrchestratorEvent::AgentProgressStep {
|
||||||
|
request_id: id.clone(),
|
||||||
|
summary: "step".into(),
|
||||||
|
},
|
||||||
|
OrchestratorEvent::AgentProgressDone { request_id: id.clone() },
|
||||||
|
OrchestratorEvent::MemoryExtractionScheduled {
|
||||||
|
request_id: id.clone(),
|
||||||
|
user_msg: "q".into(),
|
||||||
|
response: "a".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for event in &events {
|
||||||
|
assert_eq!(event.request_id(), &id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/orchestrator/mod.rs
Normal file
187
src/orchestrator/mod.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
//! Event-driven orchestrator — the single response generation pipeline.
|
||||||
|
//!
|
||||||
|
//! The orchestrator owns the Mistral tool loop and emits events through
|
||||||
|
//! a `tokio::broadcast` channel. Transport bridges (Matrix, gRPC, etc.)
|
||||||
|
//! subscribe to these events and translate them to their protocol.
|
||||||
|
//!
|
||||||
|
//! Phase 1: types + channel wiring only. No behavior change.
|
||||||
|
|
||||||
|
pub mod event;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::sync::{broadcast, oneshot, Mutex};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub use event::*;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::conversations::ConversationRegistry;
|
||||||
|
use crate::persistence::Store;
|
||||||
|
use crate::tools::ToolRegistry;
|
||||||
|
|
||||||
|
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||||
|
|
||||||
|
/// Result payload from a client-side tool execution.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ToolResultPayload {
|
||||||
|
pub text: String,
|
||||||
|
pub is_error: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The orchestrator — Sol's response generation pipeline.
|
||||||
|
///
|
||||||
|
/// Owns the event broadcast channel. Bridges subscribe via `subscribe()`.
|
||||||
|
/// Phase 2+ will add `generate_chat_response()` and `generate_code_response()`.
|
||||||
|
pub struct Orchestrator {
|
||||||
|
pub config: Arc<Config>,
|
||||||
|
pub tools: Arc<ToolRegistry>,
|
||||||
|
pub store: Arc<Store>,
|
||||||
|
pub mistral: Arc<mistralai_client::v1::client::Client>,
|
||||||
|
pub conversation_registry: Arc<ConversationRegistry>,
|
||||||
|
pub system_prompt: String,
|
||||||
|
|
||||||
|
/// Broadcast sender — all orchestration events go here.
|
||||||
|
event_tx: broadcast::Sender<OrchestratorEvent>,
|
||||||
|
|
||||||
|
/// Pending client-side tool calls awaiting results from gRPC clients.
|
||||||
|
/// Key: call_id, Value: oneshot sender to unblock the engine.
|
||||||
|
pending_client_tools: Arc<Mutex<HashMap<String, oneshot::Sender<ToolResultPayload>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Orchestrator {
|
||||||
|
/// Create a new orchestrator. Returns the orchestrator and an initial
|
||||||
|
/// event receiver (for the first subscriber, typically the Matrix bridge).
|
||||||
|
pub fn new(
|
||||||
|
config: Arc<Config>,
|
||||||
|
tools: Arc<ToolRegistry>,
|
||||||
|
store: Arc<Store>,
|
||||||
|
mistral: Arc<mistralai_client::v1::client::Client>,
|
||||||
|
conversation_registry: Arc<ConversationRegistry>,
|
||||||
|
system_prompt: String,
|
||||||
|
) -> Self {
|
||||||
|
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||||
|
|
||||||
|
info!("Orchestrator initialized (event channel capacity: {EVENT_CHANNEL_CAPACITY})");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
tools,
|
||||||
|
store,
|
||||||
|
mistral,
|
||||||
|
conversation_registry,
|
||||||
|
system_prompt,
|
||||||
|
event_tx,
|
||||||
|
pending_client_tools: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to the event stream. Each subscriber gets its own receiver
|
||||||
|
/// that independently tracks position in the broadcast buffer.
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<OrchestratorEvent> {
|
||||||
|
self.event_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit an event to all subscribers.
|
||||||
|
pub fn emit(&self, event: OrchestratorEvent) {
|
||||||
|
// Ignore send errors (no subscribers is fine during startup).
|
||||||
|
let _ = self.event_tx.send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit a tool result from an external source (e.g., gRPC client).
|
||||||
|
/// Unblocks the engine's tool loop for the matching call_id.
|
||||||
|
pub async fn submit_tool_result(
|
||||||
|
&self,
|
||||||
|
call_id: &str,
|
||||||
|
result: ToolResultPayload,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let sender = self
|
||||||
|
.pending_client_tools
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(call_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No pending tool call with id {call_id}"))?;
|
||||||
|
|
||||||
|
sender
|
||||||
|
.send(result)
|
||||||
|
.map_err(|_| anyhow::anyhow!("Tool result receiver dropped for {call_id}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a pending client-side tool call. Returns a oneshot receiver
|
||||||
|
/// that the engine awaits for the result.
|
||||||
|
pub async fn register_pending_tool(
|
||||||
|
&self,
|
||||||
|
call_id: &str,
|
||||||
|
) -> oneshot::Receiver<ToolResultPayload> {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
self.pending_client_tools
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(call_id.to_string(), tx);
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_subscribe_and_emit() {
|
||||||
|
let (event_tx, _) = broadcast::channel(16);
|
||||||
|
let mut rx = event_tx.subscribe();
|
||||||
|
|
||||||
|
let id = RequestId::new();
|
||||||
|
let _ = event_tx.send(OrchestratorEvent::Thinking {
|
||||||
|
request_id: id.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let received = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(received.request_id(), &id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multiple_subscribers() {
|
||||||
|
let (event_tx, _) = broadcast::channel(16);
|
||||||
|
let mut rx1 = event_tx.subscribe();
|
||||||
|
let mut rx2 = event_tx.subscribe();
|
||||||
|
|
||||||
|
let id = RequestId::new();
|
||||||
|
let _ = event_tx.send(OrchestratorEvent::Thinking {
|
||||||
|
request_id: id.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let r1 = rx1.recv().await.unwrap();
|
||||||
|
let r2 = rx2.recv().await.unwrap();
|
||||||
|
assert_eq!(r1.request_id(), &id);
|
||||||
|
assert_eq!(r2.request_id(), &id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_submit_tool_result() {
|
||||||
|
let pending: Arc<Mutex<HashMap<String, oneshot::Sender<ToolResultPayload>>>> =
|
||||||
|
Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
pending
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert("call-1".into(), tx);
|
||||||
|
|
||||||
|
// Simulate submitting result
|
||||||
|
let sender = pending.lock().await.remove("call-1").unwrap();
|
||||||
|
sender
|
||||||
|
.send(ToolResultPayload {
|
||||||
|
text: "file contents".into(),
|
||||||
|
is_error: false,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = rx.await.unwrap();
|
||||||
|
assert_eq!(result.text, "file contents");
|
||||||
|
assert!(!result.is_error);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user