diff --git a/dev-notes/skill-disable-model-invocation.md b/dev-notes/skill-disable-model-invocation.md new file mode 100644 index 000000000..21f6f960c --- /dev/null +++ b/dev-notes/skill-disable-model-invocation.md @@ -0,0 +1,74 @@ +# Skill `disable-model-invocation` flag + +## What changed + +Skills can now opt out of being advertised to the model. Adding +`disable-model-invocation: true` to a `SKILL.md` frontmatter keeps the skill +loaded and usable via `/skill:`, but removes it from the +`` block in the system prompt so the model never sees it and +cannot trigger it on its own. + +```md +--- +description: Cut a release. Only run when explicitly asked. +disable-model-invocation: true +--- + +Steps to prepare and publish a release... +``` + +Behavior matrix: + +| Frontmatter | Listed in system prompt | `/skill:` works | +| --- | --- | --- | +| (none) | yes | yes | +| `disable-model-invocation: true` | no | yes | +| `disable-model-invocation: false` | yes | yes | + +The field defaults to `False`, so existing skills are unaffected. + +## Why it exists + +Every loaded skill is normally listed in the system prompt, which invites the +model to read and apply any of them proactively. Some skills are user-only: +workflows with side effects (releases, deployments, sending messages) or +sequences that should run strictly on request. This flag lets an author keep +such a skill discoverable and invocable by the user while withholding it from +the model's autonomous toolbox. + +## Architecture notes + +The change stays entirely inside `tau_coding`, consistent with the layer +boundary that skills and resource loading live there: + +- `Skill` gains a `disable_model_invocation: bool = False` field + (`src/tau_coding/skills.py`). It is parsed from the `disable-model-invocation` + frontmatter key via the existing `parse_markdown_resource()` helper — no new + parsing machinery. +- `build_skill_index()` and `format_skills_for_prompt()` + (`src/tau_coding/skills.py`, `src/tau_coding/system_prompt.py`) filter out + flagged skills before formatting, so the model-facing surfaces exclude them. A + directory of only-disabled skills yields no `` block. +- `expand_skill_command()` is unchanged: it resolves names against the full + loaded set, so `/skill:` keeps expanding disabled skills. + +No changes touch `tau_agent` or `tau_ai`; the harness is unaware of the flag. + +## How to test + +Automated checks: + +```bash +uv run pytest tests/test_skills.py tests/test_system_prompt.py +uv run ruff check . +uv run ruff format --check . +uv run mypy +``` + +Manual check: + +1. Create `.tau/skills/secret/SKILL.md` with `disable-model-invocation: true` + in its frontmatter. +2. Run `uv run tau`. +3. Confirm the skill is absent from the system prompt / model-facing skill list. +4. Run `/skill:secret` and confirm it still expands and runs. diff --git a/src/tau_coding/skills.py b/src/tau_coding/skills.py index 8cdc79429..5c0396d0f 100644 --- a/src/tau_coding/skills.py +++ b/src/tau_coding/skills.py @@ -24,6 +24,7 @@ class Skill: path: Path content: str description: str | None = None + disable_model_invocation: bool = False @dataclass(frozen=True, slots=True) @@ -140,10 +141,11 @@ def parse_skill_invocation(text: str) -> SkillInvocation | None: def build_skill_index(skills: Sequence[Skill]) -> str: """Build a concise index of available skills for future system prompt assembly.""" - if not skills: + visible = [skill for skill in skills if not skill.disable_model_invocation] + if not visible: return "Available skills: none" lines = ["Available skills:"] - for skill in sorted(skills, key=lambda item: item.name): + for skill in sorted(visible, key=lambda item: item.name): description = skill.description or "No description" lines.append(f"- {skill.name}: {description}") return "\n".join(lines) @@ -234,4 +236,11 @@ def _load_skill(name: str, path: Path) -> Skill: raw = path.read_text(encoding="utf-8") metadata, content = parse_markdown_resource(raw) description = metadata.get("description") or derive_description(content) - return Skill(name=name, path=path, content=content, description=description) + disable_model_invocation = metadata.get("disable-model-invocation", "").lower() == "true" + return Skill( + name=name, + path=path, + content=content, + description=description, + disable_model_invocation=disable_model_invocation, + ) diff --git a/src/tau_coding/system_prompt.py b/src/tau_coding/system_prompt.py index 1fe339f27..3de61f658 100644 --- a/src/tau_coding/system_prompt.py +++ b/src/tau_coding/system_prompt.py @@ -138,6 +138,7 @@ def format_project_context(context_files: Sequence[ProjectContextFile]) -> str: def format_skills_for_prompt(skills: Sequence[Skill]) -> str: """Format skills for inclusion in a system prompt using Pi's XML style.""" + skills = [skill for skill in skills if not skill.disable_model_invocation] if not skills: return "" diff --git a/tests/test_skills.py b/tests/test_skills.py index 778eca70c..5d3658e58 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -247,6 +247,60 @@ def test_expand_skill_command_rejects_unknown_skill() -> None: expand_skill_command("/skill:missing", []) +def test_load_skill_parses_disable_model_invocation(tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + (skills_dir / "user-only").mkdir(parents=True) + (skills_dir / "user-only" / "SKILL.md").write_text( + "---\ndescription: User-only skill\ndisable-model-invocation: true\n---\nBody", + encoding="utf-8", + ) + (skills_dir / "normal").mkdir() + (skills_dir / "normal" / "SKILL.md").write_text("# Normal", encoding="utf-8") + (skills_dir / "explicit-false").mkdir() + (skills_dir / "explicit-false" / "SKILL.md").write_text( + "---\ndisable-model-invocation: false\n---\n# Explicit False", + encoding="utf-8", + ) + + loaded = load_skills(TauResourcePaths(root=tmp_path, agents_root=None)) + skills = {skill.name: skill for skill in loaded} + + assert skills["user-only"].disable_model_invocation is True + assert skills["normal"].disable_model_invocation is False + assert skills["explicit-false"].disable_model_invocation is False + + +def test_expand_skill_command_works_for_disabled_model_invocation_skill(tmp_path: Path) -> None: + """`/skill:name` still expands skills the model cannot invoke.""" + skills_dir = tmp_path / "skills" / "user-only" + skills_dir.mkdir(parents=True) + (skills_dir / "SKILL.md").write_text( + "---\ndisable-model-invocation: true\n---\n# User Only\nDo the thing.", + encoding="utf-8", + ) + skills = load_skills(TauResourcePaths(root=tmp_path, agents_root=None)) + + expanded = expand_skill_command("/skill:user-only", skills) + + assert expanded is not None + assert "Do the thing." in expanded + + +def test_build_skill_index_omits_disabled_model_invocation_skills(tmp_path: Path) -> None: + skills = [ + Skill(name="visible", path=tmp_path / "visible" / "SKILL.md", content="", description="A"), + Skill( + name="hidden", + path=tmp_path / "hidden" / "SKILL.md", + content="", + description="B", + disable_model_invocation=True, + ), + ] + + assert build_skill_index(skills) == "Available skills:\n- visible: A" + + def test_build_skill_index(tmp_path: Path) -> None: skills_dir = tmp_path / "skills" / "testing" skills_dir.mkdir(parents=True) diff --git a/tests/test_system_prompt.py b/tests/test_system_prompt.py index 954d9291a..b8cfa9d9b 100644 --- a/tests/test_system_prompt.py +++ b/tests/test_system_prompt.py @@ -109,6 +109,45 @@ def test_skills_are_formatted_as_xml_and_escaped(tmp_path: Path) -> None: assert f"{skill_path}" in formatted +def test_skills_with_disabled_model_invocation_are_excluded_from_prompt(tmp_path: Path) -> None: + visible = Skill( + name="visible", + path=tmp_path / "skills" / "visible" / "SKILL.md", + content="", + description="Shown to the model", + ) + hidden = Skill( + name="hidden", + path=tmp_path / "skills" / "hidden" / "SKILL.md", + content="", + description="User-only", + disable_model_invocation=True, + ) + + formatted = format_skills_for_prompt([visible, hidden]) + + assert "visible" in formatted + assert "hidden" not in formatted + + +def test_only_disabled_skills_produce_no_skills_section(tmp_path: Path) -> None: + hidden = Skill( + name="hidden", + path=tmp_path / "skills" / "hidden" / "SKILL.md", + content="", + description="User-only", + disable_model_invocation=True, + ) + + assert format_skills_for_prompt([hidden]) == "" + prompt = build_system_prompt( + BuildSystemPromptOptions( + cwd=tmp_path, tools=create_coding_tools(cwd=tmp_path), skills=[hidden] + ) + ) + assert "" not in prompt + + def test_skills_are_included_only_when_read_tool_is_available(tmp_path: Path) -> None: skill = Skill(name="testing", path=tmp_path / "testing.md", content="", description="Test") no_read_tool = AgentTool( diff --git a/website/content/guides/skills-and-prompts.md b/website/content/guides/skills-and-prompts.md index 2e34b60ff..8acba697f 100644 --- a/website/content/guides/skills-and-prompts.md +++ b/website/content/guides/skills-and-prompts.md @@ -81,6 +81,25 @@ explicitly: `/skill:` is a *prompt-expansion* path — Tau expands the skill into your prompt and runs it as a normal turn. +### User-only skills + +Set `disable-model-invocation: true` in the frontmatter to keep a skill out of +the model-facing skill list. The skill is still loaded and works with +`/skill:`, but the model never sees it in the system prompt and cannot +trigger it on its own: + +```md +--- +description: Cut a release. Only run when explicitly asked. +disable-model-invocation: true +--- + +Steps to prepare and publish a release... +``` + +Use this for skills with side effects (releases, deployments, messaging) or +workflows that should only run when you ask for them. + ## Prompt templates A prompt template is a saved prompt you trigger by its filename. For example