- 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
373 lines
15 KiB
Rust
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:?}");
|
|
}
|
|
}
|