Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions dev-notes/skill-disable-model-invocation.md
Original file line number Diff line number Diff line change
@@ -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:<name>`, but removes it from the
`<available_skills>` 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:<name>` 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 `<available_skills>` block.
- `expand_skill_command()` is unchanged: it resolves names against the full
loaded set, so `/skill:<name>` 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.
15 changes: 12 additions & 3 deletions src/tau_coding/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Skill:
path: Path
content: str
description: str | None = None
disable_model_invocation: bool = False


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
1 change: 1 addition & 0 deletions src/tau_coding/system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""

Expand Down
54 changes: 54 additions & 0 deletions tests/test_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions tests/test_system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,45 @@ def test_skills_are_formatted_as_xml_and_escaped(tmp_path: Path) -> None:
assert f"<location>{skill_path}</location>" 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 "<name>visible</name>" 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 "<available_skills>" 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(
Expand Down
19 changes: 19 additions & 0 deletions website/content/guides/skills-and-prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,25 @@ explicitly:
`/skill:<name>` 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:<name>`, 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
Expand Down