feat: phase 2 — modèles locaux + catalogue distant + suggest (closes #70 #71 #73 #60 #67 #61)

Axe 11 (modèles) :
- am models : inventaire des runtimes locaux (ollama list --format json,
  llama.cpp .gguf sur disque, LM Studio API locale) avec cache façon
  probe.rs (TTL 30s) et absence de runtime signalée sans erreur fatale
- am run <agent> --model <m> : résolution dans l'inventaire, transmission
  via model_env/model_arg de l'agent (défaut AM_MODEL), tracé en session
- am models --prune : candidats inutilisés depuis settings.models_prune_days
  (défaut 30), :latest toujours protégé, dry-run + confirmation + événement
  EventKind::Model journalisé

Axes 7/9 (catalogue) :
- am catalog update : fetch du catalogue officiel, diff (ajoutés/retirés/
  modifiés), confirmation, enregistrement en include
- am catalog add <url> : validation du format (parse + validate), cache
  catalog-cache.json (TTL 3600s), événement EventKind::Catalog journalisé
- include par URL (http/https/file) dans config.yaml, résolu au chargement
  avec cache + TTL ; override local toujours prioritaire (issue #67)
- am suggest <requête> : réutilise le scoring fuzzy du catalogue, boost par
  usage réel (lancements + durée depuis le journal), top 5 justifié, --json

REPL : commandes models/catalog/suggest (parse, complétion, descriptions),
help specs, tip cheat sheet, man pages régénérées (49 pages, dont 3 nouvelles).
Version bump 0.4.5, ROADMAP cases cochées, 266 tests verts.
This commit is contained in:
2026-08-18 09:06:16 -04:00
parent 890ad953e5
commit 22bc529818
31 changed files with 2122 additions and 40 deletions
+68 -3
View File
@@ -69,6 +69,16 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
&extra_args,
&extra_env,
)?;
// --model (issue #71): resolve against the local runtimes, then
// transmit through the agent's declared channel (env var or flag).
let mut extra_args = extra_args;
let mut extra_env = extra_env;
if let Some(m) = opts.model.as_deref() {
let agent = require_agent(app, &target)?;
let (a, e) = apply_model(app, agent, m)?;
extra_args.extend(a);
extra_env.extend(e);
}
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
let members = app.catalog.group_members(group);
if members.is_empty() {
@@ -299,10 +309,19 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
}
/// run: execute the agent command directly, no process management.
pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
pub fn run(app: &App, target: &str, model: Option<&str>, extra: &[OsString]) -> Result<i32> {
let agent = require_agent(app, target)?;
let extra_args: Vec<String> = extra.iter().map(|o| o.to_string_lossy().to_string()).collect();
let exec = resolve_exec(app, agent, &extra_args, &BTreeMap::new())?;
let mut extra_args: Vec<String> = extra
.iter()
.map(|o| o.to_string_lossy().to_string())
.collect();
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
if let Some(m) = model {
let (a, e) = apply_model(app, agent, m)?;
extra_args.extend(a);
extra_env.extend(e);
}
let exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
if app.dry_run() {
app.log.dry(format!(
"would run {} {}",
@@ -339,6 +358,52 @@ fn cwd_string() -> Option<String> {
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<String>, BTreeMap<String, String>)> {
let inventory = crate::models::detect(app);
if inventory.find(model).is_none() {
let known: Vec<String> = 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))
}
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
fn seconds_since(ts: Option<&str>) -> Option<u64> {
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;