diff --git a/agentdescent/cli.py b/agentdescent/cli.py index 0e487bd0..eb13025c 100644 --- a/agentdescent/cli.py +++ b/agentdescent/cli.py @@ -47,7 +47,8 @@ from . import runstore from .evolvespec import (KINDS, EvolveSpec, SpecError, budget_round_overshoot_warning, - compose, estimate, load_spec, _POLICY_SLOTS) + compose, estimate, fold_kind, load_spec, resolve_kind, + _POLICY_SLOTS) __all__ = ["main", "doctor_report", "starter_spec"] @@ -1105,6 +1106,8 @@ def write_starter_spec( if value is not None and (isinstance(value, bool) or not isinstance(value, int) or value < 1): raise ValueError(f"{flag} must be an integer >= 1, got {value!r}") + if kind is not None: + kind = resolve_kind(kind, where="--kind") agent_explicit = agent is not None if agent is None: agent, found = default_agent_ref() @@ -3341,8 +3344,8 @@ def cmd_algorithms(a: argparse.Namespace) -> int: file=sys.stderr) as_json = getattr(a, "json", False) if a.action in (None, "list"): - if a.kind is not None and a.kind not in KINDS: - raise SpecError(f"--kind must be one of {list(KINDS)}, got {a.kind!r}") + if a.kind is not None: + a.kind = resolve_kind(a.kind, where="--kind") found = algorithms.listing(a.kind) # Same explanation MCP list_algorithms already returns for this kind # (#799): policy_slot evolves a policy value, so no named algorithm @@ -3441,11 +3444,13 @@ def algorithms_run_payload( except algorithms.AlgorithmError as e: return {"ok": False, "error": str(e), "_stage": "pre"} out = out or f"agentdescent.{name}.spec.json" - if kind is not None and kind not in KINDS: - # The group-level --kind is free text; fail closed with the real choices - # before anything is written (#688). - return {"ok": False, "_stage": "pre", - "error": f"--kind must be one of {list(KINDS)}, got {kind!r}"} + if kind is not None: + # The group-level --kind is free text; fold aliases and fail closed + # with the real choices before anything is written (#688, #882). + try: + kind = resolve_kind(kind, where="--kind") + except SpecError as e: + return {"ok": False, "_stage": "pre", "error": str(e)} # The name is positional here: say so, route the one-file fix through # `algorithms run`, and name --kind when the kind was only guessed (#702). alg = algorithms.get(name) @@ -4617,8 +4622,7 @@ def build_parser() -> argparse.ArgumentParser: s = sub.add_parser("init", help="write a starter spec for a path") s.add_argument("path") - s.add_argument("--kind", choices=("text", "skill_dir", "skill_library", "agent_dir", - "agent_code", "plugin", "policy_slot")) + s.add_argument("--kind", type=fold_kind, choices=KINDS) s.add_argument("--data", help="cases file, or a module:attribute problems ref for policy_slot") s.add_argument("--agent", default=None, help="worker agent short name (default: the first of claude_code, " @@ -4905,8 +4909,7 @@ def build_parser() -> argparse.ArgumentParser: help="endpoint URL for that model (written as default_base_url)") s2.add_argument("--key-env", dest="key_env", default=None, help="environment variable holding its key") - s2.add_argument("--kind", choices=("text", "skill_dir", "skill_library", "agent_dir", - "agent_code", "plugin", "policy_slot"), + s2.add_argument("--kind", type=fold_kind, choices=KINDS, default=argparse.SUPPRESS, help="spec kind (default: guessed from PATH, as init --kind)") s2.add_argument("--out", help="where to write the spec " diff --git a/agentdescent/evolvespec.py b/agentdescent/evolvespec.py index 22778265..a7bfd01c 100644 --- a/agentdescent/evolvespec.py +++ b/agentdescent/evolvespec.py @@ -45,6 +45,7 @@ from __future__ import annotations import csv +import difflib import json import os import shlex @@ -73,6 +74,8 @@ "KINDS", "KIND_ROWS", "SHORT_REFS", + "fold_kind", + "resolve_kind", "Composition", "EvolveSpec", "SpecError", @@ -89,6 +92,40 @@ KINDS: Tuple[str, ...] = ("text", "skill_dir", "skill_library", "agent_dir", "agent_code", "plugin", "policy_slot") + +def fold_kind(value: Any) -> Any: + """Canonical :data:`KINDS` entry when ``value`` is a case/hyphen alias. + + ``skill-library`` and ``SKILL_LIBRARY`` become ``skill_library``. A value + that is not such an alias is returned unchanged, so argparse ``choices=`` + can still report an unknown flag. + """ + if isinstance(value, str): + folded = value.strip().casefold().replace("-", "_") + if folded in KINDS: + return folded + return value + + +def resolve_kind(value: Any, *, where: str = "kind") -> str: + """The :data:`KINDS` entry ``value`` names, ignoring case and ``-`` vs ``_``. + + Raises :class:`SpecError` when nothing matches. A near-miss typo + (``skil_library``) appends ``did you mean 'skill_library'?``. + """ + folded = fold_kind(value) + if folded in KINDS: + return folded + shown = list(KINDS) if where.startswith("-") else KINDS + msg = f"{where} must be one of {shown}, got {value!r}" + if isinstance(value, str) and value.strip(): + close = difflib.get_close_matches( + value.strip().casefold().replace("-", "_"), list(KINDS), n=1, + cutoff=0.75) + if close: + msg += f"; did you mean {close[0]!r}?" + raise SpecError(msg) + #: The composition table, for readers and for the test that checks it against #: the quickstarts. Values are the *names* of what is assembled; :func:`compose` #: is the code. @@ -265,6 +302,12 @@ class EvolveSpec: record_rollouts: bool = True version: int = 1 + def __post_init__(self) -> None: + # Aliases such as ``skill-library`` become the canonical kind so later + # checks (absolutise, compose) see ``skill_library``. Unknown values + # stay as written; :func:`compose` / :func:`resolve_kind` name them. + self.kind = fold_kind(self.kind) + # -- (de)serialisation ------------------------------------------------ @classmethod @@ -1117,8 +1160,9 @@ def compose(spec: EvolveSpec, *, usage: Optional[Usage] = None, defaults the kind wants. Raises :class:`SpecError` for anything the spec gets wrong, *before* a single rollout runs -- which is what ``plan`` is for. """ - if spec.kind not in KINDS: - raise SpecError(f"kind must be one of {KINDS}, got {spec.kind!r}") + resolved = resolve_kind(spec.kind) + if resolved != spec.kind: + spec = replace(spec, kind=resolved) if spec.agent is None: raise SpecError("agent is required (for kind='text' it is the model)") if spec.version != 1: diff --git a/agentdescent/mcp.py b/agentdescent/mcp.py index 88d72772..1ed85d9c 100644 --- a/agentdescent/mcp.py +++ b/agentdescent/mcp.py @@ -428,10 +428,17 @@ def doctor(self, home: Optional[str] = None) -> Dict[str, Any]: def list_algorithms(self, kind: Optional[str] = None) -> Dict[str, Any]: from . import algorithms - from .evolvespec import KINDS + from .evolvespec import KINDS, SpecError, resolve_kind - if kind is not None and kind not in KINDS: - return {"error": f"unknown kind {kind!r}; choose from {list(KINDS)}"} + if kind is not None: + try: + kind = resolve_kind(kind) + except SpecError as e: + extra = "" + text = str(e) + if "did you mean" in text: + extra = "; " + text.split("; ", 1)[1] + return {"error": f"unknown kind {kind!r}; choose from {list(KINDS)}{extra}"} payload: Dict[str, Any] = { "algorithms": [a.describe() for a in algorithms.listing(kind)], "surface": "card", diff --git a/changelog.d/882-kind-aliases.md b/changelog.d/882-kind-aliases.md new file mode 100644 index 00000000..406e946f --- /dev/null +++ b/changelog.d/882-kind-aliases.md @@ -0,0 +1,4 @@ +Fixed +`--kind` and spec `kind` now fold case and `-`/`_` (`skill-library` → +`skill_library`) and a near-miss typo ends with "did you mean 'skill_library'?", +matching how algorithm and parameter names already resolve (#882). diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index d2951957..d81257ad 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -489,7 +489,11 @@ def test_mcp_lists_and_describes_the_registry(): ["archive_selection", "evoskill"] assert [a["name"] for a in t.list_algorithms("agent_dir")["algorithms"]] == \ ["archive_selection", "evoskill"] + assert [a["name"] for a in t.list_algorithms("skill-library")["algorithms"]] == \ + [a["name"] for a in t.list_algorithms("skill_library")["algorithms"]] assert "unknown kind" in t.list_algorithms("nope")["error"] + err = t.list_algorithms("skil_library")["error"] + assert "unknown kind" in err and "did you mean 'skill_library'?" in err card = t.describe_algorithm("rrsi") assert card["name"] == "rrsi" and any(p["name"] == "b_max" for p in card["params"]) assert "unknown algorithm" in t.describe_algorithm("nope")["error"] diff --git a/tests/test_cli_algorithms_bare.py b/tests/test_cli_algorithms_bare.py index ee838e9f..f49a741c 100644 --- a/tests/test_cli_algorithms_bare.py +++ b/tests/test_cli_algorithms_bare.py @@ -49,3 +49,27 @@ def test_bare_algorithms_bad_kind_names_the_flag(capsys): def test_show_refuses_kind(capsys): assert cli.main(["algorithms", "--kind", "text", "show", "gepa"]) == 2 assert "--kind" in capsys.readouterr().err + + +def test_list_kind_folds_hyphen_and_case(capsys): + """#882: `--kind skill-library` / `SKILL_LIBRARY` filter like `skill_library`.""" + assert cli.main(["algorithms", "list", "--kind", "skill_library", "--json"]) == 0 + canonical = [card["name"] for card in json.loads(capsys.readouterr().out)] + assert "evoskill" in canonical and "archive_selection" in canonical + assert "gepa" not in canonical + for alias in ("skill-library", "SKILL_LIBRARY"): + assert cli.main(["algorithms", "list", "--kind", alias, "--json"]) == 0 + assert [card["name"] for card in json.loads(capsys.readouterr().out)] == canonical + assert cli.main(["algorithms", "--kind", alias, "--json"]) == 0 + assert [card["name"] for card in json.loads(capsys.readouterr().out)] == canonical + + +def test_list_kind_suggests_a_near_miss(capsys): + assert cli.main(["algorithms", "list", "--kind", "skil_library"]) == 2 + err = capsys.readouterr().err + assert "--kind must be one of" in err + assert "did you mean 'skill_library'?" in err + assert cli.main(["--json", "algorithms", "list", "--kind", "skil_library"]) == 2 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + assert "did you mean 'skill_library'?" in payload["error"] diff --git a/tests/test_evolvespec.py b/tests/test_evolvespec.py index 36e5376a..81d38581 100644 --- a/tests/test_evolvespec.py +++ b/tests/test_evolvespec.py @@ -17,7 +17,8 @@ ) from agentdescent.advantage import AdvantageAcceptance, AdvantageConflict from agentdescent.evolvespec import ( - KIND_ROWS, KINDS, SHORT_REFS, build_policies, estimate, to_ref, + KIND_ROWS, KINDS, SHORT_REFS, build_policies, estimate, fold_kind, + resolve_kind, to_ref, ) from agentdescent.evolution import SeededAppendRules, SingleSlot from agentdescent.fusion import KeepContradictions, ReflectiveFusion @@ -104,6 +105,39 @@ def test_every_kind_has_a_row_in_the_composition_table(): assert set(row) == {"strategy", "run", "propose", "reward", "blast_radius"} +@pytest.mark.parametrize("raw, canonical", [ + ("skill-library", "skill_library"), + ("SKILL_LIBRARY", "skill_library"), + ("skill-dir", "skill_dir"), + ("Text", "text"), + ("policy-slot", "policy_slot"), + ("AGENT_CODE", "agent_code"), +]) +def test_resolve_kind_folds_hyphen_and_case(raw, canonical): + """#882: kind aliases match how algorithm / param names already fold.""" + assert fold_kind(raw) == canonical + assert resolve_kind(raw) == canonical + + +def test_from_dict_stores_the_canonical_kind(tmp_path): + spec = _dir_spec(str(tmp_path), kind="skill-library") + assert spec.kind == "skill_library" + + +def test_resolve_kind_suggests_a_near_miss(): + with pytest.raises(SpecError, match=r"did you mean 'skill_library'\?"): + resolve_kind("skil_library") + with pytest.raises(SpecError, match=r"--kind must be one of") as e: + resolve_kind("skil_library", where="--kind") + assert "did you mean 'skill_library'?" in str(e.value) + + +def test_resolve_kind_unknown_has_no_guess(): + with pytest.raises(SpecError, match="kind must be one of") as e: + resolve_kind("nope") + assert "did you mean" not in str(e.value) + + def test_every_short_ref_resolves_to_a_public_factory(): for name, target in SHORT_REFS.items(): assert ":" in target diff --git a/tests/test_guess_kind_skill_library.py b/tests/test_guess_kind_skill_library.py index 63cfef39..f0104d7f 100644 --- a/tests/test_guess_kind_skill_library.py +++ b/tests/test_guess_kind_skill_library.py @@ -68,3 +68,32 @@ def test_algorithms_run_evoskill_on_library(tmp_path, capsys, monkeypatch): .read_text(encoding="utf-8")) assert spec["kind"] == "skill_library" assert spec["algorithm"]["name"] == "evoskill" + + + +def test_init_kind_alias_writes_canonical_skill_library(tmp_path, capsys, monkeypatch): + """#882: `init --kind skill-library` writes the same spec as `--kind skill_library`.""" + monkeypatch.chdir(tmp_path) + lib = tmp_path / "skills" + _skill(lib, "alpha") + _skill(lib, "beta") + assert cli.main(["init", "skills", "--kind", "skill-library", "--json"]) == 0 + out = json.loads(capsys.readouterr().out) + assert out["kind"] == "skill_library" + spec = json.loads(Path(out["spec"]).read_text(encoding="utf-8")) + assert spec["kind"] == "skill_library" + assert spec["layout"] == "skill_library" + + +def test_algorithms_run_kind_alias_on_library(tmp_path, capsys, monkeypatch): + monkeypatch.chdir(tmp_path) + lib = tmp_path / "skills" + _skill(lib, "alpha") + _skill(lib, "beta") + cli.main(["algorithms", "run", "evoskill", "skills", + "--kind", "SKILL_LIBRARY", "--json"]) + capsys.readouterr() + spec = json.loads((tmp_path / "agentdescent.evoskill.spec.json") + .read_text(encoding="utf-8")) + assert spec["kind"] == "skill_library" + assert spec["algorithm"]["name"] == "evoskill"