fix: détection externe — champ detect (sonde PATH explicite) pour les lanceurs interpréteurs
lazycodex (run: npx lazycodex-ai) et nanobot (run: python -m nanobot) étaient déclarés « external » dès que npx/python étaient sur le PATH : la sonde utilisait le premier mot de run au lieu du vrai binaire. - AgentDef.detect : sonde PATH explicite, repli sur first_token sinon - probe.rs : cache invalidé quand les tokens du catalogue changent (token_hash), plus besoin d'attendre un changement de PATH - config.yaml : detect: lazycodex-ai / detect: nanobot - tests : detect_token_prefers_explicit_binary + 432 tests verts - v1.0.3, man pages régénérées
This commit is contained in:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
|
||||
+4
-2
@@ -29,8 +29,10 @@
|
||||
> am serve (API HTTP + WebSocket). v1.0.1 : self-update décompresse les
|
||||
> archives, prompt REPL avec shell actif.
|
||||
>
|
||||
> 🔧 **Maintenance v1.0.2 (2026-08-20)** : commandes REPL ask/serve/registry
|
||||
> routées vers am (au lieu du shell système) ; docs alignées.
|
||||
> 🔧 **Maintenance v1.0.3 (2026-08-20)** : commandes REPL ask/serve/registry
|
||||
> routées vers am (v1.0.2) ; détection externe corrigée — champ `detect`
|
||||
> (sonde PATH explicite) pour les lanceurs interpréteurs (npx/python),
|
||||
> plus de faux « external » ; docs alignées.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -798,6 +798,9 @@ agents:
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: "npx lazycodex-ai"
|
||||
# Sonde PATH explicite : le premier mot de `run` est npx (interpréteur) —
|
||||
# sans `detect`, npx sur le PATH suffirait à déclarer l'agent « external ».
|
||||
detect: lazycodex-ai
|
||||
note: "s'intègre dans Codex (nécessite @openai/codex)"
|
||||
tags: [harness, codex, plan]
|
||||
|
||||
@@ -895,6 +898,9 @@ agents:
|
||||
dependencies:
|
||||
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
|
||||
run: "python -m nanobot"
|
||||
# Sonde PATH explicite : le premier mot de `run` est python (interpréteur) —
|
||||
# sans `detect`, /usr/bin/python suffirait à déclarer l'agent « external ».
|
||||
detect: nanobot
|
||||
tags: [python, lightweight]
|
||||
|
||||
- name: zeroclaw
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am 1 "am 1.0.2"
|
||||
.TH am 1 "am 1.0.3"
|
||||
.SH NAME
|
||||
am \- agent\-manager (am) — manage local AI coding agents
|
||||
.SH SYNOPSIS
|
||||
@@ -244,4 +244,4 @@ Export the configuration and installation state (backup)
|
||||
am\-import(1)
|
||||
Import a previously exported configuration and state
|
||||
.SH VERSION
|
||||
v1.0.2
|
||||
v1.0.3
|
||||
|
||||
@@ -408,6 +408,7 @@ mod tests {
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some("x".to_string()),
|
||||
detect: None,
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
|
||||
@@ -655,6 +655,7 @@ mod tests {
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some("demo".to_string()),
|
||||
detect: None,
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
|
||||
@@ -190,6 +190,7 @@ mod tests {
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some(name.to_string()),
|
||||
detect: None,
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
|
||||
@@ -398,6 +398,13 @@ pub struct AgentDef {
|
||||
/// Command used to start the agent (e.g. "claude", "python -m nanobot").
|
||||
#[serde(default)]
|
||||
pub run: Option<String>,
|
||||
/// Binary name probed on the PATH for external detection. When unset,
|
||||
/// the first token of `run` is used — which is wrong for interpreter
|
||||
/// launchers ("npx pkg", "python -m pkg"): the interpreter itself would
|
||||
/// be matched, reporting the agent as present whenever node/python is
|
||||
/// on the PATH. Set it to the real entry point (e.g. "lazycodex-ai").
|
||||
#[serde(default)]
|
||||
pub detect: Option<String>,
|
||||
/// Default arguments appended to the run command.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
@@ -516,6 +523,12 @@ impl AgentDef {
|
||||
})
|
||||
}
|
||||
|
||||
/// Binary probed on the PATH for external detection: the explicit
|
||||
/// `detect` name when set, otherwise the first token of `run`.
|
||||
pub fn detect_token(&self) -> Option<String> {
|
||||
self.detect.clone().or_else(|| self.first_token())
|
||||
}
|
||||
|
||||
/// The run command split into tokens (empty when unset).
|
||||
pub fn run_tokens(&self) -> Vec<String> {
|
||||
self.run
|
||||
@@ -1167,6 +1180,31 @@ mod tests {
|
||||
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_token_prefers_explicit_binary() {
|
||||
// Interpreter launcher: without `detect`, the interpreter itself
|
||||
// would be probed (false "external" when node/python is on PATH).
|
||||
let a: AgentDef = serde_yaml::from_str(
|
||||
"name: lazycodex\nrun: \"npx lazycodex-ai\"\ndetect: lazycodex-ai\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(a.detect_token().as_deref(), Some("lazycodex-ai"));
|
||||
// No `detect`: falls back to the first token of run.
|
||||
let b: AgentDef =
|
||||
serde_yaml::from_str("name: nanobot\nrun: \"python -m nanobot\"\n").unwrap();
|
||||
assert_eq!(b.detect_token().as_deref(), Some("python"));
|
||||
assert_eq!(b.first_token().as_deref(), Some("python"));
|
||||
// Plain binary command is unchanged.
|
||||
let c: AgentDef = serde_yaml::from_str("name: claude\nrun: claude\n").unwrap();
|
||||
assert_eq!(c.detect_token().as_deref(), Some("claude"));
|
||||
// The embedded catalog must stay valid with the new field.
|
||||
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
|
||||
let lazy = cfg.agents.iter().find(|a| a.name == "lazycodex").unwrap();
|
||||
assert_eq!(lazy.detect_token().as_deref(), Some("lazycodex-ai"));
|
||||
let nano = cfg.agents.iter().find(|a| a.name == "nanobot").unwrap();
|
||||
assert_eq!(nano.detect_token().as_deref(), Some("nanobot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expands_paths() {
|
||||
std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value");
|
||||
|
||||
+33
-5
@@ -79,6 +79,11 @@ struct ProbeCache {
|
||||
/// FNV-1a hash of the PATH string the token index was built from.
|
||||
#[serde(default)]
|
||||
path_hash: u64,
|
||||
/// FNV-1a hash of the catalog's detect tokens. When the catalog changes
|
||||
/// which binaries it probes (new agent, `detect` added), the token
|
||||
/// index must be rebuilt even if the PATH is unchanged.
|
||||
#[serde(default)]
|
||||
token_hash: u64,
|
||||
/// Catalog run-command first token -> resolved path (null = not found).
|
||||
#[serde(default)]
|
||||
tokens: BTreeMap<String, Option<String>>,
|
||||
@@ -98,6 +103,26 @@ fn path_hash() -> u64 {
|
||||
h
|
||||
}
|
||||
|
||||
/// Hash of the sorted catalog detect tokens: cache invalidation for catalog
|
||||
/// changes (new agent, added `detect` field) without re-scanning the PATH.
|
||||
fn catalog_token_hash(app: &App) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut tokens: Vec<String> = app
|
||||
.catalog
|
||||
.agents()
|
||||
.iter()
|
||||
.filter_map(|a| a.detect_token())
|
||||
.collect();
|
||||
tokens.sort();
|
||||
for t in tokens {
|
||||
for b in t.bytes() {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Build an index of every executable on the PATH in a single pass.
|
||||
/// Much cheaper than one which() call per agent when the catalog is large.
|
||||
fn build_path_index() -> BTreeMap<String, PathBuf> {
|
||||
@@ -163,13 +188,15 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
|
||||
.unwrap_or_default();
|
||||
let mut dirty = false;
|
||||
|
||||
// Resolve the run-command tokens of the catalog (once per PATH change).
|
||||
// Resolve the detect tokens of the catalog (once per PATH change or
|
||||
// catalog change).
|
||||
let current_hash = path_hash();
|
||||
if cache.path_hash != current_hash {
|
||||
let current_tokens = catalog_token_hash(app);
|
||||
if cache.path_hash != current_hash || cache.token_hash != current_tokens {
|
||||
let index = build_path_index();
|
||||
cache.tokens.clear();
|
||||
for agent in app.catalog.agents() {
|
||||
let Some(token) = agent.first_token() else {
|
||||
let Some(token) = agent.detect_token() else {
|
||||
continue;
|
||||
};
|
||||
if !cache.tokens.contains_key(&token) {
|
||||
@@ -180,6 +207,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
|
||||
}
|
||||
}
|
||||
cache.path_hash = current_hash;
|
||||
cache.token_hash = current_tokens;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
@@ -195,7 +223,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
|
||||
let index = build_path_index();
|
||||
cache.tokens.clear();
|
||||
for agent in app.catalog.agents() {
|
||||
let Some(token) = agent.first_token() else {
|
||||
let Some(token) = agent.detect_token() else {
|
||||
continue;
|
||||
};
|
||||
if !cache.tokens.contains_key(&token) {
|
||||
@@ -211,7 +239,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
|
||||
if agent.hidden || !agent.installable || managed.contains_key(&agent.name) {
|
||||
continue;
|
||||
}
|
||||
let Some(token) = agent.first_token() else {
|
||||
let Some(token) = agent.detect_token() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(Some(path)) = cache.tokens.get(&token) {
|
||||
|
||||
Reference in New Issue
Block a user