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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user