Skip to content
Merged
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
9 changes: 9 additions & 0 deletions backend/src/apis/app_api/skills/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@

from apis.shared.auth import User, get_current_user_from_session
from apis.shared.skills.access import resolve_accessible_skill_ids
from apis.shared.skills.bundle import slugify_skill_name
from apis.shared.skills.models import (
SkillDefinition,
SkillResourceRef,
Expand Down Expand Up @@ -76,6 +77,13 @@ class UserSkillResponse(BaseModel):
category: Optional[str] = None
user_enabled: Optional[bool] = Field(None, alias="userEnabled")
is_enabled: bool = Field(..., alias="isEnabled")
# The runtime's activation key for this skill — the same slug the
# ``AgentSkills`` plugin injects as ``Skill.name`` and accepts on its
# ``skills`` tool. Served rather than re-derived client-side so the token
# the composer's `/` menu writes into a message is byte-identical to the
# one the model reads in ``<available_skills>``; a slug rule that drifted
# between the two would show the user a command the model cannot resolve.
slug: str

model_config = {"populate_by_name": True}

Expand Down Expand Up @@ -128,6 +136,7 @@ async def get_user_skills(
# the two must agree or the UI would show skills as active that the
# turn never loads.
is_enabled=preferences.get(record.skill_id, False),
slug=slugify_skill_name(record.skill_id),
)
for record in records
if record.status == SkillStatus.ACTIVE
Expand Down
11 changes: 11 additions & 0 deletions backend/src/apis/inference_api/chat/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,17 @@ class InvocationRequest(BaseModel):
# — client input can narrow the set, never grant. An empty (or fully
# inaccessible) list yields zero skills, so the turn is plain chat.
enabled_skills: Optional[List[str]] = None
# Skills the user named with a `/` slash command in the composer, for this
# turn only. A strict subset of the turn's effective skills — it is
# intersected server-side exactly like ``enabled_skills``, so it can never
# widen the set and an id that is not already active is simply dropped.
#
# It changes nothing about what is *disclosed*: the same skills are in
# ``<available_skills>`` either way, so the cacheable prefix is untouched.
# All it adds is a short directive on the user message telling the model to
# activate the named skill before answering, which is what makes a slash
# command deterministic rather than a hint the model may ignore.
invoked_skills: Optional[List[str]] = None
# User-selected custom system prompt ("conversation mode") for this
# turn. The frontend forwards the active selection on every submit so
# the inference path doesn't have to round-trip session metadata to
Expand Down
65 changes: 65 additions & 0 deletions backend/src/apis/inference_api/chat/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
)

