commit d5bde0a97ef95c594d5b8fc9eca9edba9ad595b7 Author: Thomas Marchand Date: Sun Dec 14 21:15:05 2025 +0000 Initial implementation: core agent with HTTP API and full toolset - Rust-based autonomous coding agent - HTTP API for task submission (POST /api/task) and status (GET /api/task/{id}) - SSE streaming for real-time progress (GET /api/task/{id}/stream) - OpenRouter integration with configurable models - Tool system with: file_ops, directory, terminal, search, web, git - Agent loop following 'tools in a loop' pattern - System prompt with tool definitions and rules diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..680d62f --- /dev/null +++ b/.cursorignore @@ -0,0 +1,4 @@ +!**/.env +!**/.env.* +!**/credentials.json +!**/secrets.json \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b95e4b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Rust build artifacts +/target/ +**/*.rs.bk +Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS files +.DS_Store +Thumbs.db diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fcd1ac3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "open_agent" +version = "0.1.0" +edition = "2021" +description = "A minimal autonomous coding agent with full machine access" +authors = ["Open Agent Contributors"] + +[dependencies] +# Async runtime +tokio = { version = "1", features = ["full"] } +tokio-stream = "0.1" + +# Web framework +axum = { version = "0.7", features = ["ws"] } +tower-http = { version = "0.5", features = ["cors", "trace"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# HTTP client +reqwest = { version = "0.12", features = ["json", "stream"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Utilities +uuid = { version = "1", features = ["v4", "serde"] } +thiserror = "1" +async-trait = "0.1" +futures = "0.3" + +# For tool implementations +walkdir = "2" +urlencoding = "2" +anyhow = "1" +async-stream = "0.3" + +[dev-dependencies] +tokio-test = "0.4" diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs new file mode 100644 index 0000000..41e5df4 --- /dev/null +++ b/src/agent/agent_loop.rs @@ -0,0 +1,172 @@ +//! Core agent loop implementation. + +use std::path::Path; +use std::sync::Arc; + +use crate::api::types::{LogEntryType, TaskLogEntry}; +use crate::config::Config; +use crate::llm::{ChatMessage, LlmClient, OpenRouterClient, Role, ToolCall}; +use crate::tools::ToolRegistry; + +use super::prompt::build_system_prompt; + +/// The autonomous agent. +pub struct Agent { + config: Config, + llm: Arc, + tools: ToolRegistry, +} + +impl Agent { + /// Create a new agent with the given configuration. + pub fn new(config: Config) -> Self { + let llm = Arc::new(OpenRouterClient::new(config.api_key.clone())); + let tools = ToolRegistry::new(); + + Self { config, llm, tools } + } + + /// Run a task and return the final response and execution log. + pub async fn run_task( + &self, + task: &str, + model: &str, + workspace_path: &Path, + ) -> anyhow::Result<(String, Vec)> { + let mut log = Vec::new(); + let workspace_str = workspace_path.to_string_lossy().to_string(); + + // Build initial messages + let system_prompt = build_system_prompt(&workspace_str, &self.tools); + let mut messages = vec![ + ChatMessage { + role: Role::System, + content: Some(system_prompt), + tool_calls: None, + tool_call_id: None, + }, + ChatMessage { + role: Role::User, + content: Some(task.to_string()), + tool_calls: None, + tool_call_id: None, + }, + ]; + + // Get tool schemas for LLM + let tool_schemas = self.tools.get_tool_schemas(); + + // Agent loop + for iteration in 0..self.config.max_iterations { + tracing::debug!("Agent iteration {}", iteration + 1); + + // Call LLM + let response = self + .llm + .chat_completion(model, &messages, Some(&tool_schemas)) + .await?; + + // Check for tool calls + if let Some(tool_calls) = &response.tool_calls { + if !tool_calls.is_empty() { + // Add assistant message with tool calls + messages.push(ChatMessage { + role: Role::Assistant, + content: response.content.clone(), + tool_calls: Some(tool_calls.clone()), + tool_call_id: None, + }); + + // Execute each tool call + for tool_call in tool_calls { + log.push(TaskLogEntry { + timestamp: chrono_now(), + entry_type: LogEntryType::ToolCall, + content: format!( + "Calling tool: {} with args: {}", + tool_call.function.name, tool_call.function.arguments + ), + }); + + let result = self + .execute_tool_call(tool_call, workspace_path) + .await; + + let result_str = match &result { + Ok(output) => output.clone(), + Err(e) => format!("Error: {}", e), + }; + + log.push(TaskLogEntry { + timestamp: chrono_now(), + entry_type: LogEntryType::ToolResult, + content: truncate_for_log(&result_str, 1000), + }); + + // Add tool result message + messages.push(ChatMessage { + role: Role::Tool, + content: Some(result_str), + tool_calls: None, + tool_call_id: Some(tool_call.id.clone()), + }); + } + + continue; + } + } + + // No tool calls - this is the final response + if let Some(content) = response.content { + log.push(TaskLogEntry { + timestamp: chrono_now(), + entry_type: LogEntryType::Response, + content: truncate_for_log(&content, 2000), + }); + return Ok((content, log)); + } + + // Empty response - shouldn't happen but handle gracefully + return Err(anyhow::anyhow!("LLM returned empty response")); + } + + Err(anyhow::anyhow!( + "Max iterations ({}) reached without completion", + self.config.max_iterations + )) + } + + /// Execute a single tool call. + async fn execute_tool_call( + &self, + tool_call: &ToolCall, + workspace_path: &Path, + ) -> anyhow::Result { + let args: serde_json::Value = serde_json::from_str(&tool_call.function.arguments) + .unwrap_or(serde_json::Value::Null); + + self.tools + .execute(&tool_call.function.name, args, workspace_path) + .await + } +} + +/// Get current timestamp as ISO 8601 string. +fn chrono_now() -> String { + use std::time::SystemTime; + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + format!("{}", now) +} + +/// Truncate a string for logging purposes. +fn truncate_for_log(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}... [truncated]", &s[..max_len]) + } +} + diff --git a/src/agent/mod.rs b/src/agent/mod.rs new file mode 100644 index 0000000..3ee49a8 --- /dev/null +++ b/src/agent/mod.rs @@ -0,0 +1,14 @@ +//! Agent module - the core autonomous agent logic. +//! +//! The agent follows a "tools in a loop" pattern: +//! 1. Build context with system prompt and user task +//! 2. Call LLM with available tools +//! 3. If LLM requests tool call, execute it and feed result back +//! 4. Repeat until LLM produces final response or max iterations reached + +mod agent_loop; +mod prompt; + +pub use agent_loop::Agent; +pub use prompt::build_system_prompt; + diff --git a/src/agent/prompt.rs b/src/agent/prompt.rs new file mode 100644 index 0000000..0c06cdf --- /dev/null +++ b/src/agent/prompt.rs @@ -0,0 +1,51 @@ +//! System prompt templates for the agent. + +use crate::tools::ToolRegistry; + +/// Build the system prompt with tool definitions. +pub fn build_system_prompt(workspace_path: &str, tools: &ToolRegistry) -> String { + let tool_descriptions = tools + .list_tools() + .iter() + .map(|t| format!("- **{}**: {}", t.name, t.description)) + .collect::>() + .join("\n"); + + format!( + r#"You are an autonomous coding agent with full access to the local machine. You operate in the workspace directory: {workspace_path} + +## Your Capabilities + +You have access to the following tools: +{tool_descriptions} + +## Rules and Guidelines + +1. **Always use tools** - Don't guess or make assumptions. Use tools to read files, check state, and verify your work. + +2. **Read before edit** - Always read a file's contents before modifying it, unless you're creating a new file. + +3. **Iterate on errors** - If a command fails or produces errors, analyze the output and try to fix the issue. Don't give up after one attempt. + +4. **Be thorough** - Complete the task fully. If asked to implement a feature, ensure it compiles, has no obvious bugs, and follows best practices. + +5. **Explain your reasoning** - Before using a tool, briefly explain why you're using it. + +6. **Stay focused** - Only make changes directly related to the task. Don't refactor unrelated code or add unrequested features. + +7. **Handle errors gracefully** - If you encounter an unrecoverable error, explain what went wrong and what the user might do to fix it. + +## Response Format + +When you've completed the task, provide a clear summary of: +- What you did +- Any files created or modified +- How to use or test the changes +- Any potential issues or next steps + +If you need to use a tool, respond with a tool call. The system will execute it and return the result."#, + workspace_path = workspace_path, + tool_descriptions = tool_descriptions + ) +} + diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..68f220d --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,15 @@ +//! HTTP API for the Open Agent. +//! +//! ## Endpoints +//! +//! - `POST /api/task` - Submit a new task +//! - `GET /api/task/{id}` - Get task status and result +//! - `GET /api/task/{id}/stream` - Stream task progress via SSE +//! - `GET /api/health` - Health check + +mod routes; +pub mod types; + +pub use routes::serve; +pub use types::*; + diff --git a/src/api/routes.rs b/src/api/routes.rs new file mode 100644 index 0000000..c18052e --- /dev/null +++ b/src/api/routes.rs @@ -0,0 +1,220 @@ +//! HTTP route handlers. + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{ + sse::{Event, Sse}, + Json, + }, + routing::{get, post}, + Router, +}; +use futures::stream::Stream; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; +use uuid::Uuid; + +use crate::agent::Agent; +use crate::config::Config; + +use super::types::*; + +/// Shared application state. +pub struct AppState { + pub config: Config, + pub tasks: RwLock>, + pub agent: Agent, +} + +/// Start the HTTP server. +pub async fn serve(config: Config) -> anyhow::Result<()> { + let agent = Agent::new(config.clone()); + + let state = Arc::new(AppState { + config: config.clone(), + tasks: RwLock::new(HashMap::new()), + agent, + }); + + let app = Router::new() + .route("/api/health", get(health)) + .route("/api/task", post(create_task)) + .route("/api/task/{id}", get(get_task)) + .route("/api/task/{id}/stream", get(stream_task)) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let addr = format!("{}:{}", config.host, config.port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + tracing::info!("Server listening on {}", addr); + axum::serve(listener, app).await?; + + Ok(()) +} + +/// Health check endpoint. +async fn health() -> Json { + Json(HealthResponse { + status: "ok".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }) +} + +/// Create a new task. +async fn create_task( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let id = Uuid::new_v4(); + let model = req.model.unwrap_or_else(|| state.config.default_model.clone()); + + let task_state = TaskState { + id, + status: TaskStatus::Pending, + task: req.task.clone(), + model: model.clone(), + iterations: 0, + result: None, + log: Vec::new(), + }; + + // Store task + { + let mut tasks = state.tasks.write().await; + tasks.insert(id, task_state); + } + + // Spawn background task to run the agent + let state_clone = Arc::clone(&state); + let task_description = req.task.clone(); + let workspace_path = req.workspace_path + .map(std::path::PathBuf::from) + .unwrap_or_else(|| state.config.workspace_path.clone()); + + tokio::spawn(async move { + run_agent_task(state_clone, id, task_description, model, workspace_path).await; + }); + + Ok(Json(CreateTaskResponse { + id, + status: TaskStatus::Pending, + })) +} + +/// Run the agent for a task (background). +async fn run_agent_task( + state: Arc, + task_id: Uuid, + task: String, + model: String, + workspace_path: std::path::PathBuf, +) { + // Update status to running + { + let mut tasks = state.tasks.write().await; + if let Some(task_state) = tasks.get_mut(&task_id) { + task_state.status = TaskStatus::Running; + } + } + + // Run the agent + let result = state.agent.run_task(&task, &model, &workspace_path).await; + + // Update task with result + { + let mut tasks = state.tasks.write().await; + if let Some(task_state) = tasks.get_mut(&task_id) { + match result { + Ok((response, log)) => { + task_state.status = TaskStatus::Completed; + task_state.result = Some(response); + task_state.log = log; + } + Err(e) => { + task_state.status = TaskStatus::Failed; + task_state.result = Some(format!("Error: {}", e)); + } + } + } + } +} + +/// Get task status and result. +async fn get_task( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let tasks = state.tasks.read().await; + + tasks + .get(&id) + .cloned() + .map(Json) + .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Task {} not found", id))) +} + +/// Stream task progress via SSE. +async fn stream_task( + State(state): State>, + Path(id): Path, +) -> Result>>, (StatusCode, String)> { + // Check task exists + { + let tasks = state.tasks.read().await; + if !tasks.contains_key(&id) { + return Err((StatusCode::NOT_FOUND, format!("Task {} not found", id))); + } + } + + // Create a stream that polls task state + let stream = async_stream::stream! { + let mut last_log_len = 0; + + loop { + let (status, log_entries, result) = { + let tasks = state.tasks.read().await; + if let Some(task) = tasks.get(&id) { + (task.status.clone(), task.log.clone(), task.result.clone()) + } else { + break; + } + }; + + // Send new log entries + for entry in log_entries.iter().skip(last_log_len) { + let event = Event::default() + .event("log") + .json_data(entry) + .unwrap(); + yield Ok(event); + } + last_log_len = log_entries.len(); + + // Check if task is done + if status == TaskStatus::Completed || status == TaskStatus::Failed { + let event = Event::default() + .event("done") + .json_data(serde_json::json!({ + "status": status, + "result": result + })) + .unwrap(); + yield Ok(event); + break; + } + + // Poll interval + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + }; + + Ok(Sse::new(stream)) +} + diff --git a/src/api/types.rs b/src/api/types.rs new file mode 100644 index 0000000..789721a --- /dev/null +++ b/src/api/types.rs @@ -0,0 +1,118 @@ +//! API request and response types. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Request to submit a new task. +#[derive(Debug, Clone, Deserialize)] +pub struct CreateTaskRequest { + /// The task description / user prompt + pub task: String, + + /// Optional model override (uses default if not specified) + pub model: Option, + + /// Optional workspace path override + pub workspace_path: Option, +} + +/// Response after creating a task. +#[derive(Debug, Clone, Serialize)] +pub struct CreateTaskResponse { + /// Unique task identifier + pub id: Uuid, + + /// Current task status + pub status: TaskStatus, +} + +/// Task status enumeration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TaskStatus { + /// Task is queued, waiting to start + Pending, + /// Task is currently running + Running, + /// Task completed successfully + Completed, + /// Task failed with an error + Failed, + /// Task was cancelled + Cancelled, +} + +/// Full task state including results. +#[derive(Debug, Clone, Serialize)] +pub struct TaskState { + /// Unique task identifier + pub id: Uuid, + + /// Current status + pub status: TaskStatus, + + /// Original task description + pub task: String, + + /// Model used for this task + pub model: String, + + /// Number of iterations completed + pub iterations: usize, + + /// Final result or error message + pub result: Option, + + /// Detailed execution log + pub log: Vec, +} + +/// A single entry in the task execution log. +#[derive(Debug, Clone, Serialize)] +pub struct TaskLogEntry { + /// Timestamp (ISO 8601) + pub timestamp: String, + + /// Entry type + pub entry_type: LogEntryType, + + /// Content of the entry + pub content: String, +} + +/// Types of log entries. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LogEntryType { + /// Agent is thinking / planning + Thinking, + /// Tool is being called + ToolCall, + /// Tool returned a result + ToolResult, + /// Agent produced final response + Response, + /// An error occurred + Error, +} + +/// Server-Sent Event for streaming task progress. +#[derive(Debug, Clone, Serialize)] +pub struct TaskEvent { + /// Event type + pub event: String, + + /// Event data (JSON serialized) + pub data: serde_json::Value, +} + +/// Health check response. +#[derive(Debug, Clone, Serialize)] +pub struct HealthResponse { + /// Service status + pub status: String, + + /// Service version + pub version: String, +} + diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..cfcc744 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,101 @@ +//! Configuration management for Open Agent. +//! +//! Configuration can be set via environment variables: +//! - `OPENROUTER_API_KEY` - Required. Your OpenRouter API key. +//! - `DEFAULT_MODEL` - Optional. The default LLM model to use. Defaults to `openai/gpt-4.1-mini`. +//! - `WORKSPACE_PATH` - Optional. The workspace directory. Defaults to current directory. +//! - `HOST` - Optional. Server host. Defaults to `127.0.0.1`. +//! - `PORT` - Optional. Server port. Defaults to `3000`. +//! - `MAX_ITERATIONS` - Optional. Maximum agent loop iterations. Defaults to `50`. + +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("Missing required environment variable: {0}")] + MissingEnvVar(String), + + #[error("Invalid value for {0}: {1}")] + InvalidValue(String, String), +} + +/// Agent configuration. +#[derive(Debug, Clone)] +pub struct Config { + /// OpenRouter API key + pub api_key: String, + + /// Default LLM model identifier (OpenRouter format) + pub default_model: String, + + /// Workspace directory for file operations + pub workspace_path: PathBuf, + + /// Server host + pub host: String, + + /// Server port + pub port: u16, + + /// Maximum iterations for the agent loop + pub max_iterations: usize, +} + +impl Config { + /// Load configuration from environment variables. + /// + /// # Errors + /// + /// Returns `ConfigError::MissingEnvVar` if `OPENROUTER_API_KEY` is not set. + pub fn from_env() -> Result { + let api_key = std::env::var("OPENROUTER_API_KEY") + .map_err(|_| ConfigError::MissingEnvVar("OPENROUTER_API_KEY".to_string()))?; + + let default_model = std::env::var("DEFAULT_MODEL") + .unwrap_or_else(|_| "openai/gpt-4.1-mini".to_string()); + + let workspace_path = std::env::var("WORKSPACE_PATH") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + + let host = std::env::var("HOST") + .unwrap_or_else(|_| "127.0.0.1".to_string()); + + let port = std::env::var("PORT") + .unwrap_or_else(|_| "3000".to_string()) + .parse() + .map_err(|e| ConfigError::InvalidValue("PORT".to_string(), format!("{}", e)))?; + + let max_iterations = std::env::var("MAX_ITERATIONS") + .unwrap_or_else(|_| "50".to_string()) + .parse() + .map_err(|e| ConfigError::InvalidValue("MAX_ITERATIONS".to_string(), format!("{}", e)))?; + + Ok(Self { + api_key, + default_model, + workspace_path, + host, + port, + max_iterations, + }) + } + + /// Create a config with custom values (useful for testing). + pub fn new( + api_key: String, + default_model: String, + workspace_path: PathBuf, + ) -> Self { + Self { + api_key, + default_model, + workspace_path, + host: "127.0.0.1".to_string(), + port: 3000, + max_iterations: 50, + } + } +} + diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..20ff410 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,35 @@ +//! # Open Agent +//! +//! A minimal autonomous coding agent with full machine access. +//! +//! This library provides: +//! - An HTTP API for task submission and monitoring +//! - A tool-based agent loop for autonomous code editing +//! - Integration with OpenRouter for LLM access +//! +//! ## Architecture +//! +//! The agent follows the "tools in a loop" pattern: +//! 1. Receive a task via the API +//! 2. Build context with system prompt and available tools +//! 3. Call LLM, parse response, execute any tool calls +//! 4. Feed results back to LLM, repeat until task complete +//! +//! ## Example +//! +//! ```rust,ignore +//! use open_agent::{config::Config, agent::Agent}; +//! +//! let config = Config::from_env()?; +//! let agent = Agent::new(config); +//! let result = agent.run_task("Create a hello world script").await?; +//! ``` + +pub mod api; +pub mod agent; +pub mod config; +pub mod llm; +pub mod tools; + +pub use config::Config; + diff --git a/src/llm/mod.rs b/src/llm/mod.rs new file mode 100644 index 0000000..96f3eb4 --- /dev/null +++ b/src/llm/mod.rs @@ -0,0 +1,86 @@ +//! LLM client module for interacting with language models. +//! +//! This module provides a trait-based abstraction over LLM providers, +//! with OpenRouter as the primary implementation. + +mod openrouter; + +pub use openrouter::OpenRouterClient; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +/// Role in a chat conversation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Role { + System, + User, + Assistant, + Tool, +} + +/// A message in a chat conversation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: Role, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +/// A tool call requested by the LLM. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub call_type: String, + pub function: FunctionCall, +} + +/// Function call details. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionCall { + pub name: String, + pub arguments: String, +} + +/// Tool definition for the LLM. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolDefinition { + #[serde(rename = "type")] + pub tool_type: String, + pub function: FunctionDefinition, +} + +/// Function definition with schema. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionDefinition { + pub name: String, + pub description: String, + pub parameters: serde_json::Value, +} + +/// Response from a chat completion. +#[derive(Debug, Clone)] +pub struct ChatResponse { + pub content: Option, + pub tool_calls: Option>, + pub finish_reason: Option, +} + +/// Trait for LLM clients. +#[async_trait] +pub trait LlmClient: Send + Sync { + /// Send a chat completion request. + async fn chat_completion( + &self, + model: &str, + messages: &[ChatMessage], + tools: Option<&[ToolDefinition]>, + ) -> anyhow::Result; +} + diff --git a/src/llm/openrouter.rs b/src/llm/openrouter.rs new file mode 100644 index 0000000..e2c29df --- /dev/null +++ b/src/llm/openrouter.rs @@ -0,0 +1,116 @@ +//! OpenRouter API client implementation. + +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +use super::{ChatMessage, ChatResponse, LlmClient, ToolCall, ToolDefinition}; + +const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/v1/chat/completions"; + +/// OpenRouter API client. +pub struct OpenRouterClient { + client: Client, + api_key: String, +} + +impl OpenRouterClient { + /// Create a new OpenRouter client. + pub fn new(api_key: String) -> Self { + Self { + client: Client::new(), + api_key, + } + } +} + +#[async_trait] +impl LlmClient for OpenRouterClient { + async fn chat_completion( + &self, + model: &str, + messages: &[ChatMessage], + tools: Option<&[ToolDefinition]>, + ) -> anyhow::Result { + let request = OpenRouterRequest { + model: model.to_string(), + messages: messages.to_vec(), + tools: tools.map(|t| t.to_vec()), + tool_choice: tools.map(|_| "auto".to_string()), + }; + + tracing::debug!("Sending request to OpenRouter: model={}", model); + + let response = self + .client + .post(OPENROUTER_API_URL) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .header("HTTP-Referer", "https://github.com/open-agent") + .header("X-Title", "Open Agent") + .json(&request) + .send() + .await?; + + let status = response.status(); + let body = response.text().await?; + + if !status.is_success() { + tracing::error!("OpenRouter error: status={}, body={}", status, body); + return Err(anyhow::anyhow!( + "OpenRouter API error: {} - {}", + status, + body + )); + } + + let response: OpenRouterResponse = serde_json::from_str(&body).map_err(|e| { + tracing::error!("Failed to parse response: {}, body: {}", e, body); + anyhow::anyhow!("Failed to parse OpenRouter response: {}", e) + })?; + + let choice = response + .choices + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No choices in response"))?; + + Ok(ChatResponse { + content: choice.message.content, + tool_calls: choice.message.tool_calls, + finish_reason: choice.finish_reason, + }) + } +} + +/// OpenRouter API request format. +#[derive(Debug, Serialize)] +struct OpenRouterRequest { + model: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_choice: Option, +} + +/// OpenRouter API response format. +#[derive(Debug, Deserialize)] +struct OpenRouterResponse { + choices: Vec, +} + +/// A choice in the OpenRouter response. +#[derive(Debug, Deserialize)] +struct OpenRouterChoice { + message: OpenRouterMessage, + finish_reason: Option, +} + +/// Message in OpenRouter response. +#[derive(Debug, Deserialize)] +struct OpenRouterMessage { + content: Option, + tool_calls: Option>, +} + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c4b7474 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,31 @@ +//! Open Agent - HTTP Server Entry Point +//! +//! Starts the HTTP server that exposes the agent API. + +use open_agent::{api, config::Config}; +use tracing::info; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize logging + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "open_agent=debug,tower_http=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // Load configuration + let config = Config::from_env()?; + info!("Loaded configuration: model={}", config.default_model); + + // Start HTTP server + let addr = format!("{}:{}", config.host, config.port); + info!("Starting server on {}", addr); + + api::serve(config).await?; + + Ok(()) +} diff --git a/src/tools/directory.rs b/src/tools/directory.rs new file mode 100644 index 0000000..5b403bc --- /dev/null +++ b/src/tools/directory.rs @@ -0,0 +1,212 @@ +//! Directory operation tools: list directory, search files by name. + +use std::path::Path; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use walkdir::WalkDir; + +use super::Tool; + +/// List contents of a directory. +pub struct ListDirectory; + +#[async_trait] +impl Tool for ListDirectory { + fn name(&self) -> &str { + "list_directory" + } + + fn description(&self) -> &str { + "List files and directories in a given path. Returns a tree-like view of the directory structure." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the directory to list, relative to workspace. Use '.' for workspace root." + }, + "max_depth": { + "type": "integer", + "description": "Maximum depth to traverse (default: 3)" + } + }, + "required": ["path"] + }) + } + + async fn execute(&self, args: Value, workspace: &Path) -> anyhow::Result { + let path = args["path"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'path' argument"))?; + let max_depth = args["max_depth"].as_u64().unwrap_or(3) as usize; + + let full_path = workspace.join(path); + + if !full_path.exists() { + return Err(anyhow::anyhow!("Directory not found: {}", path)); + } + + if !full_path.is_dir() { + return Err(anyhow::anyhow!("Not a directory: {}", path)); + } + + let mut entries = Vec::new(); + let walker = WalkDir::new(&full_path) + .max_depth(max_depth) + .sort_by_file_name(); + + for entry in walker.into_iter().filter_map(|e| e.ok()) { + let depth = entry.depth(); + let path = entry.path(); + let relative = path.strip_prefix(&full_path).unwrap_or(path); + + if relative.as_os_str().is_empty() { + continue; + } + + let prefix = " ".repeat(depth.saturating_sub(1)); + let name = relative + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let suffix = if path.is_dir() { "/" } else { "" }; + entries.push(format!("{}{}{}", prefix, name, suffix)); + } + + if entries.is_empty() { + Ok("Directory is empty".to_string()) + } else { + Ok(entries.join("\n")) + } + } +} + +/// Search for files by name pattern. +pub struct SearchFiles; + +#[async_trait] +impl Tool for SearchFiles { + fn name(&self) -> &str { + "search_files" + } + + fn description(&self) -> &str { + "Search for files by name pattern (glob-style). Returns matching file paths." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "File name pattern to search for (e.g., '*.rs', 'test_*.py', 'README*')" + }, + "path": { + "type": "string", + "description": "Directory to search in, relative to workspace. Defaults to workspace root." + } + }, + "required": ["pattern"] + }) + } + + async fn execute(&self, args: Value, workspace: &Path) -> anyhow::Result { + let pattern = args["pattern"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'pattern' argument"))?; + let path = args["path"].as_str().unwrap_or("."); + + let full_path = workspace.join(path); + + if !full_path.exists() { + return Err(anyhow::anyhow!("Directory not found: {}", path)); + } + + // Convert glob pattern to simple matching + let pattern_lower = pattern.to_lowercase(); + let is_glob = pattern.contains('*'); + + let mut matches = Vec::new(); + let walker = WalkDir::new(&full_path).into_iter().filter_map(|e| e.ok()); + + for entry in walker { + if !entry.file_type().is_file() { + continue; + } + + let file_name = entry + .file_name() + .to_string_lossy() + .to_lowercase(); + + let matched = if is_glob { + // Simple glob matching + glob_match(&pattern_lower, &file_name) + } else { + file_name.contains(&pattern_lower) + }; + + if matched { + let relative = entry + .path() + .strip_prefix(workspace) + .unwrap_or(entry.path()); + matches.push(relative.to_string_lossy().to_string()); + } + + // Limit results + if matches.len() >= 100 { + matches.push("... (results truncated, showing first 100)".to_string()); + break; + } + } + + if matches.is_empty() { + Ok(format!("No files matching '{}' found", pattern)) + } else { + Ok(matches.join("\n")) + } + } +} + +/// Simple glob pattern matching. +fn glob_match(pattern: &str, text: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + + if parts.len() == 1 { + // No wildcards + return pattern == text; + } + + let mut pos = 0; + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + + match text[pos..].find(part) { + Some(idx) => { + // First part must be at start if pattern doesn't start with * + if i == 0 && idx != 0 { + return false; + } + pos += idx + part.len(); + } + None => return false, + } + } + + // Last part must be at end if pattern doesn't end with * + if !pattern.ends_with('*') && !parts.last().unwrap().is_empty() { + return text.ends_with(parts.last().unwrap()); + } + + true +} + diff --git a/src/tools/file_ops.rs b/src/tools/file_ops.rs new file mode 100644 index 0000000..5d963d6 --- /dev/null +++ b/src/tools/file_ops.rs @@ -0,0 +1,179 @@ +//! File operation tools: read, write, delete files. + +use std::path::Path; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use super::Tool; + +/// Read the contents of a file. +pub struct ReadFile; + +#[async_trait] +impl Tool for ReadFile { + fn name(&self) -> &str { + "read_file" + } + + fn description(&self) -> &str { + "Read the contents of a file. Returns the file content as text. Use this to inspect files before editing them." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the file to read, relative to workspace" + }, + "start_line": { + "type": "integer", + "description": "Optional: start reading from this line number (1-indexed)" + }, + "end_line": { + "type": "integer", + "description": "Optional: stop reading at this line number (inclusive)" + } + }, + "required": ["path"] + }) + } + + async fn execute(&self, args: Value, workspace: &Path) -> anyhow::Result { + let path = args["path"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'path' argument"))?; + + let full_path = workspace.join(path); + + if !full_path.exists() { + return Err(anyhow::anyhow!("File not found: {}", path)); + } + + let content = tokio::fs::read_to_string(&full_path).await?; + + // Handle optional line range + let start_line = args["start_line"].as_u64().map(|n| n as usize); + let end_line = args["end_line"].as_u64().map(|n| n as usize); + + if start_line.is_some() || end_line.is_some() { + let lines: Vec<&str> = content.lines().collect(); + let start = start_line.unwrap_or(1).saturating_sub(1); + let end = end_line.unwrap_or(lines.len()).min(lines.len()); + + let selected: Vec = lines[start..end] + .iter() + .enumerate() + .map(|(i, line)| format!("{:4}| {}", start + i + 1, line)) + .collect(); + + return Ok(selected.join("\n")); + } + + // Return with line numbers for context + let numbered: Vec = content + .lines() + .enumerate() + .map(|(i, line)| format!("{:4}| {}", i + 1, line)) + .collect(); + + Ok(numbered.join("\n")) + } +} + +/// Write content to a file (create or overwrite). +pub struct WriteFile; + +#[async_trait] +impl Tool for WriteFile { + fn name(&self) -> &str { + "write_file" + } + + fn description(&self) -> &str { + "Write content to a file. Creates the file if it doesn't exist, or overwrites if it does. Creates parent directories as needed." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the file to write, relative to workspace" + }, + "content": { + "type": "string", + "description": "The content to write to the file" + } + }, + "required": ["path", "content"] + }) + } + + async fn execute(&self, args: Value, workspace: &Path) -> anyhow::Result { + let path = args["path"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'path' argument"))?; + let content = args["content"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'content' argument"))?; + + let full_path = workspace.join(path); + + // Create parent directories if needed + if let Some(parent) = full_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + tokio::fs::write(&full_path, content).await?; + + Ok(format!("Successfully wrote {} bytes to {}", content.len(), path)) + } +} + +/// Delete a file. +pub struct DeleteFile; + +#[async_trait] +impl Tool for DeleteFile { + fn name(&self) -> &str { + "delete_file" + } + + fn description(&self) -> &str { + "Delete a file. Use with caution." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the file to delete, relative to workspace" + } + }, + "required": ["path"] + }) + } + + async fn execute(&self, args: Value, workspace: &Path) -> anyhow::Result { + let path = args["path"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'path' argument"))?; + + let full_path = workspace.join(path); + + if !full_path.exists() { + return Err(anyhow::anyhow!("File not found: {}", path)); + } + + tokio::fs::remove_file(&full_path).await?; + + Ok(format!("Successfully deleted {}", path)) + } +} + diff --git a/src/tools/git.rs b/src/tools/git.rs new file mode 100644 index 0000000..9927b1d --- /dev/null +++ b/src/tools/git.rs @@ -0,0 +1,222 @@ +//! Git operation tools. + +use std::path::Path; +use std::process::Stdio; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use tokio::process::Command; + +use super::Tool; + +/// Get git status. +pub struct GitStatus; + +#[async_trait] +impl Tool for GitStatus { + fn name(&self) -> &str { + "git_status" + } + + fn description(&self) -> &str { + "Get the current git status, showing modified, staged, and untracked files." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": {} + }) + } + + async fn execute(&self, _args: Value, workspace: &Path) -> anyhow::Result { + run_git_command(&["status", "--porcelain=v2", "--branch"], workspace).await + } +} + +/// Get git diff. +pub struct GitDiff; + +#[async_trait] +impl Tool for GitDiff { + fn name(&self) -> &str { + "git_diff" + } + + fn description(&self) -> &str { + "Show git diff of changes. Can diff staged changes, specific files, or commits." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "staged": { + "type": "boolean", + "description": "Show staged changes instead of unstaged (default: false)" + }, + "file": { + "type": "string", + "description": "Optional: show diff for specific file only" + } + } + }) + } + + async fn execute(&self, args: Value, workspace: &Path) -> anyhow::Result { + let staged = args["staged"].as_bool().unwrap_or(false); + let file = args["file"].as_str(); + + let mut git_args = vec!["diff"]; + + if staged { + git_args.push("--staged"); + } + + if let Some(f) = file { + git_args.push("--"); + git_args.push(f); + } + + let result = run_git_command(&git_args, workspace).await?; + + if result.is_empty() { + Ok("No changes".to_string()) + } else if result.len() > 10000 { + Ok(format!( + "{}... [diff truncated, showing first 10000 chars]", + &result[..10000] + )) + } else { + Ok(result) + } + } +} + +/// Create a git commit. +pub struct GitCommit; + +#[async_trait] +impl Tool for GitCommit { + fn name(&self) -> &str { + "git_commit" + } + + fn description(&self) -> &str { + "Stage all changes and create a git commit with the given message." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The commit message" + }, + "files": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional: specific files to stage. If not provided, stages all changes." + } + }, + "required": ["message"] + }) + } + + async fn execute(&self, args: Value, workspace: &Path) -> anyhow::Result { + let message = args["message"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'message' argument"))?; + + let files: Vec<&str> = args["files"] + .as_array() + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + // Stage files + if files.is_empty() { + run_git_command(&["add", "-A"], workspace).await?; + } else { + let mut git_args = vec!["add", "--"]; + git_args.extend(files); + run_git_command(&git_args, workspace).await?; + } + + // Commit + run_git_command(&["commit", "-m", message], workspace).await + } +} + +/// Get git log. +pub struct GitLog; + +#[async_trait] +impl Tool for GitLog { + fn name(&self) -> &str { + "git_log" + } + + fn description(&self) -> &str { + "Show recent git commits." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "num_commits": { + "type": "integer", + "description": "Number of commits to show (default: 10)" + }, + "oneline": { + "type": "boolean", + "description": "Show condensed one-line format (default: true)" + } + } + }) + } + + async fn execute(&self, args: Value, workspace: &Path) -> anyhow::Result { + let num_commits = args["num_commits"].as_u64().unwrap_or(10); + let oneline = args["oneline"].as_bool().unwrap_or(true); + + let mut git_args = vec!["log", "-n"]; + let num_str = num_commits.to_string(); + git_args.push(&num_str); + + if oneline { + git_args.push("--oneline"); + } + + run_git_command(&git_args, workspace).await + } +} + +/// Run a git command and return its output. +async fn run_git_command(args: &[&str], workspace: &Path) -> anyhow::Result { + let output = Command::new("git") + .args(args) + .current_dir(workspace) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| anyhow::anyhow!("Failed to run git: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + if stderr.is_empty() { + return Err(anyhow::anyhow!( + "Git command failed: {}", + stdout.trim() + )); + } + return Err(anyhow::anyhow!("Git error: {}", stderr.trim())); + } + + Ok(stdout.to_string()) +} + diff --git a/src/tools/mod.rs b/src/tools/mod.rs new file mode 100644 index 0000000..ccbdce1 --- /dev/null +++ b/src/tools/mod.rs @@ -0,0 +1,130 @@ +//! Tool system for the agent. +//! +//! Tools are the "hands and eyes" of the agent - they allow it to interact with +//! the file system, run commands, search code, and access the web. + +mod directory; +mod file_ops; +mod git; +mod search; +mod terminal; +mod web; + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::llm::{FunctionDefinition, ToolDefinition}; + +/// Information about a tool for display purposes. +#[derive(Debug, Clone)] +pub struct ToolInfo { + pub name: String, + pub description: String, +} + +/// Trait for implementing tools. +#[async_trait] +pub trait Tool: Send + Sync { + /// The unique name of this tool. + fn name(&self) -> &str; + + /// A description of what this tool does. + fn description(&self) -> &str; + + /// JSON schema for the tool's parameters. + fn parameters_schema(&self) -> Value; + + /// Execute the tool with the given arguments. + async fn execute(&self, args: Value, workspace: &Path) -> anyhow::Result; +} + +/// Registry of available tools. +pub struct ToolRegistry { + tools: HashMap>, +} + +impl ToolRegistry { + /// Create a new registry with all default tools. + pub fn new() -> Self { + let mut tools: HashMap> = HashMap::new(); + + // File operations + tools.insert("read_file".to_string(), Arc::new(file_ops::ReadFile)); + tools.insert("write_file".to_string(), Arc::new(file_ops::WriteFile)); + tools.insert("delete_file".to_string(), Arc::new(file_ops::DeleteFile)); + + // Directory operations + tools.insert("list_directory".to_string(), Arc::new(directory::ListDirectory)); + tools.insert("search_files".to_string(), Arc::new(directory::SearchFiles)); + + // Terminal + tools.insert("run_command".to_string(), Arc::new(terminal::RunCommand)); + + // Search + tools.insert("grep_search".to_string(), Arc::new(search::GrepSearch)); + + // Web + tools.insert("web_search".to_string(), Arc::new(web::WebSearch)); + tools.insert("fetch_url".to_string(), Arc::new(web::FetchUrl)); + + // Git + tools.insert("git_status".to_string(), Arc::new(git::GitStatus)); + tools.insert("git_diff".to_string(), Arc::new(git::GitDiff)); + tools.insert("git_commit".to_string(), Arc::new(git::GitCommit)); + tools.insert("git_log".to_string(), Arc::new(git::GitLog)); + + Self { tools } + } + + /// List all available tools. + pub fn list_tools(&self) -> Vec { + self.tools + .values() + .map(|t| ToolInfo { + name: t.name().to_string(), + description: t.description().to_string(), + }) + .collect() + } + + /// Get tool schemas in LLM-compatible format. + pub fn get_tool_schemas(&self) -> Vec { + self.tools + .values() + .map(|t| ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: t.name().to_string(), + description: t.description().to_string(), + parameters: t.parameters_schema(), + }, + }) + .collect() + } + + /// Execute a tool by name. + pub async fn execute( + &self, + name: &str, + args: Value, + workspace: &Path, + ) -> anyhow::Result { + let tool = self + .tools + .get(name) + .ok_or_else(|| anyhow::anyhow!("Unknown tool: {}", name))?; + + tool.execute(args, workspace).await + } +} + +impl Default for ToolRegistry { + fn default() -> Self { + Self::new() + } +} + diff --git a/src/tools/search.rs b/src/tools/search.rs new file mode 100644 index 0000000..87a3655 --- /dev/null +++ b/src/tools/search.rs @@ -0,0 +1,143 @@ +//! Code search tools: grep/regex search. + +use std::path::Path; +use std::process::Stdio; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use tokio::process::Command; + +use super::Tool; + +/// Search file contents with regex/grep. +pub struct GrepSearch; + +#[async_trait] +impl Tool for GrepSearch { + fn name(&self) -> &str { + "grep_search" + } + + fn description(&self) -> &str { + "Search for a pattern in file contents using regex. Returns matching lines with file paths and line numbers. Great for finding function definitions, usages, or specific code patterns." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regex pattern to search for" + }, + "path": { + "type": "string", + "description": "Directory to search in, relative to workspace. Defaults to workspace root." + }, + "file_pattern": { + "type": "string", + "description": "Optional: only search files matching this glob (e.g., '*.rs', '*.py')" + }, + "case_sensitive": { + "type": "boolean", + "description": "Whether search is case-sensitive (default: false)" + } + }, + "required": ["pattern"] + }) + } + + async fn execute(&self, args: Value, workspace: &Path) -> anyhow::Result { + let pattern = args["pattern"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'pattern' argument"))?; + let path = args["path"].as_str().unwrap_or("."); + let file_pattern = args["file_pattern"].as_str(); + let case_sensitive = args["case_sensitive"].as_bool().unwrap_or(false); + + let search_path = workspace.join(path); + + // Try to use ripgrep (rg) if available, fall back to grep + let mut cmd = if which_exists("rg") { + let mut c = Command::new("rg"); + c.arg("--line-number"); + c.arg("--no-heading"); + c.arg("--color=never"); + + if !case_sensitive { + c.arg("-i"); + } + + if let Some(fp) = file_pattern { + c.arg("-g").arg(fp); + } + + c.arg("--").arg(pattern).arg(&search_path); + c + } else { + let mut c = Command::new("grep"); + c.arg("-rn"); + + if !case_sensitive { + c.arg("-i"); + } + + if let Some(fp) = file_pattern { + c.arg("--include").arg(fp); + } + + c.arg(pattern).arg(&search_path); + c + }; + + let output = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| anyhow::anyhow!("Failed to execute search: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // grep returns exit code 1 when no matches found + if !output.status.success() && output.status.code() != Some(1) { + if !stderr.is_empty() { + return Err(anyhow::anyhow!("Search error: {}", stderr)); + } + } + + if stdout.is_empty() { + return Ok(format!("No matches found for pattern: {}", pattern)); + } + + // Process output to make paths relative + let workspace_str = workspace.to_string_lossy(); + let result: String = stdout + .lines() + .take(100) // Limit results + .map(|line| line.replace(&*workspace_str, ".").replace("./", "")) + .collect::>() + .join("\n"); + + let line_count = result.lines().count(); + if line_count >= 100 { + Ok(format!( + "{}\n\n... (showing first 100 matches)", + result + )) + } else { + Ok(result) + } + } +} + +/// Check if a command exists in PATH. +fn which_exists(cmd: &str) -> bool { + std::process::Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + diff --git a/src/tools/terminal.rs b/src/tools/terminal.rs new file mode 100644 index 0000000..646185b --- /dev/null +++ b/src/tools/terminal.rs @@ -0,0 +1,98 @@ +//! Terminal/shell command execution tool. + +use std::path::Path; +use std::process::Stdio; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use tokio::process::Command; + +use super::Tool; + +/// Run a shell command. +pub struct RunCommand; + +#[async_trait] +impl Tool for RunCommand { + fn name(&self) -> &str { + "run_command" + } + + fn description(&self) -> &str { + "Execute a shell command in the workspace directory. Returns stdout and stderr. Use for running tests, installing dependencies, compiling code, etc." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute" + }, + "timeout_secs": { + "type": "integer", + "description": "Timeout in seconds (default: 60)" + } + }, + "required": ["command"] + }) + } + + async fn execute(&self, args: Value, workspace: &Path) -> anyhow::Result { + let command = args["command"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'command' argument"))?; + let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(60); + + tracing::info!("Executing command: {}", command); + + // Determine shell based on OS + let (shell, shell_arg) = if cfg!(target_os = "windows") { + ("cmd", "/C") + } else { + ("sh", "-c") + }; + + let output = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + Command::new(shell) + .arg(shell_arg) + .arg(command) + .current_dir(workspace) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(), + ) + .await + .map_err(|_| anyhow::anyhow!("Command timed out after {} seconds", timeout_secs))? + .map_err(|e| anyhow::anyhow!("Failed to execute command: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let exit_code = output.status.code().unwrap_or(-1); + + let mut result = String::new(); + + result.push_str(&format!("Exit code: {}\n", exit_code)); + + if !stdout.is_empty() { + result.push_str("\n--- stdout ---\n"); + result.push_str(&stdout); + } + + if !stderr.is_empty() { + result.push_str("\n--- stderr ---\n"); + result.push_str(&stderr); + } + + // Truncate if too long + if result.len() > 10000 { + result.truncate(10000); + result.push_str("\n... [output truncated]"); + } + + Ok(result) + } +} + diff --git a/src/tools/web.rs b/src/tools/web.rs new file mode 100644 index 0000000..2f3a062 --- /dev/null +++ b/src/tools/web.rs @@ -0,0 +1,244 @@ +//! Web access tools: search and fetch URLs. + +use std::path::Path; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use super::Tool; + +/// Search the web (placeholder - uses DuckDuckGo HTML). +pub struct WebSearch; + +#[async_trait] +impl Tool for WebSearch { + fn name(&self) -> &str { + "web_search" + } + + fn description(&self) -> &str { + "Search the web for information. Returns search results with titles and snippets. Use for finding documentation, examples, or current information." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query" + }, + "num_results": { + "type": "integer", + "description": "Maximum number of results to return (default: 5)" + } + }, + "required": ["query"] + }) + } + + async fn execute(&self, args: Value, _workspace: &Path) -> anyhow::Result { + let query = args["query"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'query' argument"))?; + let _num_results = args["num_results"].as_u64().unwrap_or(5); + + // Use DuckDuckGo HTML search (no API key needed) + let encoded_query = urlencoding::encode(query); + let url = format!("https://html.duckduckgo.com/html/?q={}", encoded_query); + + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (compatible; OpenAgent/1.0)") + .build()?; + + let response = client.get(&url).send().await?; + let html = response.text().await?; + + // Parse results (simple extraction) + let results = extract_ddg_results(&html); + + if results.is_empty() { + Ok(format!("No results found for: {}", query)) + } else { + Ok(results.join("\n\n")) + } + } +} + +/// Extract search results from DuckDuckGo HTML. +fn extract_ddg_results(html: &str) -> Vec { + let mut results = Vec::new(); + + // Simple regex-free extraction + // Look for result divs + for (i, chunk) in html.split("class=\"result__body\"").enumerate().skip(1) { + if i > 5 { + break; + } + + // Extract title + let title = chunk + .split("class=\"result__a\"") + .nth(1) + .and_then(|s| s.split('>').nth(1)) + .and_then(|s| s.split('<').next()) + .unwrap_or("No title"); + + // Extract snippet + let snippet = chunk + .split("class=\"result__snippet\"") + .nth(1) + .and_then(|s| s.split('>').nth(1)) + .and_then(|s| s.split('<').next()) + .unwrap_or("No snippet"); + + // Extract URL + let url = chunk + .split("class=\"result__url\"") + .nth(1) + .and_then(|s| s.split('>').nth(1)) + .and_then(|s| s.split('<').next()) + .map(|s| s.trim()) + .unwrap_or(""); + + if !title.is_empty() && title != "No title" { + results.push(format!( + "**{}**\n{}\nURL: {}", + html_decode(title), + html_decode(snippet), + url + )); + } + } + + results +} + +/// Basic HTML entity decoding. +fn html_decode(s: &str) -> String { + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace(" ", " ") +} + +/// Fetch content from a URL. +pub struct FetchUrl; + +#[async_trait] +impl Tool for FetchUrl { + fn name(&self) -> &str { + "fetch_url" + } + + fn description(&self) -> &str { + "Fetch the content of a URL. Returns the text content of the page. Useful for reading documentation, APIs, or downloading data." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to fetch" + } + }, + "required": ["url"] + }) + } + + async fn execute(&self, args: Value, _workspace: &Path) -> anyhow::Result { + let url = args["url"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'url' argument"))?; + + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (compatible; OpenAgent/1.0)") + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + let response = client.get(url).send().await?; + let status = response.status(); + + if !status.is_success() { + return Err(anyhow::anyhow!("HTTP error: {}", status)); + } + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_default(); + + let body = response.text().await?; + + // If HTML, try to extract text content + let result = if content_type.contains("text/html") { + extract_text_from_html(&body) + } else { + body + }; + + // Truncate if too long + if result.len() > 20000 { + Ok(format!( + "{}... [content truncated, showing first 20000 chars]", + &result[..20000] + )) + } else { + Ok(result) + } + } +} + +/// Extract readable text from HTML (simple approach). +fn extract_text_from_html(html: &str) -> String { + // Remove script and style tags + let mut text = html.to_string(); + + // Remove scripts + while let Some(start) = text.find("") { + text = format!("{}{}", &text[..start], &text[start + end + 9..]); + } else { + break; + } + } + + // Remove styles + while let Some(start) = text.find("") { + text = format!("{}{}", &text[..start], &text[start + end + 8..]); + } else { + break; + } + } + + // Remove all tags + let mut result = String::new(); + let mut in_tag = false; + + for c in text.chars() { + if c == '<' { + in_tag = true; + } else if c == '>' { + in_tag = false; + result.push(' '); + } else if !in_tag { + result.push(c); + } + } + + // Clean up whitespace + let result: String = result + .split_whitespace() + .collect::>() + .join(" "); + + html_decode(&result) +} +