diff --git a/src/main.rs b/src/main.rs index b4ebe7b..fbf3238 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,9 +72,31 @@ async fn main() -> anyhow::Result<()> { match &cli.command { Commands::Init { spec_dir } => { - let dir = spec_dir.clone().unwrap_or_else(|| ".".to_string()); - println!("Initializing agent in directory: {}", dir); - // TODO: Implement initialization logic + let dir = spec_dir.clone().unwrap_or_else(|| "specs".to_string()); + + let spec_path = std::path::Path::new(&dir); + if !spec_path.exists() { + std::fs::create_dir_all(spec_path)?; + println!("Created spec directory: {}", dir); + } else { + println!("Spec directory already exists: {}", dir); + } + + let config_path = std::path::Path::new("rustagent.toml"); + if !config_path.exists() { + let template = include_str!("../rustagent.toml.example"); + std::fs::write(config_path, template)?; + println!("Created config template: rustagent.toml"); + println!("Please edit rustagent.toml to add your API keys."); + } else { + println!("Config file already exists: rustagent.toml"); + } + + println!("\nInitialization complete!"); + println!("Next steps:"); + println!(" 1. Edit rustagent.toml with your API keys"); + println!(" 2. Run 'rustagent plan' to create a spec"); + println!(" 3. Run 'rustagent run ' to execute"); } Commands::Plan { spec_dir } => { // Load config from standard locations diff --git a/src/ralph/mod.rs b/src/ralph/mod.rs index 40165b4..7462ec1 100644 --- a/src/ralph/mod.rs +++ b/src/ralph/mod.rs @@ -173,9 +173,29 @@ impl RalphLoop { ResponseContent::ToolCalls(tool_calls) => { println!(" Executing {} tool calls", tool_calls.len()); - // Execute all tool calls + // Check for signal_completion tool call first + for tool_call in &tool_calls { + if tool_call.name == "signal_completion" { + let tool = self + .tools + .get(&tool_call.name) + .context("signal_completion tool not found")?; + let result = tool.execute(tool_call.parameters.clone()).await?; + + if result.starts_with("SIGNAL:complete:") { + return Ok("TASK_COMPLETE".to_string()); + } else if result.starts_with("SIGNAL:blocked:") { + return Ok("TASK_BLOCKED".to_string()); + } + } + } + + // Execute all other tool calls let mut results = Vec::new(); for tool_call in tool_calls { + if tool_call.name == "signal_completion" { + continue; + } println!(" Tool: {}", tool_call.name); let tool = self.tools.get(&tool_call.name).context("Tool not found")?; @@ -195,7 +215,9 @@ impl RalphLoop { // Note: Using User role for now as Anthropic expects tool results // in user messages. Future OpenAI provider will use Message::tool_result() let results_text = results.join("\n\n"); - messages.push(Message::user(results_text)); + if !results_text.is_empty() { + messages.push(Message::user(results_text)); + } } } } @@ -245,9 +267,9 @@ impl RalphLoop { context.push_str("1. Execute the task using available tools\n"); context.push_str("2. Use read_file to examine code\n"); context.push_str("3. Use write_file to create/modify files\n"); - context.push_str("4. Use shell_command to run tests, builds, git commands\n"); - context.push_str("5. When complete, respond with TASK_COMPLETE\n"); - context.push_str("6. If blocked, explain why and respond with TASK_BLOCKED\n"); + context.push_str("4. Use run_command to run tests, builds, git commands\n"); + context.push_str("5. When complete, call signal_completion with signal='complete'\n"); + context.push_str("6. If blocked, call signal_completion with signal='blocked' and explain why\n"); context.push('\n'); context.push_str("Begin executing the task now.\n"); diff --git a/src/security/permission.rs b/src/security/permission.rs index f64f52d..aebf2a9 100644 --- a/src/security/permission.rs +++ b/src/security/permission.rs @@ -20,7 +20,8 @@ pub enum ResourceType { pub enum PermissionResult { Allow, Deny, - AllowAlways(String), // Remember this decision for future + AllowAlways(String), + Quit, } pub struct CliPermissionHandler; @@ -51,7 +52,7 @@ impl PermissionHandler for CliPermissionHandler { }; PermissionResult::AllowAlways(resource.to_string()) } - "q" | "quit" => std::process::exit(0), + "q" | "quit" => PermissionResult::Quit, _ => PermissionResult::Deny, } } diff --git a/src/tools/factory.rs b/src/tools/factory.rs index a55c80d..e3662d1 100644 --- a/src/tools/factory.rs +++ b/src/tools/factory.rs @@ -2,6 +2,7 @@ use crate::security::permission::PermissionHandler; use crate::security::SecurityValidator; use crate::tools::file::{ListFilesTool, ReadFileTool, WriteFileTool}; use crate::tools::shell::RunCommandTool; +use crate::tools::signal::SignalTool; use crate::tools::ToolRegistry; use std::sync::Arc; @@ -27,6 +28,7 @@ pub fn create_default_registry( validator.clone(), permission_handler.clone(), ))); + registry.register(Arc::new(SignalTool::new())); registry } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 873dc5d..f8938dc 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -84,3 +84,4 @@ pub mod factory; pub mod file; pub mod permission_check; pub mod shell; +pub mod signal; diff --git a/src/tools/permission_check.rs b/src/tools/permission_check.rs index 3e08254..4b7021d 100644 --- a/src/tools/permission_check.rs +++ b/src/tools/permission_check.rs @@ -65,6 +65,9 @@ impl FilePermissionChecker { allowed.insert(p); Ok(()) } + PermissionResult::Quit => { + anyhow::bail!("User requested quit") + } } } } diff --git a/src/tools/shell.rs b/src/tools/shell.rs index 2acef57..429e296 100644 --- a/src/tools/shell.rs +++ b/src/tools/shell.rs @@ -145,6 +145,9 @@ impl Tool for RunCommandTool { } self.execute_command(¶ms).await } + PermissionResult::Quit => { + anyhow::bail!("User requested quit") + } } } } diff --git a/src/tools/signal.rs b/src/tools/signal.rs new file mode 100644 index 0000000..e42bcae --- /dev/null +++ b/src/tools/signal.rs @@ -0,0 +1,82 @@ +use crate::tools::Tool; +use anyhow::Result; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CompletionSignal { + Complete, + Blocked, +} + +#[derive(Debug, Deserialize)] +struct SignalParams { + signal: CompletionSignal, + #[serde(default)] + message: Option, + #[serde(default)] + reason: Option, +} + +pub struct SignalTool; + +impl SignalTool { + pub fn new() -> Self { + Self + } +} + +impl Default for SignalTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for SignalTool { + fn name(&self) -> &str { + "signal_completion" + } + + fn description(&self) -> &str { + "Signal task completion or blocked status. Use 'complete' when the task is finished successfully, or 'blocked' if you cannot proceed." + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "signal": { + "type": "string", + "enum": ["complete", "blocked"], + "description": "The completion signal: 'complete' for success, 'blocked' if unable to proceed" + }, + "message": { + "type": "string", + "description": "Optional message describing what was accomplished (for 'complete')" + }, + "reason": { + "type": "string", + "description": "Required reason explaining why the task is blocked (for 'blocked')" + } + }, + "required": ["signal"] + }) + } + + async fn execute(&self, params: serde_json::Value) -> Result { + let params: SignalParams = serde_json::from_value(params)?; + + match params.signal { + CompletionSignal::Complete => { + let msg = params.message.unwrap_or_else(|| "Task completed".to_string()); + Ok(format!("SIGNAL:complete:{}", msg)) + } + CompletionSignal::Blocked => { + let reason = params.reason.unwrap_or_else(|| "Unknown reason".to_string()); + Ok(format!("SIGNAL:blocked:{}", reason)) + } + } + } +} diff --git a/tests/init_test.rs b/tests/init_test.rs new file mode 100644 index 0000000..7f56ab9 --- /dev/null +++ b/tests/init_test.rs @@ -0,0 +1,61 @@ +use std::process::Command; +use tempfile::TempDir; + +#[test] +fn test_init_creates_spec_directory() { + let temp = TempDir::new().unwrap(); + let spec_dir = temp.path().join("specs"); + + let exe = env!("CARGO_BIN_EXE_rustagent"); + + let output = Command::new(exe) + .args(["init", "--spec-dir", spec_dir.to_str().unwrap()]) + .current_dir(temp.path()) + .output() + .unwrap(); + + assert!(output.status.success(), "Command failed: {:?}", output); + assert!(spec_dir.exists()); + assert!(spec_dir.is_dir()); +} + +#[test] +fn test_init_creates_config_template() { + let temp = TempDir::new().unwrap(); + let spec_dir = temp.path().join("specs"); + + let exe = env!("CARGO_BIN_EXE_rustagent"); + + let output = Command::new(exe) + .args(["init", "--spec-dir", spec_dir.to_str().unwrap()]) + .current_dir(temp.path()) + .output() + .unwrap(); + + assert!(output.status.success(), "Command failed: {:?}", output); + + let config_path = temp.path().join("rustagent.toml"); + assert!(config_path.exists(), "Config file was not created"); +} + +#[test] +fn test_init_idempotent() { + let temp = TempDir::new().unwrap(); + let spec_dir = temp.path().join("specs"); + + let exe = env!("CARGO_BIN_EXE_rustagent"); + + Command::new(exe) + .args(["init", "--spec-dir", spec_dir.to_str().unwrap()]) + .current_dir(temp.path()) + .output() + .unwrap(); + + let output = Command::new(exe) + .args(["init", "--spec-dir", spec_dir.to_str().unwrap()]) + .current_dir(temp.path()) + .output() + .unwrap(); + + assert!(output.status.success(), "Second init failed: {:?}", output); +} diff --git a/tests/signal_test.rs b/tests/signal_test.rs new file mode 100644 index 0000000..8fe1a43 --- /dev/null +++ b/tests/signal_test.rs @@ -0,0 +1,38 @@ +use rustagent::tools::signal::SignalTool; +use rustagent::tools::Tool; + +#[tokio::test] +async fn test_signal_complete() { + let tool = SignalTool::new(); + + let params = serde_json::json!({ + "signal": "complete", + "message": "Task finished successfully" + }); + + let result = tool.execute(params).await.unwrap(); + assert!(result.contains("complete")); +} + +#[tokio::test] +async fn test_signal_blocked() { + let tool = SignalTool::new(); + + let params = serde_json::json!({ + "signal": "blocked", + "reason": "Missing dependency" + }); + + let result = tool.execute(params).await.unwrap(); + assert!(result.contains("blocked")); +} + +#[test] +fn test_signal_tool_parameters() { + let tool = SignalTool::new(); + let params = tool.parameters(); + + assert!(params["properties"]["signal"].is_object()); + assert!(params["properties"]["message"].is_object()); + assert!(params["properties"]["reason"].is_object()); +}