//! am roles — the aichat copilot layer (épique v1.1.0, F3). //! //! Generates aichat agent definitions that turn `am ai` into a //! contextual copilot: guide & catalog, secured OS operator, dev helper, //! raw execution, analyst, orchestrator. Each prompt is bilingual (French //! by default, English supported) and instructs the model to use the //! stable `--json` contracts of `am` to inspect the environment. //! //! Two layouts are written for compatibility with every aichat version: //! - aichat >= 0.30 : `functions/agents//index.yaml` (definition) //! + `functions/agents.txt` (registry), under the aichat config dir; //! - aichat <= 0.29 : legacy markdown agents `agents/.md`. //! //! The aichat config file is only created when it does not exist yet — //! an existing configuration is never touched (respect of the user setup). use crate::app::App; use anyhow::Result; use std::path::{Path, PathBuf}; /// The six copilot roles shipped by `am setup` / `am setup --roles`. pub const ROLE_NAMES: &[&str] = &[ "am-copilot", "am-operator", "am-dev", "am-do", "am-analyst", "am-orchestrator", ]; /// Base config directory of aichat. Candidates are probed in order: an /// existing aichat config wins, otherwise the platform default (XDG on /// unix, %APPDATA% on Windows). pub fn aichat_config_dir() -> PathBuf { let mut candidates: Vec = Vec::new(); if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { candidates.push(PathBuf::from(xdg).join("aichat")); } if let Some(home) = crate::config::home_dir() { candidates.push(home.join(".config").join("aichat")); } if cfg!(windows) { if let Ok(appdata) = std::env::var("APPDATA") { candidates.push(PathBuf::from(appdata).join("aichat")); } } for c in &candidates { if c.exists() { return c.clone(); } } // No existing aichat config: platform default. if cfg!(windows) { if let Ok(appdata) = std::env::var("APPDATA") { return PathBuf::from(appdata).join("aichat"); } } crate::config::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".config") .join("aichat") } /// Legacy agents directory (aichat <= 0.29): `agents/.md` files /// with frontmatter. aichat >= 0.30 reads the new definitions instead /// (see [`aichat_agent_def_dir`]); the .md files are still written so /// older aichat versions keep working. pub fn aichat_agents_dir() -> PathBuf { aichat_config_dir().join("agents") } /// Functions directory (aichat >= 0.30): agent definitions live in /// `functions/agents//index.yaml` and the agent registry in /// `functions/agents.txt`. pub fn aichat_functions_dir() -> PathBuf { aichat_config_dir().join("functions") } /// Directory of one agent definition (aichat >= 0.30). pub fn aichat_agent_def_dir(name: &str) -> PathBuf { aichat_functions_dir().join("agents").join(name) } /// Path of the aichat configuration file (created only if absent). pub fn aichat_config_path() -> PathBuf { aichat_config_dir().join("config.yaml") } /// The list of `am` commands, embedded in the copilot prompt so the model /// always knows what the CLI can do (kept in sync with `am help commands`). pub fn command_list() -> String { let mut out = String::from( "list, status, sessions, stats, top, report, projects, timeline, log, logs, history, ", ); out.push_str( "init, alias, secret, open, watch, search, info, install, uninstall, update, start, stop, ", ); out.push_str( "restart, run, doctor, config, completion, self-update, self-uninstall, export, import, ", ); out.push_str( "shell, theme, lang, tip, dashboard, favorite, unfavorite, note, tag, untag, tags, ", ); out.push_str( "profile, man, models, catalog, providers, suggest, ask, ai, registry, audit, service, ", ); out.push_str("schedule, monitor, web, serve, sync, migrate, playbook, lab, plugins, setup"); out } fn frontmatter(name: &str, description: &str, tools: &[&str]) -> String { let mut out = String::from("---\n"); out.push_str(&format!("name: {name}\n")); out.push_str(&format!("description: {description}\n")); if !tools.is_empty() { out.push_str(&format!("tools: [{}]\n", tools.join(", "))); } out.push_str("---\n"); out } /// The bilingual instruction footer shared by every role. const LANG_FOOTER: &str = "\n\n## Langue / Language\nRéponds en français par défaut, dans la langue de la demande si une autre est utilisée.\nAnswer in French by default, or in the language of the request when it differs.\n"; /// The (description, tools, body) of one role. fn role_parts(name: &str) -> Option<(&'static str, &'static [&'static str], String)> { let (desc, tools, body) = match name { "am-copilot" => ( "Copilot de agent-manager : guide, catalogue, dépannage", &[][..], format!( "Tu es le copilot de **agent-manager (am)**, le gestionnaire d'agents IA locaux.\n\ Tu aides l'utilisateur à exploiter am : catalogue, agents, installation, REPL.\n\ \n\ Commandes am disponibles : {}\n\ \n\ Règles :\n\ - Privilégie les sorties --json pour inspecter l'environnement : am list --json, am status --json, am info , am search .\n\ - Pour comprendre l'état : am doctor, am stats --json, am log --kind error.\n\ - Propose TOUJOURS la commande exacte à taper, dans un bloc de code.\n\ - Si une commande est incertaine, dis-le et propose am help .", command_list() ), ), "am-operator" => ( "Opérateur système sécurisé : commandes shell, fichiers, processus", &["shell"][..], "Tu es un opérateur système SÉCURISÉ. Tu transformes les demandes en commandes shell précises.\n\ \n\ Règles de sécurité (non négociables) :\n\ - N'exécute jamais directement : propose la commande, laisse l'utilisateur ou am ai --exec l'exécuter après confirmation.\n\ - Signale clairement les commandes RISKY (rm -rf, dd, mkfs, chmod -R 777, git push --force...) et propose une alternative plus sûre quand elle existe.\n\ - Adapte-toi à l'OS détecté (Windows/cmd/powershell vs Linux/macOS/bash).\n\ - Pour lister/trier/compresser des fichiers, propose la commande la plus simple possible.".to_string(), ), "am-dev" => ( "Assistant développeur : code, git, tests, revue", &[][..], "Tu es un assistant développeur qui travaille AVEC am (agent-manager).\n\ \n\ Tu aides sur :\n\ - Refactoring, debugging, écriture de tests, revue de code (diff).\n\ - Git : am ne gère pas git directement — propose les commandes git exactes.\n\ - Suivi de projets : am projects --json, am timeline --json pour le contexte d'activité.\n\ - Choix d'agent : am search pour recommander un coding agent du catalogue, puis am install / am run.\n\ \n\ Réponds avec du code concret et des commandes exactes.".to_string(), ), "am-do" => ( "Exécution pure : génère la commande brute pour am ai --exec", &["shell"][..], "Tu es le moteur d'exécution de am ai --exec.\n\ \n\ Réponds UNIQUEMENT par la commande shell exacte à exécuter, sur une seule ligne si possible.\n\ - Aucune explication avant/après : seulement la commande.\n\ - Si la demande est dangereuse, réponds par la commande la plus conservatrice qui satisfait la demande.\n\ - Adapte-toi à l'OS détecté.".to_string(), ), "am-analyst" => ( "Analyste : coûts, logs, statistiques, santé du parc d'agents", &[][..], "Tu es l'analyste de la flotte d'agents gérée par am.\n\ \n\ Sources de données (toujours en --json) :\n\ - Coûts : am stats --costs --json, am top --json\n\ - Logs & erreurs : am log --kind error --limit 50 --json, am logs --lines 50\n\ - Activité : am timeline --json, am sessions --json, am report --period week\n\ - Santé : am doctor --json, am status --json\n\ \n\ Produis des synthèses courtes avec les chiffres clés, et propose des actions concrètes.".to_string(), ), "am-orchestrator" => ( "Orchestrateur : groupes, lab, benchmarks, automatisation", &[][..], "Tu es l'orchestrateur des agents am.\n\ \n\ Tu aides à :\n\ - Lancer des groupes : am start group:dev, am start group:dev --parallel\n\ - Benchmarker : am lab --agents a,b --task --json\n\ - Automatiser : am schedule add , am service install , am playbook \n\ - Planifier des runs et interpréter les résultats (am lab --json).\n\ \n\ Propose toujours des commandes exactes et des benchmarks comparables.".to_string(), ), _ => return None, }; Some((desc, tools, body)) } /// The aichat >= 0.30 agent definition (`index.yaml`): the subset of /// aichat's AgentDefinition that am generates. #[derive(serde::Serialize)] struct AgentDefinition { name: String, description: String, version: String, instructions: String, } /// Build the legacy markdown content of one role (aichat <= 0.29). pub fn role_content(name: &str) -> Option { let (desc, tools, body) = role_parts(name)?; Some(format!( "{}{}{}", frontmatter(name, desc, tools), body, LANG_FOOTER )) } /// The aichat >= 0.30 `index.yaml` definition of one role. pub fn role_definition(name: &str) -> Option { let (desc, _tools, body) = role_parts(name)?; let def = AgentDefinition { name: name.to_string(), description: desc.to_string(), version: "1.0.0".to_string(), instructions: format!("{body}{LANG_FOOTER}"), }; serde_yaml::to_string(&def).ok() } /// Write every am-* role into the aichat agent stores (creates the /// directories when missing). Two layouts are written so any aichat /// version works: /// - aichat >= 0.30 : `functions/agents//index.yaml` (definition) /// + `functions/agents.txt` (registry); /// - aichat <= 0.29 : legacy markdown `agents/.md`. /// Existing files with the same name are overwritten — they are generated /// artifacts of am. pub fn generate_all(_app: &App) -> Result> { let mut written = Vec::new(); // New layout (aichat >= 0.30): index.yaml definitions + agents.txt. let funcs_dir = aichat_functions_dir(); let agents_dir = funcs_dir.join("agents"); std::fs::create_dir_all(&agents_dir)?; for name in ROLE_NAMES { let def = role_definition(name).expect("known role"); let def_path = agents_dir.join(name).join("index.yaml"); std::fs::create_dir_all(def_path.parent().expect("parent dir"))?; std::fs::write(&def_path, def)?; written.push(def_path); } let list_path = funcs_dir.join("agents.txt"); let mut list = String::from("# generated by `am setup` — aichat >= 0.30 agent registry\n"); list.push_str(&format!("{}\n", ROLE_NAMES.join("\n"))); std::fs::write(&list_path, list)?; written.push(list_path); // Legacy layout (aichat <= 0.29): markdown agents in agents/. let legacy_dir = aichat_agents_dir(); std::fs::create_dir_all(&legacy_dir)?; for name in ROLE_NAMES { let content = role_content(name).expect("known role"); let path = legacy_dir.join(format!("{name}.md")); std::fs::write(&path, content)?; written.push(path); } Ok(written) } /// Create the aichat config file (provider + default model) ONLY when it /// does not exist. An existing configuration is left untouched. pub fn ensure_config_file(app: &App) -> Result { let path = aichat_config_path(); if path.exists() { return Ok(false); } let provider = crate::providers::default_name(&app.config); let model = provider.and_then(|p| crate::providers::get(&app.config, p)) .and_then(|def| def.default_model.clone()); let mut body = String::from("# generated by `am setup` — first run only\n"); if let Some(p) = provider { body.push_str(&format!("model_provider: {p}\n")); } if let Some(m) = model { body.push_str(&format!("model: {m}\n")); } if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } std::fs::write(&path, body)?; Ok(true) } #[cfg(test)] mod tests { use super::*; #[test] fn generates_all_six_roles_with_frontmatter() { for name in ROLE_NAMES { let content = role_content(name).expect("role"); assert!(content.starts_with("---\n"), "{name}"); assert!(content.contains(&format!("name: {name}")), "{name}"); assert!(content.contains("description:"), "{name}"); // Bilingual footer present. assert!(content.contains("Réponds en français"), "{name}"); } } #[test] fn shell_tools_only_on_execution_roles() { let operator = role_content("am-operator").unwrap(); assert!(operator.contains("tools: [shell]")); let copilot = role_content("am-copilot").unwrap(); assert!(!copilot.contains("tools:"), "copilot must not have tools"); } #[test] fn unknown_role_returns_none() { assert!(role_content("am-inconnu").is_none()); } #[test] fn copilot_prompt_embeds_the_command_list() { let copilot = role_content("am-copilot").unwrap(); assert!(copilot.contains("am list --json")); assert!(copilot.contains("setup"), "command list must include setup"); } #[test] fn role_definition_is_valid_index_yaml() { for name in ROLE_NAMES { let def = role_definition(name).expect("role"); assert!(def.starts_with("name:"), "{name}:\n{def}"); assert!(def.contains("description:"), "{name}"); assert!(def.contains("instructions:"), "{name}"); assert!(def.contains("Tu es"), "{name}"); } } #[test] fn agent_def_dir_targets_functions_layout() { let s = aichat_agent_def_dir("am-copilot") .to_string_lossy() .to_string(); assert!(s.contains("functions"), "{s}"); assert!(s.ends_with("am-copilot"), "{s}"); // The registry lives next to the definitions. let registry = aichat_functions_dir().join("agents.txt"); assert!(registry.to_string_lossy().ends_with("agents.txt")); } #[test] fn agents_dir_prefers_existing_aichat_config() { // With a home dir, the unix candidate is ~/.config/aichat/agents. let dir = aichat_agents_dir(); assert!(dir.ends_with("agents"), "{dir:?}"); } }