mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
Remove unused imports (F401) and duplicate/shadowed import redefinitions (F811) across the codebase using ruff's safe autofixes. No behavioral changes -- imports only. - ~1400 safe autofixes applied across 644 files (net -1072 lines) - __init__.py re-exports preserved (excluded from F401 removal so public re-export surfaces stay intact) - Re-exports that are imported or monkeypatched by tests but look unused in their defining module are kept with explicit # noqa: F401 (gateway/run.py load_dotenv; run_agent re-exports from agent.message_sanitization, agent.context_compressor, agent.retry_utils, agent.prompt_builder, agent.process_bootstrap, agent.codex_responses_adapter) - Unsafe F841 (unused-variable) fixes deliberately skipped -- those can change behavior when the RHS has side effects - ruff lints remain disabled in pyproject.toml (only PLW1514 is selected); this is a one-time cleanup, not a config change Verification: - python -m compileall: clean - pytest --collect-only: all 27161 tests collect (zero import errors) - core entry points import clean (run_agent, model_tools, cli, toolsets, hermes_state, batch_runner, gateway) - static scan: every name any test imports directly from an edited module still resolves
336 lines
12 KiB
Python
336 lines
12 KiB
Python
"""Tests for agent/skill_bundles.py — YAML-defined skill bundles."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from agent.skill_bundles import (
|
|
_slugify,
|
|
build_bundle_invocation_message,
|
|
delete_bundle,
|
|
get_bundle,
|
|
get_skill_bundles,
|
|
list_bundles,
|
|
reload_bundles,
|
|
resolve_bundle_command_key,
|
|
save_bundle,
|
|
scan_bundles,
|
|
)
|
|
|
|
|
|
def _make_bundle_yaml(
|
|
bundles_dir: Path, slug: str, skills: list[str],
|
|
description: str = "", instruction: str = "", name: str | None = None,
|
|
) -> Path:
|
|
bundles_dir.mkdir(parents=True, exist_ok=True)
|
|
lines = []
|
|
if name is not None:
|
|
lines.append(f"name: {name}")
|
|
else:
|
|
lines.append(f"name: {slug}")
|
|
if description:
|
|
lines.append(f"description: {description}")
|
|
lines.append("skills:")
|
|
for s in skills:
|
|
lines.append(f" - {s}")
|
|
if instruction:
|
|
lines.append(f"instruction: |")
|
|
for ln in instruction.splitlines():
|
|
lines.append(f" {ln}")
|
|
path = bundles_dir / f"{slug}.yaml"
|
|
path.write_text("\n".join(lines) + "\n")
|
|
return path
|
|
|
|
|
|
def _make_skill(skills_dir: Path, name: str, body: str = "Do the thing.") -> Path:
|
|
skill_dir = skills_dir / name
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
f"---\nname: {name}\ndescription: Description for {name}\n---\n\n# {name}\n\n{body}\n"
|
|
)
|
|
return skill_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def bundles_env(tmp_path, monkeypatch):
|
|
"""Isolated bundles dir + skills dir."""
|
|
bundles_dir = tmp_path / "skill-bundles"
|
|
skills_dir = tmp_path / "skills"
|
|
skills_dir.mkdir()
|
|
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
|
|
# Patch SKILLS_DIR so skill loading hits our temp tree.
|
|
import tools.skills_tool as skills_tool_module
|
|
monkeypatch.setattr(skills_tool_module, "SKILLS_DIR", skills_dir)
|
|
# Reset module-level cache between tests.
|
|
import agent.skill_bundles as mod
|
|
mod._bundles_cache = {}
|
|
mod._bundles_cache_mtime = None
|
|
return bundles_dir, skills_dir
|
|
|
|
|
|
class TestSlugify:
|
|
def test_basic(self):
|
|
assert _slugify("Backend Dev") == "backend-dev"
|
|
|
|
def test_underscores(self):
|
|
assert _slugify("backend_dev") == "backend-dev"
|
|
|
|
def test_strips_invalid_chars(self):
|
|
assert _slugify("hello, world!") == "hello-world"
|
|
|
|
def test_collapses_hyphens(self):
|
|
assert _slugify("a--b---c") == "a-b-c"
|
|
|
|
def test_empty(self):
|
|
assert _slugify("") == ""
|
|
assert _slugify("!!!") == ""
|
|
|
|
|
|
class TestScanBundles:
|
|
def test_empty_dir(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
result = scan_bundles()
|
|
assert result == {}
|
|
|
|
def test_finds_bundle(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
_make_bundle_yaml(bundles_dir, "backend", ["skill-a", "skill-b"])
|
|
result = scan_bundles()
|
|
assert "/backend" in result
|
|
assert result["/backend"]["name"] == "backend"
|
|
assert result["/backend"]["skills"] == ["skill-a", "skill-b"]
|
|
|
|
def test_skips_invalid_yaml(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
bundles_dir.mkdir(parents=True)
|
|
(bundles_dir / "broken.yaml").write_text("{not: valid yaml: [")
|
|
_make_bundle_yaml(bundles_dir, "good", ["skill-a"])
|
|
result = scan_bundles()
|
|
assert "/good" in result
|
|
assert "/broken" not in result
|
|
|
|
def test_skips_bundle_without_skills(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
bundles_dir.mkdir(parents=True)
|
|
(bundles_dir / "noskills.yaml").write_text("name: noskills\nskills: []\n")
|
|
result = scan_bundles()
|
|
assert "/noskills" not in result
|
|
|
|
def test_duplicate_slug_first_wins(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
# Two files normalizing to the same slug. Sort order is by filename:
|
|
# 'alpha-dup.yaml' sorts before 'alpha.yaml' (`-` < `.` in ASCII), so
|
|
# the first-seen file wins.
|
|
_make_bundle_yaml(bundles_dir, "alpha", ["s1"], name="alpha")
|
|
_make_bundle_yaml(bundles_dir, "alpha-dup", ["s2"], name="ALPHA")
|
|
result = scan_bundles()
|
|
assert "/alpha" in result
|
|
# alpha-dup.yaml is scanned first → its skills win
|
|
assert result["/alpha"]["skills"] == ["s2"]
|
|
|
|
def test_uses_filename_as_fallback_name(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
bundles_dir.mkdir(parents=True)
|
|
(bundles_dir / "fallback.yaml").write_text("skills:\n - foo\n")
|
|
result = scan_bundles()
|
|
assert "/fallback" in result
|
|
assert result["/fallback"]["name"] == "fallback"
|
|
|
|
|
|
class TestGetSkillBundles:
|
|
def test_returns_cache(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
_make_bundle_yaml(bundles_dir, "a", ["s1"])
|
|
first = get_skill_bundles()
|
|
# Second call should hit cache (no rescan unless mtime changed).
|
|
second = get_skill_bundles()
|
|
assert first is second or first == second
|
|
|
|
def test_rescans_on_change(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
_make_bundle_yaml(bundles_dir, "a", ["s1"])
|
|
assert "/a" in get_skill_bundles()
|
|
# Add a second bundle and bump mtime.
|
|
import time as _t
|
|
_t.sleep(0.05) # ensure mtime granularity is exceeded
|
|
_make_bundle_yaml(bundles_dir, "b", ["s2"])
|
|
os.utime(bundles_dir, None)
|
|
result = get_skill_bundles()
|
|
assert "/a" in result
|
|
assert "/b" in result
|
|
|
|
|
|
class TestResolveBundleCommandKey:
|
|
def test_exact_match(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
_make_bundle_yaml(bundles_dir, "my-bundle", ["s1"])
|
|
scan_bundles()
|
|
assert resolve_bundle_command_key("my-bundle") == "/my-bundle"
|
|
|
|
def test_underscore_alias(self, bundles_env):
|
|
"""Telegram converts hyphens to underscores in command names."""
|
|
bundles_dir, _ = bundles_env
|
|
_make_bundle_yaml(bundles_dir, "my-bundle", ["s1"])
|
|
scan_bundles()
|
|
assert resolve_bundle_command_key("my_bundle") == "/my-bundle"
|
|
|
|
def test_unknown(self, bundles_env):
|
|
scan_bundles()
|
|
assert resolve_bundle_command_key("missing") is None
|
|
|
|
def test_empty(self, bundles_env):
|
|
assert resolve_bundle_command_key("") is None
|
|
|
|
|
|
class TestBuildBundleInvocationMessage:
|
|
def test_loads_all_skills(self, bundles_env):
|
|
bundles_dir, skills_dir = bundles_env
|
|
_make_skill(skills_dir, "skill-a", body="Skill A content.")
|
|
_make_skill(skills_dir, "skill-b", body="Skill B content.")
|
|
_make_bundle_yaml(bundles_dir, "combo", ["skill-a", "skill-b"])
|
|
scan_bundles()
|
|
|
|
result = build_bundle_invocation_message("/combo")
|
|
assert result is not None
|
|
msg, loaded, missing = result
|
|
assert set(loaded) == {"skill-a", "skill-b"}
|
|
assert missing == []
|
|
assert "Skill A content." in msg
|
|
assert "Skill B content." in msg
|
|
assert "combo" in msg
|
|
|
|
def test_skips_missing_skills(self, bundles_env):
|
|
bundles_dir, skills_dir = bundles_env
|
|
_make_skill(skills_dir, "skill-a")
|
|
_make_bundle_yaml(bundles_dir, "combo", ["skill-a", "skill-ghost"])
|
|
scan_bundles()
|
|
|
|
result = build_bundle_invocation_message("/combo")
|
|
assert result is not None
|
|
msg, loaded, missing = result
|
|
assert loaded == ["skill-a"]
|
|
assert missing == ["skill-ghost"]
|
|
assert "skill-ghost" in msg # called out in header
|
|
|
|
def test_unknown_bundle_returns_none(self, bundles_env):
|
|
scan_bundles()
|
|
assert build_bundle_invocation_message("/nope") is None
|
|
|
|
def test_no_loadable_skills_returns_none(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
_make_bundle_yaml(bundles_dir, "ghost", ["nonexistent-skill"])
|
|
scan_bundles()
|
|
result = build_bundle_invocation_message("/ghost")
|
|
assert result is None
|
|
|
|
def test_includes_user_instruction(self, bundles_env):
|
|
bundles_dir, skills_dir = bundles_env
|
|
_make_skill(skills_dir, "skill-a")
|
|
_make_bundle_yaml(bundles_dir, "combo", ["skill-a"])
|
|
scan_bundles()
|
|
result = build_bundle_invocation_message(
|
|
"/combo", user_instruction="extra context here"
|
|
)
|
|
assert result is not None
|
|
msg, _, _ = result
|
|
assert "extra context here" in msg
|
|
|
|
def test_includes_bundle_instruction(self, bundles_env):
|
|
bundles_dir, skills_dir = bundles_env
|
|
_make_skill(skills_dir, "skill-a")
|
|
_make_bundle_yaml(
|
|
bundles_dir, "combo", ["skill-a"],
|
|
instruction="Always check tests first.",
|
|
)
|
|
scan_bundles()
|
|
result = build_bundle_invocation_message("/combo")
|
|
assert result is not None
|
|
msg, _, _ = result
|
|
assert "Always check tests first." in msg
|
|
|
|
def test_dedupes_skills(self, bundles_env):
|
|
bundles_dir, skills_dir = bundles_env
|
|
_make_skill(skills_dir, "skill-a")
|
|
_make_bundle_yaml(bundles_dir, "combo", ["skill-a", "skill-a"])
|
|
scan_bundles()
|
|
result = build_bundle_invocation_message("/combo")
|
|
assert result is not None
|
|
_, loaded, _ = result
|
|
assert loaded == ["skill-a"]
|
|
|
|
|
|
class TestSaveAndDeleteBundle:
|
|
def test_save_creates_file(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
path = save_bundle("test-bundle", ["s1", "s2"], description="d", instruction="i")
|
|
assert path.exists()
|
|
assert path.parent == bundles_dir
|
|
content = path.read_text()
|
|
assert "test-bundle" in content
|
|
assert "s1" in content
|
|
assert "s2" in content
|
|
assert "description: d" in content
|
|
|
|
def test_save_refuses_overwrite_by_default(self, bundles_env):
|
|
save_bundle("dup", ["s1"])
|
|
with pytest.raises(FileExistsError):
|
|
save_bundle("dup", ["s2"])
|
|
|
|
def test_save_overwrites_with_force(self, bundles_env):
|
|
save_bundle("dup", ["s1"])
|
|
save_bundle("dup", ["s2"], overwrite=True)
|
|
info = get_bundle("dup")
|
|
assert info is not None
|
|
assert info["skills"] == ["s2"]
|
|
|
|
def test_save_requires_skills(self, bundles_env):
|
|
with pytest.raises(ValueError):
|
|
save_bundle("empty", [])
|
|
|
|
def test_save_requires_name(self, bundles_env):
|
|
with pytest.raises(ValueError):
|
|
save_bundle("", ["s1"])
|
|
|
|
def test_delete_removes_file(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
save_bundle("doomed", ["s1"])
|
|
assert get_bundle("doomed") is not None
|
|
delete_bundle("doomed")
|
|
assert get_bundle("doomed") is None
|
|
|
|
def test_delete_missing_raises(self, bundles_env):
|
|
with pytest.raises(FileNotFoundError):
|
|
delete_bundle("ghost")
|
|
|
|
|
|
class TestReloadBundles:
|
|
def test_reports_added_and_removed(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
_make_bundle_yaml(bundles_dir, "old", ["s1"])
|
|
scan_bundles() # populate cache with {old}
|
|
|
|
# Mutate the disk WITHOUT going through save/delete helpers (which
|
|
# would refresh the cache mid-way). reload_bundles() diffs the
|
|
# in-memory cache against the freshly-scanned disk state.
|
|
(bundles_dir / "old.yaml").unlink()
|
|
_make_bundle_yaml(bundles_dir, "new", ["s2"])
|
|
|
|
diff = reload_bundles()
|
|
added_names = {e["name"] for e in diff["added"]}
|
|
removed_names = {e["name"] for e in diff["removed"]}
|
|
assert "new" in added_names
|
|
assert "old" in removed_names
|
|
assert diff["total"] == 1
|
|
|
|
|
|
class TestListBundles:
|
|
def test_sorted_by_slug(self, bundles_env):
|
|
bundles_dir, _ = bundles_env
|
|
_make_bundle_yaml(bundles_dir, "zebra", ["s1"])
|
|
_make_bundle_yaml(bundles_dir, "apple", ["s2"])
|
|
_make_bundle_yaml(bundles_dir, "mango", ["s3"])
|
|
scan_bundles()
|
|
info_list = list_bundles()
|
|
slugs = [b["slug"] for b in info_list]
|
|
assert slugs == sorted(slugs)
|