Keep slash commands out of agent skills
Return skills only from skills_tool and remove the command-definition loading bridge. Leave slash-command discovery, invocation, and authoring with the Commands plugin and its user-facing surfaces.
Alessandro committed
Aug 11, 2026 at 19:11 UTC
864e311c6046db0892b6ae96638485c7c171f506
7 files changed
+16
-193
helpers/skills.py
-48
@@ -400,54 +400,6 @@ def list_skills(
400
return _filter_hidden_skills(agent, result)
401
402
403
-def list_slash_commands(agent: Agent | None = None) -> list[dict[str, Any]]:
404
- """List effective, picker-visible slash commands for the agent's project."""
405
- # Local import avoids the commands helper's import of split_frontmatter above.
406
- from plugins._commands.helpers import commands as commands_helper
407
-
408
- commands, _ = commands_helper.list_effective_commands(
409
- _get_agent_project_name(agent)
410
- )
411
- return [
412
- command
413
- for command in commands
414
- if not bool((command.get("frontmatter_extra") or {}).get("webui_hidden"))
415
- ]
416
-
417
-
418
-def find_slash_command(
419
- command_name: str,
420
- agent: Agent | None = None,
421
-) -> dict[str, Any] | None:
422
- """Find one effective slash command by its canonical ``/name``."""
423
- target = str(command_name or "").strip().lstrip("/").lower()
424
- if not target:
425
- return None
426
- return next(
427
- (command for command in list_slash_commands(agent) if command["name"] == target),
428
- None,
429
- )
430
-
431
-
432
-def format_slash_command(command: dict[str, Any]) -> str:
433
- """Render a slash command definition for a skills-tool result."""
434
- lines = [f"Slash command: /{command['name']}"]
435
- if description := str(command.get("description") or "").strip():
436
- lines.append(f"Description: {description}")
437
- if argument_hint := str(command.get("argument_hint") or "").strip():
438
- lines.append(f"Arguments: {argument_hint}")
439
- lines.append(f"Type: {command.get('command_type') or 'text'}")
440
- if scope := str(command.get("scope_label") or "").strip():
441
- lines.append(f"Scope: {scope}")
442
-
443
- body = str(command.get("body") or "").strip()
444
- if body:
445
- if len(body) > 24000:
446
- body = body[:24000].rstrip() + "\n\n[truncated]"
447
- lines.extend(["", "Definition:", body])
448
- return "\n".join(lines)
449
-
450
-
403
def delete_skill(
404
skill_path: str,
405
) -> None:
helpers/skills.py.dox.md
-3
@@ -27,9 +27,6 @@
27
- `parse_frontmatter(frontmatter_text: str) -> Tuple[Dict[str, Any], List[str]]`: Parse YAML frontmatter with PyYAML when available,
28
- `skill_from_markdown(skill_md_path: Path, include_content: bool=..., validate: bool=...) -> Optional[Skill]`
29
- `list_skills(agent: Agent | None=..., include_content: bool=..., include_hidden: bool=...) -> List[Skill]`: List skills, optionally filtered by agent scope.
30
-- `list_slash_commands(agent: Agent | None=...) -> list[dict[str, Any]]`: List picker-visible effective slash commands for the agent project.
31
-- `find_slash_command(command_name: str, agent: Agent | None=...) -> dict[str, Any] | None`
32
-- `format_slash_command(command: dict[str, Any]) -> str`
30
- `delete_skill(skill_path: str) -> None`: Delete a skill directory.
31
- `find_skill(skill_name: str, agent: Agent | None=..., include_content: bool=..., include_hidden: bool=..., validate: bool=...) -> Optional[Skill]`
32
- `load_skill_for_agent(skill_name: str, agent: Agent | None=...) -> str`: Load skill and format it as a complete string for agent context.
prompts/agent.system.tool.skills.md
+1
-1
@@ -5,7 +5,7 @@ common args: action skill_name query file_path
5
workflow:
6
- action `search`: find candidate skills by keywords or trigger phrases from the current task
7
- action `list`: discover available skills
8
-- action `load`: append one skill's full instructions to chat history by `skill_name`; use `skill_name=/command` to read an effective slash-command definition without invoking it
8
+- action `load`: append one skill's full instructions to chat history by `skill_name`
9
- action `read_file`: open one file inside a loaded skill directory
10
if the user says "find/search a skill", call `search` before `load` even when the likely skill name seems obvious
11
`read_file` requires both `skill_name` and `file_path`; load the skill first, then read `SKILL.md` or the named relative file
tests/test_skills_runtime.py
-57
@@ -141,63 +141,6 @@ def test_active_skills_cap_is_twenty():
141
assert runtime.get_max_active_skills() == 20
142
143
144
-def test_slash_commands_use_agent_scope_and_hide_picker_hidden(monkeypatch):
145
- plugins_pkg = types.ModuleType("plugins")
146
- plugins_pkg.__path__ = []
147
- commands_plugin_pkg = types.ModuleType("plugins._commands")
148
- commands_plugin_pkg.__path__ = []
149
- commands_helpers_pkg = types.ModuleType("plugins._commands.helpers")
150
- commands_helpers_pkg.__path__ = []
151
- commands = types.ModuleType("plugins._commands.helpers.commands")
152
- calls = []
153
- commands.list_effective_commands = lambda project_name: (
154
- [
155
- {
156
- "name": "visible",
157
- "description": "Visible command.",
158
- "argument_hint": "<text>",
159
- "command_type": "text",
160
- "scope_label": "Project",
161
- "body": "Template {text}",
162
- "frontmatter_extra": {},
163
- },
164
- {
165
- "name": "hidden",
166
- "frontmatter_extra": {"webui_hidden": True},
167
- },
168
- ],
169
- {"project_name": project_name},
170
- )
171
- commands_helpers_pkg.commands = commands
172
- for name, module in (
173
- ("plugins", plugins_pkg),
174
- ("plugins._commands", commands_plugin_pkg),
175
- ("plugins._commands.helpers", commands_helpers_pkg),
176
- ("plugins._commands.helpers.commands", commands),
177
- ):
178
- monkeypatch.setitem(sys.modules, name, module)
179
- monkeypatch.setattr(
180
- runtime,
181
- "_get_agent_project_name",
182
- lambda _agent: calls.append("project") or "project",
183
- )
184
-
185
- command = runtime.find_slash_command("/visible", DummyAgent())
186
-
187
- assert calls == ["project"]
188
- assert command["name"] == "visible"
189
- assert runtime.find_slash_command("/hidden", DummyAgent()) is None
190
- assert runtime.format_slash_command(command) == (
191
- "Slash command: /visible\n"
192
- "Description: Visible command.\n"
193
- "Arguments: <text>\n"
194
- "Type: text\n"
195
- "Scope: Project\n\n"
196
- "Definition:\n"
197
- "Template {text}"
198
- )
199
-
200
-
144
def test_skills_config_can_raise_active_cap_above_default():
145
config = runtime.normalize_skills_config(
146
{
tests/test_tool_action_contracts.py
+1
-36
@@ -130,20 +130,6 @@ def _load_skills_tool(monkeypatch, skill_root: Path):
130
tags=[],
131
)
132
skills_stub.list_skills = lambda *args, **kwargs: [fake_skill]
133
- fake_command = {
134
- "name": "summarize",
135
- "description": "Summarize the current work.",
136
- "argument_hint": "[focus]",
137
- }
138
- skills_stub.list_slash_commands = lambda *args, **kwargs: [fake_command]
139
- skills_stub.find_slash_command = (
140
- lambda command_name, *args, **kwargs: (
141
- fake_command if command_name == "/summarize" else None
142
- )
143
- )
144
- skills_stub.format_slash_command = (
145
- lambda command: f"Slash command: /{command['name']}\nDefinition: prompt"
146
- )
133
skills_stub.search_skills = lambda *args, **kwargs: [fake_skill]
134
skills_stub.find_skill = lambda *args, **kwargs: fake_skill
135
skills_stub.load_skill_for_agent = (
@@ -359,28 +345,7 @@ def test_skills_tool_defaults_missing_action_to_list(monkeypatch, tmp_path: Path
345
346
assert "Available skills" in response.message
347
assert "browser-form-workflows" in response.message
362
- assert "Available slash commands" in response.message
363
- assert "/summarize [focus]" in response.message
364
-
365
-
366
-def test_skills_tool_load_reads_slash_command_without_loading_a_skill(
367
- monkeypatch, tmp_path: Path
368
-):
369
- module = _load_skills_tool(monkeypatch, tmp_path)
370
- agent = _FakeAgent()
371
- tool = module.SkillsTool(
372
- agent,
373
- "skills_tool",
374
- None,
375
- {"action": "load", "skill_name": "/summarize"},
376
- "",
377
- None,
378
- )
379
-
380
- response = asyncio.run(tool.execute(**tool.args))
381
-
382
- assert response.message == "Slash command: /summarize\nDefinition: prompt"
383
- assert agent.context.get_data("loaded_skills") is None
348
+ assert "slash commands" not in response.message
349
350
351
def test_skills_tool_load_appends_skill_instructions_as_tool_result(
tools/skills_tool.py
+14
-47
@@ -18,7 +18,7 @@ class SkillsTool(Tool):
18
Actions (tool_args.action):
19
- list
20
- search (query)
21
- - load (skill_name, or /command)
21
+ - load (skill_name)
22
- read_file (skill_name, file_path)
23
24
Script execution is handled by code_execution_tool directly.
@@ -144,38 +144,20 @@ class SkillsTool(Tool):
144
agent=self.agent,
145
include_content=False,
146
)
147
- commands = skills_helper.list_slash_commands(agent=self.agent)
148
- if not skills and not commands:
149
- return "No skills or slash commands found."
150
-
151
- lines: List[str] = []
152
- if skills:
153
- # Stable output: sort by name
154
- skills_sorted = sorted(skills, key=lambda s: s.name.lower())
155
- lines.append(f"Available skills ({len(skills_sorted)}):")
156
- for s in skills_sorted:
157
- tags = f" tags={','.join(s.tags)}" if s.tags else ""
158
- ver = f" v{s.version}" if s.version else ""
159
- desc = (s.description or "").strip()
160
- if len(desc) > 200:
161
- desc = desc[:200].rstrip() + "..."
162
- lines.append(f"- {s.name}{ver}{tags}: {desc}")
163
-
164
- if commands:
165
- if lines:
166
- lines.append("")
167
- lines.append(f"Available slash commands ({len(commands)}):")
168
- for command in commands:
169
- arguments = str(command.get("argument_hint") or "").strip()
170
- desc = str(command.get("description") or "").strip()
171
- if len(desc) > 200:
172
- desc = desc[:200].rstrip() + "..."
173
- suffix = f" {arguments}" if arguments else ""
174
- lines.append(f"- /{command['name']}{suffix}: {desc}")
147
+ if not skills:
148
+ return "No skills found."
149
+
150
+ skills_sorted = sorted(skills, key=lambda s: s.name.lower())
151
+ lines = [f"Available skills ({len(skills_sorted)}):"]
152
+ for s in skills_sorted:
153
+ tags = f" tags={','.join(s.tags)}" if s.tags else ""
154
+ ver = f" v{s.version}" if s.version else ""
155
+ desc = (s.description or "").strip()
156
+ if len(desc) > 200:
157
+ desc = desc[:200].rstrip() + "..."
158
+ lines.append(f"- {s.name}{ver}{tags}: {desc}")
159
lines.append("")
176
- lines.append(
177
- "Tip: use skills_tool action=search for skills or action=load skill_name=/name to read a slash command."
178
- )
160
+ lines.append("Tip: use skills_tool action=search or action=load for details.")
161
return "\n".join(lines)
162
163
def _search(self, query: str) -> str:
@@ -212,21 +194,6 @@ class SkillsTool(Tool):
194
break_loop=False,
195
)
196
215
- if skill_name.startswith("/"):
216
- command = skills_helper.find_slash_command(skill_name, agent=self.agent)
217
- if not command:
218
- return Response(
219
- message=(
220
- f"Error: slash command not found: {skill_name!r}. "
221
- "Try skills_tool action=list."
222
- ),
223
- break_loop=False,
224
- )
225
- return Response(
226
- message=skills_helper.format_slash_command(command),
227
- break_loop=False,
228
- )
229
-
197
# Verify skill exists
198
skill = skills_helper.find_skill(
199
skill_name,
tools/skills_tool.py.dox.md
-1
@@ -30,7 +30,6 @@
30
- Loaded skill IDs are stored in chat-wide context data.
31
- Duplicate loads omit the full body when the same skill name remains visible in model history.
32
- Missing or empty `action` defaults to `list`, and legacy `method` is accepted as a deprecated alias when `action` is absent.
33
-- `list` includes picker-visible slash commands for the active project. `load` with `skill_name=/name` reads the effective command definition without executing it or adding it to the loaded-skill ledger.
33
- Observed side-effect areas: filesystem reads, filesystem deletion, settings/state persistence, chat history persistence.
34
- Imported dependency areas include: `__future__`, `helpers`, `helpers.print_style`, `helpers.tool`, `pathlib`, `typing`.
35