diff --git a/README.md b/README.md index c044073f0..6d7763c42 100644 --- a/README.md +++ b/README.md @@ -1597,6 +1597,26 @@ When enabled, all incoming messages — regardless of which channel they arrive > This is designed for single-user, multi-device setups. It is **off by default** — existing users see zero behavior change. +### Disabled Skills + +nanobot ships with built-in skills, and your workspace can also define custom skills under `skills/`. If you want to hide specific skills from the agent, set `agents.defaults.disabledSkills` to a list of skill directory names: + +```json +{ + "agents": { + "defaults": { + "disabledSkills": ["github", "weather"] + } + } +} +``` + +Disabled skills are excluded from the main agent's skill summary, from always-on skill injection, and from subagent skill summaries. This is useful when some bundled skills are unnecessary for your deployment or should not be exposed to end users. + +| Option | Default | Description | +|--------|---------|-------------| +| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. | + ## 🧩 Multiple Instances Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance. diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index e3460ddfd..23b597197 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -22,11 +22,11 @@ class ContextBuilder: _MAX_RECENT_HISTORY = 50 _RUNTIME_CONTEXT_END = "[/Runtime Context]" - 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 675865350..5631e12a0 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -152,6 +152,7 @@ class AgentLoop: session_ttl_minutes: int = 0, hooks: list[AgentHook] | None = None, unified_session: bool = False, + disabled_skills: list[str] | None = None, ): from nanobot.config.schema import ExecToolConfig, WebToolsConfig @@ -184,7 +185,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) @@ -197,6 +198,7 @@ class AgentLoop: max_tool_result_chars=self.max_tool_result_chars, exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, + disabled_skills=disabled_skills, ) self._unified_session = unified_session self._running = False 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/agent/subagent.py b/nanobot/agent/subagent.py index 63aa7ad7a..571bcc792 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -52,6 +52,7 @@ class SubagentManager: web_config: "WebToolsConfig | None" = None, exec_config: "ExecToolConfig | None" = None, restrict_to_workspace: bool = False, + disabled_skills: list[str] | None = None, ): from nanobot.config.schema import ExecToolConfig @@ -63,6 +64,7 @@ class SubagentManager: self.max_tool_result_chars = max_tool_result_chars self.exec_config = exec_config or ExecToolConfig() self.restrict_to_workspace = restrict_to_workspace + self.disabled_skills = set(disabled_skills or []) self.runner = AgentRunner(provider) self._running_tasks: dict[str, asyncio.Task[None]] = {} self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...} @@ -236,7 +238,10 @@ class SubagentManager: from nanobot.agent.skills import SkillsLoader time_ctx = ContextBuilder._build_runtime_context(None, None) - skills_summary = SkillsLoader(self.workspace).build_skills_summary() + skills_summary = SkillsLoader( + self.workspace, + disabled_skills=self.disabled_skills, + ).build_skills_summary() return render_template( "agent/subagent_system.md", time_ctx=time_ctx, diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 9d818a9db..48400bbd9 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, session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes, ) @@ -684,6 +685,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, session_ttl_minutes=config.agents.defaults.session_ttl_minutes, ) @@ -917,6 +919,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, session_ttl_minutes=config.agents.defaults.session_ttl_minutes, ) restart_notice = consume_restart_notice_from_env() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index a841fe159..aa5ab9932 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"]) session_ttl_minutes: int = Field( default=0, ge=0, diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index df0e49842..44560a588 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, session_ttl_minutes=defaults.session_ttl_minutes, ) 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 diff --git a/tests/tools/test_search_tools.py b/tests/tools/test_search_tools.py index 1b4e77a04..3153caa45 100644 --- a/tests/tools/test_search_tools.py +++ b/tests/tools/test_search_tools.py @@ -323,3 +323,27 @@ async def test_subagent_registers_grep_and_glob(tmp_path: Path) -> None: assert "grep" in captured["tool_names"] assert "glob" in captured["tool_names"] + + +def test_subagent_prompt_respects_disabled_skills(tmp_path: Path) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + skills_dir = tmp_path / "skills" + (skills_dir / "alpha").mkdir(parents=True) + (skills_dir / "alpha" / "SKILL.md").write_text("# Alpha\n\nhidden\n", encoding="utf-8") + (skills_dir / "beta").mkdir(parents=True) + (skills_dir / "beta" / "SKILL.md").write_text("# Beta\n\nshown\n", encoding="utf-8") + + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=4096, + disabled_skills=["alpha"], + ) + + prompt = mgr._build_subagent_prompt() + + assert "alpha" not in prompt + assert "beta" in prompt