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 //! Orchestrator event types — the public contract between Sol's response
//! and its presentation layers (Matrix, gRPC, future web/API). //! pipeline and any presentation layer.
//! //!
//! Every state transition in the response lifecycle is an event. //! These types are transport-agnostic. The orchestrator has zero knowledge
//! Transport bridges subscribe to these and translate to their protocol. //! of Matrix, gRPC, or any specific UI. Transport-specific data flows
//! through as opaque `Metadata`.
use std::collections::HashMap;
use std::fmt; use std::fmt;
/// Unique identifier for a response generation request. // ── Request ID ──────────────────────────────────────────────────────────
/// One per user message → response cycle (including all tool iterations).
/// Unique identifier for a response generation cycle (including all tool iterations).
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestId(pub String); pub struct RequestId(pub String);
@@ -23,23 +26,43 @@ impl fmt::Display for RequestId {
} }
} }
/// Which transport initiated this request. // ── Metadata ────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum ResponseMode { /// Opaque key-value bag that flows from request to events untouched.
/// Standard Matrix chat (DM, room, thread). /// The orchestrator never inspects this. Transport bridges use it to
Chat { /// carry routing data (room_id, session_id, event_id, etc.).
room_id: String, #[derive(Debug, Clone, Default)]
is_spontaneous: bool, pub struct Metadata(pub HashMap<String, String>);
use_thread: bool,
trigger_event_id: String, impl Metadata {
}, pub fn new() -> Self {
/// Coding session via gRPC (`sunbeam code`). Self(HashMap::new())
Code { }
session_id: String,
room_id: String, 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. /// Whether a tool executes on the server or on a connected client.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolSide { pub enum ToolSide {
@@ -47,40 +70,66 @@ pub enum ToolSide {
Client, 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. /// An event emitted by the orchestrator during response generation.
/// Transport bridges subscribe to these and translate to their protocol. /// Transport bridges subscribe to these and translate to their protocol.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum OrchestratorEvent { pub enum OrchestratorEvent {
// ── Lifecycle ────────────────────────────────────────────── /// Generation has begun. Carries metadata for bridge routing.
Started {
/// Response generation has begun.
ResponseStarted {
request_id: RequestId, request_id: RequestId,
mode: ResponseMode, metadata: Metadata,
}, },
/// The model is generating (typing indicator equivalent). /// The model is generating.
Thinking { Thinking {
request_id: RequestId, 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. /// A tool call was detected in the model's output.
ToolCallDetected { ToolCallDetected {
request_id: RequestId, request_id: RequestId,
@@ -91,46 +140,32 @@ pub enum OrchestratorEvent {
}, },
/// A tool started executing. /// A tool started executing.
ToolExecutionStarted { ToolStarted {
request_id: RequestId, request_id: RequestId,
call_id: String, call_id: String,
name: String, name: String,
}, },
/// A tool finished executing. /// A tool finished executing.
ToolExecutionCompleted { ToolCompleted {
request_id: RequestId, request_id: RequestId,
call_id: String, call_id: String,
name: String, name: String,
result: String, result_preview: String,
success: bool, success: bool,
}, },
// ── Progress (presentation hints) ────────────────────────── /// Final response ready.
Done {
/// Agentic progress started (first tool call in a response).
AgentProgressStarted {
request_id: RequestId, request_id: RequestId,
text: String,
usage: TokenUsage,
}, },
/// A progress step (tool call summary for display). /// Generation failed.
AgentProgressStep { Failed {
request_id: RequestId, request_id: RequestId,
summary: String, error: 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,
}, },
} }
@@ -138,49 +173,18 @@ impl OrchestratorEvent {
/// Get the request ID for any event variant. /// Get the request ID for any event variant.
pub fn request_id(&self) -> &RequestId { pub fn request_id(&self) -> &RequestId {
match self { match self {
Self::ResponseStarted { request_id, .. } Self::Started { request_id, .. }
| Self::Thinking { request_id } | Self::Thinking { request_id }
| Self::ResponseReady { request_id, .. }
| Self::ResponseFailed { request_id, .. }
| Self::ToolCallDetected { request_id, .. } | Self::ToolCallDetected { request_id, .. }
| Self::ToolExecutionStarted { request_id, .. } | Self::ToolStarted { request_id, .. }
| Self::ToolExecutionCompleted { request_id, .. } | Self::ToolCompleted { request_id, .. }
| Self::AgentProgressStarted { request_id } | Self::Done { request_id, .. }
| Self::AgentProgressStep { request_id, .. } | Self::Failed { request_id, .. } => request_id,
| Self::AgentProgressDone { request_id }
| Self::MemoryExtractionScheduled { request_id, .. } => request_id,
} }
} }
} }
/// Request to generate a chat response (Matrix path). // ── Tests ───────────────────────────────────────────────────────────────
#[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)] #[cfg(test)]
mod tests { mod tests {
@@ -194,29 +198,50 @@ mod tests {
} }
#[test] #[test]
fn test_event_request_id_accessor() { fn test_metadata_roundtrip() {
let id = RequestId::new(); let meta = Metadata::new()
let event = OrchestratorEvent::Thinking { .with("room_id", "!abc:test")
request_id: id.clone(), .with("session_id", "sess-1");
}; assert_eq!(meta.get("room_id"), Some("!abc:test"));
assert_eq!(event.request_id(), &id); assert_eq!(meta.get("session_id"), Some("sess-1"));
assert_eq!(meta.get("missing"), None);
} }
#[test] #[test]
fn test_response_mode_variants() { fn test_event_request_id_accessor() {
let chat = ResponseMode::Chat { let id = RequestId::new();
room_id: "!room:test".into(), let events = vec![
is_spontaneous: false, OrchestratorEvent::Started { request_id: id.clone(), metadata: Metadata::new() },
use_thread: true, OrchestratorEvent::Thinking { request_id: id.clone() },
trigger_event_id: "$event".into(), OrchestratorEvent::Done {
}; request_id: id.clone(),
assert!(matches!(chat, ResponseMode::Chat { .. })); text: "hi".into(),
usage: TokenUsage::default(),
let code = ResponseMode::Code { },
session_id: "sess-1".into(), OrchestratorEvent::Failed { request_id: id.clone(), error: "err".into() },
room_id: "!room:test".into(), OrchestratorEvent::ToolCallDetected {
}; request_id: id.clone(),
assert!(matches!(code, ResponseMode::Code { .. })); 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] #[test]
@@ -225,64 +250,13 @@ mod tests {
} }
#[test] #[test]
fn test_all_event_variants_have_request_id() { fn test_tool_context() {
let id = RequestId::new(); let ctx = ToolContext {
let events = vec![ user_id: "sienna".into(),
OrchestratorEvent::ResponseStarted { scope_key: "!room:test".into(),
request_id: id.clone(), is_direct: true,
mode: ResponseMode::Chat { };
room_id: "!r:t".into(), assert_eq!(ctx.user_id, "sienna");
is_spontaneous: false, assert!(ctx.is_direct);
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);
}
} }
} }