Skip to content
Merged

Dev #12

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
6 changes: 3 additions & 3 deletions src/agent/mod.rs
Original file line number Diff line number Diff line change
@@ -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> {
Expand Down Expand Up @@ -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(),
));

Expand Down
15 changes: 15 additions & 0 deletions src/llm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,18 @@ pub trait LlmClient: Send + Sync {
async fn chat(&self, messages: &[Message], tools: &[ToolDefinition]) -> Result<ChatResponse>;
fn provider_name(&self) -> &str;
}

/// Remove <think>...</think> 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("<think>") {
out.push_str(&rest[..start]);
match rest.find("</think>") {
Some(end) => rest = &rest[end + "</think>".len()..],
None => break,
}
}
out.push_str(rest);
out.trim().to_string()
}
68 changes: 52 additions & 16 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
};
Expand Down Expand Up @@ -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(&reg.status_summary());
}

let llm: Arc<dyn llm::LlmClient> = match &config.provider {
ProviderConfig::Eullm { base_url, model } => {
info!("provider=eullm base_url={base_url} model={model}");
Expand All @@ -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 => {
Expand All @@ -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}");
}
Expand All @@ -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<Mutex<ModuleRegistry>>,
) -> 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 &reg.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")
}
60 changes: 60 additions & 0 deletions src/modules/builtin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
use super::{ModuleManifest, ModuleToolSpec};
use serde_json::json;

pub fn all_modules() -> Vec<ModuleManifest> {
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(),
}],
}
}
85 changes: 85 additions & 0 deletions src/modules/mod.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub install_macos: Vec<String>,
pub tools: Vec<ModuleToolSpec>,
}

#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ModuleState {
pub installed: HashSet<String>,
}

pub struct ModuleRegistry {
pub manifests: Vec<ModuleManifest>,
pub state: ModuleState,
pub state_path: PathBuf,
}

impl ModuleRegistry {
pub fn load(state_path: PathBuf) -> Result<Self> {
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
}
}
28 changes: 19 additions & 9 deletions src/tools/mod.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -14,28 +16,36 @@ pub trait Tool: Send + Sync {
async fn execute(&self, arguments: &Value) -> Result<String>;
}

/// 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<Box<dyn Tool>>,
tools: Arc<Mutex<Vec<Arc<dyn Tool>>>>,
}

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<dyn Tool>) {
self.tools.push(tool);
pub fn register(&self, tool: Arc<dyn Tool>) {
self.tools.lock().unwrap().push(tool);
}

pub fn definitions(&self) -> Vec<ToolDefinition> {
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<String> {
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}")),
}
}
}

Expand Down
Loading
Loading