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::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(
128    requested: Option<String>,
129    available: &[String],
130) -> Result<Vec<String>> {
131    let agents = match requested {
132        Some(name) => {
133            if !available.contains(&name) {
134                return Err(anyhow!("Agent '{}' not found", name));
135            }
136            vec![name]
137        },
138        None => available.to_vec(),
139    };
140
141    if agents.is_empty() {
142        return Err(anyhow!("No agents to delete"));
143    }
144
145    Ok(agents)
146}
147
148#[must_use]
149pub fn delete_confirm_message(all: bool, targets: &[String]) -> String {
150    if all {
151        format!("Delete ALL {} agents?", targets.len())
152    } else {
153        format!("Delete agent '{}'?", targets[0])
154    }
155}
156
157#[must_use]
158pub fn delete_success_message(deleted: &[String]) -> String {
159    if deleted.len() == 1 {
160        format!("Agent '{}' deleted successfully", deleted[0])
161    } else {
162        format!("{} agent(s) deleted successfully", deleted.len())
163    }
164}
165
166async fn build_orchestrator(ctx: &CommandContext) -> Result<Option<AgentOrchestrator>, String> {
167    let app = match ctx.app_context().await {
168        Ok(app) => app,
169        Err(e) => {
170            tracing::debug!(error = %e, "Failed to create AppContext for agent deletion");
171            return Ok(None);
172        },
173    };
174
175    let jwt_provider = match JwtValidationProviderImpl::from_config() {
176        Ok(p) => Arc::new(p),
177        Err(e) => {
178            tracing::debug!(error = %e, "Failed to create JWT provider");
179            return Err(format!("Failed to initialize: {e}"));
180        },
181    };
182
183    let agent_state = Arc::new(AgentState::new(
184        Arc::clone(app.db_pool()),
185        Arc::new(app.config().clone()),
186        jwt_provider,
187        Arc::clone(app.a2a_repositories()),
188    ));
189
190    Ok(
191        AgentOrchestrator::new(agent_state, Arc::clone(app.app_paths_arc()), None)
192            .await
193            .ok(),
194    )
195}
196
197pub async fn delete_single_agent(
198    agent_name: &str,
199    agent_port: Option<u16>,
200    orchestrator: Option<&AgentOrchestrator>,
201    authoring: &AgentConfigAuthoringService,
202    force: bool,
203) -> Result<(), String> {
204    CliService::info(&format!("Deleting agent '{}'...", agent_name));
205
206    let process_stopped = stop_agent_process(agent_name, agent_port, orchestrator).await;
207
208    if !process_stopped && !force {
209        let msg = format!(
210            "Failed to stop agent '{}' process. Use --force to delete anyway.",
211            agent_name
212        );
213        CliService::error(&msg);
214        return Err(msg);
215    }
216
217    if !process_stopped && force {
218        CliService::warning(&format!(
219            "Force deleting agent '{}' (process may still be running)",
220            agent_name
221        ));
222    }
223
224    match authoring.delete(agent_name) {
225        Ok(()) => {
226            CliService::success(&format!("Agent '{}' deleted", agent_name));
227            Ok(())
228        },
229        Err(e) => {
230            CliService::error(&format!("Failed to delete agent '{}': {}", agent_name, e));
231            Err(format!("{}: {}", agent_name, e))
232        },
233    }
234}
235
236pub async fn stop_agent_process(
237    agent_name: &str,
238    agent_port: Option<u16>,
239    orchestrator: Option<&AgentOrchestrator>,
240) -> bool {
241    if let Some(orch) = orchestrator {
242        match orch.delete_agent(agent_name).await {
243            Ok(()) => {
244                tracing::debug!(agent = %agent_name, "Agent stopped via orchestrator");
245                return true;
246            },
247            Err(e) => {
248                tracing::debug!(
249                    agent = %agent_name,
250                    error = %e,
251                    "Orchestrator termination failed, trying port-based cleanup"
252                );
253            },
254        }
255    }
256
257    let Some(port) = agent_port else {
258        tracing::debug!(agent = %agent_name, "No port configured, assuming not running");
259        return true;
260    };
261
262    if ProcessCleanup::check_port(port).is_none() {
263        tracing::debug!(agent = %agent_name, port, "No process on port, assuming stopped");
264        return true;
265    }
266
267    CliService::info(&format!(
268        "Stopping agent '{}' on port {}...",
269        agent_name, port
270    ));
271
272    let Some(pid) = ProcessCleanup::check_port(port) else {
273        tracing::warn!(agent = %agent_name, port, "No process found on port to stop");
274        return false;
275    };
276
277    if !ProcessCleanup::kill_process(pid) {
278        tracing::warn!(agent = %agent_name, port, pid, "Failed to kill process on port");
279        return false;
280    }
281
282    tracing::debug!(agent = %agent_name, port, pid, "Killed process on port");
283    true
284}