Files
agent-manager/src/roles.rs
T
bruno e637c066fe fix: am ai avec aichat >= 0.30 — rôles index.yaml + agents.txt, contexte filtré, generation --code (v1.1.5)
- roles.rs: aichat 0.30 a remplace les agents markdown agents/<name>.md
  par functions/agents/<name>/index.yaml + registre functions/agents.txt;
  generate_all ecrit les DEUX formats (compat <= 0.29 et >= 0.30)
- ai_cmd.rs: role_args detecte la definition index.yaml en premier
  ('Unknown agent am-copilot' corrige) ; contexte par defaut (dossier
  courant) filtre par am (.git, target, node_modules, binaires non-UTF-8,
  .env*, > 64 KiB, budget 128 KiB, 40 fichiers max) — aichat ne filtre
  rien et echouait sur .git/index ; exec mode utilise le role am-do
  par defaut (commande brute)
- shell_ai.rs: generation via --code au lieu de --execute --dry-run
  (aichat 0.30: --dry-run echo la requete sans appeler l'API)
- version 1.1.5, man pages et ROADMAP maj
2026-08-21 12:22:18 -04:00

373 lines
15 KiB
Rust

//! 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/<name>/index.yaml` (definition)
//! + `functions/agents.txt` (registry), under the aichat config dir;
//! - aichat <= 0.29 : legacy markdown agents `agents/<name>.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<PathBuf> = 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/<name>.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/<name>/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 <agent>, am search <mot>.\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 <cmd>.",
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 <tâche> 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 <agent> --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 <fichier> --json\n\
- Automatiser : am schedule add <cmd...>, am service install <agent>, am playbook <fichier>\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<String> {
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<String> {
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/<name>/index.yaml` (definition)
/// + `functions/agents.txt` (registry);
/// - aichat <= 0.29 : legacy markdown `agents/<name>.md`.
/// Existing files with the same name are overwritten — they are generated
/// artifacts of am.
pub fn generate_all(_app: &App) -> Result<Vec<PathBuf>> {
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<bool> {
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:?}");
}
}