Skip to main content

systemprompt_cli/commands/admin/agents/
delete.rs

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