mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-12 03:42:08 +00:00
feat(profiles): --no-skills flag for empty profile creation (#20986)
Adds `hermes profile create <name> --no-skills` to create a profile with
zero bundled skills. Writes a `.no-bundled-skills` marker file in the
profile root so `hermes update`'s all-profile skill sync loop also skips
the profile — without the marker, every update would re-seed skills and
the user would have to delete them again.
Use case (from @hiut1u): orchestrator profiles and narrow-task profiles
don't need 100+ bundled skills polluting their system prompt.
- create_profile() gains a `no_skills` param, mutually exclusive with
`--clone` / `--clone-all` (cloning explicitly copies skills).
- seed_profile_skills() no-ops on opted-out profiles and returns
`{skipped_opt_out: True}` so callers can report cleanly.
- Web API (POST /api/profiles) accepts `no_skills: bool`.
- Delete `.no-bundled-skills` to opt back in — next `hermes update`
re-seeds normally.
6 new tests in TestNoSkillsOptOut cover marker write, mutual exclusion
with clone, seed_profile_skills opt-out, fresh profile unaffected, and
delete-marker-re-enables-seeding.
This commit is contained in:
parent
49c3c2e0d3
commit
51f9953e69
4 changed files with 188 additions and 4 deletions
|
|
@ -33,6 +33,9 @@ from hermes_cli.profiles import (
|
|||
generate_zsh_completion,
|
||||
_get_profiles_root,
|
||||
_get_default_hermes_home,
|
||||
seed_profile_skills,
|
||||
has_bundled_skills_opt_out,
|
||||
NO_BUNDLED_SKILLS_MARKER,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -243,6 +246,116 @@ class TestCreateProfile:
|
|||
assert (profile_dir / "SOUL.md").exists()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestNoSkillsOptOut
|
||||
# ===================================================================
|
||||
|
||||
class TestNoSkillsOptOut:
|
||||
"""Tests for `hermes profile create --no-skills` and the opt-out marker."""
|
||||
|
||||
def test_no_skills_writes_marker_and_skips_seeding(self, profile_env):
|
||||
profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True)
|
||||
|
||||
# Marker file is present
|
||||
marker = profile_dir / NO_BUNDLED_SKILLS_MARKER
|
||||
assert marker.is_file(), "expected .no-bundled-skills marker in profile root"
|
||||
assert "--no-skills" in marker.read_text()
|
||||
|
||||
# has_bundled_skills_opt_out() agrees
|
||||
assert has_bundled_skills_opt_out(profile_dir) is True
|
||||
|
||||
# skills/ dir exists (profile bootstrapping still creates the dir) but
|
||||
# contains nothing yet because create_profile itself doesn't seed.
|
||||
assert (profile_dir / "skills").is_dir()
|
||||
assert list((profile_dir / "skills").iterdir()) == []
|
||||
|
||||
def test_no_skills_conflicts_with_clone(self, profile_env):
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
create_profile(
|
||||
"orchestrator",
|
||||
no_alias=True,
|
||||
no_skills=True,
|
||||
clone_config=True,
|
||||
)
|
||||
|
||||
def test_no_skills_conflicts_with_clone_all(self, profile_env):
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
create_profile(
|
||||
"orchestrator",
|
||||
no_alias=True,
|
||||
no_skills=True,
|
||||
clone_all=True,
|
||||
)
|
||||
|
||||
def test_seed_profile_skills_respects_marker(self, profile_env):
|
||||
"""seed_profile_skills() must no-op on opted-out profiles even when
|
||||
called directly (e.g. by `hermes update`'s all-profile sync loop)."""
|
||||
profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True)
|
||||
|
||||
# Call seed_profile_skills() directly — it should NOT invoke subprocess,
|
||||
# NOT modify the skills/ dir, and return a dict with skipped_opt_out=True.
|
||||
result = seed_profile_skills(profile_dir, quiet=True)
|
||||
|
||||
assert result is not None
|
||||
assert result.get("skipped_opt_out") is True
|
||||
assert result.get("copied") == []
|
||||
# skills/ stays empty — no subprocess ran
|
||||
assert list((profile_dir / "skills").iterdir()) == []
|
||||
|
||||
def test_default_profile_gets_skills_seeded(self, profile_env, monkeypatch):
|
||||
"""Sanity: without --no-skills, seed_profile_skills() runs the real
|
||||
subprocess path. Mock the subprocess so the test is hermetic, and
|
||||
just confirm the marker is NOT checked in the non-opt-out case."""
|
||||
import subprocess as _sp
|
||||
|
||||
profile_dir = create_profile("coder", no_alias=True)
|
||||
# No marker — not opted out
|
||||
assert not (profile_dir / NO_BUNDLED_SKILLS_MARKER).exists()
|
||||
assert has_bundled_skills_opt_out(profile_dir) is False
|
||||
|
||||
# Mock subprocess.run to avoid actually running skill sync in tests
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append(args)
|
||||
return _sp.CompletedProcess(
|
||||
args=args, returncode=0, stdout='{"copied": ["x"]}', stderr=""
|
||||
)
|
||||
|
||||
monkeypatch.setattr("subprocess.run", fake_run)
|
||||
result = seed_profile_skills(profile_dir, quiet=True)
|
||||
|
||||
# Subprocess was invoked (the opt-out branch did NOT short-circuit)
|
||||
assert len(calls) == 1
|
||||
assert result == {"copied": ["x"]}
|
||||
|
||||
def test_delete_marker_re_enables_seeding(self, profile_env, monkeypatch):
|
||||
"""Deleting .no-bundled-skills opts the profile back in."""
|
||||
import subprocess as _sp
|
||||
|
||||
profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True)
|
||||
assert has_bundled_skills_opt_out(profile_dir) is True
|
||||
|
||||
# First call: opted out, returns skipped dict without touching subprocess
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
"subprocess.run",
|
||||
lambda *a, **kw: (called.append(a), _sp.CompletedProcess(
|
||||
args=a, returncode=0, stdout='{"copied": []}', stderr=""
|
||||
))[1],
|
||||
)
|
||||
r1 = seed_profile_skills(profile_dir, quiet=True)
|
||||
assert r1.get("skipped_opt_out") is True
|
||||
assert called == []
|
||||
|
||||
# Delete marker → next call runs the real path
|
||||
(profile_dir / NO_BUNDLED_SKILLS_MARKER).unlink()
|
||||
assert has_bundled_skills_opt_out(profile_dir) is False
|
||||
r2 = seed_profile_skills(profile_dir, quiet=True)
|
||||
assert r2 == {"copied": []}
|
||||
assert len(called) == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestDeleteProfile
|
||||
# ===================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue