Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9c8e47d
πŸ€– feat: route skills to model classes (modelClasses + skillModelClasses)
asm Aug 31, 2026
1b93391
πŸ€– fix: address squash-review findings (candidate identity, scratch tr…
asm Aug 31, 2026
d97fa73
πŸ€– fix: catalog-gate the availability fallback, disable writes after a…
asm Aug 31, 2026
224e94d
πŸ€– fix: freshness-gated writes, factory-faithful availability, verifie…
asm Aug 31, 2026
95c8b73
πŸ€– fix: derive the Coder availability fallback from instance metadata
asm Aug 31, 2026
b2422de
πŸ€– fix: subscribe-before-fetch, registry-gated fallbacks, surfaced fol…
asm Aug 31, 2026
bbd80b5
πŸ€– fix: acceptance-safe follow-up recovery, rejection markers, honest …
asm Aug 31, 2026
3e532b4
πŸ€– fix: row-atomic rejection markers, dequeued-only preservation, cons…
asm Aug 31, 2026
5256d34
πŸ€– fix: drop rejected rows from provider requests, gate every routed-t…
asm Aug 31, 2026
c27b51d
πŸ€– fix: filter rejected rows from side-channel calls, survive subscrip…
asm Aug 31, 2026
aa7a4c9
πŸ€– fix: fence fetches that outlive a dead config subscription
asm Aug 31, 2026
657cd60
πŸ€– fix: per-entry model-class writes, coder identity gating before gen…
asm Aug 31, 2026
f4e308a
πŸ€– fix: refetch after a write ack fences concurrent config snapshots
asm Aug 31, 2026
d261da6
πŸ€– fix: await the authoritative post-ack refetch
asm Aug 31, 2026
dd30892
πŸ€– fix: report preservation failure to callers, await the revert refetch
asm Aug 31, 2026
ed7e342
πŸ€– fix: drain superseding fetches, recheck trust at materialization, t…
asm Aug 31, 2026
1a7edc8
πŸ€– fix: first-class trust-revocation gates, follow-up text rebuild
asm Aug 31, 2026
052fe45
πŸ€– fix: edit turns materialize before truncation; dedupe-proof revalid…
asm Aug 31, 2026
aa9787e
πŸ€– fix: final pre-dispatch consent check, staged notices survive retry…
asm Aug 31, 2026
3b9ba9e
πŸ€– fix: consent to the provider boundary, accepted-failure semantics, …
asm Aug 31, 2026
35282db
πŸ€– fix: consent at provider dispatch, durable rejection stamps, retry …
asm Aug 31, 2026
5b7babf
πŸ€– fix: consent in the stream-start critical section, resume gate, que…
asm Aug 31, 2026
3f74763
πŸ€– fix: per-step consent gate, quarantine everywhere, untrusted-histor…
asm Aug 31, 2026
82ba4d1
πŸ€– fix: fallback consent, refusal classification through the error pip…
asm Sep 1, 2026
d58879e
πŸ€– fix: remove the in-flight assistant when a mid-turn consent rejecti…
asm Sep 1, 2026
5f9b4c8
πŸ€– fix: startup repair also removes the rejected turn's surviving partial
asm Sep 1, 2026
b57128a
πŸ€– fix: request-time quarantine repair, edit recheck after truncate-ta…
asm Sep 1, 2026
63c0b5f
πŸ€– fix: filter rejected rows at the memory-harvest boundary
asm Sep 1, 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
8 changes: 8 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ const preview: Preview = {
styles: { width: "375px", height: "667px" },
type: "mobile",
},
// Mirrors the Pixel snapshot matrix's named "phone" width (390px):
// breakpoint-pinned stories must render locally at the exact width CI
// captures, or wrap-point regressions hide between the two.
pixelPhone: {
name: "Pixel phone (390px)",
styles: { width: "390px", height: "844px" },
type: "mobile",
},
mobile2: {
name: "iPhone XR",
styles: { width: "414px", height: "896px" },
Expand Down
41 changes: 41 additions & 0 deletions docs/agents/agent-skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,47 @@ 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 `~/.xum/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
```

For project skills, class routing only applies in **trusted projects** β€” repo-controlled content must not silently reroute your conversation to a different configured provider. In an untrusted project a project skill is never routed: not by its frontmatter, and not by a `skillModelClasses` entry either, since project skills win name collisions and a repo-shipped shadow would otherwise inherit a binding you meant for your own skill. Global and built-in skills route normally.

- **Config routing table** β€” for skills you don't own, `skillModelClasses` in `~/.xum/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, and the compaction request starts from whichever of the two models β€” the workspace's or the routed one β€” has the larger context window (it must read the full uncompacted history). As with any compaction, settings configured for the Compact agent take precedence over that base.

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 @@ -66,12 +66,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
70 changes: 56 additions & 14 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,9 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
api,
discovery: skillDiscovery,
signal: resolutionSignal,
// 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",
});
if (!isSendScopeCurrent()) return;
parsed = resolution.parsed;
Expand Down Expand Up @@ -1971,6 +1974,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 @@ -1993,7 +1999,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 =
Expand Down Expand Up @@ -2032,11 +2038,25 @@ 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()
)
Comment thread
asm marked this conversation as resolved.
: null;
const skillMuxMetadata = skillInvocation
? buildSkillInvocationMetadata(
appendStagedAttachmentNotice(messageText, sendAttachments),
// Trimmed like the view parseCommand accepted: the transcript
// badge requires rawCommand.startsWith(commandPrefix), so leading
// whitespace (" /haiku+0 /done") would silently drop the badge
// (and the composed prefix highlight) while the skill turn still
// sends.
appendStagedAttachmentNotice(messageText.trim(), sendAttachments),
skillInvocation.descriptor,
skillInvocation.argumentText
skillInvocation.argumentText,
composedPrefixMatch?.[0]
)
: undefined;
const promptMuxMetadata: MuxMessageMetadata | undefined = mcpPromptInvocation
Expand All @@ -2052,8 +2072,14 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
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(isPdfAttachment);
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 @@ -2177,6 +2203,10 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
compactionMessageText: actualMessageText,
appendStagedNotice: appendStagedNoticeToUserMessage,
modelOneShot,
// One-shot composed with a skill invocation ("/haiku+0 /done"):
// option overrides ride the invocation, not a bare model-oneshot.
skillOneShot: skillInvocation?.oneShot ?? null,
hasSkillInvocation: skillInvocation != null,
policyModel,
transferredDraftProjectDiscovery,
additionalSystemContextHydrated,
Expand Down Expand Up @@ -2223,17 +2253,29 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
setDraft(preSendDraft);
setDraftReviews(preSendReviews);
} else {
// Track telemetry for successful message send
telemetry.messageSent(
props.workspaceId,
effectiveModel,
sendMessageOptions.agentId ?? agentId ?? WORKSPACE_DEFAULTS.agentId,
finalMessageText.length,
runtimeType,
sendMessageOptions.thinkingLevel ?? "off"
);
// 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. A
// QUEUED skill send acknowledges before it dispatches β€” routed or
// not, the backend's dequeue-dispatch capture owns its attribution
// (recording here would double-count, and a later cancellation or
// rejection would make the event wholly false).
const queuedSkillSend = result.data?.queued === true && skillInvocation != null;
if (!queuedSkillSend) {
telemetry.messageSent(
props.workspaceId,
result.data?.routedModel ?? effectiveModel,
sendMessageOptions.agentId ?? agentId ?? WORKSPACE_DEFAULTS.agentId,
finalMessageText.length,
runtimeType,
// 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
35 changes: 30 additions & 5 deletions src/browser/features/ChatInput/prepareMessagePayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "@/common/types/message";
import { resolveThinkingInput } from "@/common/utils/thinking/policy";
import { appendStagedAttachmentNotice } from "./stagedAttachments";
import { hasProjectScopedSkillRef } from "./utils";
import { hasProjectScopedSkillRef, type SkillInvocation } from "./utils";
import type { GoalInterventionPolicy, QueueDispatchMode } from "./types";

type ModelOneShot = Extract<NonNullable<ParsedCommand>, { type: "model-oneshot" }>;
Expand All @@ -34,6 +34,15 @@ interface PrepareMessagePayloadInput {
compactionMessageText?: string;
appendStagedNotice?: boolean;
modelOneShot?: ModelOneShot | null;
/**
* One-shot override composed with a skill invocation ("/haiku+0 /done").
* It rides the invocation rather than parsing as a bare model-oneshot, so
* only option building consumes it β€” the transcript prefix metadata is the
* skill path's job (composedPrefixMatch in the caller).
*/
skillOneShot?: SkillInvocation["oneShot"] | null;
/** True for slash skill invocations (routable unless a model override rides along). */
hasSkillInvocation?: boolean;
policyModel: string;
transferredDraftProjectDiscovery: boolean;
additionalSystemContextHydrated: boolean;
Expand Down Expand Up @@ -86,8 +95,12 @@ export function prepareMessagePayload(input: PrepareMessagePayloadInput): Prepar
const additionalSystemInstructions =
compactionOptions.additionalSystemInstructions ??
input.sendMessageOptions.additionalSystemInstructions;
// 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 = input.modelOneShot ?? input.skillOneShot ?? null;
const oneShotModelOverride = oneShotOverride?.modelString;
const effectiveModel =
input.modelOneShot?.modelString ?? compactionOptions.model ?? input.sendMessageOptions.model;
oneShotModelOverride ?? compactionOptions.model ?? input.sendMessageOptions.model;
const trimmedMessageText = input.messageText.trim();
const commandPrefix = input.modelOneShot
? trimmedMessageText
Expand All @@ -103,7 +116,7 @@ export function prepareMessagePayload(input: PrepareMessagePayloadInput): Prepar
...(rawCommand ? { rawCommand, commandPrefix } : {}),
};

const rawThinkingOverride = input.modelOneShot?.thinkingLevel;
const rawThinkingOverride = oneShotOverride?.thinkingLevel;
const thinkingOverride =
rawThinkingOverride != null
? resolveThinkingInput(rawThinkingOverride, input.policyModel)
Expand All @@ -119,9 +132,21 @@ export function prepareMessagePayload(input: PrepareMessagePayloadInput): Prepar
...(input.transferredDraftProjectDiscovery && hasProjectScopedSkillRef(input.agentSkillRefs)
? { disableWorkspaceAgents: true }
: {}),
...(input.modelOneShot?.modelString ? { model: input.modelOneShot.modelString } : {}),
...(oneShotModelOverride ? { model: oneShotModelOverride } : {}),
...(thinkingOverride ? { thinkingLevel: thinkingOverride } : {}),
...(input.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.
...(oneShotModelOverride ? { 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.
...(input.hasSkillInvocation === true &&
!oneShotModelOverride &&
typeof rawThinkingOverride === "number"
? { oneShotThinkingIndex: rawThinkingOverride }
: {}),
...(input.goalInterventionPolicy
? { goalInterventionPolicy: input.goalInterventionPolicy }
: {}),
Expand Down
19 changes: 19 additions & 0 deletions src/browser/features/ChatInput/stagedAttachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@ export function appendStagedAttachmentNotice(text: string, attachments: ChatAtta
return text.trim().length > 0 ? `${text}\n${notice}` : notice.trimStart();
}

/**
* The raw <attached-files> blocks in a message text, verbatim. For rebuilds
* that regenerate the surrounding model-facing text (compact-and-retry):
* staged attachments are deliberately absent from fileParts, so dropping the
* generated notice would silently lose the file and its workspace path.
*/
export function extractStagedAttachmentNotices(text: string): string[] {
return (
text
.match(ATTACHED_FILES_BLOCK_PATTERN)
?.map((block) => block.replace(/^\n/, ""))
// Only GENERATED notices: a skill argument can itself contain an
// <attached-files> example (pasted XML, fenced block) that the rebuilt
// argument text already restores β€” re-appending it would duplicate
// user content and change the retried prompt.
.filter((block) => isGeneratedStagedAttachmentBlock(block)) ?? []
);
}

export function parseStagedAttachmentNotice(text: string): {
text: string;
attachments: DisplayStagedAttachment[];
Expand Down
Loading