From e637c066fedd4bf0cc0de2a21c95de0ced14747b Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 21 Aug 2026 12:22:18 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20am=20ai=20avec=20aichat=20>=3D=200.30=20?= =?UTF-8?q?=E2=80=94=20r=C3=B4les=20index.yaml=20+=20agents.txt,=20context?= =?UTF-8?q?e=20filtr=C3=A9,=20generation=20--code=20(v1.1.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - roles.rs: aichat 0.30 a remplace les agents markdown agents/.md par functions/agents//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 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ROADMAP.md | 13 ++++ man/am.1 | 4 +- src/commands/ai_cmd.rs | 121 ++++++++++++++++++++++++++++++---- src/help.rs | 2 +- src/roles.rs | 143 ++++++++++++++++++++++++++++++++++------- src/shell_ai.rs | 13 +++- 8 files changed, 256 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8668a8e..45c1bc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "1.1.3" +version = "1.1.5" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index dd3f8a6..80b37d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "1.1.4" +version = "1.1.5" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/ROADMAP.md b/ROADMAP.md index 09cf583..82c7459 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -100,6 +100,19 @@ > onboard/model/auth), avec substitution des placeholders `{model}`, > `{provider}` et `{base_url}` depuis le provider résolu. picoclaw est le > premier agent du catalogue à les déclarer. +> +> 🔧 **Maintenance v1.1.5 (2026-08-21)** : fix `am ai` / rôles copilot avec +> aichat ≥ 0.30 — aichat a changé son format d'agents (le markdown +> `agents/.md` ≤ 0.29 est remplacé par une définition +> `functions/agents//index.yaml` + un registre `functions/agents.txt`). +> `am setup` / `am setup --roles` écrivent désormais LES DEUX formats +> (compatibilité totale) et `am ai` détecte la définition nouvelle génération +> en premier (« Unknown agent `am-copilot` » corrigé). Bonus : le contexte +> par défaut (dossier courant) est filtré par am (`.git`, `target`, +> `node_modules`…, binaires non-UTF-8, `.env*`, > 64 KiB, budget 128 KiB) +> — aichat ne filtre rien et échoue sur `.git/index`. Génération `--exec` : +> `--code` au lieu de `--execute --dry-run` (aichat ≥ 0.30 : `--dry-run` +> n'appelle plus l'API, il écho la requête). --- diff --git a/man/am.1 b/man/am.1 index 4628a36..6578256 100644 --- a/man/am.1 +++ b/man/am.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH am 1 "am 1.1.0" +.TH am 1 "am 1.1.5" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -250,4 +250,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v1.1.0 +v1.1.5 diff --git a/src/commands/ai_cmd.rs b/src/commands/ai_cmd.rs index 8da1f66..4c43cd2 100644 --- a/src/commands/ai_cmd.rs +++ b/src/commands/ai_cmd.rs @@ -2,9 +2,9 @@ //! //! Conversational mode (no `--exec`) spawns AIChat with the prompt and the //! context files (default: the current directory). Exec mode generates the -//! shell command through aichat in dry-run, classifies it, applies the -//! safety policy (settings.shell_ai, dry-run by default) and only executes -//! after confirmation when required. +//! shell command through aichat (`--code` — never executed by aichat), +//! classifies it, applies the safety policy (settings.shell_ai, dry-run by +//! default) and only executes after confirmation when required. use crate::app::App; use crate::cli::AiArgs; @@ -45,9 +45,11 @@ pub fn run(app: &App, args: &AiArgs) -> Result { let prompt = args.prompt.join(" "); // Context files: explicit --files wins, else the current directory - // (issue #96: `aichat -f . ""`). + // (issue #96: `aichat -f . ""`). The default directory is + // walked by am to skip noise/vendored/binary files — aichat itself + // filters nothing and fails on non-UTF-8 content (e.g. .git/index). let files: Vec = if args.files.is_empty() { - vec![".".to_string()] + context_files(std::path::Path::new(".")) } else { args.files .iter() @@ -75,23 +77,92 @@ fn aichat_installed(app: &App) -> bool { || which::which("aichat").is_ok() } +/// Walk `dir` and collect text files usable as aichat context (the +/// default `--files` when the flag is absent). aichat filters nothing +/// and fails on non-UTF-8 content (e.g. `.git/index`), so am pre-filters: +/// - skipped directories: `.git`, vendored/build noise (`target`, +/// `node_modules`, `dist`, `build`, `__pycache__`, `vendor`…); +/// - skipped files: `.env*` (secrets), > 64 KiB, invalid UTF-8. +/// Then the survivors are sorted and taken while a total budget of +/// ~128 KiB holds (≈32K tokens — fits common 64K-token models), capped +/// at 40 files. Empty when nothing qualifies (aichat is called without +/// `-f`). +fn context_files(dir: &std::path::Path) -> Vec { + const MAX_FILES: usize = 40; + const MAX_SIZE: u64 = 64 * 1024; + const TOTAL_BUDGET: u64 = 128 * 1024; + const SKIP_DIRS: &[&str] = &[ + ".git", "target", "node_modules", "dist", "build", "__pycache__", ".venv", "venv", + ".idea", ".vscode", ".next", ".cache", "vendor", ".terraform", "coverage", ".gradle", + ".cargo", "obj", "out", ".svn", ".hg", + ]; + fn walk(dir: &std::path::Path, out: &mut Vec<(String, u64)>) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for entry in rd.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if path.is_dir() { + if SKIP_DIRS.contains(&name.as_ref()) { + continue; + } + walk(&path, out); + } else { + if name.starts_with(".env") { + continue; + } + let Ok(meta) = entry.metadata() else { continue }; + if meta.len() > MAX_SIZE { + continue; + } + // UTF-8 sniff on the first bytes — aichat rejects binaries. + let Ok(mut f) = std::fs::File::open(&path) else { continue }; + use std::io::Read; + let mut head = Vec::new(); + if f.take(8192).read_to_end(&mut head).is_err() + || std::str::from_utf8(&head).is_err() + { + continue; + } + out.push((path.display().to_string(), meta.len())); + } + } + } + let mut found = Vec::new(); + walk(dir, &mut found); + found.sort(); + let mut files = Vec::new(); + let mut total: u64 = 0; + for (p, len) in found { + if files.len() >= MAX_FILES || total + len > TOTAL_BUDGET { + break; + } + total += len; + files.push(p); + } + files +} + /// Build the aichat `--agent` arguments for the requested role. Without a /// `--role` flag the am-copilot role is used by default (v1.1.2): when its -/// file is missing the roles are regenerated on the fly, and a generation -/// failure degrades gracefully (no role). An explicit unknown role is an -/// error. +/// definition is missing the roles are regenerated on the fly, and a +/// generation failure degrades gracefully (no role). An explicit unknown +/// role is an error. fn role_args(app: &App, role: Option<&str>) -> Result> { let role = role.unwrap_or("am-copilot"); - let dir = crate::roles::aichat_agents_dir(); - let path = dir.join(format!("{role}.md")); - if path.exists() { + // aichat >= 0.30 reads functions/agents//index.yaml; the legacy + // agents/.md covers aichat <= 0.29 (roles.rs writes both). + let def = crate::roles::aichat_agent_def_dir(role).join("index.yaml"); + let legacy = crate::roles::aichat_agents_dir().join(format!("{role}.md")); + let present = || def.exists() || legacy.exists(); + if present() { return Ok(vec!["--agent".to_string(), role.to_string()]); } if role == "am-copilot" { if let Err(e) = crate::roles::generate_all(app) { app.log.verbose(&format!("roles generation skipped: {e:#}")); } - if path.exists() { + if present() { return Ok(vec!["--agent".to_string(), role.to_string()]); } app.log.warn(crate::i18n::tr_in( @@ -171,7 +242,9 @@ fn exec_mode( "would generate & classify via aichat (exec) — commande non exécutée" )); } - let role = role_args(app, args.role.as_deref())?; + // Exec mode asks for the raw command: default to the am-do role + // ("Réponds UNIQUEMENT par la commande") unless --role is explicit. + let role = role_args(app, args.role.as_deref().or(Some("am-do")))?; let cmd = crate::shell_ai::generate( app, prompt, @@ -256,4 +329,26 @@ mod tests { let s = crate::config::ShellAiSettings::default(); assert_eq!(s.safety_mode(), SafetyMode::DryRun); } + + #[test] + fn context_files_skips_binary_noise_and_secrets() { + let base = std::env::temp_dir().join(format!("am-ai-ctx-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join(".git")).unwrap(); + std::fs::create_dir_all(base.join("target")).unwrap(); + std::fs::create_dir_all(base.join("src")).unwrap(); + std::fs::write(base.join("src/a.txt"), "hello").unwrap(); + std::fs::write(base.join("src/b.md"), "# titre").unwrap(); + std::fs::write(base.join(".env"), "TOKEN=secret").unwrap(); + std::fs::write(base.join("bin.dat"), [0u8, 159, 146, 150]).unwrap(); + std::fs::write(base.join(".git/index"), "binary-ish").unwrap(); + std::fs::write(base.join("target/x.rs"), "fn main(){}").unwrap(); + let files = context_files(&base); + let names: Vec = files + .iter() + .map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_string()) + .collect(); + assert_eq!(names, vec!["a.txt", "b.md"], "{files:?}"); + let _ = std::fs::remove_dir_all(&base); + } } diff --git a/src/help.rs b/src/help.rs index 1231c0f..9148426 100644 --- a/src/help.rs +++ b/src/help.rs @@ -899,7 +899,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[ name: "ai", category: "Commands", usage: "ai {flags} ", - about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f ). Avec --exec : génère la commande (aichat --dry-run), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.", + about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f ). Avec --exec : génère la commande (aichat --code — jamais exécutée par aichat), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.", search_terms: &["shell", "nlp", "natural", "language", "commande", "exec", "langage"], flags: &[ HelpFlag { short: "-e", long: "--exec", value: "", desc: "Génère puis exécute la commande shell (après la politique de sécurité)" }, diff --git a/src/roles.rs b/src/roles.rs index 2dde67a..2fb9bb3 100644 --- a/src/roles.rs +++ b/src/roles.rs @@ -1,12 +1,16 @@ //! am roles — the aichat copilot layer (épique v1.1.0, F3). //! -//! Generates aichat agent files (`~/.config/aichat/agents/am-*.md` — or -//! `%APPDATA%\aichat\agents\` on Windows) that turn `am ai` into a +//! 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). @@ -24,20 +28,20 @@ pub const ROLE_NAMES: &[&str] = &[ "am-orchestrator", ]; -/// Directory where aichat stores its agent definitions. Candidates are -/// probed in order: an existing aichat config wins, otherwise the platform -/// default (XDG on unix, %APPDATA% on Windows). -pub fn aichat_agents_dir() -> PathBuf { +/// 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").join("agents")); + candidates.push(PathBuf::from(xdg).join("aichat")); } if let Some(home) = crate::config::home_dir() { - candidates.push(home.join(".config").join("aichat").join("agents")); + 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").join("agents")); + candidates.push(PathBuf::from(appdata).join("aichat")); } } for c in &candidates { @@ -48,19 +52,38 @@ pub fn aichat_agents_dir() -> PathBuf { // No existing aichat config: platform default. if cfg!(windows) { if let Ok(appdata) = std::env::var("APPDATA") { - return PathBuf::from(appdata).join("aichat").join("agents"); + return PathBuf::from(appdata).join("aichat"); } } crate::config::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".config") .join("aichat") - .join("agents") +} + +/// 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_agents_dir().parent().unwrap().join("config.yaml") + aichat_config_dir().join("config.yaml") } /// The list of `am` commands, embedded in the copilot prompt so the model @@ -99,8 +122,8 @@ fn frontmatter(name: &str, description: &str, tools: &[&str]) -> String { /// 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"; -/// Build the markdown content of one role. -pub fn role_content(name: &str) -> Option { +/// 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", @@ -181,25 +204,76 @@ pub fn role_content(name: &str) -> Option { ), _ => return None, }; - let content = format!( + 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 - ); - Some(content) + )) } -/// Write every am-* role into the aichat agents directory (creates the -/// directory when missing). Existing files with the same name are -/// overwritten — they are generated artifacts of am. +/// 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 dir = aichat_agents_dir(); - std::fs::create_dir_all(&dir)?; 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 = dir.join(format!("{name}.md")); + let path = legacy_dir.join(format!("{name}.md")); std::fs::write(&path, content)?; written.push(path); } @@ -266,6 +340,29 @@ mod tests { 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. diff --git a/src/shell_ai.rs b/src/shell_ai.rs index eef4ffd..71b3e29 100644 --- a/src/shell_ai.rs +++ b/src/shell_ai.rs @@ -7,8 +7,9 @@ //! - conversational mode (no `--exec`): `aichat -f ""` is //! spawned in the foreground, exactly as the user would run it; //! - exec mode (`--exec`): aichat is asked to generate the command with -//! `--dry-run` (it prints the command and never runs it), then the -//! generated command is classified (safe / risky), a confidence score +//! `--code` (it prints only the command and never runs it — aichat is +//! invoked without `--execute`), then the generated command is +//! classified (safe / risky), a confidence score //! is estimated, the configured safety policy is applied (dry-run by //! default), and only then — after confirmation when required — is the //! command executed through the user's shell. @@ -283,7 +284,13 @@ pub fn generate( role_args: &[String], ) -> Result { let agent = require_agent(app, "aichat")?; - let mut extra_args: Vec = vec!["--exec".to_string(), "--dry-run".to_string()]; + // aichat >= 0.30 renamed --exec to --execute AND changed --dry-run to + // skip the API call entirely (it echoes the request), so the old + // `--execute --dry-run` no longer yields the generated command. Plain + // chat with `--code` extracts and prints only the command (same output + // shape, works on every aichat version). aichat NEVER executes here — + // am enforces the safety policy before running the command itself. + let mut extra_args: Vec = vec!["--code".to_string()]; extra_args.extend(role_args.iter().cloned()); for f in files { extra_args.push("-f".to_string());