diff --git a/crates/ironclaw_engine/src/lib.rs b/crates/ironclaw_engine/src/lib.rs index 9281574d..152cae72 100644 --- a/crates/ironclaw_engine/src/lib.rs +++ b/crates/ironclaw_engine/src/lib.rs @@ -53,10 +53,15 @@ pub use capability::policy::{PolicyDecision, PolicyEngine}; // ── Re-exports: runtime ─────────────────────────────────────── +pub use runtime::conversation::ConversationManager; pub use runtime::manager::ThreadManager; pub use runtime::messaging::ThreadOutcome; pub use runtime::tree::ThreadTree; +pub use types::conversation::{ + ConversationEntry, ConversationId, ConversationSurface, EntrySender, +}; + // ── Re-exports: executor ────────────────────────────────────── pub use executor::ExecutionLoop; diff --git a/crates/ironclaw_engine/src/runtime/conversation.rs b/crates/ironclaw_engine/src/runtime/conversation.rs new file mode 100644 index 00000000..0215e830 --- /dev/null +++ b/crates/ironclaw_engine/src/runtime/conversation.rs @@ -0,0 +1,393 @@ +//! Conversation manager — routes UI messages to threads. +//! +//! The ConversationManager is the bridge between channel I/O (user messages, +//! status updates) and the thread execution model. It maintains conversation +//! surfaces and decides whether to spawn new threads or inject messages into +//! existing ones. + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::RwLock; +use tracing::debug; + +use crate::runtime::manager::ThreadManager; +use crate::runtime::messaging::ThreadOutcome; +use crate::types::conversation::{ + ConversationEntry, ConversationId, ConversationSurface, +}; +use crate::types::error::EngineError; +use crate::types::message::ThreadMessage; +use crate::types::project::ProjectId; +use crate::types::thread::{ThreadConfig, ThreadId, ThreadType}; + +/// Manages conversation surfaces and routes messages to threads. +/// +/// Each channel message arrives here. The manager decides whether to: +/// 1. Spawn a new foreground thread for the message +/// 2. Inject the message into an existing active thread +/// 3. Create a new conversation if none exists for this channel+user +pub struct ConversationManager { + thread_manager: Arc, + conversations: RwLock>, + /// Maps (channel, user_id) → conversation ID for lookup. + channel_user_index: RwLock>, +} + +impl ConversationManager { + pub fn new(thread_manager: Arc) -> Self { + Self { + thread_manager, + conversations: RwLock::new(HashMap::new()), + channel_user_index: RwLock::new(HashMap::new()), + } + } + + /// Get or create a conversation for a channel+user pair. + pub async fn get_or_create_conversation( + &self, + channel: &str, + user_id: &str, + ) -> ConversationId { + // Check index first + let key = (channel.to_string(), user_id.to_string()); + { + let index = self.channel_user_index.read().await; + if let Some(conv_id) = index.get(&key) { + return *conv_id; + } + } + + // Create new conversation + let conv = ConversationSurface::new(channel, user_id); + let conv_id = conv.id; + + let mut convs = self.conversations.write().await; + let mut index = self.channel_user_index.write().await; + convs.insert(conv_id, conv); + index.insert(key, conv_id); + + debug!(conversation_id = %conv_id, channel, user_id, "created conversation"); + conv_id + } + + /// Handle an incoming user message. + /// + /// If the conversation has an active foreground thread, the message is + /// injected into it. Otherwise, a new foreground thread is spawned. + /// + /// Returns the thread ID that is handling the message. + pub async fn handle_user_message( + &self, + conversation_id: ConversationId, + content: &str, + project_id: ProjectId, + user_id: &str, + thread_config: ThreadConfig, + ) -> Result { + let mut convs = self.conversations.write().await; + let conv = convs + .get_mut(&conversation_id) + .ok_or(EngineError::Store { + reason: format!("conversation {conversation_id} not found"), + })?; + + // Record the user entry + conv.add_entry(ConversationEntry::user(content)); + + // Check for an active foreground thread + let active_foreground = self.find_active_foreground(conv).await; + + match active_foreground { + Some(thread_id) => { + // Inject into existing thread + debug!( + conversation_id = %conversation_id, + thread_id = %thread_id, + "injecting message into active thread" + ); + self.thread_manager + .inject_message(thread_id, ThreadMessage::user(content)) + .await?; + Ok(thread_id) + } + None => { + // Spawn new foreground thread + let thread_id = self + .thread_manager + .spawn_thread( + content, // use message as goal + ThreadType::Foreground, + project_id, + thread_config, + None, + user_id, + ) + .await?; + + conv.track_thread(thread_id); + conv.add_entry(ConversationEntry::system_for_thread( + thread_id, + "Thread started", + )); + + debug!( + conversation_id = %conversation_id, + thread_id = %thread_id, + "spawned new foreground thread" + ); + Ok(thread_id) + } + } + } + + /// Record a thread's outcome in its conversation. + pub async fn record_thread_outcome( + &self, + conversation_id: ConversationId, + thread_id: ThreadId, + outcome: &ThreadOutcome, + ) { + let mut convs = self.conversations.write().await; + if let Some(conv) = convs.get_mut(&conversation_id) { + match outcome { + ThreadOutcome::Completed { response } => { + if let Some(text) = response { + conv.add_entry(ConversationEntry::agent(thread_id, text)); + } + conv.untrack_thread(thread_id); + } + ThreadOutcome::Stopped => { + conv.add_entry(ConversationEntry::system_for_thread( + thread_id, + "Thread stopped", + )); + conv.untrack_thread(thread_id); + } + ThreadOutcome::MaxIterations => { + conv.add_entry(ConversationEntry::system_for_thread( + thread_id, + "Thread reached max iterations", + )); + conv.untrack_thread(thread_id); + } + ThreadOutcome::Failed { error } => { + conv.add_entry(ConversationEntry::system_for_thread( + thread_id, + format!("Thread failed: {error}"), + )); + conv.untrack_thread(thread_id); + } + ThreadOutcome::NeedApproval { + action_name, + call_id: _, + parameters: _, + } => { + conv.add_entry(ConversationEntry::system_for_thread( + thread_id, + format!("Approval needed for action: {action_name}"), + )); + // Thread stays active — waiting for approval + } + } + } + } + + /// Get a snapshot of a conversation. + pub async fn get_conversation( + &self, + conversation_id: ConversationId, + ) -> Option { + let convs = self.conversations.read().await; + convs.get(&conversation_id).cloned() + } + + /// List all conversations for a user. + pub async fn list_conversations(&self, user_id: &str) -> Vec { + let convs = self.conversations.read().await; + convs + .values() + .filter(|c| c.user_id == user_id) + .cloned() + .collect() + } + + /// Find an active foreground thread in a conversation. + async fn find_active_foreground(&self, conv: &ConversationSurface) -> Option { + for &tid in &conv.active_threads { + if self.thread_manager.is_running(tid).await { + return Some(tid); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::capability::lease::LeaseManager; + use crate::capability::policy::PolicyEngine; + use crate::capability::registry::CapabilityRegistry; + use crate::traits::effect::EffectExecutor; + use crate::traits::llm::{LlmBackend, LlmCallConfig, LlmOutput}; + use crate::types::conversation::EntrySender; + use crate::types::capability::{ActionDef, CapabilityLease}; + use crate::types::event::ThreadEvent; + use crate::types::memory::{DocId, MemoryDoc}; + use crate::types::project::Project; + use crate::types::step::{ActionResult, LlmResponse, Step, TokenUsage}; + use crate::types::thread::ThreadState; + use crate::traits::store::Store; + use std::sync::Mutex; + use std::time::Duration; + + // ── Mocks (same as manager tests) ─────────────────────── + + struct MockLlm(Mutex>); + + #[async_trait::async_trait] + impl LlmBackend for MockLlm { + async fn complete( + &self, _: &[ThreadMessage], _: &[ActionDef], _: &LlmCallConfig, + ) -> Result { + let mut r = self.0.lock().unwrap(); + if r.is_empty() { + Ok(LlmOutput { response: LlmResponse::Text("done".into()), usage: TokenUsage::default() }) + } else { + Ok(r.remove(0)) + } + } + fn model_name(&self) -> &str { "mock" } + } + + struct MockEffects; + + #[async_trait::async_trait] + impl EffectExecutor for MockEffects { + async fn execute_action(&self, _: &str, _: serde_json::Value, _: &CapabilityLease, _: &crate::traits::effect::ThreadExecutionContext) -> Result { + Ok(ActionResult { call_id: String::new(), action_name: String::new(), output: serde_json::json!({}), is_error: false, duration: Duration::from_millis(1) }) + } + async fn available_actions(&self, _: &[CapabilityLease]) -> Result, EngineError> { Ok(vec![]) } + } + + struct MockStore; + + #[async_trait::async_trait] + impl Store for MockStore { + async fn save_thread(&self, _: &crate::types::thread::Thread) -> Result<(), EngineError> { Ok(()) } + async fn load_thread(&self, _: ThreadId) -> Result, EngineError> { Ok(None) } + async fn list_threads(&self, _: ProjectId) -> Result, EngineError> { Ok(vec![]) } + async fn update_thread_state(&self, _: ThreadId, _: ThreadState) -> Result<(), EngineError> { Ok(()) } + async fn save_step(&self, _: &Step) -> Result<(), EngineError> { Ok(()) } + async fn load_steps(&self, _: ThreadId) -> Result, EngineError> { Ok(vec![]) } + async fn append_events(&self, _: &[ThreadEvent]) -> Result<(), EngineError> { Ok(()) } + async fn load_events(&self, _: ThreadId) -> Result, EngineError> { Ok(vec![]) } + async fn save_project(&self, _: &Project) -> Result<(), EngineError> { Ok(()) } + async fn load_project(&self, _: ProjectId) -> Result, EngineError> { Ok(None) } + async fn save_memory_doc(&self, _: &MemoryDoc) -> Result<(), EngineError> { Ok(()) } + async fn load_memory_doc(&self, _: DocId) -> Result, EngineError> { Ok(None) } + async fn list_memory_docs(&self, _: ProjectId) -> Result, EngineError> { Ok(vec![]) } + async fn save_lease(&self, _: &CapabilityLease) -> Result<(), EngineError> { Ok(()) } + async fn load_active_leases(&self, _: ThreadId) -> Result, EngineError> { Ok(vec![]) } + async fn revoke_lease(&self, _: crate::types::capability::LeaseId, _: &str) -> Result<(), EngineError> { Ok(()) } + } + + fn make_conv_manager() -> (Arc, ConversationManager) { + let tm = Arc::new(ThreadManager::new( + Arc::new(MockLlm(Mutex::new(vec![ + LlmOutput { response: LlmResponse::Text("Hello!".into()), usage: TokenUsage::default() }, + ]))), + Arc::new(MockEffects), + Arc::new(MockStore), + Arc::new(CapabilityRegistry::new()), + Arc::new(LeaseManager::new()), + Arc::new(PolicyEngine::new()), + )); + let cm = ConversationManager::new(Arc::clone(&tm)); + (tm, cm) + } + + // ── Tests ─────────────────────────────────────────────── + + #[tokio::test] + async fn get_or_create_conversation() { + let (_, cm) = make_conv_manager(); + let c1 = cm.get_or_create_conversation("telegram", "user1").await; + let c2 = cm.get_or_create_conversation("telegram", "user1").await; + assert_eq!(c1, c2); // same channel+user returns same conversation + + let c3 = cm.get_or_create_conversation("slack", "user1").await; + assert_ne!(c1, c3); // different channel → different conversation + } + + #[tokio::test] + async fn handle_message_spawns_thread() { + let (tm, cm) = make_conv_manager(); + let conv_id = cm.get_or_create_conversation("web", "user1").await; + let project = ProjectId::new(); + + let tid = cm + .handle_user_message(conv_id, "Hello", project, "user1", ThreadConfig::default()) + .await + .unwrap(); + + // Thread was spawned + let conv = cm.get_conversation(conv_id).await.unwrap(); + assert!(conv.active_threads.contains(&tid)); + assert_eq!(conv.entries.len(), 2); // user message + "Thread started" + + // Wait for thread to complete + let outcome = tm.join_thread(tid).await.unwrap(); + assert!(matches!(outcome, ThreadOutcome::Completed { .. })); + } + + #[tokio::test] + async fn record_outcome_adds_entry() { + let (_, cm) = make_conv_manager(); + let conv_id = cm.get_or_create_conversation("cli", "user1").await; + let tid = ThreadId::new(); + + // Manually track a thread + { + let mut convs = cm.conversations.write().await; + let conv = convs.get_mut(&conv_id).unwrap(); + conv.track_thread(tid); + } + + // Record completion + cm.record_thread_outcome( + conv_id, + tid, + &ThreadOutcome::Completed { + response: Some("Done!".into()), + }, + ) + .await; + + let conv = cm.get_conversation(conv_id).await.unwrap(); + assert!(conv.active_threads.is_empty()); + assert_eq!(conv.entries.len(), 1); + assert_eq!(conv.entries[0].content, "Done!"); + + // Check sender is agent + assert!(matches!( + conv.entries[0].sender, + EntrySender::Agent { thread_id } if thread_id == tid + )); + } + + #[tokio::test] + async fn list_conversations_filters_by_user() { + let (_, cm) = make_conv_manager(); + cm.get_or_create_conversation("web", "alice").await; + cm.get_or_create_conversation("telegram", "alice").await; + cm.get_or_create_conversation("web", "bob").await; + + let alice_convs = cm.list_conversations("alice").await; + assert_eq!(alice_convs.len(), 2); + + let bob_convs = cm.list_conversations("bob").await; + assert_eq!(bob_convs.len(), 1); + } +} diff --git a/crates/ironclaw_engine/src/runtime/mod.rs b/crates/ironclaw_engine/src/runtime/mod.rs index 82f20454..ed341e35 100644 --- a/crates/ironclaw_engine/src/runtime/mod.rs +++ b/crates/ironclaw_engine/src/runtime/mod.rs @@ -4,10 +4,12 @@ //! - [`ThreadTree`] — parent-child relationship tracking //! - [`messaging`] — inter-thread signal channel +pub mod conversation; pub mod manager; pub mod messaging; pub mod tree; +pub use conversation::ConversationManager; pub use manager::ThreadManager; pub use messaging::ThreadOutcome; pub use tree::ThreadTree; diff --git a/crates/ironclaw_engine/src/types/conversation.rs b/crates/ironclaw_engine/src/types/conversation.rs new file mode 100644 index 00000000..2aadeb7b --- /dev/null +++ b/crates/ironclaw_engine/src/types/conversation.rs @@ -0,0 +1,263 @@ +//! Conversation surface — the UI layer, separate from execution. +//! +//! A conversation is a stream of entries visible to the user. Threads +//! (the execution units) run independently and produce entries that +//! appear in conversations. One conversation can have multiple active +//! threads; one thread can outlive its originating conversation. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::types::thread::ThreadId; + +/// Strongly-typed conversation identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ConversationId(pub Uuid); + +impl ConversationId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +impl Default for ConversationId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for ConversationId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Strongly-typed entry identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct EntryId(pub Uuid); + +impl EntryId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +impl Default for EntryId { + fn default() -> Self { + Self::new() + } +} + +/// Who sent a conversation entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EntrySender { + /// The human user. + User, + /// The agent (from a specific thread). + Agent { thread_id: ThreadId }, + /// System notification (thread started, completed, etc.). + System, +} + +/// A single entry in a conversation — a message visible to the user. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationEntry { + pub id: EntryId, + pub sender: EntrySender, + pub content: String, + /// Which thread produced this entry (if any). + pub origin_thread_id: Option, + pub timestamp: DateTime, + /// Optional metadata (channel-specific formatting, attachments, etc.). + pub metadata: serde_json::Value, +} + +impl ConversationEntry { + /// Create a user entry. + pub fn user(content: impl Into) -> Self { + Self { + id: EntryId::new(), + sender: EntrySender::User, + content: content.into(), + origin_thread_id: None, + timestamp: Utc::now(), + metadata: serde_json::Value::Null, + } + } + + /// Create an agent entry from a thread. + pub fn agent(thread_id: ThreadId, content: impl Into) -> Self { + Self { + id: EntryId::new(), + sender: EntrySender::Agent { thread_id }, + content: content.into(), + origin_thread_id: Some(thread_id), + timestamp: Utc::now(), + metadata: serde_json::Value::Null, + } + } + + /// Create a system notification entry. + pub fn system(content: impl Into) -> Self { + Self { + id: EntryId::new(), + sender: EntrySender::System, + content: content.into(), + origin_thread_id: None, + timestamp: Utc::now(), + metadata: serde_json::Value::Null, + } + } + + /// Create a system notification linked to a thread. + pub fn system_for_thread(thread_id: ThreadId, content: impl Into) -> Self { + Self { + id: EntryId::new(), + sender: EntrySender::System, + content: content.into(), + origin_thread_id: Some(thread_id), + timestamp: Utc::now(), + metadata: serde_json::Value::Null, + } + } +} + +/// A conversation surface — the UI-facing view of a chat. +/// +/// Conversations are NOT execution boundaries. They are streams of entries +/// that may come from multiple concurrent threads. A user can start a new +/// thread while another is still running, and both produce entries in the +/// same conversation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationSurface { + pub id: ConversationId, + /// Which channel this conversation is on (e.g. "telegram", "web", "cli"). + pub channel: String, + /// The user who owns this conversation. + pub user_id: String, + /// All entries in chronological order. + pub entries: Vec, + /// Currently active (non-terminal) thread IDs. + pub active_threads: Vec, + /// Metadata (channel-specific state, external thread IDs, etc.). + pub metadata: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ConversationSurface { + pub fn new(channel: impl Into, user_id: impl Into) -> Self { + let now = Utc::now(); + Self { + id: ConversationId::new(), + channel: channel.into(), + user_id: user_id.into(), + entries: Vec::new(), + active_threads: Vec::new(), + metadata: serde_json::Value::Null, + created_at: now, + updated_at: now, + } + } + + /// Add an entry and update the timestamp. + pub fn add_entry(&mut self, entry: ConversationEntry) { + self.entries.push(entry); + self.updated_at = Utc::now(); + } + + /// Register a thread as active in this conversation. + pub fn track_thread(&mut self, thread_id: ThreadId) { + if !self.active_threads.contains(&thread_id) { + self.active_threads.push(thread_id); + } + } + + /// Remove a thread from the active list (it completed or failed). + pub fn untrack_thread(&mut self, thread_id: ThreadId) { + self.active_threads.retain(|id| *id != thread_id); + } + + /// Get the most recent entry, if any. + pub fn last_entry(&self) -> Option<&ConversationEntry> { + self.entries.last() + } + + /// Get all entries from a specific thread. + pub fn entries_for_thread(&self, thread_id: ThreadId) -> Vec<&ConversationEntry> { + self.entries + .iter() + .filter(|e| e.origin_thread_id == Some(thread_id)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn conversation_lifecycle() { + let mut conv = ConversationSurface::new("telegram", "user_123"); + assert!(conv.entries.is_empty()); + assert!(conv.active_threads.is_empty()); + + // User sends a message + conv.add_entry(ConversationEntry::user("Hello!")); + assert_eq!(conv.entries.len(), 1); + + // Thread starts + let tid = ThreadId::new(); + conv.track_thread(tid); + conv.add_entry(ConversationEntry::system_for_thread( + tid, + "Thread started", + )); + assert_eq!(conv.active_threads.len(), 1); + + // Agent responds + conv.add_entry(ConversationEntry::agent(tid, "Hi there!")); + assert_eq!(conv.entries.len(), 3); + + // Thread completes + conv.untrack_thread(tid); + conv.add_entry(ConversationEntry::system_for_thread(tid, "Thread completed")); + assert!(conv.active_threads.is_empty()); + assert_eq!(conv.entries.len(), 4); + } + + #[test] + fn multiple_concurrent_threads() { + let mut conv = ConversationSurface::new("web", "user_456"); + + let t1 = ThreadId::new(); + let t2 = ThreadId::new(); + + conv.track_thread(t1); + conv.track_thread(t2); + assert_eq!(conv.active_threads.len(), 2); + + conv.add_entry(ConversationEntry::agent(t1, "Research result A")); + conv.add_entry(ConversationEntry::agent(t2, "Research result B")); + conv.add_entry(ConversationEntry::agent(t1, "More from A")); + + let t1_entries = conv.entries_for_thread(t1); + assert_eq!(t1_entries.len(), 2); + + let t2_entries = conv.entries_for_thread(t2); + assert_eq!(t2_entries.len(), 1); + + conv.untrack_thread(t1); + assert_eq!(conv.active_threads.len(), 1); + } + + #[test] + fn track_thread_is_idempotent() { + let mut conv = ConversationSurface::new("cli", "user"); + let tid = ThreadId::new(); + conv.track_thread(tid); + conv.track_thread(tid); + assert_eq!(conv.active_threads.len(), 1); + } +} diff --git a/crates/ironclaw_engine/src/types/mod.rs b/crates/ironclaw_engine/src/types/mod.rs index f2ad90ec..73a46a63 100644 --- a/crates/ironclaw_engine/src/types/mod.rs +++ b/crates/ironclaw_engine/src/types/mod.rs @@ -4,6 +4,7 @@ //! validation logic. pub mod capability; +pub mod conversation; pub mod error; pub mod event; pub mod memory;