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