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