from apis.shared.rbac.service import get_app_role_service
from apis.shared.skills.bundle import slugify_skill_name
from apis.inference_api.chat.agent_binding_resolver import (
AgentBindingBlockedError,
resolve_agent_invocation,
Expand Down Expand Up @@ -1206,6 +1207,55 @@ def _apply_enabled_skills_filter(
return [sid for sid in accessible_skill_ids if sid in requested]


def _resolve_invoked_skill_slugs(
effective_skill_ids: Optional[list[str]], invoked_skills: Optional[list[str]]
) -> list[str]:
"""Activation slugs for the skills the user named with a `/` command.

Intersected against the turn's **effective** set — the same narrow-never-grant
rule ``_apply_enabled_skills_filter`` applies, re-run here because the effective
set can still shrink after that call (an Agent's skill bindings replace it
wholesale). A slash command for a skill the turn does not actually disclose is
dropped rather than honoured: the directive would name a skill that is absent
from ``<available_skills>``, and the model would burn a tool call discovering
that.

Returns slugs, not ids, because the slug is the activation key the ``skills``
tool takes — the id never appears in anything the model can see.
"""
if not invoked_skills or not effective_skill_ids:
return []
requested = set(invoked_skills)
# Ordered by the effective set, not by the request: the directive is part of
# the persisted message, and a list whose order followed client input would
# differ between two turns that named the same skills.
return [slugify_skill_name(sid) for sid in effective_skill_ids if sid in requested]


def _build_skill_invocation_note(skill_slugs: list[str]) -> str:
"""Directive appended to a turn whose user invoked skills by slash command.

A slash command is an explicit instruction, not a hint — the user picked the
skill by name from a menu. But the only activation path is the plugin's
``skills`` tool, which the *model* has to call, so "explicit" has to be
expressed as a directive rather than enforced by pre-loading the instructions
(doing that server-side would duplicate the plugin's response formatting and
bypass its activation-state tracking).

Kept to one line per skill. It rides the user message, so it is paid once as
input on this turn and then again as cached history on every later turn of the
session; the disclosure block it points at is already in the prefix either way,
so this is the whole cost of the feature.
"""
named = ", ".join(f"`{slug}`" for slug in skill_slugs)
plural = "s" if len(skill_slugs) > 1 else ""
return (
f"[The user invoked the {named} skill{plural} with a slash command. "
f"Activate {'each' if plural else 'it'} with the `skills` tool before "
"answering, and follow the loaded instructions for this message.]"
)


@router.post("/invocations")
async def invocations(request: InvocationRequest, current_user: User = Depends(get_current_user_trusted)):
"""
Expand Down Expand Up @@ -2631,6 +2681,21 @@ def _session_title_sse() -> Optional[str]:
f"{_build_interruption_note(interrupted_turn_reason)}\n\n{final_message}"
)

# Slash commands: the user named one or more skills in the
# composer. Appended LAST, after every prepended note, so the
# directive is the closest thing to the model's first token —
# an instruction about what to do with the message it follows.
# Narrowed against the effective set here rather than at parse
# time because an Agent's skill bindings can still have
# replaced that set above.
invoked_skill_slugs = _resolve_invoked_skill_slugs(
effective_skill_ids, input_data.invoked_skills
)
if invoked_skill_slugs:
final_message = (
f"{final_message}\n\n{_build_skill_invocation_note(invoked_skill_slugs)}"
)

message_will_be_modified = (
final_message != input_data.message # RAG augmentation / attachment guidance / inventory
or bool(files_to_send) # File attachments
Expand Down
16 changes: 16 additions & 0 deletions backend/tests/apis/app_api/test_user_skills_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ def test_lists_active_accessible_skills_with_prefs_merged(self, monkeypatch):
"web_research",
]

def test_serves_the_runtime_activation_slug(self, monkeypatch):
"""The `/` command menu writes this slug into the message verbatim.

It has to be the same string the ``AgentSkills`` plugin injects as
``Skill.name``, so it is derived here from the one shared slug rule
rather than re-implemented client-side.
"""
repo = _FakeRepo(
skills=[_skill("pdf_workflows_v2", "PDF Workflows")],
prefs={},
)
client = _make_client(monkeypatch, ["pdf_workflows_v2"], repo)

body = client.get("/skills/").json()
assert body["skills"][0]["slug"] == "pdf-workflows-v2"

def test_non_active_skills_are_hidden(self, monkeypatch):
repo = _FakeRepo(
skills=[
Expand Down
72 changes: 72 additions & 0 deletions backend/tests/apis/inference_api/test_skill_slash_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Slash-command skill invocation (`/skill-name` in the composer).

Two halves, both in inference-api chat routes:

* ``_resolve_invoked_skill_slugs`` — narrow-never-grant, the same rule
``_apply_enabled_skills_filter`` applies to ``enabled_skills``, re-run
against the turn's FINAL effective set because an Agent's skill bindings can
replace that set after the first filter runs.
* ``_build_skill_invocation_note`` — the directive appended to the user
message. A slash command has to be expressed as an instruction because the
only activation path is the plugin's own ``skills`` tool, which the model
calls.
"""

from apis.inference_api.chat.routes import (
_build_skill_invocation_note,
_resolve_invoked_skill_slugs,
)


class TestResolveInvokedSkillSlugs:
def test_returns_activation_slugs_not_catalog_ids(self):
# The id never appears in anything the model can see; the slug is what
# the `skills` tool takes and what `<available_skills>` lists.
assert _resolve_invoked_skill_slugs(["pdf_workflows"], ["pdf_workflows"]) == [
"pdf-workflows"
]

def test_drops_a_skill_the_turn_does_not_disclose(self):
# Narrow, never grant. A directive naming a skill absent from
# <available_skills> would cost the model a tool call to discover.
assert _resolve_invoked_skill_slugs(["web_research"], ["pdf_workflows"]) == []

def test_orders_by_the_effective_set_not_the_request(self):
# The directive is persisted in the message, so two turns naming the
# same skills must produce byte-identical text regardless of the order
# the client happened to send them in.
effective = ["alpha", "beta", "gamma"]
assert _resolve_invoked_skill_slugs(effective, ["gamma", "alpha"]) == [
"alpha",
"gamma",
]

def test_no_skills_on_the_turn_means_no_invocation(self):
# An Agent binding that replaced the skill set with nothing, or a turn
# that never asked for skills at all.
assert _resolve_invoked_skill_slugs(None, ["web_research"]) == []
assert _resolve_invoked_skill_slugs([], ["web_research"]) == []

def test_absent_selection_is_inert(self):
assert _resolve_invoked_skill_slugs(["web_research"], None) == []
assert _resolve_invoked_skill_slugs(["web_research"], []) == []


class TestBuildSkillInvocationNote:
def test_names_the_slug_and_the_activation_tool(self):
note = _build_skill_invocation_note(["pdf-workflows"])
assert "`pdf-workflows`" in note
assert "`skills`" in note
assert "skill with a slash command" in note

def test_pluralizes_for_more_than_one_skill(self):
note = _build_skill_invocation_note(["pdf-workflows", "web-research"])
assert "skills with a slash command" in note
assert "Activate each" in note

def test_is_one_bounded_line(self):
# It rides the user message: paid as input this turn and as cached
# history on every later turn of the session. Keep it small.
note = _build_skill_invocation_note(["pdf-workflows"])
assert "\n" not in note
assert len(note) < 250
167 changes: 167 additions & 0 deletions docs/specs/skill-slash-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Skill slash commands

Typing `/web-research` in the composer invokes that skill for **that message**. It is the
sibling of the `@`-mention (Marketplace D11): same menu shape, same keyboard, same
"rides one turn, does not bind the conversation" semantics.

The menu's last row is **Browse skills →**, which goes to Customize → Skills.

## Scope: the skills the user has turned on

The menu lists exactly the skills already switched on for the conversation — the same set
the turn already discloses to the model in `<available_skills>`.

That is the decision the whole design rests on. Because the invoked skill is already in
`enabled_skills`, a slash command **changes nothing about the cacheable prefix**. The
system prompt, the `toolConfig` and the disclosure block are byte-identical whether or not
a command was used. The entire cost of the feature is one short directive appended to the
turn's user message.

Offering a switched-off skill would have meant one of two bad things:

- **Widening the disclosure on the fly** — a new `<available_skills>` entry mid-session
rewrites a 30k–150k-token prefix at the cache-write premium, triggered by a keystroke.
- **Showing a command that does nothing** — the directive would name a skill the model
cannot see, and the model would burn a tool call discovering that.

Turning a skill on stays where it belongs: the Customize page, which the menu links to.

An Agent-bound conversation resolves through `visibleSkills` / `isSkillShownEnabled` like
every other skill surface, so it offers that Agent's bound skills — again, exactly the set
the turn will disclose.

## The text is the binding

Unlike the `@` menu, there is **no remembered pick**. The invoked set is derived from the
composer text on every keystroke:

```
/web-research what is the top headline on npr.org?
└─ findSkillCommands() → ['web-research'] → ['web_research']
```

A slug is a single unambiguous token (an Agent name is not — it contains spaces, which is
why the `@` menu has to remember what was picked). Deriving is therefore exact, and it buys
two things:

- A hand-typed command works identically to a menu pick.
- The chip and what gets sent **cannot disagree**. The chip's `✕` removes the `/slug` from
the text, because that is the only place the binding lives.

### The token rule

`/` is ordinary punctuation, so the rule has to keep the menu shut far more often than it
opens it. A command must start a word **and** must not be followed by another `/`:

| Input | Result |
|---|---|
| `/web-research …` | command |
| `use /web-research, then …` | command (ordinary punctuation after is fine) |
| `and/or`, `24/7` | prose — does not start a word |
| `https://x.com/docs`, `src/app/docx` | prose — same reason |
| `/usr/bin/env` | prose — *starts* a word, excluded by the trailing-slash half |
| `/not-a-skill` | prose — only slugs the user can invoke resolve |

That last-but-one row is the one that matters: an absolute path starts a word exactly like
a command does, so without the trailing-slash clause a skill slugged `usr` would be invoked
silently. The same rule is implemented three times — the composer's caret-anchored token,
`findSkillCommands`, and the thread renderer's `splitSkillCommands` — and all three must
agree, or a message would render as something different from what it sent.

## The slug is served, not derived

`GET /skills/` returns a `slug` per skill, computed with `slugify_skill_name` — the same
function that produces the `Skill.name` the `AgentSkills` plugin injects and the key its
`skills` tool accepts. The SPA never re-implements the rule; a client that drifted would
write a command the model cannot resolve.

`slug` is **optional** in the SPA's `UserSkill`. The SPA and the backend deploy
independently and in no enforced order, so a client that lands first must degrade to "no
slash commands", not to a menu of `/undefined`.

## Wire format

The SPA sends `invoked_skills` alongside `enabled_skills`:

```jsonc
{
"message": "/web-research what is the top headline on npr.org?",
"enabled_skills": ["docx", "rubric_authoring", "web_research"],
"invoked_skills": ["web_research"] // always a subset
}
```

`_resolve_invoked_skill_slugs` intersects it against the turn's **effective** skill set —
the same narrow-never-grant rule `_apply_enabled_skills_filter` applies, re-run because an
Agent's skill bindings can still replace that set afterwards. The result is ordered by the
effective set rather than by the request, so two turns naming the same skills produce
byte-identical text.

## Why a directive and not a pre-load

The only activation path is the plugin's own `skills` tool, which the *model* calls. So
"explicit" is expressed as an instruction:

```
[The user invoked the `web-research` skill with a slash command. Activate it with the
`skills` tool before answering, and follow the loaded instructions for this message.]
```

Pre-loading the instructions server-side would duplicate the plugin's response formatting
and bypass its activation-state tracking, for the sake of saving one tool call.

The note is appended **last**, after every prepended note (interruption, attachment
recovery, app context), so it sits closest to the model's first token. It rides
`original_message`, so the thread shows the user only what they typed — the literal
`/slug` — while the note stays an honest part of persisted history. It costs one line as
input this turn and as cached history thereafter; the disclosure block it points at is in
the prefix either way.

## Not compatible with mid-turn steering

A steer lands as a text block on the *tool-result* message of a turn whose skills were
already resolved, so its directive would have nothing to attach to. A queued follow-up
carrying a slash command is therefore never armed — it flushes as a normal turn, the same
way a follow-up with an attachment or an `@`-mention does.

## Gating

None of its own. It rides `SKILLS_ENABLED`: with skills off, `GET /skills/` 404s, the
command list is empty, and the menu never opens. The two embedded previews (Agent Designer,
marketplace test drive) pass `[showSkillCommands]="false"` for the same reason they pass
`[showAgentMentions]="false"` — those panes exercise one Agent whose skills the Agent
dictates.

## Contrast, and why the chip is neutral

**Do not use `bg-primary-50` / `-100` / `-200` as a tint.** The `primary` scale is generated
from `#0033a0` by lightness offset alone — `oklch(from #0033a0 calc(l + 0.4) c h)` — so it
keeps the full chroma of Boise State blue at every step. `primary-50` resolves to
**rgb(118, 179, 255)**: a saturated mid-blue, not the pale wash its name implies. Used as a
chip fill it reads as a blue blob behind small text. The `state-*` scales *are* real tints
(`state-success-50` is `rgb(240, 253, 244)`); `primary` is the exception, and the naming
hides it.

So the chip and the menu's icon tile use **neutral surfaces with the brand blue in the
text**: white / `gray-100` in light, `gray-700` in dark, label `primary-accessible`
(`#0033a0`) / `primary-50`.

Measured, composited against the real page background:

| Element | Light | Dark | Bar |
|---|---|---|---|
| Chip label (12px, 500) | 10.60 | 4.74 | 4.5 (AA normal text) |
| Chip `✕` glyph | 4.84 | 3.96 | 3.0 (UI component) |
| Chip border | 1.47 | 2.13 | — (decorative; the label carries the meaning) |
| Menu icon tile | 9.63 | 4.74 | 3.0 (decorative, `aria-hidden`) |
| Menu `/slug` | 16.13 | 10.30 | 4.5 |

Two traps worth keeping written down:

- On `gray-700`, `primary-200` measures **3.42** and `primary-100` **4.02** — both fail.
`primary-50` (4.74) is the only step that clears AA on that surface. On `gray-800` the
whole range passes, but a `gray-800` chip disappears into the composer, which is also
`gray-800`.
- Verify light mode with **both** levers — remove `dark` from `<html>` *and* emulate
`prefers-color-scheme: light`. The class alone leaves the `dark:` variants applying, and a
"light-mode" screenshot silently shows dark.
Loading