refactor(orchestrator): transport-agnostic event types

Replace transport-coupled types with clean abstractions:
- GenerateRequest: single entry point, no room_id/session_id
- Metadata: opaque key-value bag for transport routing data
- ToolContext: replaces ResponseContext in tool execution
- TokenUsage: clean token count struct
- Simplified OrchestratorEvent: remove AgentProgress*, MemoryExtraction*

Removed: ResponseMode, ChatRequest, CodeRequest.
This commit is contained in:
2026-03-23 19:21:22 +00:00
parent 9e5f7e61be
commit bde770956c

View File

@@ -1,13 +1,16 @@
//! Orchestrator events — the contract between Sol's response pipeline
//! and its presentation layers (Matrix, gRPC, future web/API).
//! Orchestrator event types — the public contract between Sol's response
//! pipeline and any presentation layer.
//!
//! Every state transition in the response lifecycle is an event.
//! Transport bridges subscribe to these and translate to their protocol.
//! These types are transport-agnostic. The orchestrator has zero knowledge
//! of Matrix, gRPC, or any specific UI. Transport-specific data flows
//! through as opaque `Metadata`.
use std::collections::HashMap;
use std::fmt;
/// Unique identifier for a response generation request.
/// One per user message → response cycle (including all tool iterations).
// ── Request ID ──────────────────────────────────────────────────────────
/// Unique identifier for a response generation cycle (including all tool iterations).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestId(pub String);
@@ -23,23 +26,43 @@ impl fmt::Display for RequestId {
}
}
/// 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,
},
// ── Metadata ────────────────────────────────────────────────────────────
/// Opaque key-value bag that flows from request to events untouched.
/// The orchestrator never inspects this. Transport bridges use it to
/// carry routing data (room_id, session_id, event_id, etc.).
#[derive(Debug, Clone, Default)]
pub struct Metadata(pub HashMap<String, String>);
impl Metadata {
pub fn new() -> Self {
Self(HashMap::new())
}
pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.0.insert(key.into(), value.into());
self
}
pub fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).map(|s| s.as_str())
}
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.0.insert(key.into(), value.into());
}
}
// ── Token usage ─────────────────────────────────────────────────────────
#[derive(Debug, Clone, Default)]
pub struct TokenUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
}
// ── Tool types ──────────────────────────────────────────────────────────
/// Whether a tool executes on the server or on a connected client.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolSide {
@@ -47,40 +70,66 @@ pub enum ToolSide {
Client,
}
/// Result payload from a client-side tool execution.
#[derive(Debug)]
pub struct ToolResultPayload {
pub text: String,
pub is_error: bool,
}
/// Minimal context the tool system needs. No transport types.
#[derive(Debug, Clone)]
pub struct ToolContext {
/// User identifier (portable, e.g. "sienna" or "sienna@sunbeam.pt").
pub user_id: String,
/// Scope key for access control (e.g. room_id for scoped search).
pub scope_key: String,
/// Whether this is a direct/private conversation.
pub is_direct: bool,
}
// ── Generate request ────────────────────────────────────────────────────
/// Request to generate a response. The single entry point for the orchestrator.
/// Transport-agnostic — callers put routing data in `metadata`.
#[derive(Debug, Clone)]
pub struct GenerateRequest {
/// Unique ID for this request cycle.
pub request_id: RequestId,
/// The user's message text.
pub text: String,
/// User identifier (portable).
pub user_id: String,
/// Display name for the user (optional).
pub display_name: Option<String>,
/// Conversation scope key. The orchestrator uses this to look up
/// or create a Mistral conversation.
pub conversation_key: String,
/// Whether this is a direct/private conversation.
pub is_direct: bool,
/// Optional image data URI.
pub image: Option<String>,
/// Opaque metadata — flows through to `Started` events unchanged.
pub metadata: Metadata,
}
// ── Events ──────────────────────────────────────────────────────────────
/// 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 {
/// Generation has begun. Carries metadata for bridge routing.
Started {
request_id: RequestId,
mode: ResponseMode,
metadata: Metadata,
},
/// The model is generating (typing indicator equivalent).
/// The model is generating.
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,
@@ -91,46 +140,32 @@ pub enum OrchestratorEvent {
},
/// A tool started executing.
ToolExecutionStarted {
ToolStarted {
request_id: RequestId,
call_id: String,
name: String,
},
/// A tool finished executing.
ToolExecutionCompleted {
ToolCompleted {
request_id: RequestId,
call_id: String,
name: String,
result: String,
result_preview: String,
success: bool,
},
// ── Progress (presentation hints) ──────────────────────────
/// Agentic progress started (first tool call in a response).
AgentProgressStarted {
/// Final response ready.
Done {
request_id: RequestId,
text: String,
usage: TokenUsage,
},
/// A progress step (tool call summary for display).
AgentProgressStep {
/// Generation failed.
Failed {
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,
error: String,
},
}
@@ -138,49 +173,18 @@ impl OrchestratorEvent {
/// Get the request ID for any event variant.
pub fn request_id(&self) -> &RequestId {
match self {
Self::ResponseStarted { request_id, .. }
Self::Started { 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,
| Self::ToolStarted { request_id, .. }
| Self::ToolCompleted { request_id, .. }
| Self::Done { request_id, .. }
| Self::Failed { 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,
}
// ── Tests ───────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
@@ -194,29 +198,50 @@ mod tests {
}
#[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);
fn test_metadata_roundtrip() {
let meta = Metadata::new()
.with("room_id", "!abc:test")
.with("session_id", "sess-1");
assert_eq!(meta.get("room_id"), Some("!abc:test"));
assert_eq!(meta.get("session_id"), Some("sess-1"));
assert_eq!(meta.get("missing"), None);
}
#[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 { .. }));
fn test_event_request_id_accessor() {
let id = RequestId::new();
let events = vec![
OrchestratorEvent::Started { request_id: id.clone(), metadata: Metadata::new() },
OrchestratorEvent::Thinking { request_id: id.clone() },
OrchestratorEvent::Done {
request_id: id.clone(),
text: "hi".into(),
usage: TokenUsage::default(),
},
OrchestratorEvent::Failed { 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::ToolStarted {
request_id: id.clone(),
call_id: "c1".into(),
name: "bash".into(),
},
OrchestratorEvent::ToolCompleted {
request_id: id.clone(),
call_id: "c1".into(),
name: "bash".into(),
result_preview: "ok".into(),
success: true,
},
];
for event in &events {
assert_eq!(event.request_id(), &id);
}
}
#[test]
@@ -225,64 +250,13 @@ mod tests {
}
#[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);
}
fn test_tool_context() {
let ctx = ToolContext {
user_id: "sienna".into(),
scope_key: "!room:test".into(),
is_direct: true,
};
assert_eq!(ctx.user_id, "sienna");
assert!(ctx.is_direct);
}
}