systemprompt_cli/commands/admin/agents/
delete.rs1use anyhow::{Context, Result, anyhow};
8use clap::Args;
9use std::path::Path;
10use std::sync::Arc;
11
12use super::types::AgentDeleteOutput;
13use crate::CliConfig;
14use crate::context::CommandContext;
15use crate::interactive::{Prompter, require_confirmation, resolve_required};
16use crate::shared::CommandOutput;
17use systemprompt_agent::AgentState;
18use systemprompt_agent::services::agent_orchestration::AgentOrchestrator;
19use systemprompt_agent::services::config_authoring::AgentConfigAuthoringService;
20use systemprompt_config::ProfileBootstrap;
21use systemprompt_loader::ConfigLoader;
22use systemprompt_logging::CliService;
23use systemprompt_oauth::JwtValidationProviderImpl;
24use systemprompt_scheduler::ProcessCleanup;
25
26#[derive(Debug, Args)]
27pub struct DeleteArgs {
28 #[arg(help = "Agent name (required in non-interactive mode)")]
29 pub name: Option<String>,
30
31 #[arg(long, help = "Delete all agents")]
32 pub all: bool,
33
34 #[arg(short = 'y', long, help = "Skip confirmation prompts")]
35 pub yes: bool,
36
37 #[arg(long, help = "Force delete even if process cannot be stopped")]
38 pub force: bool,
39}
40
41pub(super) async fn execute(args: DeleteArgs, ctx: &CommandContext) -> Result<CommandOutput> {
42 let prompter = ctx.prompter();
43 let config = &ctx.cli;
44 let services_config = ConfigLoader::load().context("Failed to load services configuration")?;
45
46 let agents_to_delete = resolve_targets(&args, prompter, &services_config, config)?;
47
48 require_confirmation(
49 prompter,
50 &delete_confirm_message(args.all, &agents_to_delete),
51 args.yes,
52 config,
53 )?;
54
55 let profile = ProfileBootstrap::get().context("Failed to get profile")?;
56 let authoring = AgentConfigAuthoringService::new(Path::new(&profile.paths.services));
57
58 let orchestrator = match build_orchestrator(ctx).await {
59 Ok(orchestrator) => orchestrator,
60 Err(message) => {
61 return Ok(CommandOutput::card_value(
62 "Delete Failed",
63 &AgentDeleteOutput {
64 deleted: vec![],
65 message,
66 },
67 ));
68 },
69 };
70
71 let mut deleted = Vec::new();
72 let mut errors = Vec::new();
73
74 for agent_name in &agents_to_delete {
75 let agent_port = services_config.agents.get(agent_name).map(|c| c.port);
76 let result = delete_single_agent(
77 agent_name,
78 agent_port,
79 orchestrator.as_ref(),
80 &authoring,
81 args.force,
82 )
83 .await;
84 match result {
85 Ok(()) => deleted.push(agent_name.clone()),
86 Err(msg) => errors.push(msg),
87 }
88 }
89
90 if !errors.is_empty() && deleted.is_empty() {
91 return Err(anyhow!("Failed to delete agents:\n{}", errors.join("\n")));
92 }
93
94 if !deleted.is_empty() {
95 ConfigLoader::reload().with_context(|| {
96 "Agent(s) deleted but configuration validation failed. Please check the configuration."
97 })?;
98 }
99
100 let message = delete_success_message(&deleted);
101 let output = AgentDeleteOutput { deleted, message };
102
103 Ok(CommandOutput::card_value("Delete Agent", &output))
104}
105
106fn resolve_targets(
107 args: &DeleteArgs,
108 prompter: &dyn Prompter,
109 services_config: &systemprompt_models::ServicesConfig,
110 config: &CliConfig,
111) -> Result<Vec<String>> {
112 let available: Vec<String> = services_config.agents.keys().cloned().collect();
113 let requested = if args.all {
114 None
115 } else {
116 Some(resolve_required(args.name.clone(), "name", config, || {
117 super::shared::prompt_agent_selection(
118 prompter,
119 "Select agent to delete",
120 services_config,
121 )
122 })?)
123 };
124 validate_delete_targets(requested, &available)
125}
126
127pub fn validate_delete_targets(
130 requested: Option<String>,
131 available: &[String],
132) -> Result<Vec<String>> {
133 let agents = match requested {
134 Some(name) => {
135 if !available.contains(&name) {
136 return Err(anyhow!("Agent '{}' not found", name));
137 }
138 vec![name]
139 },
140 None => available.to_vec(),
141 };
142
143 if agents.is_empty() {
144 return Err(anyhow!("No agents to delete"));
145 }
146
147 Ok(agents)
148}
149
150#[must_use]
151pub fn delete_confirm_message(all: bool, targets: &[String]) -> String {
152 if all {
153 format!("Delete ALL {} agents?", targets.len())
154 } else {
155 format!("Delete agent '{}'?", targets[0])
156 }
157}
158
159#[must_use]
160pub fn delete_success_message(deleted: &[String]) -> String {
161 if deleted.len() == 1 {
162 format!("Agent '{}' deleted successfully", deleted[0])
163 } else {
164 format!("{} agent(s) deleted successfully", deleted.len())
165 }
166}
167
168async fn build_orchestrator(ctx: &CommandContext) -> Result<Option<AgentOrchestrator>, String> {
169 let app = match ctx.app_context().await {
170 Ok(app) => app,
171 Err(e) => {
172 tracing::debug!(error = %e, "Failed to create AppContext for agent deletion");
173 return Ok(None);
174 },
175 };
176
177 let jwt_provider = match JwtValidationProviderImpl::from_config() {
178 Ok(p) => Arc::new(p),
179 Err(e) => {
180 tracing::debug!(error = %e, "Failed to create JWT provider");
181 return Err(format!("Failed to initialize: {e}"));
182 },
183 };
184
185 let agent_state = Arc::new(AgentState::new(
186 Arc::clone(app.db_pool()),
187 Arc::new(app.config().clone()),
188 jwt_provider,
189 ));
190
191 Ok(
192 AgentOrchestrator::new(agent_state, Arc::clone(app.app_paths_arc()), None)
193 .await
194 .ok(),
195 )
196}
197
198pub async fn delete_single_agent(
199 agent_name: &str,
200 agent_port: Option<u16>,
201 orchestrator: Option<&AgentOrchestrator>,
202 authoring: &AgentConfigAuthoringService,
203 force: bool,
204) -> Result<(), String> {
205 CliService::info(&format!("Deleting agent '{}'...", agent_name));
206
207 let process_stopped = stop_agent_process(agent_name, agent_port, orchestrator).await;
208
209 if !process_stopped && !force {
210 let msg = format!(
211 "Failed to stop agent '{}' process. Use --force to delete anyway.",
212 agent_name
213 );
214 CliService::error(&msg);
215 return Err(msg);
216 }
217
218 if !process_stopped && force {
219 CliService::warning(&format!(
220 "Force deleting agent '{}' (process may still be running)",
221 agent_name
222 ));
223 }
224
225 match authoring.delete(agent_name) {
226 Ok(()) => {
227 CliService::success(&format!("Agent '{}' deleted", agent_name));
228 Ok(())
229 },
230 Err(e) => {
231 CliService::error(&format!("Failed to delete agent '{}': {}", agent_name, e));
232 Err(format!("{}: {}", agent_name, e))
233 },
234 }
235}
236
237pub async fn stop_agent_process(
238 agent_name: &str,
239 agent_port: Option<u16>,
240 orchestrator: Option<&AgentOrchestrator>,
241) -> bool {
242 if let Some(orch) = orchestrator {
243 match orch.delete_agent(agent_name).await {
244 Ok(()) => {
245 tracing::debug!(agent = %agent_name, "Agent stopped via orchestrator");
246 return true;
247 },
248 Err(e) => {
249 tracing::debug!(
250 agent = %agent_name,
251 error = %e,
252 "Orchestrator termination failed, trying port-based cleanup"
253 );
254 },
255 }
256 }
257
258 let Some(port) = agent_port else {
259 tracing::debug!(agent = %agent_name, "No port configured, assuming not running");
260 return true;
261 };
262
263 if ProcessCleanup::check_port(port).is_none() {
264 tracing::debug!(agent = %agent_name, port, "No process on port, assuming stopped");
265 return true;
266 }
267
268 CliService::info(&format!(
269 "Stopping agent '{}' on port {}...",
270 agent_name, port
271 ));
272
273 let Some(pid) = ProcessCleanup::check_port(port) else {
274 tracing::warn!(agent = %agent_name, port, "No process found on port to stop");
275 return false;
276 };
277
278 if !ProcessCleanup::kill_process(pid) {
279 tracing::warn!(agent = %agent_name, port, pid, "Failed to kill process on port");
280 return false;
281 }
282
283 tracing::debug!(agent = %agent_name, port, pid, "Killed process on port");
284 true
285}