Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
731f1e8
feat: route skills to model classes (modelClasses + skillModelClasses…
asm Aug 14, 2026
260780d
πŸ€– fix: address review findings in skill model-class routing
asm Aug 14, 2026
439b776
πŸ€– fix: resolve one-shot thinking against the routed model, carry over…
asm Aug 14, 2026
506730a
πŸ€– fix: reserve headroom for the pending turn in routed-send compaction
asm Aug 14, 2026
da402b6
πŸ€– fix: apply routed compaction policy mid-stream, pick a compaction m…
asm Aug 14, 2026
07f1bb7
πŸ€– fix: gate direct OpenAI routes by usable credentials
asm Aug 14, 2026
af27026
πŸ€– fix: model-aware factory routing, defer PDF preflight, report route…
asm Aug 14, 2026
a9a3450
πŸ€– fix: drop manual useCallback in useModelClasses, pin phone snapshot…
asm Aug 14, 2026
9e0387f
πŸ€– fix: persist class edits before publishing, report routed thinking …
asm Aug 14, 2026
e7a12e2
πŸ€– fix: preserve queued PDF rejections, report post-policy routed thin…
asm Aug 14, 2026
a878583
πŸ€– fix: report the effective thinking level for every routed send
asm Aug 14, 2026
e1cc0da
πŸ€– fix: carry routed compaction context across stream retries
asm Aug 14, 2026
81fe8ea
πŸ€– fix: preserve the composed command prefix through compact-and-retry
asm Aug 14, 2026
9cecf92
πŸ€– fix: exempt shadowing custom OpenAI providers from OAuth gating
asm Aug 14, 2026
a36ca68
πŸ€– fix: edit-safe gate rejections, pending-row locking, durable routed…
asm Aug 14, 2026
b299bc7
πŸ€– fix: restore persisted compaction context on relaunch, edit-guard t…
asm Aug 14, 2026
a89254a
πŸ€– fix: resume routed retries on the persisted class model in child wo…
asm Aug 14, 2026
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
39 changes: 39 additions & 0 deletions docs/agents/agent-skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,45 @@ Substitution rules:

Use the `argument-hint` frontmatter field to document the expected arguments in invocation UIs.

## Per-skill model routing

Mechanical skills (session wrap-up, worktree helpers, PR chores) rarely need your frontier model. Skill invocations can be routed to a **model class** β€” an indirection that survives model churn, since bindings name a class and only the class map names concrete models.

Configure the three canonical classes β€” `large`, `medium`, `small` β€” in **Settings β†’ Models β†’ Model Classes** (a model plus an optional thinking level per class). Canonical names keep skill bindings portable across machines. The classes are stored in `~/.mux/config.json`, where values use the [one-shot override syntax](/config/models#one-shot-overrides): a model alias or full `provider:model` id, with an optional `+thinking` suffix (named level or model-relative numeric index). Hand-edited custom class names in config.json also work and are preserved by the Settings editor:

```json
{
"modelClasses": {
"large": "fable+max",
"medium": "sonnet+high",
"small": "haiku+0"
}
}
```

Bind skills to classes in either of two places:

- **Skill frontmatter** β€” the spec-standard `metadata` map, so the binding travels with the skill and other agent tools ignore it:

```yaml
metadata:
model-class: small
```

- **Config routing table** β€” for skills you don't own, `skillModelClasses` in `~/.mux/config.json` maps skill names to classes and **wins over frontmatter**:

```json
{
"skillModelClasses": { "done": "small", "wt": "small" }
}
```

Routing applies to the slash invocation's send only: the workspace's selected model is untouched, and your next message streams on it again. If auto-compaction triggers, the threshold is computed against the routed model's context window, while the compaction request itself keeps your model (it must fit the uncompacted history).

Broken bindings fail loudly: when a bound class exists but its value is malformed, or no configured provider route can serve its model (a retired model, a removed provider or key), the send fails with an error naming the mapping to fix β€” and the Model Classes editor shows the same "no configured route" warning inline. A dangling `skillModelClasses` table entry (naming a class you deleted) also errors, since the table is your own explicit routing intent. Frontmatter bindings to a class you never defined are simply ignored, so skills you don't own can ship `model-class` metadata without ever breaking your sends; infrastructure hiccups (an unreadable skill or config) likewise fall back to the workspace model instead of failing the send.

To override routing for one invocation, compose a one-shot prefix with the skill: `/sonnet+high /done` runs the skill on Sonnet regardless of its class. A model-carrying one-shot always wins over class routing; a thinking-only one-shot (`/+2 /done`) layers on top of it β€” the skill still routes to its class model, at the overridden thinking level. Numeric thinking indices are model-relative and resolve against the model that actually streams: in `/+0 /done`, the `0` means the class model's lowest allowed level, not the workspace model's. Both overrides survive compact-and-retry: the rebuilt send keeps the one-shot's model and thinking instead of falling back to routing or ambient settings.

## Dynamic context injection (experiment)

Enable the **Skill dynamic context injection** experiment (Settings β†’ Experiments) to let skills pull live command output into their instructions. When you invoke a skill, any line whose entire content is `` !`command` `` runs in the workspace, and the line is replaced with a fenced block containing the command’s output before the model sees the skill:
Expand Down
15 changes: 9 additions & 6 deletions docs/config/models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,15 @@ Override the model or thinking level for a single message using slash commands.

### Syntax

| Command | Effect |
| --------------------------- | ---------------------------------------- |
| `/sonnet explain this code` | Use Sonnet for one message |
| `/opus+high deep review` | Use Opus with high thinking |
| `/haiku+0 quick answer` | Use Haiku at its lowest thinking level |
| `/+2 analyze this` | Keep current model, set thinking level 2 |
| Command | Effect |
| --------------------------- | ------------------------------------------- |
| `/sonnet explain this code` | Use Sonnet for one message |
| `/opus+high deep review` | Use Opus with high thinking |
| `/haiku+0 quick answer` | Use Haiku at its lowest thinking level |
| `/+2 analyze this` | Keep current model, set thinking level 2 |
| `/haiku+0 /done` | Run the `done` skill on Haiku for this send |

One-shot prefixes compose with [skill invocations](/agents/agent-skills): `/haiku+0 /done cleanup` invokes the skill normally (arguments, snapshots) while overriding the model for that send. An explicit one-shot also wins over the skill's own [model-class routing](/agents/agent-skills#per-skill-model-routing).

### Thinking levels

Expand Down
55 changes: 46 additions & 9 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2542,6 +2542,9 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
agentSkillDescriptors,
api,
discovery: skillDiscovery,
// One-shot Γ— skill composition ("/haiku+0 /done") ships for workspace
// sends; the creation composer has no one-shot support to compose with.
composeOneShot: variant === "workspace",
});
const combinedSkillRefs = await resolveInlineSkillRefsForSend({
messageText,
Expand Down Expand Up @@ -2662,6 +2665,9 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

try {
const modelOneShot = parsed?.type === "model-oneshot" ? parsed : null;
// Model/thinking override from either a bare one-shot ("/haiku+0 msg")
// or one composed with a skill invocation ("/haiku+0 /done args").
const oneShotOverride = modelOneShot ?? skillInvocation?.oneShot ?? null;
// Mirror the creation-composer /goal bypass: with attachments present,
// send the raw text as a normal message instead of processing the
// command, which would drop the files. Transferred staging-failure
Expand All @@ -2684,7 +2690,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
// the composer, it must not restore stale command text over the newer turn.
asyncCommandTokenRef.current++;

const modelOverride = modelOneShot?.modelString;
const modelOverride = oneShotOverride?.modelString;

// Regular message (or /<model-alias> one-shot override) - send directly via API
const messageTextForSend = modelOneShot?.message ?? skillInvocation?.userText ?? messageText;
Expand Down Expand Up @@ -2719,23 +2725,38 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
}
}

// Composed one-shot sends highlight the full "/haiku+0 /done" prefix:
// the transcript badge check requires rawCommand.startsWith(commandPrefix),
// and the combined prefix also keeps the explicit override visible.
const composedPrefixMatch = skillInvocation?.oneShot
? new RegExp(`^\\S+\\s+/${skillInvocation.descriptor.name}(?=\\s|$)`).exec(
messageText.trim()
)
: null;
const skillMuxMetadata = skillInvocation
? buildSkillInvocationMetadata(
appendStagedAttachmentNotice(messageText, sendAttachments),
skillInvocation.descriptor,
skillInvocation.argumentText
skillInvocation.argumentText,
composedPrefixMatch?.[0]
)
: undefined;

const policyModel = modelOverride ?? baseModel;

// Preflight: if the message includes PDFs, ensure the selected model can accept them.
// Routable skill invocations (no explicit model override) may stream on a
// class model with different PDF capabilities than the workspace model, so
// this local check would judge the wrong model both ways β€” defer to the
// backend gate, which validates against the routed model and rejects with
// a persisted, visible error.
const pdfPreflightModelIsAuthoritative = !(skillInvocation && !modelOverride);
const pdfAttachments = attachments.filter(
(attachment): attachment is Extract<ChatAttachment, { kind: "provider" }> =>
attachment.kind === "provider" &&
getBaseMediaType(attachment.mediaType) === PDF_MEDIA_TYPE
);
if (pdfAttachments.length > 0) {
if (pdfAttachments.length > 0 && pdfPreflightModelIsAuthoritative) {
const caps = getModelCapabilitiesResolved(policyModel, providersConfig);
if (caps && !caps.supportsPdfInput) {
const pdfCapableKnownModels = Object.values(KNOWN_MODELS)
Expand Down Expand Up @@ -2902,7 +2923,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

// One-shot models/thinking shouldn't update the persisted session defaults.
// Resolve thinking level: numeric indices are model-relative (0 = model's lowest allowed level)
const rawThinkingOverride = modelOneShot?.thinkingLevel;
const rawThinkingOverride = oneShotOverride?.thinkingLevel;
const thinkingOverride =
rawThinkingOverride != null
? resolveThinkingInput(rawThinkingOverride, policyModel)
Comment thread
asm marked this conversation as resolved.
Expand All @@ -2919,7 +2940,17 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
: {}),
...(modelOverride ? { model: modelOverride } : {}),
...(thinkingOverride ? { thinkingLevel: thinkingOverride } : {}),
...(modelOneShot ? { skipAiSettingsPersistence: true } : {}),
...(oneShotOverride ? { skipAiSettingsPersistence: true } : {}),
// Only a model-carrying one-shot bypasses class routing; a
// thinking-only override (/+2 /skill) layers on top of routing.
...(modelOverride ? { skipSkillModelRouting: true } : {}),
// Numeric thinking is model-relative and thinkingOverride above was
// resolved against the workspace model. A routable skill send may
// stream on a different (class) model, so pass the raw index for the
// backend to re-resolve against whatever model actually streams.
...(skillInvocation && !modelOverride && typeof rawThinkingOverride === "number"
? { oneShotThinkingIndex: rawThinkingOverride }
: {}),
...(goalInterventionPolicy ? { goalInterventionPolicy } : {}),
...(overrides?.queueDispatchMode
? { queueDispatchMode: overrides.queueDispatchMode }
Expand Down Expand Up @@ -2958,17 +2989,23 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
setDraft(preSendDraft);
setDraftReviews(preSendReviews);
} else {
// Track telemetry for successful message send
// Track telemetry for successful message send. Skill class routing
// can swap the model and thinking backend-side; the send result
// reports both so usage is attributed to what actually streams
// (queued sends report none and fall back to the requested values).
telemetry.messageSent(
props.workspaceId,
effectiveModel,
result.data?.routedModel ?? effectiveModel,
sendMessageOptions.agentId ?? agentId ?? WORKSPACE_DEFAULTS.agentId,
finalMessageText.length,
runtimeType,
sendMessageOptions.thinkingLevel ?? "off"
// Fall back to what this send actually carried (sendOptions
// includes a composed one-shot's thinking), not the ambient
// workspace setting.
result.data?.routedThinkingLevel ?? sendOptions.thinkingLevel ?? "off"
);

if (modelOneShot) {
if (oneShotOverride) {
trackCommandUsed("model");
}

Expand Down
139 changes: 139 additions & 0 deletions src/browser/features/ChatInput/utils.oneShotSkillComposition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, test } from "bun:test";

import { KNOWN_MODELS } from "@/common/constants/knownModels";
import type { AgentSkillDescriptor } from "@/common/types/agentSkill";
import { parseCommandWithSkillInvocation } from "./utils";

function descriptor(name: string): AgentSkillDescriptor {
return { name, description: `${name} description`, scope: "project" };
}

describe("parseCommandWithSkillInvocation one-shot composition", () => {
test("composes '/haiku+0 /done args' into a skill invocation with a one-shot override", async () => {
const result = await parseCommandWithSkillInvocation({
messageText: "/haiku+0 /done now please",
agentSkillDescriptors: [descriptor("done")],
api: null,
discovery: null,
composeOneShot: true,
});

expect(result.parsed).toBeNull();
expect(result.skillInvocation?.descriptor.name).toBe("done");
expect(result.skillInvocation?.userText).toBe("Using skill done: now please");
// Arguments are relative to the skill token so $ARGUMENTS substitution
// sees "now please", not the one-shot prefix.
expect(result.skillInvocation?.argumentText).toBe("now please");
expect(result.skillInvocation?.oneShot).toEqual({
modelString: KNOWN_MODELS.HAIKU.id,
thinkingLevel: 0,
});
});

test("composes a thinking-only override ('/+2 /done')", async () => {
const result = await parseCommandWithSkillInvocation({
messageText: "/+2 /done",
agentSkillDescriptors: [descriptor("done")],
api: null,
discovery: null,
composeOneShot: true,
});

expect(result.parsed).toBeNull();
expect(result.skillInvocation?.userText).toBe("Use skill done");
expect(result.skillInvocation?.oneShot).toEqual({ thinkingLevel: 2 });
});

test("does not compose without the composeOneShot opt-in (creation composer)", async () => {
const result = await parseCommandWithSkillInvocation({
messageText: "/haiku+0 /done now",
agentSkillDescriptors: [descriptor("done")],
api: null,
discovery: null,
});

expect(result.skillInvocation).toBeNull();
expect(result.parsed?.type).toBe("model-oneshot");
});

test("keeps valid registered-command invocations out of composition ('/haiku+0 /compact')", async () => {
const result = await parseCommandWithSkillInvocation({
messageText: "/haiku+0 /compact",
agentSkillDescriptors: [descriptor("compact")],
api: null,
discovery: null,
composeOneShot: true,
});

expect(result.skillInvocation).toBeNull();
expect(result.parsed).toMatchObject({ type: "model-oneshot", message: "/compact" });
});

test("mirrors direct invocation for unknown-command remainders, even on command-colliding names", async () => {
// "/compact now" is an invalid compact usage: its handler returns
// unknown-command, and unknown commands are exactly what skill invocation
// consumes. Direct typing already resolves a skill named "compact" here,
// so the composed form must behave identically.
const direct = await parseCommandWithSkillInvocation({
messageText: "/compact now",
agentSkillDescriptors: [descriptor("compact")],
api: null,
discovery: null,
composeOneShot: true,
});
const composed = await parseCommandWithSkillInvocation({
messageText: "/haiku+0 /compact now",
agentSkillDescriptors: [descriptor("compact")],
api: null,
discovery: null,
composeOneShot: true,
});

expect(direct.skillInvocation?.descriptor.name).toBe("compact");
expect(composed.skillInvocation?.descriptor.name).toBe("compact");
expect(composed.skillInvocation?.oneShot).toEqual({
modelString: KNOWN_MODELS.HAIKU.id,
thinkingLevel: 0,
});
});

test("keeps nested one-shots out of composition ('/haiku+0 /opus hi')", async () => {
const result = await parseCommandWithSkillInvocation({
messageText: "/haiku+0 /opus hi",
agentSkillDescriptors: [descriptor("done")],
api: null,
discovery: null,
composeOneShot: true,
});

expect(result.skillInvocation).toBeNull();
expect(result.parsed).toMatchObject({ type: "model-oneshot", message: "/opus hi" });
});

test("falls back to a plain one-shot when the remainder is not a known skill", async () => {
const result = await parseCommandWithSkillInvocation({
messageText: "/haiku+0 /nothere do it",
agentSkillDescriptors: [descriptor("done")],
api: null,
discovery: null,
composeOneShot: true,
});

expect(result.skillInvocation).toBeNull();
expect(result.parsed).toMatchObject({ type: "model-oneshot", message: "/nothere do it" });
});

test("plain skill invocations are unaffected by the composition flag", async () => {
const result = await parseCommandWithSkillInvocation({
messageText: "/done now",
agentSkillDescriptors: [descriptor("done")],
api: null,
discovery: null,
composeOneShot: true,
});

expect(result.parsed).toBeNull();
expect(result.skillInvocation?.descriptor.name).toBe("done");
expect(result.skillInvocation?.oneShot).toBeUndefined();
});
});
Loading