//! 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, tenant: &crate::tenant::TenantCtx, ) -> 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(tenant, title, description, category) .await? } MessageIntent::CheckJobStatus { job_id } => { self.handle_check_status(tenant, job_id).await? } MessageIntent::CancelJob { job_id } => self.handle_cancel_job(tenant, &job_id).await?, MessageIntent::ListJobs { filter } => self.handle_list_jobs(tenant, filter).await?, MessageIntent::HelpJob { job_id } => self.handle_help_job(tenant, &job_id).await?, MessageIntent::Command { command, args } => { match self .handle_command(&command, &args, &message.channel, tenant) .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, tenant: &crate::tenant::TenantCtx, title: String, description: String, category: Option, ) -> Result { let job_id = self .scheduler .dispatch_job(tenant.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, tenant: &crate::tenant::TenantCtx, 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. // TenantScope.get_job() auto-filters by ownership — no manual check needed. if let Some(store) = tenant.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 != tenant.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. // TenantScope methods auto-scope to user — no user_id parameter needed. if let Some(store) = tenant.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(tenant.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, tenant: &crate::tenant::TenantCtx, 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 != tenant.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. // Use TenantScope — ownership already verified above. if let Some(store) = tenant.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, tenant: &crate::tenant::TenantCtx, _filter: Option, ) -> Result { // List from DB for consistency with Jobs tab. // TenantScope methods auto-scope to user. if let Some(store) = tenant.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(tenant.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, tenant: &crate::tenant::TenantCtx, 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 != tenant.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, tenant: &crate::tenant::TenantCtx, job_id: Option<&str>, ) -> Result { match self .handle_check_status(tenant, 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, tenant: &crate::tenant::TenantCtx, job_id: &str, ) -> Result { match self.handle_cancel_job(tenant, 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(), ); 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()).with_model_name(self.llm().active_model_name()); 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()).with_model_name(self.llm().active_model_name()); 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 `/expected ` — capture expected behavior and fire into /// the self-improvement pipeline. /// /// Collects recent conversation turns (user input, tool calls, responses) and /// packages them with the user's description of what should have happened. /// This fires a `user_feedback:expected_behavior` system event that the /// expected-behavior learning mission picks up. pub(super) async fn process_expected( &self, session: Arc>, thread_id: Uuid, description: &str, user_id: &str, ) -> Result { // Extract recent turns from the session (last 5 turns for context) let recent_context = { let sess = session.lock().await; let thread = sess .threads .get(&thread_id) .ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?; let turns: Vec = thread .turns .iter() .rev() .take(5) .collect::>() .into_iter() .rev() .map(|turn| { let tool_calls: Vec = turn .tool_calls .iter() .map(|tc| { serde_json::json!({ "tool": tc.name, "error": tc.error, }) }) .collect(); serde_json::json!({ "user_input": turn.user_input, "response": turn.response, "tool_calls": tool_calls, "state": format!("{:?}", turn.state), "error": turn.error, }) }) .collect(); turns }; if recent_context.is_empty() { return Ok(SubmissionResult::ok_with_message( "No conversation history to attach feedback to.", )); } let payload = serde_json::json!({ "expected_behavior": description, "thread_id": thread_id.to_string(), "recent_turns": recent_context, }); // Fire into v2 mission manager (learning missions) let mut fired: usize = 0; if let Some(mgr) = self.mission_manager().await { match mgr .fire_on_system_event( "user_feedback", "expected_behavior", user_id, Some(payload.clone()), ) .await { Ok(ids) => fired += ids.len(), Err(e) => { tracing::debug!("failed to fire expected-behavior mission: {e}"); } } } // Also fire through v1 routine engine (if routines listen for this) if let Some(engine) = self.routine_engine().await { fired += engine .emit_system_event( "user_feedback", "expected_behavior", &payload, Some(user_id), ) .await; } if fired > 0 { Ok(SubmissionResult::ok_with_message(format!( "Feedback captured. Fired {fired} self-improvement thread(s) to investigate." ))) } else { Ok(SubmissionResult::ok_with_message( "Feedback noted but no self-improvement missions are configured to handle it. \ The engine will use this context in future learning cycles.", )) } } /// Handle `/reasoning [N|all]` — show reasoning history for the active thread. pub(super) async fn handle_reasoning_command( &self, args: &[String], session: &Arc>, thread_id: Uuid, ) -> SubmissionResult { // Clone the turn data we need, then drop the session lock. let turns_snapshot: Vec<( usize, Option, Vec, )>; { let sess = session.lock().await; let thread = match sess.threads.get(&thread_id) { Some(t) => t, None => return SubmissionResult::error("No active thread."), }; if thread.turns.is_empty() { return SubmissionResult::ok_with_message("No turns yet."); } // Parse argument: default=last turn, "all"=all turns, N=specific turn (1-based). let selected: Vec<&crate::agent::session::Turn> = match args.first().map(|s| s.as_str()) { Some("all") => thread.turns.iter().collect(), Some(n) => match n.parse::() { Ok(0) => return SubmissionResult::error("Turn numbers start at 1."), Ok(num) if num > thread.turns.len() => { return SubmissionResult::error(format!( "Turn {} does not exist (max: {}).", num, thread.turns.len() )); } Ok(num) => vec![&thread.turns[num - 1]], Err(_) => return SubmissionResult::error("Usage: /reasoning [N|all]"), }, None => { // Default: last turn that has tool calls match thread.turns.iter().rev().find(|t| !t.tool_calls.is_empty()) { Some(t) => vec![t], None => { return SubmissionResult::ok_with_message("No turns with tool calls."); } } } }; turns_snapshot = selected .into_iter() .map(|t| (t.turn_number, t.narrative.clone(), t.tool_calls.clone())) .collect(); } // Session lock is now dropped — format output without holding it. let mut output = String::new(); for (turn_number, narrative, tool_calls) in &turns_snapshot { output.push_str(&format!("--- Turn {} ---\n", turn_number + 1)); if let Some(narrative) = narrative { output.push_str(&format!("Reasoning: {}\n", narrative)); } if tool_calls.is_empty() { output.push_str(" (no tool calls)\n"); } else { for tc in tool_calls { let status = if tc.error.is_some() { "error" } else if tc.result.is_some() { "ok" } else { "pending" }; output.push_str(&format!(" {} [{}]", tc.name, status)); if let Some(ref rationale) = tc.rationale { output.push_str(&format!(" — {}", rationale)); } output.push('\n'); } } output.push('\n'); } SubmissionResult::response(output.trim_end()) } /// Handle system commands that bypass thread-state checks entirely. pub(super) async fn handle_system_command( &self, command: &str, args: &[String], channel: &str, tenant: &crate::tenant::TenantCtx, ) -> 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", " /reasoning [N|all] Show agent reasoning for turns\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); } } if self.config.multi_tenant { // Multi-tenant: only persist to per-user DB settings. // Do NOT call set_model() on the shared provider — that // would change the default for all users. The per-request // model_override in the dispatcher reads from the same // "selected_model" setting and applies it per-user. self.persist_selected_model(tenant, requested).await; Ok(SubmissionResult::response(format!( "Model preference set to: {} (per-user)", requested ))) } else { match self.llm().set_model(requested) { Ok(()) => { // Persist the model choice so it survives restarts. self.persist_selected_model(tenant, requested).await; 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, tenant: &crate::tenant::TenantCtx, ) -> 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, tenant) .await? { SubmissionResult::Response { content } => Ok(Some(content)), SubmissionResult::Ok { message } => Ok(message), SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))), _ => Ok(None), } } /// Persist the selected model to the settings store (DB and/or TOML config). /// /// Best-effort: logs warnings on failure but does not propagate errors, /// since the in-memory model switch already succeeded. /// /// In multi-tenant mode, only the per-user DB setting is written — global /// .env and TOML files are shared across users and must not be mutated. async fn persist_selected_model(&self, tenant: &crate::tenant::TenantCtx, model: &str) { // 1. Persist to DB if available (per-user scoped via TenantScope). if let Some(store) = tenant.store() { let value = serde_json::Value::String(model.to_string()); if let Err(e) = store.set_setting("selected_model", &value).await { tracing::warn!("Failed to persist model to DB: {}", e); } else { tracing::debug!( user_id = tenant.user_id(), "Persisted selected_model to DB: {}", model ); } } else { tracing::warn!("No database store available — model choice will not persist to DB"); } // 2. In multi-tenant mode, skip .env/TOML writes — these are global // files shared by all users. The per-user DB setting is sufficient. if self.config.multi_tenant { return; } // 3. Update .env and TOML config file (sync I/O in spawn_blocking). let model_owned = model.to_string(); let backend = self.deps.llm_backend.clone(); if let Err(e) = tokio::task::spawn_blocking(move || { // 2a. Update the backend-specific model env var in ~/.ironclaw/.env. // // Env vars have the HIGHEST priority in LlmConfig::resolve_model() // (env var > TOML > DB > default). If the .env file has e.g. // NEARAI_MODEL=old-model, it shadows everything else. We must // update this var or the /model change is invisible on restart. let registry = crate::llm::ProviderRegistry::load(); let model_env = registry.model_env_var(&backend); let env_var_prefix = format!("{}=", model_env); // Only update the .env file if the var is actually set there // (avoid injecting new vars the user never configured). let env_path = crate::bootstrap::ironclaw_env_path(); let env_has_var = std::fs::read_to_string(&env_path) .ok() .is_some_and(|content| { content.lines().any(|line| { let trimmed = line.trim_start(); !trimmed.starts_with('#') && trimmed.starts_with(&env_var_prefix) }) }); if env_has_var { if let Err(e) = crate::bootstrap::upsert_bootstrap_var(model_env, &model_owned) { tracing::warn!("Failed to update {} in .env: {}", model_env, e); } else { tracing::debug!("Updated {} in .env to {}", model_env, model_owned); } } // 2b. Update (or create) the TOML config file. // // The TOML overlay has higher priority than DB settings on // startup, so it MUST stay in sync with the DB. let toml_path = crate::settings::Settings::default_toml_path(); match crate::settings::Settings::load_toml(&toml_path) { Ok(Some(mut settings)) => { settings.selected_model = Some(model_owned); if let Err(e) = settings.save_toml(&toml_path) { tracing::warn!("Failed to persist model to config.toml: {}", e); } } Ok(None) => { // No config file yet — create one so the model choice // survives restarts even when the DB is unavailable. let settings = crate::settings::Settings { selected_model: Some(model_owned), ..Default::default() }; if let Err(e) = settings.save_toml(&toml_path) { tracing::warn!("Failed to create config.toml for model persistence: {}", e); } } Err(e) => { tracing::warn!("Failed to load config.toml for model persistence: {}", e); } } }) .await { tracing::warn!("Model persistence task failed: {}", e); } } }