diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 3ac19e7f3..56e42d845 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -21,11 +21,11 @@ class ContextBuilder: _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" _MAX_RECENT_HISTORY = 50 - def __init__(self, workspace: Path, timezone: str | None = None): + def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None): self.workspace = workspace self.timezone = timezone self.memory = MemoryStore(workspace) - self.skills = SkillsLoader(workspace) + self.skills = SkillsLoader(workspace, disabled_skills=set(disabled_skills) if disabled_skills else None) def build_system_prompt( self, diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9128b8840..80205ceae 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -147,6 +147,7 @@ class AgentLoop: timezone: str | None = None, hooks: list[AgentHook] | None = None, unified_session: bool = False, + disabled_skills: list[str] | None = None, ): from nanobot.config.schema import ExecToolConfig, WebToolsConfig @@ -179,7 +180,7 @@ class AgentLoop: self._last_usage: dict[str, int] = {} self._extra_hooks: list[AgentHook] = hooks or [] - self.context = ContextBuilder(workspace, timezone=timezone) + self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills) self.sessions = session_manager or SessionManager(workspace) self.tools = ToolRegistry() self.runner = AgentRunner(provider) diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index ca215cc96..e9ef1986f 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -28,10 +28,11 @@ class SkillsLoader: specific tools or perform certain tasks. """ - def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None): + def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None, disabled_skills: set[str] | None = None): self.workspace = workspace self.workspace_skills = workspace / "skills" self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR + self.disabled_skills = disabled_skills or set() def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]: if not base.exists(): @@ -66,6 +67,9 @@ class SkillsLoader: self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names) ) + if self.disabled_skills: + skills = [s for s in skills if s["name"] not in self.disabled_skills] + if filter_unavailable: return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))] return skills diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5ce8b7937..04a21b3f9 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -591,6 +591,7 @@ def serve( channels_config=runtime_config.channels, timezone=runtime_config.agents.defaults.timezone, unified_session=runtime_config.agents.defaults.unified_session, + disabled_skills=runtime_config.agents.defaults.disabled_skills, ) model_name = runtime_config.agents.defaults.model @@ -683,6 +684,7 @@ def gateway( channels_config=config.channels, timezone=config.agents.defaults.timezone, unified_session=config.agents.defaults.unified_session, + disabled_skills=config.agents.defaults.disabled_skills, ) # Set cron callback (needs agent) @@ -915,6 +917,7 @@ def agent( channels_config=config.channels, timezone=config.agents.defaults.timezone, unified_session=config.agents.defaults.unified_session, + disabled_skills=config.agents.defaults.disabled_skills, ) restart_notice = consume_restart_notice_from_env() if restart_notice and should_show_cli_restart_notice(restart_notice, session_id): diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index b011d765f..d6e7f9045 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -77,6 +77,7 @@ class AgentDefaults(Base): reasoning_effort: str | None = None # low / medium / high / adaptive - enables LLM thinking mode timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York" unified_session: bool = False # Share one session across all channels (single-user multi-device) + disabled_skills: list[str] = Field(default_factory=list) # Skill names to exclude from loading (e.g. ["summarize", "skill-creator"]) dream: DreamConfig = Field(default_factory=DreamConfig) diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 9166acb27..75d030d7a 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -82,6 +82,7 @@ class Nanobot: mcp_servers=config.tools.mcp_servers, timezone=defaults.timezone, unified_session=defaults.unified_session, + disabled_skills=defaults.disabled_skills, ) return cls(loop) diff --git a/tests/agent/test_skills_loader.py b/tests/agent/test_skills_loader.py index 46923c806..4284fa0c6 100644 --- a/tests/agent/test_skills_loader.py +++ b/tests/agent/test_skills_loader.py @@ -250,3 +250,63 @@ def test_list_skills_openclaw_metadata_parsed_for_requirements( assert entries == [ {"name": "openclaw_skill", "path": str(skill_path), "source": "workspace"}, ] + + +def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + ws_skills = workspace / "skills" + ws_skills.mkdir(parents=True) + _write_skill(ws_skills, "alpha", body="# Alpha") + beta_path = _write_skill(ws_skills, "beta", body="# Beta") + builtin = tmp_path / "builtin" + builtin.mkdir() + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills={"alpha"}) + entries = loader.list_skills(filter_unavailable=False) + assert len(entries) == 1 + assert entries[0]["name"] == "beta" + assert entries[0]["path"] == str(beta_path) + + +def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + ws_skills = workspace / "skills" + ws_skills.mkdir(parents=True) + _write_skill(ws_skills, "alpha", body="# Alpha") + _write_skill(ws_skills, "beta", body="# Beta") + builtin = tmp_path / "builtin" + builtin.mkdir() + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills=set()) + entries = loader.list_skills(filter_unavailable=False) + assert len(entries) == 2 + + +def test_disabled_skills_excluded_from_build_skills_summary(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + ws_skills = workspace / "skills" + ws_skills.mkdir(parents=True) + _write_skill(ws_skills, "alpha", body="# Alpha") + _write_skill(ws_skills, "beta", body="# Beta") + builtin = tmp_path / "builtin" + builtin.mkdir() + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills={"alpha"}) + summary = loader.build_skills_summary() + assert "alpha" not in summary + assert "beta" in summary + + +def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + ws_skills = workspace / "skills" + ws_skills.mkdir(parents=True) + _write_skill(ws_skills, "alpha", metadata_json={"always": True}, body="# Alpha") + _write_skill(ws_skills, "beta", metadata_json={"always": True}, body="# Beta") + builtin = tmp_path / "builtin" + builtin.mkdir() + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills={"alpha"}) + always = loader.get_always_skills() + assert "alpha" not in always + assert "beta" in always