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:
2026-08-20 10:53:59 -04:00
parent 79e032b563
commit ee490f5eaf
10 changed files with 88 additions and 11 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "1.0.2"
version = "1.0.3"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -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
View File
@@ -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.
---
+6
View File
@@ -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
+2 -2
View File
@@ -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
+1
View File
@@ -408,6 +408,7 @@ mod tests {
install: None,
dependencies: vec![],
run: Some("x".to_string()),
detect: None,
args: vec![],
env: BTreeMap::new(),
version: None,
+1
View File
@@ -655,6 +655,7 @@ mod tests {
install: None,
dependencies: vec![],
run: Some("demo".to_string()),
detect: None,
args: vec![],
env: BTreeMap::new(),
version: None,
+1
View File
@@ -190,6 +190,7 @@ mod tests {
install: None,
dependencies: vec![],
run: Some(name.to_string()),
detect: None,
args: vec![],
env: BTreeMap::new(),
version: None,
+38
View File
@@ -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
View File
@@ -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) {