Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-05-08 09:15:51 -04:00
commit ae0968bf44
6 changed files with 397 additions and 43 deletions

View file

@ -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;

View file

@ -34,12 +34,20 @@ pub struct LlmConfig {
pub context_window: u32,
#[serde(skip_serializing_if = "Option::is_none")]
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 {
128000
}
fn default_max_tool_rounds() -> u32 {
10
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryConfig {
pub git_enabled: bool,

View file

@ -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<crate::bridge::bifrost::ToolDefinition> = 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<char> = 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<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(
&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;
}

View file

@ -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(

View file

@ -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<RwLock<ConsciousnessConfig>>,

View file

@ -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<Instant>,
/// Receiver for `/model` listing results from async Bifrost call.
pub model_rx: Option<oneshot::Receiver<String>>,
}
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 <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() {
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();
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 <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).
/// 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<BackendEvent> = 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(""),