feat(v2): complete multi-LLM providers, TUI redesign, and advanced agent features
Multi-LLM Provider Support: - Add llm-core crate with LlmProvider trait abstraction - Implement Anthropic Claude API client with streaming - Implement OpenAI API client with streaming - Add token counting with SimpleTokenCounter and ClaudeTokenCounter - Add retry logic with exponential backoff and jitter Borderless TUI Redesign: - Rewrite theme system with terminal capability detection (Full/Unicode256/Basic) - Add provider tabs component with keybind switching [1]/[2]/[3] - Implement vim-modal input (Normal/Insert/Visual/Command modes) - Redesign chat panel with timestamps and streaming indicators - Add multi-provider status bar with cost tracking - Add Nerd Font icons with graceful ASCII fallbacks - Add syntax highlighting (syntect) and markdown rendering (pulldown-cmark) Advanced Agent Features: - Add system prompt builder with configurable components - Enhance subagent orchestration with parallel execution - Add git integration module for safe command detection - Add streaming tool results via channels - Expand tool set: AskUserQuestion, TodoWrite, LS, MultiEdit, BashOutput, KillShell - Add WebSearch with provider abstraction Plugin System Enhancement: - Add full agent definition parsing from YAML frontmatter - Add skill system with progressive disclosure - Wire plugin hooks into HookManager 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
11
crates/tools/ask/Cargo.toml
Normal file
11
crates/tools/ask/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "tools-ask"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
color-eyre = "0.6"
|
||||
60
crates/tools/ask/src/lib.rs
Normal file
60
crates/tools/ask/src/lib.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
//! AskUserQuestion tool for interactive user input
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
/// A question option
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuestionOption {
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// A question to ask the user
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Question {
|
||||
pub question: String,
|
||||
pub header: String,
|
||||
pub options: Vec<QuestionOption>,
|
||||
pub multi_select: bool,
|
||||
}
|
||||
|
||||
/// Request sent to the UI to ask questions
|
||||
#[derive(Debug)]
|
||||
pub struct AskRequest {
|
||||
pub questions: Vec<Question>,
|
||||
pub response_tx: oneshot::Sender<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Channel for sending ask requests to the UI
|
||||
pub type AskSender = mpsc::Sender<AskRequest>;
|
||||
pub type AskReceiver = mpsc::Receiver<AskRequest>;
|
||||
|
||||
/// Create a channel pair for ask requests
|
||||
pub fn create_ask_channel() -> (AskSender, AskReceiver) {
|
||||
mpsc::channel(1)
|
||||
}
|
||||
|
||||
/// Ask the user questions (called by agent)
|
||||
pub async fn ask_user(
|
||||
sender: &AskSender,
|
||||
questions: Vec<Question>,
|
||||
) -> color_eyre::Result<HashMap<String, String>> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
|
||||
sender.send(AskRequest { questions, response_tx }).await
|
||||
.map_err(|_| color_eyre::eyre::eyre!("Failed to send ask request"))?;
|
||||
|
||||
response_rx.await
|
||||
.map_err(|_| color_eyre::eyre::eyre!("Failed to receive ask response"))
|
||||
}
|
||||
|
||||
/// Parse questions from JSON tool input
|
||||
pub fn parse_questions(input: &serde_json::Value) -> color_eyre::Result<Vec<Question>> {
|
||||
let questions = input.get("questions")
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("Missing 'questions' field"))?;
|
||||
|
||||
serde_json::from_value(questions.clone())
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Invalid questions format: {}", e))
|
||||
}
|
||||
@@ -6,9 +6,11 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.39", features = ["process", "io-util", "time", "sync"] }
|
||||
tokio = { version = "1.39", features = ["process", "io-util", "time", "sync", "rt"] }
|
||||
color-eyre = "0.6"
|
||||
tempfile = "3.23.0"
|
||||
parking_lot = "0.12"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use std::collections::HashMap;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -19,6 +22,7 @@ pub struct CommandOutput {
|
||||
|
||||
pub struct BashSession {
|
||||
child: Mutex<Child>,
|
||||
last_output: Option<String>,
|
||||
}
|
||||
|
||||
impl BashSession {
|
||||
@@ -40,6 +44,7 @@ impl BashSession {
|
||||
|
||||
Ok(Self {
|
||||
child: Mutex::new(child),
|
||||
last_output: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,7 +59,13 @@ impl BashSession {
|
||||
let result = timeout(timeout_duration, self.execute_internal(command)).await;
|
||||
|
||||
match result {
|
||||
Ok(output) => output,
|
||||
Ok(output) => {
|
||||
// Store the output for potential retrieval via BashOutput tool
|
||||
let combined = format!("{}{}", output.as_ref().map(|o| o.stdout.as_str()).unwrap_or(""),
|
||||
output.as_ref().map(|o| o.stderr.as_str()).unwrap_or(""));
|
||||
self.last_output = Some(combined);
|
||||
output
|
||||
},
|
||||
Err(_) => Err(eyre!("Command timed out after {}ms", timeout_duration.as_millis())),
|
||||
}
|
||||
}
|
||||
@@ -158,6 +169,106 @@ impl BashSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages background bash shells by ID
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ShellManager {
|
||||
shells: Arc<RwLock<HashMap<String, BashSession>>>,
|
||||
}
|
||||
|
||||
impl ShellManager {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Start a new background shell, returns shell ID
|
||||
pub async fn start_shell(&self) -> Result<String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let session = BashSession::new().await?;
|
||||
self.shells.write().insert(id.clone(), session);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Execute command in background shell
|
||||
pub async fn execute(&self, shell_id: &str, command: &str, timeout: Option<Duration>) -> Result<CommandOutput> {
|
||||
// We need to handle this carefully to avoid holding the lock across await
|
||||
// First check if the shell exists and clone what we need
|
||||
let exists = self.shells.read().contains_key(shell_id);
|
||||
if !exists {
|
||||
return Err(eyre!("Shell not found: {}", shell_id));
|
||||
}
|
||||
|
||||
// For now, we need to use a more complex approach since BashSession contains async operations
|
||||
// We'll execute and then update in a separate critical section
|
||||
let timeout_ms = timeout.map(|d| d.as_millis() as u64);
|
||||
|
||||
// Take temporary ownership for execution
|
||||
let mut session = {
|
||||
let mut shells = self.shells.write();
|
||||
shells.remove(shell_id)
|
||||
.ok_or_else(|| eyre!("Shell not found: {}", shell_id))?
|
||||
};
|
||||
|
||||
// Execute without holding the lock
|
||||
let result = session.execute(command, timeout_ms).await;
|
||||
|
||||
// Put the session back
|
||||
self.shells.write().insert(shell_id.to_string(), session);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get output from a shell (BashOutput tool)
|
||||
pub fn get_output(&self, shell_id: &str) -> Result<Option<String>> {
|
||||
let shells = self.shells.read();
|
||||
let session = shells.get(shell_id)
|
||||
.ok_or_else(|| eyre!("Shell not found: {}", shell_id))?;
|
||||
// Return any buffered output
|
||||
Ok(session.last_output.clone())
|
||||
}
|
||||
|
||||
/// Kill a shell (KillShell tool)
|
||||
pub fn kill_shell(&self, shell_id: &str) -> Result<()> {
|
||||
let mut shells = self.shells.write();
|
||||
if shells.remove(shell_id).is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(eyre!("Shell not found: {}", shell_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// List active shells
|
||||
pub fn list_shells(&self) -> Vec<String> {
|
||||
self.shells.read().keys().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a background bash command, returns shell ID
|
||||
pub async fn run_background(manager: &ShellManager, command: &str) -> Result<String> {
|
||||
let shell_id = manager.start_shell().await?;
|
||||
// Execute in background (non-blocking)
|
||||
tokio::spawn({
|
||||
let manager = manager.clone();
|
||||
let command = command.to_string();
|
||||
let shell_id = shell_id.clone();
|
||||
async move {
|
||||
let _ = manager.execute(&shell_id, &command, None).await;
|
||||
}
|
||||
});
|
||||
Ok(shell_id)
|
||||
}
|
||||
|
||||
/// Get output from background shell (BashOutput tool)
|
||||
pub fn bash_output(manager: &ShellManager, shell_id: &str) -> Result<String> {
|
||||
manager.get_output(shell_id)?
|
||||
.ok_or_else(|| eyre!("No output available"))
|
||||
}
|
||||
|
||||
/// Kill a background shell (KillShell tool)
|
||||
pub fn kill_shell(manager: &ShellManager, shell_id: &str) -> Result<String> {
|
||||
manager.kill_shell(shell_id)?;
|
||||
Ok(format!("Shell {} terminated", shell_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -13,6 +13,8 @@ grep-regex = "0.1"
|
||||
grep-searcher = "0.1"
|
||||
color-eyre = "0.6"
|
||||
similar = "2.7"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
humantime = "2.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.23.0"
|
||||
@@ -3,6 +3,7 @@ use ignore::WalkBuilder;
|
||||
use grep_regex::RegexMatcher;
|
||||
use grep_searcher::{sinks::UTF8, SearcherBuilder};
|
||||
use globset::Glob;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn read_file(path: &str) -> Result<String> {
|
||||
@@ -127,4 +128,81 @@ pub fn grep(root: &str, pattern: &str) -> Result<Vec<(String, usize, String)>> {
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Edit operation for MultiEdit
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditOperation {
|
||||
pub old_string: String,
|
||||
pub new_string: String,
|
||||
}
|
||||
|
||||
/// Perform multiple edits on a file atomically
|
||||
pub fn multi_edit_file(path: &str, edits: Vec<EditOperation>) -> Result<String> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let mut new_content = content.clone();
|
||||
|
||||
// Apply edits in order
|
||||
for edit in &edits {
|
||||
if !new_content.contains(&edit.old_string) {
|
||||
return Err(eyre!("String not found: '{}'", edit.old_string));
|
||||
}
|
||||
new_content = new_content.replacen(&edit.old_string, &edit.new_string, 1);
|
||||
}
|
||||
|
||||
// Create backup and write
|
||||
let backup_path = format!("{}.bak", path);
|
||||
std::fs::copy(path, &backup_path)?;
|
||||
std::fs::write(path, &new_content)?;
|
||||
|
||||
Ok(format!("Applied {} edits to {}", edits.len(), path))
|
||||
}
|
||||
|
||||
/// Entry in directory listing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DirEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: Option<u64>,
|
||||
pub modified: Option<String>,
|
||||
}
|
||||
|
||||
/// List contents of a directory
|
||||
pub fn list_directory(path: &str, show_hidden: bool) -> Result<Vec<DirEntry>> {
|
||||
let entries = std::fs::read_dir(path)?;
|
||||
let mut result = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
// Skip hidden files unless requested
|
||||
if !show_hidden && name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = entry.metadata()?;
|
||||
let modified = metadata.modified().ok().map(|t| {
|
||||
// Format as ISO 8601
|
||||
humantime::format_rfc3339(t).to_string()
|
||||
});
|
||||
|
||||
result.push(DirEntry {
|
||||
name,
|
||||
is_dir: metadata.is_dir(),
|
||||
size: if metadata.is_file() { Some(metadata.len()) } else { None },
|
||||
modified,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort directories first, then alphabetically
|
||||
result.sort_by(|a, b| {
|
||||
match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use tools_fs::{read_file, glob_list, grep, write_file, edit_file};
|
||||
use tools_fs::{read_file, glob_list, grep, write_file, edit_file, multi_edit_file, list_directory, EditOperation};
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -101,4 +101,124 @@ fn edit_file_fails_on_no_match() {
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("not found") || err_msg.contains("String to replace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_edit_file_applies_multiple_edits() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
let original = "line 1\nline 2\nline 3\n";
|
||||
fs::write(&file_path, original).unwrap();
|
||||
|
||||
let edits = vec![
|
||||
EditOperation {
|
||||
old_string: "line 1".to_string(),
|
||||
new_string: "modified 1".to_string(),
|
||||
},
|
||||
EditOperation {
|
||||
old_string: "line 2".to_string(),
|
||||
new_string: "modified 2".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = multi_edit_file(file_path.to_str().unwrap(), edits).unwrap();
|
||||
assert!(result.contains("Applied 2 edits"));
|
||||
|
||||
let content = read_file(file_path.to_str().unwrap()).unwrap();
|
||||
assert_eq!(content, "modified 1\nmodified 2\nline 3\n");
|
||||
|
||||
// Backup file should exist
|
||||
let backup_path = format!("{}.bak", file_path.display());
|
||||
assert!(std::path::Path::new(&backup_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_edit_file_fails_on_missing_string() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
let original = "line 1\nline 2\n";
|
||||
fs::write(&file_path, original).unwrap();
|
||||
|
||||
let edits = vec![
|
||||
EditOperation {
|
||||
old_string: "line 1".to_string(),
|
||||
new_string: "modified 1".to_string(),
|
||||
},
|
||||
EditOperation {
|
||||
old_string: "nonexistent".to_string(),
|
||||
new_string: "modified".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = multi_edit_file(file_path.to_str().unwrap(), edits);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("String not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_directory_shows_files_and_dirs() {
|
||||
let dir = tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
|
||||
// Create test structure
|
||||
fs::write(root.join("file1.txt"), "content").unwrap();
|
||||
fs::write(root.join("file2.txt"), "content").unwrap();
|
||||
fs::create_dir(root.join("subdir")).unwrap();
|
||||
fs::write(root.join(".hidden"), "hidden content").unwrap();
|
||||
|
||||
let entries = list_directory(root.to_str().unwrap(), false).unwrap();
|
||||
|
||||
// Should find 2 files and 1 directory (hidden file excluded)
|
||||
assert_eq!(entries.len(), 3);
|
||||
|
||||
// Verify directory appears first (sorted)
|
||||
assert_eq!(entries[0].name, "subdir");
|
||||
assert!(entries[0].is_dir);
|
||||
|
||||
// Verify files
|
||||
let file_names: Vec<_> = entries.iter().skip(1).map(|e| e.name.as_str()).collect();
|
||||
assert!(file_names.contains(&"file1.txt"));
|
||||
assert!(file_names.contains(&"file2.txt"));
|
||||
|
||||
// Hidden file should not be present
|
||||
assert!(!entries.iter().any(|e| e.name == ".hidden"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_directory_shows_hidden_when_requested() {
|
||||
let dir = tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
|
||||
fs::write(root.join("visible.txt"), "content").unwrap();
|
||||
fs::write(root.join(".hidden"), "hidden content").unwrap();
|
||||
|
||||
let entries = list_directory(root.to_str().unwrap(), true).unwrap();
|
||||
|
||||
// Should find both files
|
||||
assert!(entries.iter().any(|e| e.name == "visible.txt"));
|
||||
assert!(entries.iter().any(|e| e.name == ".hidden"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_directory_includes_metadata() {
|
||||
let dir = tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
|
||||
fs::write(root.join("test.txt"), "hello world").unwrap();
|
||||
fs::create_dir(root.join("testdir")).unwrap();
|
||||
|
||||
let entries = list_directory(root.to_str().unwrap(), false).unwrap();
|
||||
|
||||
// Directory entry should have no size
|
||||
let dir_entry = entries.iter().find(|e| e.name == "testdir").unwrap();
|
||||
assert!(dir_entry.is_dir);
|
||||
assert!(dir_entry.size.is_none());
|
||||
assert!(dir_entry.modified.is_some());
|
||||
|
||||
// File entry should have size
|
||||
let file_entry = entries.iter().find(|e| e.name == "test.txt").unwrap();
|
||||
assert!(!file_entry.is_dir);
|
||||
assert_eq!(file_entry.size, Some(11)); // "hello world" is 11 bytes
|
||||
assert!(file_entry.modified.is_some());
|
||||
}
|
||||
@@ -10,5 +10,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
color-eyre = "0.6"
|
||||
permissions = { path = "../../platform/permissions" }
|
||||
plugins = { path = "../../platform/plugins" }
|
||||
parking_lot = "0.12"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,8 +1,180 @@
|
||||
// Note: Result and eyre will be used by spawn_subagent when implemented
|
||||
#[allow(unused_imports)]
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use parking_lot::RwLock;
|
||||
use permissions::Tool;
|
||||
use plugins::AgentDefinition;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A specialized subagent with limited tool access
|
||||
/// Configuration for spawning a subagent
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubagentConfig {
|
||||
/// Agent type/name (e.g., "code-reviewer", "explore")
|
||||
pub agent_type: String,
|
||||
|
||||
/// Task prompt for the agent
|
||||
pub prompt: String,
|
||||
|
||||
/// Optional model override
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Tool whitelist (if None, uses agent's default)
|
||||
pub tools: Option<Vec<String>>,
|
||||
|
||||
/// Parsed agent definition (if from plugin)
|
||||
pub definition: Option<AgentDefinition>,
|
||||
}
|
||||
|
||||
impl SubagentConfig {
|
||||
/// Create a new subagent config with just type and prompt
|
||||
pub fn new(agent_type: String, prompt: String) -> Self {
|
||||
Self {
|
||||
agent_type,
|
||||
prompt,
|
||||
model: None,
|
||||
tools: None,
|
||||
definition: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder method to set model override
|
||||
pub fn with_model(mut self, model: String) -> Self {
|
||||
self.model = Some(model);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder method to set tool whitelist
|
||||
pub fn with_tools(mut self, tools: Vec<String>) -> Self {
|
||||
self.tools = Some(tools);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder method to set agent definition
|
||||
pub fn with_definition(mut self, definition: AgentDefinition) -> Self {
|
||||
self.definition = Some(definition);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry of available subagents
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SubagentRegistry {
|
||||
agents: Arc<RwLock<HashMap<String, AgentDefinition>>>,
|
||||
}
|
||||
|
||||
impl SubagentRegistry {
|
||||
/// Create a new empty registry
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register agents from plugin manager
|
||||
pub fn register_from_plugins(&self, agents: Vec<AgentDefinition>) {
|
||||
let mut map = self.agents.write();
|
||||
for agent in agents {
|
||||
map.insert(agent.name.clone(), agent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register built-in agents
|
||||
pub fn register_builtin(&self) {
|
||||
let mut map = self.agents.write();
|
||||
|
||||
// Explore agent - for codebase exploration
|
||||
map.insert("explore".to_string(), AgentDefinition {
|
||||
name: "explore".to_string(),
|
||||
description: "Explores codebases to find files and understand structure".to_string(),
|
||||
tools: vec!["read".to_string(), "glob".to_string(), "grep".to_string(), "ls".to_string()],
|
||||
model: None,
|
||||
color: Some("blue".to_string()),
|
||||
system_prompt: "You are an exploration agent. Your purpose is to find relevant files and understand code structure. Use glob to find files by pattern, grep to search for content, ls to list directories, and read to examine files. Be thorough and systematic in your exploration.".to_string(),
|
||||
source_path: PathBuf::new(),
|
||||
});
|
||||
|
||||
// Plan agent - for designing implementations
|
||||
map.insert("plan".to_string(), AgentDefinition {
|
||||
name: "plan".to_string(),
|
||||
description: "Designs implementation plans and architectures".to_string(),
|
||||
tools: vec!["read".to_string(), "glob".to_string(), "grep".to_string()],
|
||||
model: None,
|
||||
color: Some("green".to_string()),
|
||||
system_prompt: "You are a planning agent. Your purpose is to design clear implementation strategies and architectures. Read existing code, understand patterns, and create detailed plans. Focus on the 'why' and 'how' rather than the 'what'.".to_string(),
|
||||
source_path: PathBuf::new(),
|
||||
});
|
||||
|
||||
// Code reviewer - read-only analysis
|
||||
map.insert("code-reviewer".to_string(), AgentDefinition {
|
||||
name: "code-reviewer".to_string(),
|
||||
description: "Reviews code for quality, bugs, and best practices".to_string(),
|
||||
tools: vec!["read".to_string(), "grep".to_string(), "glob".to_string()],
|
||||
model: None,
|
||||
color: Some("yellow".to_string()),
|
||||
system_prompt: "You are a code review agent. Analyze code for quality, potential bugs, performance issues, and adherence to best practices. Provide constructive feedback with specific examples.".to_string(),
|
||||
source_path: PathBuf::new(),
|
||||
});
|
||||
|
||||
// Test writer - can read and write test files
|
||||
map.insert("test-writer".to_string(), AgentDefinition {
|
||||
name: "test-writer".to_string(),
|
||||
description: "Writes and updates test files".to_string(),
|
||||
tools: vec!["read".to_string(), "write".to_string(), "edit".to_string(), "grep".to_string(), "glob".to_string()],
|
||||
model: None,
|
||||
color: Some("cyan".to_string()),
|
||||
system_prompt: "You are a test writing agent. Write comprehensive, well-structured tests that cover edge cases and ensure code correctness. Follow testing best practices and patterns used in the codebase.".to_string(),
|
||||
source_path: PathBuf::new(),
|
||||
});
|
||||
|
||||
// Documentation agent - can read code and write docs
|
||||
map.insert("doc-writer".to_string(), AgentDefinition {
|
||||
name: "doc-writer".to_string(),
|
||||
description: "Writes and maintains documentation".to_string(),
|
||||
tools: vec!["read".to_string(), "write".to_string(), "edit".to_string(), "grep".to_string(), "glob".to_string()],
|
||||
model: None,
|
||||
color: Some("magenta".to_string()),
|
||||
system_prompt: "You are a documentation agent. Write clear, comprehensive documentation that helps users understand the code. Include examples, explain concepts, and maintain consistency with existing documentation style.".to_string(),
|
||||
source_path: PathBuf::new(),
|
||||
});
|
||||
|
||||
// Refactoring agent - full file access but no bash
|
||||
map.insert("refactorer".to_string(), AgentDefinition {
|
||||
name: "refactorer".to_string(),
|
||||
description: "Refactors code while preserving functionality".to_string(),
|
||||
tools: vec!["read".to_string(), "write".to_string(), "edit".to_string(), "grep".to_string(), "glob".to_string()],
|
||||
model: None,
|
||||
color: Some("red".to_string()),
|
||||
system_prompt: "You are a refactoring agent. Improve code structure, readability, and maintainability while preserving functionality. Follow SOLID principles and language idioms. Make small, incremental changes.".to_string(),
|
||||
source_path: PathBuf::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Get an agent by name
|
||||
pub fn get(&self, name: &str) -> Option<AgentDefinition> {
|
||||
self.agents.read().get(name).cloned()
|
||||
}
|
||||
|
||||
/// List all available agents with their descriptions
|
||||
pub fn list(&self) -> Vec<(String, String)> {
|
||||
self.agents.read()
|
||||
.iter()
|
||||
.map(|(name, def)| (name.clone(), def.description.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if an agent exists
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.agents.read().contains_key(name)
|
||||
}
|
||||
|
||||
/// Get all agent names
|
||||
pub fn agent_names(&self) -> Vec<String> {
|
||||
self.agents.read().keys().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A specialized subagent with limited tool access (legacy API for backward compatibility)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Subagent {
|
||||
/// Unique identifier for the subagent
|
||||
@@ -39,92 +211,6 @@ impl Subagent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry for managing subagents
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubagentRegistry {
|
||||
subagents: Vec<Subagent>,
|
||||
}
|
||||
|
||||
impl SubagentRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
subagents: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new subagent
|
||||
pub fn register(&mut self, subagent: Subagent) {
|
||||
self.subagents.push(subagent);
|
||||
}
|
||||
|
||||
/// Select the most appropriate subagent for a task
|
||||
pub fn select(&self, task_description: &str) -> Option<&Subagent> {
|
||||
// Find the first subagent that matches the task description
|
||||
self.subagents
|
||||
.iter()
|
||||
.find(|agent| agent.matches_task(task_description))
|
||||
}
|
||||
|
||||
/// Get a subagent by name
|
||||
pub fn get(&self, name: &str) -> Option<&Subagent> {
|
||||
self.subagents.iter().find(|agent| agent.name == name)
|
||||
}
|
||||
|
||||
/// Check if a specific subagent can use a tool
|
||||
pub fn can_use_tool(&self, agent_name: &str, tool: Tool) -> Result<bool> {
|
||||
let agent = self.get(agent_name)
|
||||
.ok_or_else(|| eyre!("Subagent '{}' not found", agent_name))?;
|
||||
Ok(agent.can_use_tool(tool))
|
||||
}
|
||||
|
||||
/// List all registered subagents
|
||||
pub fn list(&self) -> &[Subagent] {
|
||||
&self.subagents
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SubagentRegistry {
|
||||
fn default() -> Self {
|
||||
let mut registry = Self::new();
|
||||
|
||||
// Register built-in subagents
|
||||
|
||||
// Code reviewer - read-only tools
|
||||
registry.register(Subagent::new(
|
||||
"code-reviewer".to_string(),
|
||||
"Reviews code for quality, bugs, and best practices".to_string(),
|
||||
vec!["review".to_string(), "analyze code".to_string(), "check code".to_string()],
|
||||
vec![Tool::Read, Tool::Grep, Tool::Glob],
|
||||
));
|
||||
|
||||
// Test writer - can read and write test files
|
||||
registry.register(Subagent::new(
|
||||
"test-writer".to_string(),
|
||||
"Writes and updates test files".to_string(),
|
||||
vec!["test".to_string(), "write tests".to_string(), "add tests".to_string()],
|
||||
vec![Tool::Read, Tool::Write, Tool::Edit, Tool::Grep, Tool::Glob],
|
||||
));
|
||||
|
||||
// Documentation agent - can read code and write docs
|
||||
registry.register(Subagent::new(
|
||||
"doc-writer".to_string(),
|
||||
"Writes and maintains documentation".to_string(),
|
||||
vec!["document".to_string(), "docs".to_string(), "readme".to_string()],
|
||||
vec![Tool::Read, Tool::Write, Tool::Edit, Tool::Grep, Tool::Glob],
|
||||
));
|
||||
|
||||
// Refactoring agent - full file access but no bash
|
||||
registry.register(Subagent::new(
|
||||
"refactorer".to_string(),
|
||||
"Refactors code while preserving functionality".to_string(),
|
||||
vec!["refactor".to_string(), "restructure".to_string(), "reorganize".to_string()],
|
||||
vec![Tool::Read, Tool::Write, Tool::Edit, Tool::Grep, Tool::Glob],
|
||||
));
|
||||
|
||||
registry
|
||||
}
|
||||
}
|
||||
|
||||
/// Task execution request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskRequest {
|
||||
@@ -149,6 +235,75 @@ pub struct TaskResult {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_subagent_registry_builtin() {
|
||||
let registry = SubagentRegistry::new();
|
||||
registry.register_builtin();
|
||||
|
||||
// Check that built-in agents are registered
|
||||
assert!(registry.contains("explore"));
|
||||
assert!(registry.contains("plan"));
|
||||
assert!(registry.contains("code-reviewer"));
|
||||
assert!(registry.contains("test-writer"));
|
||||
assert!(registry.contains("doc-writer"));
|
||||
assert!(registry.contains("refactorer"));
|
||||
|
||||
// Get an agent and verify its properties
|
||||
let explore = registry.get("explore").unwrap();
|
||||
assert_eq!(explore.name, "explore");
|
||||
assert!(explore.tools.contains(&"read".to_string()));
|
||||
assert!(explore.tools.contains(&"glob".to_string()));
|
||||
assert!(explore.tools.contains(&"grep".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subagent_registry_list() {
|
||||
let registry = SubagentRegistry::new();
|
||||
registry.register_builtin();
|
||||
|
||||
let agents = registry.list();
|
||||
assert!(agents.len() >= 6);
|
||||
|
||||
// Check that we have expected agents
|
||||
let names: Vec<String> = agents.iter().map(|(name, _)| name.clone()).collect();
|
||||
assert!(names.contains(&"explore".to_string()));
|
||||
assert!(names.contains(&"plan".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subagent_config_builder() {
|
||||
let config = SubagentConfig::new("explore".to_string(), "Find all Rust files".to_string())
|
||||
.with_model("claude-3-opus".to_string())
|
||||
.with_tools(vec!["read".to_string(), "glob".to_string()]);
|
||||
|
||||
assert_eq!(config.agent_type, "explore");
|
||||
assert_eq!(config.prompt, "Find all Rust files");
|
||||
assert_eq!(config.model, Some("claude-3-opus".to_string()));
|
||||
assert_eq!(config.tools, Some(vec!["read".to_string(), "glob".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_from_plugins() {
|
||||
let registry = SubagentRegistry::new();
|
||||
|
||||
let plugin_agent = AgentDefinition {
|
||||
name: "custom-agent".to_string(),
|
||||
description: "A custom agent from plugin".to_string(),
|
||||
tools: vec!["read".to_string()],
|
||||
model: Some("haiku".to_string()),
|
||||
color: Some("purple".to_string()),
|
||||
system_prompt: "Custom prompt".to_string(),
|
||||
source_path: PathBuf::from("/path/to/plugin"),
|
||||
};
|
||||
|
||||
registry.register_from_plugins(vec![plugin_agent]);
|
||||
|
||||
assert!(registry.contains("custom-agent"));
|
||||
let agent = registry.get("custom-agent").unwrap();
|
||||
assert_eq!(agent.model, Some("haiku".to_string()));
|
||||
}
|
||||
|
||||
// Legacy API tests for backward compatibility
|
||||
#[test]
|
||||
fn subagent_tool_whitelist() {
|
||||
let agent = Subagent::new(
|
||||
@@ -177,45 +332,4 @@ mod tests {
|
||||
assert!(agent.matches_task("Add test coverage"));
|
||||
assert!(!agent.matches_task("Refactor the database layer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_selection() {
|
||||
let registry = SubagentRegistry::default();
|
||||
|
||||
let reviewer = registry.select("Review the authentication code");
|
||||
assert!(reviewer.is_some());
|
||||
assert_eq!(reviewer.unwrap().name, "code-reviewer");
|
||||
|
||||
let tester = registry.select("Write tests for the API endpoints");
|
||||
assert!(tester.is_some());
|
||||
assert_eq!(tester.unwrap().name, "test-writer");
|
||||
|
||||
let doc_writer = registry.select("Update the README documentation");
|
||||
assert!(doc_writer.is_some());
|
||||
assert_eq!(doc_writer.unwrap().name, "doc-writer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_tool_validation() {
|
||||
let registry = SubagentRegistry::default();
|
||||
|
||||
// Code reviewer can only use read-only tools
|
||||
assert!(registry.can_use_tool("code-reviewer", Tool::Read).unwrap());
|
||||
assert!(registry.can_use_tool("code-reviewer", Tool::Grep).unwrap());
|
||||
assert!(!registry.can_use_tool("code-reviewer", Tool::Write).unwrap());
|
||||
assert!(!registry.can_use_tool("code-reviewer", Tool::Bash).unwrap());
|
||||
|
||||
// Test writer can write but not run bash
|
||||
assert!(registry.can_use_tool("test-writer", Tool::Read).unwrap());
|
||||
assert!(registry.can_use_tool("test-writer", Tool::Write).unwrap());
|
||||
assert!(!registry.can_use_tool("test-writer", Tool::Bash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonexistent_agent_error() {
|
||||
let registry = SubagentRegistry::default();
|
||||
let result = registry.can_use_tool("nonexistent", Tool::Read);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("not found"));
|
||||
}
|
||||
}
|
||||
|
||||
12
crates/tools/todo/Cargo.toml
Normal file
12
crates/tools/todo/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "tools-todo"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
parking_lot = "0.12"
|
||||
color-eyre = "0.6"
|
||||
113
crates/tools/todo/src/lib.rs
Normal file
113
crates/tools/todo/src/lib.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
//! TodoWrite tool for task list management
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Status of a todo item
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TodoStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
}
|
||||
|
||||
/// A todo item
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Todo {
|
||||
pub content: String,
|
||||
pub status: TodoStatus,
|
||||
pub active_form: String, // Present continuous form for display
|
||||
}
|
||||
|
||||
/// Shared todo list state
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TodoList {
|
||||
inner: Arc<RwLock<Vec<Todo>>>,
|
||||
}
|
||||
|
||||
impl TodoList {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Replace all todos with new list
|
||||
pub fn write(&self, todos: Vec<Todo>) {
|
||||
*self.inner.write() = todos;
|
||||
}
|
||||
|
||||
/// Get current todos
|
||||
pub fn read(&self) -> Vec<Todo> {
|
||||
self.inner.read().clone()
|
||||
}
|
||||
|
||||
/// Get the current in-progress task (for status display)
|
||||
pub fn current_task(&self) -> Option<String> {
|
||||
self.inner.read()
|
||||
.iter()
|
||||
.find(|t| t.status == TodoStatus::InProgress)
|
||||
.map(|t| t.active_form.clone())
|
||||
}
|
||||
|
||||
/// Get summary stats
|
||||
pub fn stats(&self) -> (usize, usize, usize) {
|
||||
let todos = self.inner.read();
|
||||
let pending = todos.iter().filter(|t| t.status == TodoStatus::Pending).count();
|
||||
let in_progress = todos.iter().filter(|t| t.status == TodoStatus::InProgress).count();
|
||||
let completed = todos.iter().filter(|t| t.status == TodoStatus::Completed).count();
|
||||
(pending, in_progress, completed)
|
||||
}
|
||||
|
||||
/// Format todos for display
|
||||
pub fn format_display(&self) -> String {
|
||||
let todos = self.inner.read();
|
||||
if todos.is_empty() {
|
||||
return "No tasks".to_string();
|
||||
}
|
||||
|
||||
todos.iter().enumerate().map(|(i, t)| {
|
||||
let status_icon = match t.status {
|
||||
TodoStatus::Pending => "○",
|
||||
TodoStatus::InProgress => "◐",
|
||||
TodoStatus::Completed => "●",
|
||||
};
|
||||
format!("{}. {} {}", i + 1, status_icon, t.content)
|
||||
}).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse todos from JSON tool input
|
||||
pub fn parse_todos(input: &serde_json::Value) -> color_eyre::Result<Vec<Todo>> {
|
||||
let todos = input.get("todos")
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("Missing 'todos' field"))?;
|
||||
|
||||
serde_json::from_value(todos.clone())
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Invalid todos format: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_todo_list() {
|
||||
let list = TodoList::new();
|
||||
|
||||
list.write(vec![
|
||||
Todo {
|
||||
content: "First task".to_string(),
|
||||
status: TodoStatus::Completed,
|
||||
active_form: "Completing first task".to_string(),
|
||||
},
|
||||
Todo {
|
||||
content: "Second task".to_string(),
|
||||
status: TodoStatus::InProgress,
|
||||
active_form: "Working on second task".to_string(),
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(list.current_task(), Some("Working on second task".to_string()));
|
||||
assert_eq!(list.stats(), (0, 1, 1));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ serde_json = "1"
|
||||
color-eyre = "0.6"
|
||||
url = "2.5"
|
||||
async-trait = "0.1"
|
||||
scraper = "0.18"
|
||||
urlencoding = "2.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use reqwest::redirect::Policy;
|
||||
use scraper::{Html, Selector};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use url::Url;
|
||||
@@ -173,6 +174,91 @@ impl SearchProvider for StubSearchProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// DuckDuckGo HTML search provider
|
||||
pub struct DuckDuckGoSearchProvider {
|
||||
client: reqwest::Client,
|
||||
max_results: usize,
|
||||
}
|
||||
|
||||
impl DuckDuckGoSearchProvider {
|
||||
/// Create a new DuckDuckGo search provider with default max results (10)
|
||||
pub fn new() -> Self {
|
||||
Self::with_max_results(10)
|
||||
}
|
||||
|
||||
/// Create a new DuckDuckGo search provider with custom max results
|
||||
pub fn with_max_results(max_results: usize) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0 (compatible; Owlen/1.0)")
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
Self { client, max_results }
|
||||
}
|
||||
|
||||
/// Parse DuckDuckGo HTML results
|
||||
fn parse_results(html: &str, max_results: usize) -> Result<Vec<SearchResult>> {
|
||||
let document = Html::parse_document(html);
|
||||
|
||||
// DuckDuckGo HTML selectors
|
||||
let result_selector = Selector::parse(".result").map_err(|e| eyre!("Invalid selector: {:?}", e))?;
|
||||
let title_selector = Selector::parse(".result__title a").map_err(|e| eyre!("Invalid selector: {:?}", e))?;
|
||||
let snippet_selector = Selector::parse(".result__snippet").map_err(|e| eyre!("Invalid selector: {:?}", e))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for result in document.select(&result_selector).take(max_results) {
|
||||
let title = result
|
||||
.select(&title_selector)
|
||||
.next()
|
||||
.map(|e| e.text().collect::<String>().trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let url = result
|
||||
.select(&title_selector)
|
||||
.next()
|
||||
.and_then(|e| e.value().attr("href"))
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let snippet = result
|
||||
.select(&snippet_selector)
|
||||
.next()
|
||||
.map(|e| e.text().collect::<String>().trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !title.is_empty() && !url.is_empty() {
|
||||
results.push(SearchResult { title, url, snippet });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DuckDuckGoSearchProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SearchProvider for DuckDuckGoSearchProvider {
|
||||
fn name(&self) -> &str {
|
||||
"duckduckgo"
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> Result<Vec<SearchResult>> {
|
||||
let encoded_query = urlencoding::encode(query);
|
||||
let url = format!("https://html.duckduckgo.com/html/?q={}", encoded_query);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let html = response.text().await?;
|
||||
|
||||
Self::parse_results(&html, self.max_results)
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSearch client with pluggable providers
|
||||
pub struct WebSearchClient {
|
||||
provider: Box<dyn SearchProvider>,
|
||||
@@ -192,6 +278,20 @@ impl WebSearchClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format search results for LLM consumption (markdown format)
|
||||
pub fn format_search_results(results: &[SearchResult]) -> String {
|
||||
if results.is_empty() {
|
||||
return "No results found.".to_string();
|
||||
}
|
||||
|
||||
results
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| format!("{}. [{}]({})\n {}", i + 1, r.title, r.url, r.snippet))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user