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]]
|
[[package]]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "1.0.2"
|
version = "1.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "1.0.2"
|
version = "1.0.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
+4
-2
@@ -29,8 +29,10 @@
|
|||||||
> am serve (API HTTP + WebSocket). v1.0.1 : self-update décompresse les
|
> am serve (API HTTP + WebSocket). v1.0.1 : self-update décompresse les
|
||||||
> archives, prompt REPL avec shell actif.
|
> archives, prompt REPL avec shell actif.
|
||||||
>
|
>
|
||||||
> 🔧 **Maintenance v1.0.2 (2026-08-20)** : commandes REPL ask/serve/registry
|
> 🔧 **Maintenance v1.0.3 (2026-08-20)** : commandes REPL ask/serve/registry
|
||||||
> routées vers am (au lieu du shell système) ; docs alignées.
|
> 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:
|
dependencies:
|
||||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||||
run: "npx lazycodex-ai"
|
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)"
|
note: "s'intègre dans Codex (nécessite @openai/codex)"
|
||||||
tags: [harness, codex, plan]
|
tags: [harness, codex, plan]
|
||||||
|
|
||||||
@@ -895,6 +898,9 @@ agents:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
|
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
|
||||||
run: "python -m nanobot"
|
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]
|
tags: [python, lightweight]
|
||||||
|
|
||||||
- name: zeroclaw
|
- name: zeroclaw
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.ie \n(.g .ds Aq \(aq
|
.ie \n(.g .ds Aq \(aq
|
||||||
.el .ds Aq '
|
.el .ds Aq '
|
||||||
.TH am 1 "am 1.0.2"
|
.TH am 1 "am 1.0.3"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
am \- agent\-manager (am) — manage local AI coding agents
|
am \- agent\-manager (am) — manage local AI coding agents
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -244,4 +244,4 @@ Export the configuration and installation state (backup)
|
|||||||
am\-import(1)
|
am\-import(1)
|
||||||
Import a previously exported configuration and state
|
Import a previously exported configuration and state
|
||||||
.SH VERSION
|
.SH VERSION
|
||||||
v1.0.2
|
v1.0.3
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ mod tests {
|
|||||||
install: None,
|
install: None,
|
||||||
dependencies: vec![],
|
dependencies: vec![],
|
||||||
run: Some("x".to_string()),
|
run: Some("x".to_string()),
|
||||||
|
detect: None,
|
||||||
args: vec![],
|
args: vec![],
|
||||||
env: BTreeMap::new(),
|
env: BTreeMap::new(),
|
||||||
version: None,
|
version: None,
|
||||||
|
|||||||
@@ -655,6 +655,7 @@ mod tests {
|
|||||||
install: None,
|
install: None,
|
||||||
dependencies: vec![],
|
dependencies: vec![],
|
||||||
run: Some("demo".to_string()),
|
run: Some("demo".to_string()),
|
||||||
|
detect: None,
|
||||||
args: vec![],
|
args: vec![],
|
||||||
env: BTreeMap::new(),
|
env: BTreeMap::new(),
|
||||||
version: None,
|
version: None,
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ mod tests {
|
|||||||
install: None,
|
install: None,
|
||||||
dependencies: vec![],
|
dependencies: vec![],
|
||||||
run: Some(name.to_string()),
|
run: Some(name.to_string()),
|
||||||
|
detect: None,
|
||||||
args: vec![],
|
args: vec![],
|
||||||
env: BTreeMap::new(),
|
env: BTreeMap::new(),
|
||||||
version: None,
|
version: None,
|
||||||
|
|||||||
@@ -398,6 +398,13 @@ pub struct AgentDef {
|
|||||||
/// Command used to start the agent (e.g. "claude", "python -m nanobot").
|
/// Command used to start the agent (e.g. "claude", "python -m nanobot").
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub run: Option<String>,
|
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.
|
/// Default arguments appended to the run command.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub args: Vec<String>,
|
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).
|
/// The run command split into tokens (empty when unset).
|
||||||
pub fn run_tokens(&self) -> Vec<String> {
|
pub fn run_tokens(&self) -> Vec<String> {
|
||||||
self.run
|
self.run
|
||||||
@@ -1167,6 +1180,31 @@ mod tests {
|
|||||||
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
|
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]
|
#[test]
|
||||||
fn expands_paths() {
|
fn expands_paths() {
|
||||||
std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value");
|
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.
|
/// FNV-1a hash of the PATH string the token index was built from.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
path_hash: u64,
|
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).
|
/// Catalog run-command first token -> resolved path (null = not found).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tokens: BTreeMap<String, Option<String>>,
|
tokens: BTreeMap<String, Option<String>>,
|
||||||
@@ -98,6 +103,26 @@ fn path_hash() -> u64 {
|
|||||||
h
|
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.
|
/// 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.
|
/// Much cheaper than one which() call per agent when the catalog is large.
|
||||||
fn build_path_index() -> BTreeMap<String, PathBuf> {
|
fn build_path_index() -> BTreeMap<String, PathBuf> {
|
||||||
@@ -163,13 +188,15 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut dirty = false;
|
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();
|
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();
|
let index = build_path_index();
|
||||||
cache.tokens.clear();
|
cache.tokens.clear();
|
||||||
for agent in app.catalog.agents() {
|
for agent in app.catalog.agents() {
|
||||||
let Some(token) = agent.first_token() else {
|
let Some(token) = agent.detect_token() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !cache.tokens.contains_key(&token) {
|
if !cache.tokens.contains_key(&token) {
|
||||||
@@ -180,6 +207,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cache.path_hash = current_hash;
|
cache.path_hash = current_hash;
|
||||||
|
cache.token_hash = current_tokens;
|
||||||
dirty = true;
|
dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +223,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
|
|||||||
let index = build_path_index();
|
let index = build_path_index();
|
||||||
cache.tokens.clear();
|
cache.tokens.clear();
|
||||||
for agent in app.catalog.agents() {
|
for agent in app.catalog.agents() {
|
||||||
let Some(token) = agent.first_token() else {
|
let Some(token) = agent.detect_token() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !cache.tokens.contains_key(&token) {
|
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) {
|
if agent.hidden || !agent.installable || managed.contains_key(&agent.name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(token) = agent.first_token() else {
|
let Some(token) = agent.detect_token() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Some(Some(path)) = cache.tokens.get(&token) {
|
if let Some(Some(path)) = cache.tokens.get(&token) {
|
||||||
|
|||||||
Reference in New Issue
Block a user