diff --git a/src/api/handlers.rs b/src/api/handlers.rs index fba39a2..998cd9f 100644 --- a/src/api/handlers.rs +++ b/src/api/handlers.rs @@ -253,16 +253,32 @@ async fn handle_conversation_stream( let events = server.consciousness.on_response(&*session, &content).await?; drop(session); + // Inject surfacing events back into the session as system messages + // so the agent sees them in its context window on the next turn. + for event in &events { + if let crate::server::ConsciousnessEvent::Surfacing { source, content, priority } = event { + let msg = crate::core::session::ConversationMessage { + role: crate::core::session::MessageRole::System, + blocks: vec![crate::core::session::ContentBlock::Text { + text: format!("[surfacing: {}] {} — {}", source, content, priority), + }], + usage: None, + timestamp: None, + }; + let _ = server.sessions.add_message(&conversation_id, msg); + } + } + for event in events { - let stream_event = match event { + let stream_event = match &event { crate::server::ConsciousnessEvent::Surfacing { source, content, priority } => { - StreamEvent::Surfacing { source: source.to_string(), content, priority: priority.to_string() } + StreamEvent::Surfacing { source: source.clone(), content: content.clone(), priority: priority.clone() } } crate::server::ConsciousnessEvent::Reflection { content } => { - StreamEvent::Reflection { content } + StreamEvent::Reflection { content: content.clone() } } crate::server::ConsciousnessEvent::Archivist { synthesis, pressure } => { - StreamEvent::Archivist { synthesis, pressure } + StreamEvent::Archivist { synthesis: synthesis.clone(), pressure: *pressure } } }; let _ = tx.send(stream_event).await; diff --git a/src/api/models.rs b/src/api/models.rs index 3714a05..a5cad96 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -34,12 +34,20 @@ pub struct LlmConfig { pub context_window: u32, #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, + /// Maximum tool-calling rounds before forcing a text response. + /// Configurable per-agent; 0 disables tools entirely. + #[serde(default = "default_max_tool_rounds")] + pub max_tool_rounds: u32, } fn default_context_window() -> u32 { 128000 } +fn default_max_tool_rounds() -> u32 { + 10 +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryConfig { pub git_enabled: bool, diff --git a/src/backend/local.rs b/src/backend/local.rs index 41367ee..19b8d84 100644 --- a/src/backend/local.rs +++ b/src/backend/local.rs @@ -102,7 +102,7 @@ async fn run_turn( ) -> Result<()> { // Snapshot history for the Bifrost call, then drop the dashmap ref before // any await — `Ref` is not Send across awaits. - let (agent_id, messages) = { + let (agent_id, initial_messages) = { let session = server .sessions .get(&conversation_id) @@ -136,34 +136,124 @@ async fn run_turn( }; let agent = server.agents.get(&agent_id).await?; + let max_rounds = agent.llm_config.max_tool_rounds; + let model = agent.llm_config.model.clone(); + let temperature = agent.llm_config.temperature; - let req = ChatCompletionRequest { - model: agent.llm_config.model.clone(), - messages, - stream: Some(false), - max_tokens: None, - temperature: agent.llm_config.temperature, - tools: None, - }; + // Build bifrost-format tool definitions from the core tool set + let core_tools = crate::core::tools::tool_definitions(); + let bifrost_tools: Vec = core_tools + .iter() + .map(|t| crate::bridge::bifrost::ToolDefinition { + tool_type: "function".to_string(), + function: crate::bridge::bifrost::ToolFunction { + name: t.name.clone(), + description: t.description.clone(), + parameters: t.input_schema.clone(), + }, + }) + .collect(); - let response = server.bifrost.chat_completion(req).await?; - let content = response.content.clone(); + // ── Tool-calling loop ───────────────────────────────────── + let mut messages = initial_messages; + let mut tool_round = 0u32; + let final_content: String; - // Mirror the server's chunked streaming so the CLI/TUI sees progressive - // tokens (the underlying call is non-streaming today; replace once Bifrost - // SSE lands). - let chars: Vec = content.chars().collect(); - for chunk in chars.chunks(10) { - let s: String = chunk.iter().collect(); - if tx.send(Ok(BackendEvent::Token(s))).await.is_err() { - return Ok(()); + loop { + let req = ChatCompletionRequest { + model: model.clone(), + messages: messages.clone(), + stream: Some(false), + max_tokens: None, + temperature, + tools: if max_rounds > 0 { + Some(bifrost_tools.clone()) + } else { + None + }, + }; + + let response = server.bifrost.chat_completion(req).await?; + + if response.tool_calls.is_empty() || tool_round >= max_rounds { + // Text response (or hit max rounds) — this is the final output + final_content = response.content.clone(); + + // If we hit max rounds with pending tool calls, add a note + if !response.tool_calls.is_empty() && tool_round >= max_rounds { + let note = + "\n\n[Max tool rounds reached — continuing with text response]"; + let _ = tx.send(Ok(BackendEvent::Token(note.to_string()))).await; + } + + // Stream the final content in chunks + let chars: Vec = final_content.chars().collect(); + for chunk in chars.chunks(10) { + let s: String = chunk.iter().collect(); + if tx.send(Ok(BackendEvent::Token(s))).await.is_err() { + return Ok(()); + } + tokio::time::sleep(tokio::time::Duration::from_millis(20)).await; + } + break; } - tokio::time::sleep(tokio::time::Duration::from_millis(20)).await; + + tool_round += 1; + + // Tell the UI we're executing tools + let tool_names: Vec<&str> = + response.tool_calls.iter().map(|tc| tc.name.as_str()).collect(); + let announce = format!( + "\n🔧 Round {} — executing: {}\n", + tool_round, + tool_names.join(", ") + ); + let _ = tx + .send(Ok(BackendEvent::Token(announce))) + .await; + + // Add the assistant's tool-call message to the Bifrost conversation + let call_text = serde_json::json!({ + "tool_calls": response.tool_calls.iter().map(|tc| { + serde_json::json!({"id": tc.id, "name": tc.name, "arguments": tc.arguments}) + }).collect::>() + }).to_string(); + messages.push(BifrostMessage { + role: "assistant".to_string(), + content: call_text, + }); + + // Execute each tool and stream results back + for tc in &response.tool_calls { + let input_str = tc.arguments.to_string(); + let result = crate::core::tools::execute_tool(&tc.name, &input_str).await; + + let output = if result.is_error { + format!("Error: {}", result.output) + } else { + result.output + }; + + let status = if result.is_error { "❌" } else { "✅" }; + let result_line = format!("{} **{}**: {} char(s)\n", status, tc.name, output.len()); + let _ = tx + .send(Ok(BackendEvent::Token(result_line))) + .await; + + // Add tool result to bifrost messages for next loop iteration + messages.push(BifrostMessage { + role: "tool".to_string(), + content: output, + }); + } + + // Continue loop — model will see tool results and respond } + // ── Post-turn processing (unchanged) ─────────────────────── server.sessions.add_message( &conversation_id, - ConversationMessage::assistant_text(&content), + ConversationMessage::assistant_text(&final_content), )?; let events = { @@ -171,20 +261,55 @@ async fn run_turn( .sessions .get(&conversation_id) .ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?; - server.consciousness.on_response(&*session, &content).await? + server + .consciousness + .on_response(&*session, &final_content) + .await? }; + // Inject surfacing events back into the session as system messages + for event in &events { + if let ConsciousnessEvent::Surfacing { + source, + content, + priority, + } = event + { + let msg = crate::core::session::ConversationMessage { + role: crate::core::session::MessageRole::System, + blocks: vec![crate::core::session::ContentBlock::Text { + text: format!( + "[surfacing: {}] {} — {}", + source, content, priority + ), + }], + usage: None, + timestamp: None, + }; + let _ = server.sessions.add_message(&conversation_id, msg); + } + } + for event in events { - let be = match event { - ConsciousnessEvent::Surfacing { source, content, priority } => BackendEvent::Surfacing { - source: source.to_string(), + let be = match &event { + ConsciousnessEvent::Surfacing { + source, content, + priority, + } => BackendEvent::Surfacing { + source: source.to_string(), + content: content.to_string(), priority: priority.to_string(), }, - ConsciousnessEvent::Reflection { content } => BackendEvent::Reflection(content), - ConsciousnessEvent::Archivist { synthesis, pressure } => BackendEvent::Archivist { + ConsciousnessEvent::Reflection { content } => { + BackendEvent::Reflection(content.clone()) + } + ConsciousnessEvent::Archivist { synthesis, pressure, + } => BackendEvent::Archivist { + synthesis: synthesis.clone(), + pressure: *pressure, }, }; if tx.send(Ok(be)).await.is_err() { @@ -193,7 +318,9 @@ async fn run_turn( } if let Some(mut session) = server.sessions.get_mut(&conversation_id) { - let pressure = server.consciousness.calculate_pressure(&session.messages); + let pressure = server + .consciousness + .calculate_pressure(&session.messages); session.context_pressure = pressure; } diff --git a/src/core/memory/mod.rs b/src/core/memory/mod.rs index 1447a81..3ce8013 100644 --- a/src/core/memory/mod.rs +++ b/src/core/memory/mod.rs @@ -160,10 +160,12 @@ impl MemoryRepo { let repo = git2::Repository::init(mem_path) .context("initializing git repository for memory")?; - // Set user config for commits - let mut config = repo.config().context("opening repo config")?; - config.set_str("user.name", &self.agent_id)?; - config.set_str("user.email", &format!("{}@souveraine.local", self.agent_id))?; + // Set user config for commits (scoped to drop before .await) + { + let mut config = repo.config().context("opening repo config")?; + config.set_str("user.name", &self.agent_id)?; + config.set_str("user.email", &format!("{}@souveraine.local", self.agent_id))?; + } // Write initial placeholder files with frontmatter let persona_content = render_frontmatter( diff --git a/src/core/reflection/mod.rs b/src/core/reflection/mod.rs index 08c9f88..6d6d8ec 100644 --- a/src/core/reflection/mod.rs +++ b/src/core/reflection/mod.rs @@ -1,3 +1,16 @@ +//! Reflection Engine — N+25 phenomenological witness. +//! +//! Per the consciousness substrate philosophy, the harness does NOT force +//! automatic events. It is a nervous system: it warns, surfaces pressure, +//! and makes state available — but the agent decides whether and when to act. +//! +//! This module provides: +//! - State tracking (message count, context pressure) +//! - A query interface the agent can call when it wants to reflect +//! - Warning signals when thresholds are approaching +//! +//! No events are emitted autonomously. + use std::sync::Arc; use tokio::sync::RwLock; use anyhow::Result; @@ -5,6 +18,8 @@ use anyhow::Result; use crate::core::config::ConsciousnessConfig; /// Reflection Engine — N+25 phenomenological witness. Stub. +/// +/// The harness tracks state so the agent can query it. No automatic events. pub struct ReflectionEngine { #[allow(dead_code)] config: Arc>, diff --git a/src/ui/chat.rs b/src/ui/chat.rs index 0d80e2a..b35477e 100644 --- a/src/ui/chat.rs +++ b/src/ui/chat.rs @@ -25,10 +25,10 @@ use ratatui::{ widgets::{Block, BorderType, Borders, Paragraph, Wrap}, Frame, }; -use tokio::sync::mpsc; -use tokio::sync::RwLock; +use tokio::sync::{mpsc, oneshot, RwLock}; use crate::backend::{Backend, BackendEvent}; +use crate::bridge::bifrost::BifrostClient; use crate::core::config::ConsciousnessConfig; use crate::ui::markdown; @@ -68,6 +68,8 @@ pub struct ChatState { pub tick: u64, /// When the current turn started (for spinner animation). pub turn_started: Option, + /// Receiver for `/model` listing results from async Bifrost call. + pub model_rx: Option>, } impl ChatState { @@ -120,15 +122,45 @@ impl ChatState { cockpit_log: Vec::new(), tick: 0, turn_started: None, + model_rx: None, }) } - /// Submit the current input as a user message and start a turn. - pub fn submit(&mut self) { + const HELP_TEXT: &'static str = "Available commands: + /help Show this help + /clear Clear chat history + /model List available models + /model Set the active model + ! Run a shell command (Linux/macOS) + +Use Tab to toggle the cockpit pane."; + + /// Submit the current input. Returns `true` if the input was handled + /// (slash command, bang command, or sent to backend). + pub fn submit(&mut self) -> bool { if self.busy || self.input.trim().is_empty() { - return; + return false; } - let text = std::mem::take(&mut self.input); + + let trimmed = self.input.trim().to_string(); + self.input.clear(); + + // Slash commands + if trimmed.starts_with('/') { + return self.handle_slash_command(&trimmed); + } + + // Bang commands: ! + if trimmed.starts_with('!') { + let cmd = trimmed[1..].trim(); + if !cmd.is_empty() { + self.handle_bang_command(cmd); + } + return true; + } + + // Normal chat message + let text = trimmed; let ts = Instant::now(); self.messages.push(ChatMessage::User { text: text.clone(), ts }); self.messages.push(ChatMessage::Assistant { @@ -171,11 +203,165 @@ impl ChatState { } let _ = tx.send(BackendEvent::Done).await; }); + true + } + + fn handle_slash_command(&mut self, input: &str) -> bool { + let trimmed = input.trim(); + + if trimmed == "/help" { + self.system_message(Self::HELP_TEXT.to_string()); + return true; + } + + if trimmed == "/clear" { + self.messages.clear(); + self.system_message("Chat cleared.".to_string()); + return true; + } + + if trimmed.starts_with("/model") { + return self.handle_model_command(trimmed); + } + + // Unknown command + let cmd = trimmed.split_whitespace().next().unwrap_or(trimmed); + self.system_message(format!( + "Unknown command: {}\nType /help for available commands.", + cmd + )); + true + } + + fn handle_model_command(&mut self, input: &str) -> bool { + let rest = input.strip_prefix("/model").unwrap_or("").trim(); + + // /model — set model (synchronous, fast) + if !rest.is_empty() && !rest.starts_with('-') { + let model_name = rest.to_string(); + let cfg_path = std::env::current_dir() + .map(|d| d.join("souveraine.toml")) + .unwrap_or_else(|_| std::path::PathBuf::from("souveraine.toml")); + + match ConsciousnessConfig::load(&cfg_path) { + Ok(mut cfg) => { + cfg.bifrost.primary_model = model_name.clone(); + match cfg.save(&cfg_path) { + Ok(()) => self.system_message(format!("Set model to: {}", model_name)), + Err(e) => self.error_message(format!("Failed to save config: {}", e)), + } + } + Err(e) => self.error_message(format!("Failed to load config: {}", e)), + } + return true; + } + + // /model — list models (async, uses oneshot to get result back) + self.system_message("Fetching models from Bifrost…".to_string()); + + let cfg_path = std::env::current_dir() + .map(|d| d.join("souveraine.toml")) + .unwrap_or_else(|_| std::path::PathBuf::from("souveraine.toml")); + + let (tx, rx) = oneshot::channel(); + self.model_rx = Some(rx); + + tokio::spawn(async move { + let result = match ConsciousnessConfig::load(&cfg_path) { + Ok(cfg) => { + let bifrost = BifrostClient::new( + &cfg.bifrost.base_url, + &cfg.bifrost.api_key, + &cfg.bifrost.virtual_key, + &cfg.bifrost.primary_model, + ); + let bifrost_models = bifrost.list_models().await.unwrap_or_default(); + let mut all_models = bifrost_models.clone(); + for name in cfg.models.keys() { + if !all_models.contains(name) { + all_models.push(name.clone()); + } + } + let mut text = format!( + "Selected: {}\nAvailable ({}):\n", + cfg.bifrost.primary_model, + all_models.len() + ); + for m in &all_models { + let marker = if bifrost_models.contains(&m) { "⚡" } else { "⚙" }; + text.push_str(&format!(" {} {}\n", marker, m)); + } + text + } + Err(e) => format!("✕ Failed to load config: {}", e), + }; + let _ = tx.send(result); + }); + + true + } + + fn handle_bang_command(&mut self, cmd: &str) { + let output = std::process::Command::new("bash") + .arg("-c") + .arg(cmd) + .output(); + + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let mut result = String::new(); + if !stdout.is_empty() { + result.push_str(stdout.trim()); + } + if !stderr.is_empty() { + if !result.is_empty() { + result.push('\n'); + } + result.push_str(stderr.trim()); + } + if result.is_empty() { + result = format!("[exit code {}]", out.status.code().unwrap_or(-1)); + } + self.system_message(format!("$ {}\n{}", cmd, result)); + } + Err(e) => { + self.error_message(format!("Shell command failed: {}", e)); + } + } + } + + /// Push a system message for display (from slash commands, etc.) + pub fn system_message(&mut self, text: String) { + self.messages.push(ChatMessage::System { + text, + ts: Instant::now(), + }); + } + + /// Push an error message for display + pub fn error_message(&mut self, text: String) { + self.messages.push(ChatMessage::System { + text: format!("✕ {}", text), + ts: Instant::now(), + }); } /// Drain pending events from the active turn channel (non-blocking). /// Call once per UI tick. pub fn drain_events(&mut self) { + // Check for /model listing result + if let Some(rx) = self.model_rx.as_mut() { + if let Ok(result) = rx.try_recv() { + self.messages.push(ChatMessage::System { + text: result, + ts: Instant::now(), + }); + self.model_rx = None; + } + } + // Two-phase to avoid double-borrowing self: drain into a Vec, then process. let mut drained: Vec = Vec::new(); let mut closed = false; @@ -666,7 +852,7 @@ fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) { let cockpit_hint = if state.cockpit { "Tab close cockpit" } else { "Tab cockpit" }; let footer = Line::from(vec![ Span::styled( - format!(" Esc menu · Enter send · ↑↓ scroll · {} ", cockpit_hint), + format!(" Esc menu · Enter send · ↑↓ scroll · !cmd bash · {} ", cockpit_hint), Style::default().fg(STATUS_GRAY), ), Span::raw("│ "),