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