//! System commands and job handlers for the agent. //! //! Extracted from `agent_loop.rs` to isolate the /help, /model, /status, //! and other command processing from the core agent loop. use std::sync::Arc; use tokio::sync::Mutex; use uuid::Uuid; use crate::agent::session::Session; use crate::agent::submission::SubmissionResult; use crate::agent::{Agent, MessageIntent}; use crate::channels::{IncomingMessage, StatusUpdate}; use crate::context::JobState; use crate::error::Error; use crate::llm::{ChatMessage, Reasoning}; /// Format a count with a suffix, using K/M abbreviations for large numbers. fn format_count(n: u64, suffix: &str) -> String { if n >= 1_000_000 { format!("{:.1}M {}", n as f64 / 1_000_000.0, suffix) } else if n >= 1_000 { format!("{:.1}K {}", n as f64 / 1_000.0, suffix) } else { format!("{} {}", n, suffix) } } impl Agent { /// Handle job-related intents without turn tracking. pub(super) async fn handle_job_or_command( &self, intent: MessageIntent, message: &IncomingMessage, ) -> Result { // Send thinking status for non-trivial operations if let MessageIntent::CreateJob { .. } = &intent { let _ = self .channels .send_status( &message.channel, StatusUpdate::Thinking("Processing...".into()), &message.metadata, ) .await; } let response = match intent { MessageIntent::CreateJob { title, description, category, } => { self.handle_create_job(&message.user_id, title, description, category) .await? } MessageIntent::CheckJobStatus { job_id } => { self.handle_check_status(&message.user_id, job_id).await? } MessageIntent::CancelJob { job_id } => { self.handle_cancel_job(&message.user_id, &job_id).await? } MessageIntent::ListJobs { filter } => { self.handle_list_jobs(&message.user_id, filter).await? } MessageIntent::HelpJob { job_id } => { self.handle_help_job(&message.user_id, &job_id).await? } MessageIntent::Command { command, args } => { match self .handle_command(&command, &args, &message.channel) .await? { Some(s) => s, None => return Ok(SubmissionResult::Ok { message: None }), // Shutdown signal } } _ => "Unknown intent".to_string(), }; Ok(SubmissionResult::response(response)) } async fn handle_create_job( &self, user_id: &str, title: String, description: String, category: Option, ) -> Result { let job_id = self .scheduler .dispatch_job(user_id, &title, &description, None) .await?; // Set the dedicated category field (not stored in metadata) if let Some(cat) = category && let Err(e) = self .context_manager .update_context(job_id, |ctx| { ctx.category = Some(cat); }) .await { tracing::warn!(job_id = %job_id, "Failed to set job category: {}", e); } Ok(format!( "Created job: {}\nID: {}\n\nThe job has been scheduled and is now running.", title, job_id )) } async fn handle_check_status( &self, user_id: &str, job_id: Option, ) -> Result { match job_id { Some(id) => { let uuid = Uuid::parse_str(&id) .map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?; // Try DB first for persistent state, fall back to ContextManager. if let Some(store) = self.store() && let Ok(Some(ctx)) = store.get_job(uuid).await { return Ok(format!( "Job: {}\nStatus: {:?}\nCreated: {}\nStarted: {}\nActual cost: {}", ctx.title, ctx.state, ctx.created_at.format("%Y-%m-%d %H:%M:%S"), ctx.started_at .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_else(|| "Not started".to_string()), ctx.actual_cost )); } let ctx = self.context_manager.get_context(uuid).await?; if ctx.user_id != user_id { return Err(crate::error::JobError::NotFound { id: uuid }.into()); } Ok(format!( "Job: {}\nStatus: {:?}\nCreated: {}\nStarted: {}\nActual cost: {}", ctx.title, ctx.state, ctx.created_at.format("%Y-%m-%d %H:%M:%S"), ctx.started_at .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_else(|| "Not started".to_string()), ctx.actual_cost )) } None => { // Show summary from DB for consistency with Jobs tab. if let Some(store) = self.store() { let mut total = 0; let mut in_progress = 0; let mut completed = 0; let mut failed = 0; let mut stuck = 0; if let Ok(s) = store.agent_job_summary().await { total += s.total; in_progress += s.in_progress; completed += s.completed; failed += s.failed; stuck += s.stuck; } if let Ok(s) = store.sandbox_job_summary().await { total += s.total; in_progress += s.running; completed += s.completed; failed += s.failed + s.interrupted; } return Ok(format!( "Jobs summary: Total: {} In Progress: {} Completed: {} Failed: {} Stuck: {}", total, in_progress, completed, failed, stuck )); } // Fallback to ContextManager if no DB. let summary = self.context_manager.summary_for(user_id).await; Ok(format!( "Jobs summary: Total: {} In Progress: {} Completed: {} Failed: {} Stuck: {}", summary.total, summary.in_progress, summary.completed, summary.failed, summary.stuck )) } } } async fn handle_cancel_job(&self, user_id: &str, job_id: &str) -> Result { let uuid = Uuid::parse_str(job_id) .map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?; let ctx = self.context_manager.get_context(uuid).await?; if ctx.user_id != user_id { return Err(crate::error::JobError::NotFound { id: uuid }.into()); } self.scheduler.stop(uuid).await?; // Also update DB so the Jobs tab reflects cancellation immediately. if let Some(store) = self.store() && let Err(e) = store .update_job_status(uuid, JobState::Cancelled, Some("Cancelled by user")) .await { tracing::warn!(job_id = %uuid, "Failed to persist cancellation to DB: {}", e); } Ok(format!("Job {} has been cancelled.", job_id)) } async fn handle_list_jobs( &self, user_id: &str, _filter: Option, ) -> Result { // List from DB for consistency with Jobs tab. if let Some(store) = self.store() { let agent_jobs = match store.list_agent_jobs().await { Ok(jobs) => jobs, Err(e) => { tracing::warn!("Failed to list agent jobs: {}", e); Vec::new() } }; let sandbox_jobs = match store.list_sandbox_jobs().await { Ok(jobs) => jobs, Err(e) => { tracing::warn!("Failed to list sandbox jobs: {}", e); Vec::new() } }; if agent_jobs.is_empty() && sandbox_jobs.is_empty() { return Ok("No jobs found.".to_string()); } let mut output = String::from("Jobs:\n"); for j in &agent_jobs { output.push_str(&format!(" {} - {} ({})\n", j.id, j.title, j.status)); } for j in &sandbox_jobs { output.push_str(&format!(" {} - {} ({})\n", j.id, j.task, j.status)); } return Ok(output); } // Fallback to ContextManager if no DB. let jobs = self.context_manager.all_jobs_for(user_id).await; if jobs.is_empty() { return Ok("No jobs found.".to_string()); } let mut output = String::from("Jobs:\n"); for job_id in jobs { if let Ok(ctx) = self.context_manager.get_context(job_id).await { output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state)); } } Ok(output) } async fn handle_help_job(&self, user_id: &str, job_id: &str) -> Result { let uuid = Uuid::parse_str(job_id) .map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?; let ctx = self.context_manager.get_context(uuid).await?; if ctx.user_id != user_id { return Err(crate::error::JobError::NotFound { id: uuid }.into()); } if ctx.state == crate::context::JobState::Stuck { // Attempt recovery self.context_manager .update_context(uuid, |ctx| ctx.attempt_recovery()) .await? .map_err(|s| crate::error::JobError::ContextError { id: uuid, reason: s, })?; // Reschedule self.scheduler.schedule(uuid).await?; Ok(format!( "Job {} was stuck. Attempting recovery (attempt #{}).", job_id, ctx.repair_attempts + 1 )) } else { Ok(format!( "Job {} is not stuck (current state: {:?}). No help needed.", job_id, ctx.state )) } } /// Show job status inline — either all jobs (no id) or a specific job. pub(super) async fn process_job_status( &self, user_id: &str, job_id: Option<&str>, ) -> Result { match self .handle_check_status(user_id, job_id.map(|s| s.to_string())) .await { Ok(text) => Ok(SubmissionResult::response(text)), Err(e) => Ok(SubmissionResult::error(format!("Job status error: {}", e))), } } /// Cancel a job by ID. pub(super) async fn process_job_cancel( &self, user_id: &str, job_id: &str, ) -> Result { match self.handle_cancel_job(user_id, job_id).await { Ok(text) => Ok(SubmissionResult::response(text)), Err(e) => Ok(SubmissionResult::error(format!("Cancel error: {}", e))), } } /// Trigger a manual heartbeat check. pub(super) async fn process_heartbeat(&self) -> Result { let Some(workspace) = self.workspace() else { return Ok(SubmissionResult::error( "Heartbeat requires a workspace (database must be connected).", )); }; let runner = crate::agent::HeartbeatRunner::new( crate::agent::HeartbeatConfig::default(), crate::workspace::hygiene::HygieneConfig::default(), workspace.clone(), self.llm().clone(), self.safety().clone(), ); match runner.check_heartbeat().await { crate::agent::HeartbeatResult::Ok => Ok(SubmissionResult::ok_with_message( "Heartbeat: all clear, nothing needs attention.", )), crate::agent::HeartbeatResult::NeedsAttention(msg) => Ok(SubmissionResult::response( format!("Heartbeat findings:\n\n{}", msg), )), crate::agent::HeartbeatResult::Skipped => Ok(SubmissionResult::ok_with_message( "Heartbeat skipped: no HEARTBEAT.md checklist found in workspace.", )), crate::agent::HeartbeatResult::Failed(err) => Ok(SubmissionResult::error(format!( "Heartbeat failed: {}", err ))), } } /// Summarize the current thread's conversation. pub(super) async fn process_summarize( &self, session: Arc>, thread_id: Uuid, ) -> Result { let messages = { let sess = session.lock().await; let thread = sess .threads .get(&thread_id) .ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?; thread.messages() }; if messages.is_empty() { return Ok(SubmissionResult::ok_with_message( "Nothing to summarize (empty thread).", )); } // Build a summary prompt with the conversation let mut context = Vec::new(); context.push(ChatMessage::system( "Summarize the conversation so far in 3-5 concise bullet points. \ Focus on decisions made, actions taken, and key outcomes. \ Be brief and factual.", )); // Include the conversation messages (truncate to last 20 to avoid context overflow) let start = if messages.len() > 20 { messages.len() - 20 } else { 0 }; context.extend_from_slice(&messages[start..]); context.push(ChatMessage::user("Summarize this conversation.")); let request = crate::llm::CompletionRequest::new(context) .with_max_tokens(512) .with_temperature(0.3); let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone()); match reasoning.complete(request).await { Ok((text, _usage)) => Ok(SubmissionResult::response(format!( "Thread Summary:\n\n{}", text.trim() ))), Err(e) => Ok(SubmissionResult::error(format!("Summarize failed: {}", e))), } } /// Suggest next steps based on the current thread. pub(super) async fn process_suggest( &self, session: Arc>, thread_id: Uuid, ) -> Result { let messages = { let sess = session.lock().await; let thread = sess .threads .get(&thread_id) .ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?; thread.messages() }; if messages.is_empty() { return Ok(SubmissionResult::ok_with_message( "Nothing to suggest from (empty thread).", )); } let mut context = Vec::new(); context.push(ChatMessage::system( "Based on the conversation so far, suggest 2-4 concrete next steps the user could take. \ Be actionable and specific. Format as a numbered list.", )); let start = if messages.len() > 20 { messages.len() - 20 } else { 0 }; context.extend_from_slice(&messages[start..]); context.push(ChatMessage::user("What should I do next?")); let request = crate::llm::CompletionRequest::new(context) .with_max_tokens(512) .with_temperature(0.5); let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone()); match reasoning.complete(request).await { Ok((text, _usage)) => Ok(SubmissionResult::response(format!( "Suggested Next Steps:\n\n{}", text.trim() ))), Err(e) => Ok(SubmissionResult::error(format!("Suggest failed: {}", e))), } } /// Handle system commands that bypass thread-state checks entirely. pub(super) async fn handle_system_command( &self, command: &str, args: &[String], channel: &str, ) -> Result { match command { "help" => Ok(SubmissionResult::response(concat!( "System:\n", " /help Show this help\n", " /model [name] Show or switch the active model\n", " /version Show version info\n", " /tools List available tools\n", " /debug Toggle debug mode\n", " /ping Connectivity check\n", "\n", "Jobs:\n", " /job Create a new job\n", " /status [id] Check job status\n", " /cancel Cancel a job\n", " /list List all jobs\n", "\n", "Session:\n", " /undo Undo last turn\n", " /redo Redo undone turn\n", " /compact Compress context window\n", " /clear Clear current thread\n", " /interrupt Stop current operation\n", " /new New conversation thread\n", " /thread Switch to thread\n", " /resume Resume from checkpoint\n", "\n", "Skills:\n", " /skills List installed skills\n", " /skills search Search ClawHub registry\n", "\n", "Agent:\n", " /heartbeat Run heartbeat check\n", " /summarize Summarize current thread\n", " /suggest Suggest next steps\n", " /restart Gracefully restart the process\n", "\n", " /quit Exit", ))), "ping" => Ok(SubmissionResult::response("pong!")), "restart" => { tracing::info!("[commands::restart] Restart command received"); // Channel authorization check: restart is only available via web interface if channel != "gateway" { tracing::warn!( "[commands::restart] Restart rejected: not from gateway channel (from: {})", channel ); return Ok(SubmissionResult::error( "Restart is only available through the web interface with explicit user confirmation. \ Use the Restart button in the UI." .to_string(), )); } // Environment check: restart is only available in Docker containers let in_docker = std::env::var("IRONCLAW_IN_DOCKER") .map(|v| v.to_lowercase() == "true") .unwrap_or(false); tracing::debug!("[commands::restart] IRONCLAW_IN_DOCKER={}", in_docker); if !in_docker { tracing::warn!( "[commands::restart] Restart rejected: not in Docker environment" ); return Ok(SubmissionResult::error( "Restart is not available in this environment. \ The IRONCLAW_IN_DOCKER environment variable must be set to 'true' for Docker deployments." .to_string(), )); } // Execute restart tool directly (don't dispatch as a job for LLM planning) // This ensures the tool runs immediately without LLM involvement use crate::tools::Tool; let tool = crate::tools::builtin::RestartTool; let params = serde_json::json!({}); // Create a minimal JobContext for the tool let dummy_ctx = crate::context::JobContext::with_user("system", "Restart", "Graceful restart"); match tool.execute(params, &dummy_ctx).await { Ok(output) => { tracing::info!("[commands::restart] RestartTool executed successfully"); // Extract text from the ToolOutput result let response = match output.result { serde_json::Value::String(s) => s, _ => output.result.to_string(), }; Ok(SubmissionResult::response(response)) } Err(e) => { tracing::error!( "[commands::restart] RestartTool execution failed: {:?}", e ); Ok(SubmissionResult::error(format!("Restart failed: {}", e))) } } } "version" => Ok(SubmissionResult::response(format!( "{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION") ))), "tools" => { let tools = self.tools().list().await; Ok(SubmissionResult::response(format!( "Available tools: {}", tools.join(", ") ))) } "debug" => { // Debug toggle is handled client-side in the REPL. // For non-REPL channels, just acknowledge. Ok(SubmissionResult::ok_with_message( "Debug toggle is handled by your client.", )) } "skills" => { if args.first().map(|s| s.as_str()) == Some("search") { let query = args[1..].join(" "); if query.is_empty() { return Ok(SubmissionResult::error("Usage: /skills search ")); } self.handle_skills_search(&query).await } else if args.is_empty() { self.handle_skills_list().await } else { Ok(SubmissionResult::error( "Usage: /skills or /skills search ", )) } } "model" => { let current = self.llm().active_model_name(); if args.is_empty() { // Show current model and list available models let mut out = format!("Active model: {}\n", current); match self.llm().list_models().await { Ok(models) if !models.is_empty() => { out.push_str("\nAvailable models:\n"); for m in &models { let marker = if *m == current { " (active)" } else { "" }; out.push_str(&format!(" {}{}\n", m, marker)); } out.push_str("\nUse /model to switch."); } Ok(_) => { out.push_str( "\nCould not fetch model list. Use /model to switch.", ); } Err(e) => { out.push_str(&format!( "\nCould not fetch models: {}. Use /model to switch.", e )); } } Ok(SubmissionResult::response(out)) } else { let requested = &args[0]; // Validate the model exists match self.llm().list_models().await { Ok(models) if !models.is_empty() => { if !models.iter().any(|m| m == requested) { return Ok(SubmissionResult::error(format!( "Unknown model: {}. Available models:\n {}", requested, models.join("\n ") ))); } } Ok(_) => { // Empty model list, can't validate but try anyway } Err(e) => { tracing::warn!("Could not fetch model list for validation: {}", e); } } match self.llm().set_model(requested) { Ok(()) => Ok(SubmissionResult::response(format!( "Switched model to: {}", requested ))), Err(e) => Ok(SubmissionResult::error(format!( "Failed to switch model: {}", e ))), } } } _ => Ok(SubmissionResult::error(format!( "Unknown command: {}. Try /help", command ))), } } /// List installed skills. async fn handle_skills_list(&self) -> Result { let Some(registry) = self.skill_registry() else { return Ok(SubmissionResult::error("Skills system not enabled.")); }; let guard = match registry.read() { Ok(g) => g, Err(e) => { return Ok(SubmissionResult::error(format!( "Skill registry lock error: {}", e ))); } }; let skills = guard.skills(); if skills.is_empty() { return Ok(SubmissionResult::response( "No skills installed.\n\nUse /skills search to find skills on ClawHub.", )); } let mut out = String::from("Installed skills:\n\n"); for s in skills { let desc = if s.manifest.description.chars().count() > 60 { let truncated: String = s.manifest.description.chars().take(57).collect(); format!("{}...", truncated) } else { s.manifest.description.clone() }; out.push_str(&format!( " {:<24} v{:<10} [{}] {}\n", s.manifest.name, s.manifest.version, s.trust, desc, )); } out.push_str("\nUse /skills search to find more on ClawHub."); Ok(SubmissionResult::response(out)) } /// Search ClawHub for skills. async fn handle_skills_search(&self, query: &str) -> Result { let catalog = match self.skill_catalog() { Some(c) => c, None => { return Ok(SubmissionResult::error("Skill catalog not available.")); } }; let outcome = catalog.search(query).await; // Enrich top results with detail data (stars, downloads, owner) let mut entries = outcome.results; catalog.enrich_search_results(&mut entries, 5).await; let mut out = format!("ClawHub results for \"{}\":\n\n", query); if entries.is_empty() { if let Some(ref err) = outcome.error { out.push_str(&format!(" (registry error: {})\n", err)); } else { out.push_str(" No results found.\n"); } } else { for entry in &entries { let owner_str = entry .owner .as_deref() .map(|o| format!(" by {}", o)) .unwrap_or_default(); let stats_parts: Vec = [ entry.stars.map(|s| format!("{} stars", s)), entry.downloads.map(|d| format_count(d, "downloads")), ] .into_iter() .flatten() .collect(); let stats_str = if stats_parts.is_empty() { String::new() } else { format!(" {}", stats_parts.join(" ")) }; out.push_str(&format!( " {:<24} v{:<10}{}{}\n", entry.name, entry.version, owner_str, stats_str, )); if !entry.description.is_empty() { out.push_str(&format!(" {}\n\n", entry.description)); } } } // Show matching installed skills if let Some(registry) = self.skill_registry() && let Ok(guard) = registry.read() { let query_lower = query.to_lowercase(); let matches: Vec<_> = guard .skills() .iter() .filter(|s| { s.manifest.name.to_lowercase().contains(&query_lower) || s.manifest.description.to_lowercase().contains(&query_lower) }) .collect(); if !matches.is_empty() { out.push_str(&format!("Installed skills matching \"{}\":\n", query)); for s in &matches { out.push_str(&format!( " {:<24} v{:<10} [{}]\n", s.manifest.name, s.manifest.version, s.trust, )); } } } Ok(SubmissionResult::response(out)) } /// Handle legacy command routing from the Router (job commands that go through /// process_user_input -> router -> handle_job_or_command -> here). pub(super) async fn handle_command( &self, command: &str, args: &[String], channel: &str, ) -> Result, Error> { // System commands are now handled directly via Submission::SystemCommand, // but the router may still send us unknown /commands. match self.handle_system_command(command, args, channel).await? { SubmissionResult::Response { content } => Ok(Some(content)), SubmissionResult::Ok { message } => Ok(message), SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))), _ => Ok(None), } } }