Skip to main content

systemprompt_cli/commands/admin/agents/
delete.rs

1//! `admin agents delete` command with target resolution and orchestrated
2//! teardown.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use 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}