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
127/// Resolves the delete target set: a specific agent when `requested` is
128/// given (which must exist), otherwise every available agent.
129pub 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        Arc::clone(app.a2a_repositories()),
190    ));
191
192    Ok(
193        AgentOrchestrator::new(agent_state, Arc::clone(app.app_paths_arc()), None)
194            .await
195            .ok(),
196    )
197}
198
199pub async fn delete_single_agent(
200    agent_name: &str,
201    agent_port: Option<u16>,
202    orchestrator: Option<&AgentOrchestrator>,
203    authoring: &AgentConfigAuthoringService,
204    force: bool,
205) -> Result<(), String> {
206    CliService::info(&format!("Deleting agent '{}'...", agent_name));
207
208    let process_stopped = stop_agent_process(agent_name, agent_port, orchestrator).await;
209
210    if !process_stopped && !force {
211        let msg = format!(
212            "Failed to stop agent '{}' process. Use --force to delete anyway.",
213            agent_name
214        );
215        CliService::error(&msg);
216        return Err(msg);
217    }
218
219    if !process_stopped && force {
220        CliService::warning(&format!(
221            "Force deleting agent '{}' (process may still be running)",
222            agent_name
223        ));
224    }
225
226    match authoring.delete(agent_name) {
227        Ok(()) => {
228            CliService::success(&format!("Agent '{}' deleted", agent_name));
229            Ok(())
230        },
231        Err(e) => {
232            CliService::error(&format!("Failed to delete agent '{}': {}", agent_name, e));
233            Err(format!("{}: {}", agent_name, e))
234        },
235    }
236}
237
238pub async fn stop_agent_process(
239    agent_name: &str,
240    agent_port: Option<u16>,
241    orchestrator: Option<&AgentOrchestrator>,
242) -> bool {
243    if let Some(orch) = orchestrator {
244        match orch.delete_agent(agent_name).await {
245            Ok(()) => {
246                tracing::debug!(agent = %agent_name, "Agent stopped via orchestrator");
247                return true;
248            },
249            Err(e) => {
250                tracing::debug!(
251                    agent = %agent_name,
252                    error = %e,
253                    "Orchestrator termination failed, trying port-based cleanup"
254                );
255            },
256        }
257    }
258
259    let Some(port) = agent_port else {
260        tracing::debug!(agent = %agent_name, "No port configured, assuming not running");
261        return true;
262    };
263
264    if ProcessCleanup::check_port(port).is_none() {
265        tracing::debug!(agent = %agent_name, port, "No process on port, assuming stopped");
266        return true;
267    }
268
269    CliService::info(&format!(
270        "Stopping agent '{}' on port {}...",
271        agent_name, port
272    ));
273
274    let Some(pid) = ProcessCleanup::check_port(port) else {
275        tracing::warn!(agent = %agent_name, port, "No process found on port to stop");
276        return false;
277    };
278
279    if !ProcessCleanup::kill_process(pid) {
280        tracing::warn!(agent = %agent_name, port, pid, "Failed to kill process on port");
281        return false;
282    }
283
284    tracing::debug!(agent = %agent_name, port, pid, "Killed process on port");
285    true
286}