| 1 | //! `/agent` command. |
| 2 | |
| 3 | use crate::commands::traits::{CommandInfo, RegisterCommand}; |
| 4 | use crate::tui::app::{App, AppAction}; |
| 5 | use codewhale_localization::MessageId; |
| 6 | |
| 7 | use super::CommandResult; |
| 8 | |
| 9 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 10 | name: "agent", |
| 11 | aliases: &["daili"], |
| 12 | usage: "/agent [N] <task>", |
| 13 | description_id: MessageId::CmdAgentDescription, |
| 14 | }; |
| 15 | |
| 16 | pub(in crate::commands) struct AgentCmd; |
| 17 | |
| 18 | impl RegisterCommand for AgentCmd { |
| 19 | fn info() -> &'static CommandInfo { |
| 20 | &COMMAND_INFO |
| 21 | } |
| 22 | |
| 23 | fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 24 | agent(app, arg) |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | pub fn agent(_app: &mut App, arg: Option<&str>) -> CommandResult { |
| 29 | if let Some(action) = parse_agent_control_action(arg) { |
| 30 | if action.action == "cancel" { |
| 31 | return CommandResult::with_message_and_action( |
| 32 | format!("Cancelling agent {}...", action.agent_id), |
| 33 | AppAction::CancelSubAgent { |
| 34 | agent_id: action.agent_id, |
| 35 | }, |
| 36 | ); |
| 37 | } |
| 38 | let message = format!( |
| 39 | "Call `agent` with action `{}`, agent_id `{}`, then summarize the returned status for the user. Do not start a new agent.", |
| 40 | action.action, action.agent_id |
| 41 | ); |
| 42 | return CommandResult::with_message_and_action( |
| 43 | format!("Agent {} requested for {}.", action.action, action.agent_id), |
| 44 | AppAction::SendMessage(message), |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | let (max_depth, task) = match super::util::parse_depth_prefixed_arg(arg, 1) { |
| 49 | Ok(parsed) => parsed, |
| 50 | Err(message) => return CommandResult::error(message), |
| 51 | }; |
| 52 | let task = match task { |
| 53 | Some(task) if !task.trim().is_empty() => task.trim().to_string(), |
| 54 | _ => { |
| 55 | return CommandResult::error( |
| 56 | "Usage: /agent [N] <task>\n\n\ |
| 57 | Opens a persistent sub-agent session with recursive agent depth N (0-3, default 1).", |
| 58 | ); |
| 59 | } |
| 60 | }; |
| 61 | let message = agent_dispatch_brief(&task, max_depth); |
| 62 | CommandResult::with_message_and_action( |
| 63 | format!("Opening persistent sub-agent at depth {max_depth}..."), |
| 64 | AppAction::SendMessage(message), |
| 65 | ) |
| 66 | } |
| 67 | |
| 68 | /// The model-facing /agent brief. `handle_read` is deferred on the default |
| 69 | /// catalog, so the brief teaches its activation path (#6747). |
| 70 | pub(crate) fn agent_dispatch_brief(task: &str, max_depth: impl std::fmt::Display) -> String { |
| 71 | format!( |
| 72 | "Launch one sub-agent for this task by calling `agent` with name `slash_agent`, `prompt: {task:?}`, and `max_depth: {max_depth}`. Use `handle_read` on the returned transcript_handle if you need more detail ({}). Verify any claimed side effects with `read` before reporting success.", |
| 73 | crate::tools::handle::HANDLE_READ_ACTIVATION_HINT |
| 74 | ) |
| 75 | } |
| 76 | |
| 77 | struct AgentControlAction { |
| 78 | action: &'static str, |
| 79 | agent_id: String, |
| 80 | } |
| 81 | |
| 82 | fn parse_agent_control_action(arg: Option<&str>) -> Option<AgentControlAction> { |
| 83 | let arg = arg?.trim(); |
| 84 | let (action, rest) = arg.split_once(char::is_whitespace)?; |
| 85 | let action = match action { |
| 86 | "status" | "inspect" => "status", |
| 87 | "peek" | "progress" => "peek", |
| 88 | "cancel" | "stop" | "abort" => "cancel", |
| 89 | _ => return None, |
| 90 | }; |
| 91 | let agent_id = rest.trim(); |
| 92 | if agent_id.is_empty() || agent_id.contains(char::is_whitespace) { |
| 93 | return None; |
| 94 | } |
| 95 | Some(AgentControlAction { |
| 96 | action, |
| 97 | agent_id: agent_id.to_string(), |
| 98 | }) |
| 99 | } |
| 100 | |
| 101 | #[cfg(test)] |
| 102 | mod tests { |
| 103 | use super::*; |
| 104 | use std::path::PathBuf; |
| 105 | |
| 106 | use crate::tui::app::TuiOptions; |
| 107 | |
| 108 | fn test_app() -> App { |
| 109 | let options = TuiOptions { |
| 110 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 111 | }; |
| 112 | App::new(options, &crate::config::Config::default()) |
| 113 | } |
| 114 | |
| 115 | /// #6747: the /agent brief names only callable tools and teaches the |
| 116 | /// deferred `handle_read` activation path. |
| 117 | #[test] |
| 118 | fn agent_dispatch_brief_names_only_callable_tools() { |
| 119 | crate::tools::canonical_action::tests::assert_text_names_only_callable_tools( |
| 120 | "/agent brief", |
| 121 | &agent_dispatch_brief("inspect the repo", 1), |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | #[test] |
| 126 | fn agent_control_actions_route_to_existing_agent_tool() { |
| 127 | let mut app = test_app(); |
| 128 | let result = agent(&mut app, Some("peek agent_123")); |
| 129 | |
| 130 | assert!(!result.is_error); |
| 131 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 132 | panic!("expected SendMessage action"); |
| 133 | }; |
| 134 | assert!(message.contains("action `peek`")); |
| 135 | assert!(message.contains("agent_id `agent_123`")); |
| 136 | assert!(message.contains("Do not start a new agent")); |
| 137 | |
| 138 | let result = agent(&mut app, Some("cancel agent_123")); |
| 139 | let Some(AppAction::CancelSubAgent { agent_id }) = result.action else { |
| 140 | panic!("expected CancelSubAgent action"); |
| 141 | }; |
| 142 | assert_eq!(agent_id, "agent_123"); |
| 143 | } |
| 144 | } |
| 145 |