//! start, stop, restart and run: process management commands. use super::*; use crate::cli::StartArgs; use crate::events::{self, Event, EventKind}; use crate::process; use crate::sessions; use anyhow::{bail, Context, Result}; use std::collections::BTreeMap; use std::ffi::OsString; use std::path::PathBuf; use std::process::Command; /// Resolve the start target: the explicit agent/alias/group when given, /// otherwise the default agent of the project containing cwd (issue #30). pub fn resolve_start_target(app: &App, cwd: &std::path::Path, agent: Option<&str>) -> Result { if let Some(a) = agent.filter(|s| !s.is_empty()) { return Ok(a.to_string()); } crate::projects::default_agent_for_dir(app, cwd).ok_or_else(|| { anyhow!( "no agent given — set default_agent in this project's profile ('am init' does it) or pass an agent name" ) }) } pub fn start(app: &App, opts: &StartArgs) -> Result { start_with_cwd(app, opts, None) } /// Apply the environment profile (issue #40): the explicit --profile wins; /// otherwise the current project's default profile applies when launching /// the project's default agent. fn apply_profile( app: &App, explicit: Option<&str>, project_default_agent: bool, extra_args: &[String], extra_env: &BTreeMap, ) -> Result<(Vec, BTreeMap)> { let mut profile = explicit; if profile.is_none() && project_default_agent { let current = std::env::current_dir().unwrap_or_default(); if let Some((_, pp)) = crate::projects::profile_for(app, ¤t) { profile = pp.profile.as_deref(); } } crate::commands::profile_cmd::apply(app, profile, extra_args, extra_env) } /// Start with an explicit working directory (used by sessions resume). pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>) -> Result { let current = std::env::current_dir().unwrap_or_default(); let explicit = opts.agent.as_deref().filter(|s| !s.is_empty()); // A profile may declare its own default agent (issue #40). let profile_default = profile_cmd::default_agent(app, opts.profile.as_deref())?; let target = match (explicit, profile_default) { (Some(a), _) => a.to_string(), (None, Some(a)) => a.to_string(), (None, None) => resolve_start_target(app, ¤t, None)?, }; let extra_env = parse_env_list(&opts.env)?; let extra_args = parse_extra_args(&opts.args); let project_default_agent = explicit.is_none() && profile_default.is_none(); let (extra_args, extra_env) = apply_profile( app, opts.profile.as_deref(), project_default_agent, &extra_args, &extra_env, )?; // --model (issue #71): resolve against the local runtimes, then // transmit through the agent's declared channel (env var or flag). // --provider (issue #92): resolve from the LLM provider registry. let mut extra_args = extra_args; let mut extra_env = extra_env; if let Some(p) = opts.provider.as_deref() { let agent = require_agent(app, &target)?; let (a, e) = apply_provider(app, agent, Some(p), opts.model.as_deref())? .expect("a provider flag was given: apply_provider always resolves or errors"); extra_args.extend(a); extra_env.extend(e); } else if let Some(m) = opts.model.as_deref() { let agent = require_agent(app, &target)?; match apply_model(app, agent, m) { Ok((a, e)) => { extra_args.extend(a); extra_env.extend(e); } Err(local_err) => match apply_provider(app, agent, None, Some(m))? { Some((a, e)) => { extra_args.extend(a); extra_env.extend(e); } None => return Err(local_err), }, } } if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) { let members = app.catalog.group_members(group); if members.is_empty() { bail!("unknown group '{group}'"); } // Issue #57: --parallel launches every member back-to-back without // waiting (the OS spawns them concurrently); the default waits each // health check before the next member. for m in members { start_one(app, m, &extra_args, &extra_env, opts.notify, true, opts.no_sandbox, cwd)?; if !opts.parallel { // Issue #57: block until healthy before the next member. crate::automation::wait_health(&m.name, m.healthcheck.as_ref(), &app.log)?; } } return Ok(0); } let agent = require_agent(app, &target)?; start_one( app, agent, &extra_args, &extra_env, opts.notify, opts.background, opts.no_sandbox, cwd, ) } fn start_one( app: &App, agent: &AgentDef, extra_args: &[String], extra_env: &BTreeMap, notify: bool, background: bool, no_sandbox: bool, cwd: Option<&std::path::Path>, ) -> Result { let exec = resolve_exec(app, agent, extra_args, extra_env)?; let bin = PathBuf::from(&exec.program); let is_managed = app.state.get(&agent.name).ok().flatten().is_some(); let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default()); let current = std::env::current_dir().unwrap_or_default(); crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(¤t)); if background { // Issue #79: sandbox enforcement before spawning. let mut env = exec.env.clone(); crate::sandbox::enforce(app, agent, &exec.program, &mut env, no_sandbox)?; let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &env, cwd)?; if app.dry_run() { return Ok(0); } if is_managed { app.state .update_pid(&agent.name, Some(pid), Some(crate::installers::now_rfc3339()))?; } else { app.log.warn(&format!( "agent '{}' is external (not installed by agent-manager); PID {pid} is not recorded", agent.name )); } app.emit( &Event::now(EventKind::Start) .with_agent(agent.name.clone()) .with_pid(pid) .with_session(crate::repl::session_id()) .with_cwd(cwd_string()) .with_project(ctx.root.clone()) .with_args(exec.args.clone()) .with_env_keys(events::env_keys(&exec.env)), ); if is_managed { let _ = app.state.touch(&agent.name); } let log = process::agent_log_path(app, &agent.name); let _ = sessions::start_agent( app, &agent.name, pid, cwd_string(), ctx.root.clone(), exec.args.clone(), events::env_keys(&exec.env), Some(log.display().to_string()), ); app.log.success(&format!( "{} started (pid {pid}); log: {}", agent.title(), log.display() )); if notify { process::desktop_notify("agent-manager", &format!("{} started", agent.title())); } Ok(0) } else { if app.dry_run() { app.log.dry(format!( "would run {} {} (foreground)", exec.program, exec.args.join(" ") )); return Ok(0); } let started = std::time::Instant::now(); let (prog, prefix) = crate::runner::resolve_program(&exec.program); let mut full_args = prefix; full_args.extend(exec.args.iter().cloned()); let mut cmd = Command::new(&prog); cmd.args(&full_args).envs(&exec.env); if let Some(dir) = cwd { cmd.current_dir(dir); } let status = cmd .status() .with_context(|| format!("failed to run {}", exec.program))?; let code = status.code().unwrap_or(1); app.emit( &Event::now(EventKind::Run) .with_agent(agent.name.clone()) .with_cwd(cwd_string()) .with_project(ctx.root.clone()) .with_args(exec.args.clone()) .with_env_keys(events::env_keys(&exec.env)) .with_exit_code(code) .with_duration(started.elapsed().as_secs()), ); let _ = app.state.touch(&agent.name); Ok(code) } } pub fn stop(app: &App, target: &str, force: bool, timeout: Option) -> Result { let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout()); if let Some(group) = crate::catalog::Catalog::parse_group_selector(target) { let members = app.catalog.group_members(group); if members.is_empty() { bail!("unknown group '{group}'"); } for m in members.iter().rev() { stop_one(app, m, force, timeout)?; } return Ok(0); } let agent = require_agent(app, target)?; stop_one(app, agent, force, timeout) } fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result { let Some(entry) = app.state.get(&agent.name).ok().flatten() else { app.log.info(&format!( "agent '{}' is not managed by agent-manager; nothing to stop", agent.name )); return Ok(0); }; let Some(pid) = entry.pid else { app.log.info(&format!("agent '{}' is not running", agent.name)); return Ok(0); }; if app.dry_run() { app.log.dry(format!( "would stop pid {pid} ({}{})", agent.name, if force { "force" } else { "graceful" } )); return Ok(0); } if !process::is_running(pid) { app.log.warn(&format!( "stale PID {pid} for '{}' (process no longer running); cleaning up", agent.name )); app.emit( &Event::now(EventKind::Stop) .with_agent(agent.name.clone()) .with_pid(pid) .with_reason("stale"), ); let _ = sessions::finish_agent(app, &agent.name, Some(pid), 0, true); app.state.update_pid(&agent.name, None, None)?; let current = std::env::current_dir().unwrap_or_default(); crate::hooks::run_hooks(app, "on_stop", ¤t); return Ok(0); } app.log.info(&format!("stopping '{}' (pid {pid})", agent.name)); let stopped = process::stop_pid(pid, force, timeout); let duration = seconds_since(entry.started_at.as_deref()); let mut ev = Event::now(EventKind::Stop) .with_agent(agent.name.clone()) .with_pid(pid) .with_reason(if force { "force" } else { "signal" }) .with_exit_code(if stopped { 0 } else { 1 }); if let Some(d) = duration { ev = ev.with_duration(d); } app.emit(&ev); let _ = sessions::finish_agent(app, &agent.name, Some(pid), if stopped { 0 } else { 1 }, stopped); app.state.update_pid(&agent.name, None, None)?; let current = std::env::current_dir().unwrap_or_default(); crate::hooks::run_hooks(app, "on_stop", ¤t); if stopped { app.log.success(&format!("{} stopped", agent.title())); Ok(0) } else { app.log.error(&format!("failed to stop pid {pid}")); Ok(1) } } pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option) -> Result { let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout()); let cwd = std::env::current_dir().unwrap_or_default(); let explicit = opts.agent.as_deref().filter(|s| !s.is_empty()); let profile_default = profile_cmd::default_agent(app, opts.profile.as_deref())?; let target = match (explicit, profile_default) { (Some(a), _) => a.to_string(), (None, Some(a)) => a.to_string(), (None, None) => resolve_start_target(app, &cwd, None)?, }; let extra_env = parse_env_list(&opts.env)?; let extra_args = parse_extra_args(&opts.args); let project_default_agent = explicit.is_none() && profile_default.is_none(); let (extra_args, extra_env) = apply_profile( app, opts.profile.as_deref(), project_default_agent, &extra_args, &extra_env, )?; if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) { let members = app.catalog.group_members(group); if members.is_empty() { bail!("unknown group '{group}'"); } for m in members.iter().rev() { stop_one(app, m, force, timeout)?; } for m in members { start_one( app, m, &extra_args, &extra_env, opts.notify, true, opts.no_sandbox, None, )?; } return Ok(0); } let agent = require_agent(app, &target)?; stop_one(app, agent, force, timeout)?; start_one( app, agent, &extra_args, &extra_env, opts.notify, opts.background, opts.no_sandbox, None, ) } /// run: execute the agent command directly, no process management. pub fn run( app: &App, target: &str, model: Option<&str>, provider: Option<&str>, container: bool, no_sandbox: bool, extra: &[OsString], ) -> Result { let agent = require_agent(app, target)?; // Issue #58: --container runs the agent inside its container profile. if container { return run_container(app, agent, extra); } let mut extra_args: Vec = extra .iter() .map(|o| o.to_string_lossy().to_string()) .collect(); let mut extra_env: BTreeMap = BTreeMap::new(); // Issue #92: --provider resolves from the LLM provider registry (cloud); // --model alone keeps the local-runtimes-first behavior (issue #71) with // a cloud fallback when the model is unknown locally. if let Some(p) = provider { let (a, e) = apply_provider(app, agent, Some(p), model)? .expect("a provider flag was given: apply_provider always resolves or errors"); extra_args.extend(a); extra_env.extend(e); } else if let Some(m) = model { match apply_model(app, agent, m) { Ok((a, e)) => { extra_args.extend(a); extra_env.extend(e); } Err(local_err) => match apply_provider(app, agent, None, Some(m))? { Some((a, e)) => { extra_args.extend(a); extra_env.extend(e); } None => return Err(local_err), }, } } let mut exec = resolve_exec(app, agent, &extra_args, &extra_env)?; if app.dry_run() { app.log.dry(format!( "would run {} {}", exec.program, exec.args.join(" ") )); return Ok(0); } let started = std::time::Instant::now(); let (prog, prefix) = crate::runner::resolve_program(&exec.program); let mut full_args = prefix; full_args.extend(exec.args.iter().cloned()); // Issue #79: sandbox enforcement (command allowlist, cwd perimeter, // network policy) — refused attempts are journalized for audit. crate::sandbox::enforce(app, agent, &prog, &mut exec.env, no_sandbox)?; let status = Command::new(&prog) .args(&full_args) .envs(&exec.env) .status() .with_context(|| format!("failed to run {}", exec.program))?; let code = status.code().unwrap_or(1); app.emit( &Event::now(EventKind::Run) .with_agent(agent.name.clone()) .with_cwd(cwd_string()) .with_args(exec.args.clone()) .with_env_keys(events::env_keys(&exec.env)) .with_exit_code(code) .with_duration(started.elapsed().as_secs()), ); let _ = app.state.touch(&agent.name); Ok(code) } /// Issue #58: run the agent inside its container profile. Detects the /// runtime (podman > docker), mounts state/log dirs and the profile /// volumes/network/env, then execs the container in the foreground. fn run_container(app: &App, agent: &crate::config::AgentDef, extra: &[OsString]) -> Result { let Some(spec) = &agent.container else { bail!( "agent '{}' has no container profile — add 'container: {{image: ...}}' to its definition", agent.name ); }; let Some(runtime) = crate::automation::detect_runtime() else { bail!( "no container runtime found (docker or podman) — install one or drop --container" ); }; let state_dir = app.paths.state_file.parent().unwrap_or(std::path::Path::new(".")); let log_dir = &app.paths.log_dir; let mut cmd = crate::automation::container_command( &runtime, &agent.name, spec, state_dir, log_dir, )?; // The agent's run command + extra args become the container command. let exec = resolve_exec(app, agent, &[], &BTreeMap::new())?; cmd.push(exec.program); cmd.extend(exec.args.iter().cloned()); cmd.extend(extra.iter().map(|o| o.to_string_lossy().to_string())); if app.dry_run() { app.log.dry(&format!("would run: {}", cmd.join(" "))); return Ok(0); } app.log.info(&format!( "running '{}' in a container ({runtime})", agent.name )); let status = Command::new(&cmd[0]) .args(&cmd[1..]) .status() .with_context(|| format!("failed to run {}", cmd.join(" ")))?; let code = status.code().unwrap_or(1); app.emit( &Event::now(EventKind::Run) .with_agent(agent.name.clone()) .with_reason(format!("container ({runtime})")) .with_args(cmd.clone()) .with_exit_code(code), ); let _ = app.state.touch(&agent.name); Ok(code) } /// Current directory as a string (None when unavailable). fn cwd_string() -> Option { std::env::current_dir().ok().map(|p| p.display().to_string()) } /// Resolve --model against the local runtimes and build the transmission /// channel for the agent (issue #71): the agent's `model_env` variable wins, /// then its `model_arg` flag, then the generic AM_MODEL variable. An unknown /// model is an explicit error listing the available runtimes. pub fn apply_model( app: &App, agent: &AgentDef, model: &str, ) -> Result<(Vec, BTreeMap)> { let inventory = crate::models::detect(app); if inventory.find(model).is_none() { let known: Vec = inventory.models.iter().map(|m| m.name.clone()).collect(); let hint = if known.is_empty() { "no model runtime detected — run 'am models' to check your ollama, llama.cpp or LM Studio setup".to_string() } else { format!("available models: {} — run 'am models' for details", known.join(", ")) }; bail!("model '{model}' not found on any local runtime ({hint})"); } let mut args = Vec::new(); let mut env = BTreeMap::new(); if let Some(var) = &agent.model_env { env.insert(var.clone(), model.to_string()); app.log.verbose(&format!( "model '{model}' transmitted to {} via {var}", agent.name )); } else if let Some(flag) = &agent.model_arg { args.push(flag.clone()); args.push(model.to_string()); app.log.verbose(&format!( "model '{model}' transmitted to {} via {flag}", agent.name )); } else { // Generic fallback: many local agents read AM_MODEL; the config can // pin a specific channel (model_env / model_arg) instead. env.insert("AM_MODEL".to_string(), model.to_string()); app.log.info(&format!( "agent '{}' declares no model_env/model_arg — exported AM_MODEL={model}; set model_env or model_arg in the config to use a specific channel", agent.name )); } Ok((args, env)) } /// Issue #92: resolve `--provider`/`--model` against the LLM provider /// registry (cloud) and transmit through the agent's declared channels: /// `model_env` / `model_arg`, or the `config.env_map` slots (issue #91). /// The token travels as the `@secret` reference resolved by /// resolve_env_secrets — never on the command line. Returns None when no /// provider is configured at all (the caller falls back to the /// local-runtime path). fn apply_provider( app: &App, agent: &AgentDef, provider: Option<&str>, model: Option<&str>, ) -> Result, BTreeMap)>> { let Some((pname, _def, resolved_model)) = crate::providers::resolve_for( &app.config, crate::agent_config::provider_pref(agent), agent.model.as_deref(), provider, model, ) else { if provider.is_some() { let known = crate::providers::names(&app.config); let hint = if known.is_empty() { "aucun (am providers add --base-url )".to_string() } else { known.join(", ") }; bail!(crate::tr_fmt!( "provider '{}' inconnu — providers enregistrés: {}", provider.unwrap_or(""), hint )); } return Ok(None); }; let mut args: Vec = Vec::new(); let mut env: BTreeMap = BTreeMap::new(); // env_map resolved against THIS provider (api_key stays @secret). crate::agent_config::apply_env_map_resolved( &mut env, agent, &app.config, Some(pname), resolved_model, ); // The model through the agent's declared channel (model_env > model_arg); // otherwise the env_map "model" slot already carried it. if let Some(m) = resolved_model { if let Some(var) = &agent.model_env { env.insert(var.clone(), m.to_string()); } else if let Some(flag) = &agent.model_arg { args.push(flag.clone()); args.push(m.to_string()); } } app.log.info(&crate::tr_fmt!( "provider {} (modèle {}) au lancement de {}", pname, resolved_model.unwrap_or("-"), agent.name )); Ok(Some((args, env))) } /// Seconds elapsed since an RFC 3339 timestamp (None when unparsable). fn seconds_since(ts: Option<&str>) -> Option { let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?; let now = chrono::Utc::now(); let secs = now .signed_duration_since(t.with_timezone(&chrono::Utc)) .num_seconds(); Some(secs.max(0) as u64) } #[cfg(test)] mod tests { use super::*; use crate::config::{AgentConfig, AgentDef, Config}; use clap::Parser; fn test_app(providers_yaml: &str) -> App { let guard = tempfile::tempdir().unwrap(); let dir = guard.path().to_path_buf(); std::mem::forget(guard); let cfg = dir.join("config.yaml"); std::fs::write( &cfg, format!( "version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{providers_yaml}agents: []\n" ), ) .unwrap(); let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); crate::app::App::from_cli(cli).unwrap() } fn agent(model_env: bool, model_arg: bool) -> AgentDef { let mut env_map = BTreeMap::new(); env_map.insert("api_key".to_string(), "MY_KEY".to_string()); env_map.insert("model".to_string(), "MY_MODEL".to_string()); env_map.insert("base_url".to_string(), "MY_URL".to_string()); AgentDef { name: "demo".to_string(), display_name: None, description: None, category: None, website: None, install: None, dependencies: vec![], run: Some("demo".to_string()), args: vec![], env: BTreeMap::new(), version: None, pin_version: None, model_env: model_env.then(|| "MY_MODEL".to_string()), model_arg: model_arg.then(|| "--model".to_string()), provider: None, model: None, config: Some(AgentConfig { env_map, files: vec![], provider_default: None, }), sandbox: None, tags: vec![], installable: false, note: None, hidden: false, platforms: vec![], healthcheck: None, container: None, cost_model: None, } } const OPENAI: &str = " default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n"; #[test] fn apply_provider_resolves_and_never_leaks_the_token() { let app = test_app(OPENAI); let (args, env) = apply_provider(&app, &agent(false, false), Some("openai"), Some("gpt-5.2")) .unwrap() .unwrap(); // The token slot is the @secret reference — never a value. assert_eq!(env.get("MY_KEY").unwrap(), "@secret"); assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2"); assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1"); assert!(!env.values().any(|v| v.contains("sk-")), "{env:?}"); assert!(args.is_empty()); } #[test] fn apply_provider_uses_the_declared_model_channel() { let app = test_app(OPENAI); // model_env wins over the env_map slot. let (args, env) = apply_provider(&app, &agent(true, false), Some("openai"), None) .unwrap() .unwrap(); assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2"); assert!(args.is_empty()); // model_arg is pushed on the command line. let (args, _) = apply_provider(&app, &agent(false, true), Some("openai"), Some("gpt-5.2")) .unwrap() .unwrap(); assert_eq!(args, vec!["--model".to_string(), "gpt-5.2".to_string()]); } #[test] fn apply_provider_unknown_is_an_error_listing_the_registry() { let app = test_app(OPENAI); let err = apply_provider(&app, &agent(false, false), Some("nope"), None).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("nope"), "{msg}"); assert!(msg.contains("openai"), "registry list missing: {msg}"); } #[test] fn apply_provider_falls_back_to_the_embedded_default_provider() { // The embedded default catalog ships providers (anthropic★ since // issue #90): even an empty user config resolves through the cloud. let app = test_app(""); let (_, env) = apply_provider(&app, &agent(false, false), None, Some("x")) .unwrap() .expect("the embedded default provider resolves"); // Model "x" is unknown: the env_map model slot is skipped, but the // provider slot is wired. assert_eq!(env.get("MY_URL").unwrap(), "https://api.anthropic.com/v1"); assert_eq!(env.get("MY_KEY").unwrap(), "@secret"); } #[test] fn model_flag_falls_back_to_the_cloud_registry() { let app = test_app(OPENAI); // apply_model bails (no local runtime in the test env) → the caller // falls back to apply_provider with the default provider. let local = apply_model(&app, &agent(false, false), "gpt-5.2").unwrap_err(); assert!(local.to_string().contains("not found on any local runtime")); let (_, env) = apply_provider(&app, &agent(false, false), None, Some("gpt-5.2")) .unwrap() .unwrap(); assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2"); assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1"); } #[test] fn config_parses_the_provider_registry_and_agents() { let _cfg: Config = serde_yaml::from_str(&format!( "version: \"1.0\"\nsettings:\n{OPENAI}agents: []\n" )) .unwrap(); } }