Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions agentdescent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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, "
Expand Down Expand Up @@ -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 "
Expand Down
48 changes: 46 additions & 2 deletions agentdescent/evolvespec.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from __future__ import annotations

import csv
import difflib
import json
import os
import shlex
Expand Down Expand Up @@ -73,6 +74,8 @@
"KINDS",
"KIND_ROWS",
"SHORT_REFS",
"fold_kind",
"resolve_kind",
"Composition",
"EvolveSpec",
"SpecError",
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 10 additions & 3 deletions agentdescent/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/882-kind-aliases.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 4 additions & 0 deletions tests/test_algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
24 changes: 24 additions & 0 deletions tests/test_cli_algorithms_bare.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
36 changes: 35 additions & 1 deletion tests/test_evolvespec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions tests/test_guess_kind_skill_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading