feat: wire tools into LocalBackend turn loop
- Added max_tool_rounds (configurable per-agent) to LlmConfig - Rewrote run_turn() with multi-round tool-calling loop (max 10 rounds by default, 0 disables tools) - Converts core ToolDefinitions → bifrost ToolDefinitions - Streams tool execution status to TUI via BackendEvent::Token - Continues consciousness processing (surfacing, reflection, archivist) after tool loop completes - Fixed git2::Config Send issue in memory/mod.rs (scoped block) Closes: docs/tasks/tool-wiring.md
This commit is contained in:
parent
bc7316c411
commit
ae0968bf44
6 changed files with 397 additions and 43 deletions
|
|
@ -253,16 +253,32 @@ async fn handle_conversation_stream(
|
||||||
let events = server.consciousness.on_response(&*session, &content).await?;
|
let events = server.consciousness.on_response(&*session, &content).await?;
|
||||||
drop(session);
|
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 {
|
for event in events {
|
||||||
let stream_event = match event {
|
let stream_event = match &event {
|
||||||
crate::server::ConsciousnessEvent::Surfacing { source, content, priority } => {
|
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 } => {
|
crate::server::ConsciousnessEvent::Reflection { content } => {
|
||||||
StreamEvent::Reflection { content }
|
StreamEvent::Reflection { content: content.clone() }
|
||||||
}
|
}
|
||||||
crate::server::ConsciousnessEvent::Archivist { synthesis, pressure } => {
|
crate::server::ConsciousnessEvent::Archivist { synthesis, pressure } => {
|
||||||
StreamEvent::Archivist { synthesis, pressure }
|
StreamEvent::Archivist { synthesis: synthesis.clone(), pressure: *pressure }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _ = tx.send(stream_event).await;
|
let _ = tx.send(stream_event).await;
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,20 @@ pub struct LlmConfig {
|
||||||
pub context_window: u32,
|
pub context_window: u32,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub temperature: Option<f32>,
|
pub temperature: Option<f32>,
|
||||||
|
/// 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 {
|
fn default_context_window() -> u32 {
|
||||||
128000
|
128000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_max_tool_rounds() -> u32 {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MemoryConfig {
|
pub struct MemoryConfig {
|
||||||
pub git_enabled: bool,
|
pub git_enabled: bool,
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ async fn run_turn(
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Snapshot history for the Bifrost call, then drop the dashmap ref before
|
// Snapshot history for the Bifrost call, then drop the dashmap ref before
|
||||||
// any await — `Ref` is not Send across awaits.
|
// any await — `Ref` is not Send across awaits.
|
||||||
let (agent_id, messages) = {
|
let (agent_id, initial_messages) = {
|
||||||
let session = server
|
let session = server
|
||||||
.sessions
|
.sessions
|
||||||
.get(&conversation_id)
|
.get(&conversation_id)
|
||||||
|
|
@ -136,34 +136,124 @@ async fn run_turn(
|
||||||
};
|
};
|
||||||
|
|
||||||
let agent = server.agents.get(&agent_id).await?;
|
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 {
|
// Build bifrost-format tool definitions from the core tool set
|
||||||
model: agent.llm_config.model.clone(),
|
let core_tools = crate::core::tools::tool_definitions();
|
||||||
messages,
|
let bifrost_tools: Vec<crate::bridge::bifrost::ToolDefinition> = core_tools
|
||||||
stream: Some(false),
|
.iter()
|
||||||
max_tokens: None,
|
.map(|t| crate::bridge::bifrost::ToolDefinition {
|
||||||
temperature: agent.llm_config.temperature,
|
tool_type: "function".to_string(),
|
||||||
tools: None,
|
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?;
|
// ── Tool-calling loop ─────────────────────────────────────
|
||||||
let content = response.content.clone();
|
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
|
loop {
|
||||||
// tokens (the underlying call is non-streaming today; replace once Bifrost
|
let req = ChatCompletionRequest {
|
||||||
// SSE lands).
|
model: model.clone(),
|
||||||
let chars: Vec<char> = content.chars().collect();
|
messages: messages.clone(),
|
||||||
for chunk in chars.chunks(10) {
|
stream: Some(false),
|
||||||
let s: String = chunk.iter().collect();
|
max_tokens: None,
|
||||||
if tx.send(Ok(BackendEvent::Token(s))).await.is_err() {
|
temperature,
|
||||||
return Ok(());
|
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<char> = 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::<Vec<_>>()
|
||||||
|
}).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(
|
server.sessions.add_message(
|
||||||
&conversation_id,
|
&conversation_id,
|
||||||
ConversationMessage::assistant_text(&content),
|
ConversationMessage::assistant_text(&final_content),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let events = {
|
let events = {
|
||||||
|
|
@ -171,20 +261,55 @@ async fn run_turn(
|
||||||
.sessions
|
.sessions
|
||||||
.get(&conversation_id)
|
.get(&conversation_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", 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 {
|
for event in events {
|
||||||
let be = match event {
|
let be = match &event {
|
||||||
ConsciousnessEvent::Surfacing { source, content, priority } => BackendEvent::Surfacing {
|
ConsciousnessEvent::Surfacing {
|
||||||
source: source.to_string(),
|
source,
|
||||||
content,
|
content,
|
||||||
|
priority,
|
||||||
|
} => BackendEvent::Surfacing {
|
||||||
|
source: source.to_string(),
|
||||||
|
content: content.to_string(),
|
||||||
priority: priority.to_string(),
|
priority: priority.to_string(),
|
||||||
},
|
},
|
||||||
ConsciousnessEvent::Reflection { content } => BackendEvent::Reflection(content),
|
ConsciousnessEvent::Reflection { content } => {
|
||||||
ConsciousnessEvent::Archivist { synthesis, pressure } => BackendEvent::Archivist {
|
BackendEvent::Reflection(content.clone())
|
||||||
|
}
|
||||||
|
ConsciousnessEvent::Archivist {
|
||||||
synthesis,
|
synthesis,
|
||||||
pressure,
|
pressure,
|
||||||
|
} => BackendEvent::Archivist {
|
||||||
|
synthesis: synthesis.clone(),
|
||||||
|
pressure: *pressure,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if tx.send(Ok(be)).await.is_err() {
|
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) {
|
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;
|
session.context_pressure = pressure;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,10 +160,12 @@ impl MemoryRepo {
|
||||||
let repo = git2::Repository::init(mem_path)
|
let repo = git2::Repository::init(mem_path)
|
||||||
.context("initializing git repository for memory")?;
|
.context("initializing git repository for memory")?;
|
||||||
|
|
||||||
// Set user config for commits
|
// 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)?;
|
let mut config = repo.config().context("opening repo config")?;
|
||||||
config.set_str("user.email", &format!("{}@souveraine.local", self.agent_id))?;
|
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
|
// Write initial placeholder files with frontmatter
|
||||||
let persona_content = render_frontmatter(
|
let persona_content = render_frontmatter(
|
||||||
|
|
|
||||||
|
|
@ -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 std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
@ -5,6 +18,8 @@ use anyhow::Result;
|
||||||
use crate::core::config::ConsciousnessConfig;
|
use crate::core::config::ConsciousnessConfig;
|
||||||
|
|
||||||
/// Reflection Engine — N+25 phenomenological witness. Stub.
|
/// Reflection Engine — N+25 phenomenological witness. Stub.
|
||||||
|
///
|
||||||
|
/// The harness tracks state so the agent can query it. No automatic events.
|
||||||
pub struct ReflectionEngine {
|
pub struct ReflectionEngine {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
config: Arc<RwLock<ConsciousnessConfig>>,
|
config: Arc<RwLock<ConsciousnessConfig>>,
|
||||||
|
|
|
||||||
200
src/ui/chat.rs
200
src/ui/chat.rs
|
|
@ -25,10 +25,10 @@ use ratatui::{
|
||||||
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
|
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::{mpsc, oneshot, RwLock};
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
|
use crate::bridge::bifrost::BifrostClient;
|
||||||
use crate::core::config::ConsciousnessConfig;
|
use crate::core::config::ConsciousnessConfig;
|
||||||
use crate::ui::markdown;
|
use crate::ui::markdown;
|
||||||
|
|
||||||
|
|
@ -68,6 +68,8 @@ pub struct ChatState {
|
||||||
pub tick: u64,
|
pub tick: u64,
|
||||||
/// When the current turn started (for spinner animation).
|
/// When the current turn started (for spinner animation).
|
||||||
pub turn_started: Option<Instant>,
|
pub turn_started: Option<Instant>,
|
||||||
|
/// Receiver for `/model` listing results from async Bifrost call.
|
||||||
|
pub model_rx: Option<oneshot::Receiver<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatState {
|
impl ChatState {
|
||||||
|
|
@ -120,15 +122,45 @@ impl ChatState {
|
||||||
cockpit_log: Vec::new(),
|
cockpit_log: Vec::new(),
|
||||||
tick: 0,
|
tick: 0,
|
||||||
turn_started: None,
|
turn_started: None,
|
||||||
|
model_rx: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Submit the current input as a user message and start a turn.
|
const HELP_TEXT: &'static str = "Available commands:
|
||||||
pub fn submit(&mut self) {
|
/help Show this help
|
||||||
|
/clear Clear chat history
|
||||||
|
/model List available models
|
||||||
|
/model <name> Set the active model
|
||||||
|
!<command> 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() {
|
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: !<cmd>
|
||||||
|
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();
|
let ts = Instant::now();
|
||||||
self.messages.push(ChatMessage::User { text: text.clone(), ts });
|
self.messages.push(ChatMessage::User { text: text.clone(), ts });
|
||||||
self.messages.push(ChatMessage::Assistant {
|
self.messages.push(ChatMessage::Assistant {
|
||||||
|
|
@ -171,11 +203,165 @@ impl ChatState {
|
||||||
}
|
}
|
||||||
let _ = tx.send(BackendEvent::Done).await;
|
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 <name> — 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).
|
/// Drain pending events from the active turn channel (non-blocking).
|
||||||
/// Call once per UI tick.
|
/// Call once per UI tick.
|
||||||
pub fn drain_events(&mut self) {
|
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.
|
// Two-phase to avoid double-borrowing self: drain into a Vec, then process.
|
||||||
let mut drained: Vec<BackendEvent> = Vec::new();
|
let mut drained: Vec<BackendEvent> = Vec::new();
|
||||||
let mut closed = false;
|
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 cockpit_hint = if state.cockpit { "Tab close cockpit" } else { "Tab cockpit" };
|
||||||
let footer = Line::from(vec![
|
let footer = Line::from(vec![
|
||||||
Span::styled(
|
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),
|
Style::default().fg(STATUS_GRAY),
|
||||||
),
|
),
|
||||||
Span::raw("│ "),
|
Span::raw("│ "),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue