diff --git a/Cargo.toml b/Cargo.toml index ff0d188..678be4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "eullm-agent" -version = "0.1.4" +version = "0.1.6" edition = "2021" license = "Apache-2.0" description = "EULLM Agent — autonomous ReAct agent with multi-provider LLM support" diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 51abbca..30cd3ca 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,7 +1,7 @@ use anyhow::Result; use tracing::{debug, info, warn}; -use crate::llm::{LlmClient, Message, ToolCall}; +use crate::llm::{strip_think_blocks, LlmClient, Message, ToolCall}; use crate::tools::ToolRegistry; pub struct Agent<'a> { @@ -34,11 +34,11 @@ impl<'a> Agent<'a> { if response.tool_calls.is_empty() { info!("done after {} iterations", iteration + 1); - return Ok(response.content); + return Ok(strip_think_blocks(&response.content)); } messages.push(Message::assistant_with_tools( - response.content.clone(), + strip_think_blocks(&response.content), response.tool_calls.clone(), )); diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 1450b22..e147247 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -65,3 +65,18 @@ pub trait LlmClient: Send + Sync { async fn chat(&self, messages: &[Message], tools: &[ToolDefinition]) -> Result; fn provider_name(&self) -> &str; } + +/// Remove ... blocks emitted by reasoning models (e.g. Qwen3, DeepSeek). +pub fn strip_think_blocks(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(start) = rest.find("") { + out.push_str(&rest[..start]); + match rest.find("") { + Some(end) => rest = &rest[end + "".len()..], + None => break, + } + } + out.push_str(rest); + out.trim().to_string() +} diff --git a/src/main.rs b/src/main.rs index b5f67b0..aa3c25f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,14 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use std::{path::PathBuf, sync::Arc}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use tracing::info; use tracing_subscriber::EnvFilter; mod agent; mod config; mod llm; +mod modules; mod telegram; mod tools; mod wizard; @@ -17,9 +19,11 @@ use llm::{ eullm::EullmClient, openai::OpenAiClient, }; +use modules::ModuleRegistry; use tools::{ filesystem::{ListDirTool, ReadFileTool, WriteFileTool}, http::FetchUrlTool, + module_tool::{InstallModuleTool, ListModulesTool, ModuleTool}, shell::ShellTool, ToolRegistry, }; @@ -56,13 +60,23 @@ async fn main() -> Result<()> { let cli = Cli::parse(); - let config = if cli.config.exists() { + let mut config = if cli.config.exists() { Config::load(&cli.config) .with_context(|| format!("Cannot load config from {:?}", cli.config))? } else { wizard::run(&cli.config)? }; + let module_registry = Arc::new(Mutex::new( + ModuleRegistry::load(module_state_path())? + )); + + // Augment system prompt with current module status + { + let reg = module_registry.lock().unwrap(); + config.system_prompt.push_str(®.status_summary()); + } + let llm: Arc = match &config.provider { ProviderConfig::Eullm { base_url, model } => { info!("provider=eullm base_url={base_url} model={model}"); @@ -79,7 +93,7 @@ async fn main() -> Result<()> { } }; - let tools = build_tool_registry(&config); + let tools = build_tool_registry(&config, Arc::clone(&module_registry)); match cli.command { Commands::Serve => { @@ -88,7 +102,7 @@ async fn main() -> Result<()> { Commands::Run { task } => { let agent = agent::Agent::new(llm.as_ref(), &tools, config.max_iterations); let result = agent - .run(&config.system_prompt, &task, |s| println!("[•] {s}")) + .run(&config.system_prompt, &task, |s| println!("[\u{2022}] {s}")) .await?; println!("{result}"); } @@ -97,27 +111,49 @@ async fn main() -> Result<()> { Ok(()) } -fn build_tool_registry(config: &Config) -> ToolRegistry { - let mut r = ToolRegistry::new(); +fn build_tool_registry( + config: &Config, + module_registry: Arc>, +) -> ToolRegistry { + let r = ToolRegistry::new(); let tc = &config.tools; if tc.shell.enabled { - r.register(Box::new(ShellTool::new( - tc.shell.allow_sudo, - tc.shell.timeout_seconds, - ))); + r.register(Arc::new(ShellTool::new(tc.shell.allow_sudo, tc.shell.timeout_seconds))); } - if tc.filesystem.enabled { let paths = tc.filesystem.allowed_paths.clone(); - r.register(Box::new(ReadFileTool::new(paths.clone()))); - r.register(Box::new(WriteFileTool::new(paths))); - r.register(Box::new(ListDirTool)); + r.register(Arc::new(ReadFileTool::new(paths.clone()))); + r.register(Arc::new(WriteFileTool::new(paths))); + r.register(Arc::new(ListDirTool)); } - if tc.http.enabled { - r.register(Box::new(FetchUrlTool::new(tc.http.timeout_seconds))); + r.register(Arc::new(FetchUrlTool::new(tc.http.timeout_seconds))); + } + + // Module management tools (always available) + r.register(Arc::new(ListModulesTool::new(Arc::clone(&module_registry)))); + // InstallModuleTool gets a clone of r so it can register new tools at runtime + r.register(Arc::new(InstallModuleTool::new(Arc::clone(&module_registry), r.clone()))); + + // Register tools from already-installed modules + { + let reg = module_registry.lock().unwrap(); + for manifest in ®.manifests { + if reg.state.installed.contains(&manifest.name) { + for spec in &manifest.tools { + r.register(Arc::new(ModuleTool::new(spec.clone()))); + } + } + } } r } + +fn module_state_path() -> PathBuf { + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join(".eullm-agent").join("module-state.json") +} diff --git a/src/modules/builtin.rs b/src/modules/builtin.rs new file mode 100644 index 0000000..a0dab73 --- /dev/null +++ b/src/modules/builtin.rs @@ -0,0 +1,60 @@ +use super::{ModuleManifest, ModuleToolSpec}; +use serde_json::json; + +pub fn all_modules() -> Vec { + vec![ocr(), pdf()] +} + +fn ocr() -> ModuleManifest { + ModuleManifest { + name: "ocr".into(), + description: "Extract text from images using Tesseract OCR".into(), + version: "1.0", + install_linux: vec!["sudo apt-get install -y tesseract-ocr".into()], + install_macos: vec!["brew install tesseract".into()], + tools: vec![ModuleToolSpec { + name: "ocr_image".into(), + description: "Extract text from an image file using OCR.".into(), + parameters: json!({ + "type": "object", + "properties": { + "image_path": { + "type": "string", + "description": "Absolute or relative path to the image file" + }, + "language": { + "type": "string", + "description": "Tesseract language code: eng (English), ita (Italian), deu (German), fra (French). Use eng if unsure." + } + }, + "required": ["image_path", "language"] + }), + command: "tesseract {image_path} stdout -l {language}".into(), + }], + } +} + +fn pdf() -> ModuleManifest { + ModuleManifest { + name: "pdf".into(), + description: "Extract text from PDF files using poppler-utils".into(), + version: "1.0", + install_linux: vec!["sudo apt-get install -y poppler-utils".into()], + install_macos: vec!["brew install poppler".into()], + tools: vec![ModuleToolSpec { + name: "pdf_to_text".into(), + description: "Extract all text content from a PDF file".into(), + parameters: json!({ + "type": "object", + "properties": { + "pdf_path": { + "type": "string", + "description": "Absolute or relative path to the PDF file" + } + }, + "required": ["pdf_path"] + }), + command: "pdftotext {pdf_path} -".into(), + }], + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs new file mode 100644 index 0000000..91c17ed --- /dev/null +++ b/src/modules/mod.rs @@ -0,0 +1,85 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::PathBuf; + +pub mod builtin; + +#[derive(Debug, Clone)] +pub struct ModuleToolSpec { + pub name: String, + pub description: String, + pub parameters: serde_json::Value, + /// Shell command template; use {arg_name} for argument substitution. + pub command: String, +} + +#[derive(Debug, Clone)] +pub struct ModuleManifest { + pub name: String, + pub description: String, + pub version: &'static str, + pub install_linux: Vec, + pub install_macos: Vec, + pub tools: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ModuleState { + pub installed: HashSet, +} + +pub struct ModuleRegistry { + pub manifests: Vec, + pub state: ModuleState, + pub state_path: PathBuf, +} + +impl ModuleRegistry { + pub fn load(state_path: PathBuf) -> Result { + let state = if state_path.exists() { + let text = std::fs::read_to_string(&state_path)?; + serde_json::from_str(&text)? + } else { + ModuleState::default() + }; + Ok(Self { + manifests: builtin::all_modules(), + state, + state_path, + }) + } + + pub fn save_state(&self) -> Result<()> { + if let Some(parent) = self.state_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&self.state_path, serde_json::to_string_pretty(&self.state)?)?; + Ok(()) + } + + /// Short text injected into the system prompt so the LLM knows what it has. + pub fn status_summary(&self) -> String { + let installed: Vec<&str> = self.manifests.iter() + .filter(|m| self.state.installed.contains(&m.name)) + .map(|m| m.name.as_str()) + .collect(); + let not_installed: Vec<&str> = self.manifests.iter() + .filter(|m| !self.state.installed.contains(&m.name)) + .map(|m| m.name.as_str()) + .collect(); + + let mut s = String::new(); + if !installed.is_empty() { + s.push_str(&format!("\n\nInstalled modules: {}", installed.join(", "))); + } + if !not_installed.is_empty() { + s.push_str(&format!( + "\nAvailable modules (not installed): {}", + not_installed.join(", ") + )); + s.push_str("\nUse list_modules to inspect them and install_module to install one."); + } + s + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index fb5f757..dbf9d49 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,11 +1,13 @@ use anyhow::Result; use async_trait::async_trait; use serde_json::Value; +use std::sync::{Arc, Mutex}; use crate::llm::ToolDefinition; pub mod filesystem; pub mod http; +pub mod module_tool; pub mod shell; #[async_trait] @@ -14,28 +16,36 @@ pub trait Tool: Send + Sync { async fn execute(&self, arguments: &Value) -> Result; } +/// Shared, cloneable tool registry with interior mutability. +/// Clones share the same underlying tool list — modules installed at runtime +/// become immediately visible to all clones (including ones held by InstallModuleTool). +#[derive(Clone)] pub struct ToolRegistry { - tools: Vec>, + tools: Arc>>>, } impl ToolRegistry { pub fn new() -> Self { - Self { tools: Vec::new() } + Self { tools: Arc::new(Mutex::new(Vec::new())) } } - pub fn register(&mut self, tool: Box) { - self.tools.push(tool); + pub fn register(&self, tool: Arc) { + self.tools.lock().unwrap().push(tool); } pub fn definitions(&self) -> Vec { - self.tools.iter().map(|t| t.definition()).collect() + self.tools.lock().unwrap().iter().map(|t| t.definition()).collect() } pub async fn execute(&self, name: &str, arguments: &Value) -> Result { - let tool = self.tools.iter() - .find(|t| t.definition().name == name) - .ok_or_else(|| anyhow::anyhow!("Unknown tool: {name}"))?; - tool.execute(arguments).await + let tool = { + let tools = self.tools.lock().unwrap(); + tools.iter().find(|t| t.definition().name == name).map(Arc::clone) + }; + match tool { + Some(t) => t.execute(arguments).await, + None => Err(anyhow::anyhow!("Unknown tool: {name}")), + } } } diff --git a/src/tools/module_tool.rs b/src/tools/module_tool.rs new file mode 100644 index 0000000..35c4c7c --- /dev/null +++ b/src/tools/module_tool.rs @@ -0,0 +1,202 @@ +use anyhow::Result; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +use crate::llm::ToolDefinition; +use crate::modules::{ModuleRegistry, ModuleToolSpec}; +use super::{Tool, ToolRegistry}; + +/// Executes a single module-defined tool via shell command template substitution. +pub struct ModuleTool { + spec: ModuleToolSpec, +} + +impl ModuleTool { + pub fn new(spec: ModuleToolSpec) -> Self { + Self { spec } + } +} + +#[async_trait] +impl Tool for ModuleTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: self.spec.name.clone(), + description: self.spec.description.clone(), + parameters: self.spec.parameters.clone(), + } + } + + async fn execute(&self, arguments: &Value) -> Result { + let mut cmd = self.spec.command.clone(); + if let Some(obj) = arguments.as_object() { + for (key, val) in obj { + let placeholder = format!("{{{}}}", key); + let value = match val { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + cmd = cmd.replace(&placeholder, &value); + } + } + + let output = tokio::process::Command::new("sh") + .arg("-c") + .arg(&cmd) + .output() + .await?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if output.status.success() { + Ok(stdout) + } else { + Err(anyhow::anyhow!("Command failed: {}", stderr.trim())) + } + } +} + +/// Lists all modules and their installation status. +pub struct ListModulesTool { + registry: Arc>, +} + +impl ListModulesTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for ListModulesTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "list_modules".into(), + description: "List all available modules and their installation status. \ + Use install_module to install a missing one.".into(), + parameters: json!({ "type": "object", "properties": {} }), + } + } + + async fn execute(&self, _: &Value) -> Result { + let reg = self.registry.lock().unwrap(); + let mut out = String::new(); + + out.push_str("=== Installed modules ===\n"); + let mut any = false; + for m in ®.manifests { + if reg.state.installed.contains(&m.name) { + any = true; + out.push_str(&format!(" {} v{} — {}\n", m.name, m.version, m.description)); + for t in &m.tools { + out.push_str(&format!(" tool: {}\n", t.name)); + } + } + } + if !any { out.push_str(" (none)\n"); } + + out.push_str("\n=== Available (not installed) ===\n"); + let mut any = false; + for m in ®.manifests { + if !reg.state.installed.contains(&m.name) { + any = true; + out.push_str(&format!(" {} — {}\n", m.name, m.description)); + } + } + if !any { out.push_str(" (all modules installed)\n"); } + + Ok(out) + } +} + +/// Installs a module and immediately registers its tools into the shared ToolRegistry. +pub struct InstallModuleTool { + registry: Arc>, + tool_registry: ToolRegistry, +} + +impl InstallModuleTool { + pub fn new(registry: Arc>, tool_registry: ToolRegistry) -> Self { + Self { registry, tool_registry } + } +} + +#[async_trait] +impl Tool for InstallModuleTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "install_module".into(), + description: "Install a module to gain new tool capabilities. \ + Runs the platform install commands, then makes the new tools \ + immediately available in this session.".into(), + parameters: json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Module name (e.g. 'ocr', 'pdf'). Use list_modules to see available ones." + } + }, + "required": ["name"] + }), + } + } + + async fn execute(&self, arguments: &Value) -> Result { + let name = arguments["name"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("'name' argument required"))? + .to_string(); + + let manifest = { + let reg = self.registry.lock().unwrap(); + if reg.state.installed.contains(&name) { + return Ok(format!("Module '{}' is already installed.", name)); + } + reg.manifests.iter().find(|m| m.name == name).cloned() + }; + + let manifest = manifest.ok_or_else(|| { + anyhow::anyhow!("Unknown module: '{}'. Use list_modules to see available modules.", name) + })?; + + #[cfg(target_os = "macos")] + let commands = &manifest.install_macos; + #[cfg(not(target_os = "macos"))] + let commands = &manifest.install_linux; + + for cmd in commands { + tracing::info!("module install: {cmd}"); + let output = tokio::process::Command::new("sh") + .arg("-c") + .arg(cmd) + .output() + .await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Install failed at '{cmd}': {}", stderr.trim() + )); + } + } + + { + let mut reg = self.registry.lock().unwrap(); + reg.state.installed.insert(name.clone()); + reg.save_state()?; + } + + let tool_names: Vec = manifest.tools.iter().map(|t| t.name.clone()).collect(); + for spec in manifest.tools { + self.tool_registry.register(Arc::new(ModuleTool::new(spec))); + } + + Ok(format!( + "Module '{}' installed.\nNew tools available: {}", + name, + tool_names.join(", ") + )) + } +} diff --git a/src/wizard.rs b/src/wizard.rs index cf4d4ee..1656619 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -71,7 +71,7 @@ fn setup_openai() -> Result { fn pick_telegram() -> Result> { println!(); - let enable = prompt("Enable Telegram bot? [y/N]", "N")?; + let enable = prompt("Enable Telegram bot?", "N")?; let enable_lower = enable.to_lowercase(); if !matches!(enable_lower.trim(), "y" | "yes") { return Ok(None); @@ -110,6 +110,6 @@ fn prompt_required(label: &str) -> Result { if !val.is_empty() { return Ok(val); } - println!(" (required — please enter a value)"); + println!(" (required \u{2014} please enter a value)"); } }