🧭 feat: Scope Model Spec Skills (#13522)

* feat: scope model spec skills

* style: format skill catalog limit

* fix: serialize model spec skill resolution

* test: satisfy model spec load config typing

* fix: apply model spec skills to added conversations

* fix: support alwaysApply frontmatter alias

* fix: address model spec skills review
This commit is contained in:
Danny Avila 2026-06-05 10:22:02 -04:00 committed by GitHub
parent 3fb48021f7
commit 6357ea10c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1398 additions and 139 deletions

View file

@ -327,6 +327,7 @@ describe('GET /api/config', () => {
{
name: 'guarded-spec',
label: 'Guarded Spec',
skills: ['private-skill'],
preset: {
endpoint: 'openAI',
model: 'gpt-4o',
@ -352,6 +353,7 @@ describe('GET /api/config', () => {
model: 'gpt-4o',
greeting: 'Hello',
});
expect(response.body.modelSpecs.list[0]).not.toHaveProperty('skills');
});
it('should include full interface config', async () => {

View file

@ -3,10 +3,14 @@ const {
ADDED_AGENT_ID,
initializeAgent,
validateAgentModel,
resolveAgentScopedSkillIds,
resolveModelSpecSkillIds,
loadAddedAgent: loadAddedAgentFn,
} = require('@librechat/api');
const { isEphemeralAgentId } = require('librechat-data-provider');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { getMCPServerTools } = require('~/server/services/Config');
const { canAuthorSkillFiles } = require('./skillDeps');
const db = require('~/models');
const loadAddedAgent = (params) =>
@ -40,6 +44,13 @@ const loadAddedAgent = (params) =>
* @param {Map} params.agentConfigs - Map of agent configs to add to
* @param {string} params.primaryAgentId - The primary agent ID
* @param {Object|undefined} params.userMCPAuthMap - User MCP auth map to merge into
* @param {Array} [params.accessibleSkillIds] - Full VIEW-accessible skill IDs for the user
* @param {Array} [params.editableSkillIds] - Full EDIT-accessible skill IDs for the user
* @param {boolean} [params.skillsCapabilityEnabled] - Whether endpoint Skills are enabled
* @param {boolean} [params.ephemeralSkillsToggle] - Per-request ephemeral Skills badge state
* @param {boolean} [params.skillCreateAllowed] - Whether the user can create Skills
* @param {Record<string, boolean>} [params.skillStates] - Per-user Skill active overrides
* @param {boolean} [params.defaultActiveOnShare] - Default active state for shared Skills
* @param {boolean} [params.codeEnvAvailable] - `execute_code` capability flag;
* forwarded verbatim to the added agent's `initializeAgent`. @see
* InitializeAgentParams.codeEnvAvailable for full semantics.
@ -60,6 +71,13 @@ const processAddedConvo = async ({
primaryAgentId,
primaryAgent,
userMCPAuthMap,
accessibleSkillIds = [],
editableSkillIds = [],
skillsCapabilityEnabled = false,
ephemeralSkillsToggle = false,
skillCreateAllowed = false,
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
}) => {
const addedConvo = endpointOption.addedConvo;
@ -94,6 +112,47 @@ const processAddedConvo = async ({
return { userMCPAuthMap };
}
const selectedModelSpec =
addedConvo.spec && Array.isArray(req.config?.modelSpecs?.list)
? req.config.modelSpecs.list.find((modelSpec) => modelSpec.name === addedConvo.spec)
: null;
if (
addedAgent &&
isEphemeralAgentId(addedAgent.id) &&
selectedModelSpec &&
Object.hasOwn(selectedModelSpec, 'skills')
) {
if (selectedModelSpec.skills === true) {
addedAgent.skills_enabled = true;
delete addedAgent.skills;
} else if (selectedModelSpec.skills === false) {
addedAgent.skills_enabled = false;
addedAgent.skills = [];
} else if (Array.isArray(selectedModelSpec.skills)) {
const resolvedSkillIds = await resolveModelSpecSkillIds({
names: selectedModelSpec.skills,
accessibleSkillIds,
getSkillByName: db.getSkillByName,
});
addedAgent.skills_enabled = true;
addedAgent.skills = resolvedSkillIds.map((id) => id.toString());
}
}
const scopedSkillIds = resolveAgentScopedSkillIds({
agent: addedAgent,
accessibleSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const scopedEditableSkillIds = resolveAgentScopedSkillIds({
agent: addedAgent,
accessibleSkillIds: editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const addedConfig = await initializeAgent(
{
req,
@ -105,7 +164,17 @@ const processAddedConvo = async ({
agent: addedAgent,
endpointOption,
allowedProviders,
accessibleSkillIds: scopedSkillIds,
skillAuthoringAvailable: canAuthorSkillFiles({
agent: addedAgent,
scopedEditableSkillIds,
skillCreateAllowed,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
codeEnvAvailable,
skillStates,
defaultActiveOnShare,
},
{
getFiles: db.getFiles,
@ -118,6 +187,9 @@ const processAddedConvo = async ({
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
listSkillsByAccess: db.listSkillsByAccess,
listAlwaysApplySkills: db.listAlwaysApplySkills,
getSkillByName: db.getSkillByName,
},
);

View file

@ -1,6 +1,9 @@
const mockInitializeAgent = jest.fn();
const mockValidateAgentModel = jest.fn();
const mockLoadAddedAgent = jest.fn();
const mockResolveAgentScopedSkillIds = jest.fn();
const mockResolveModelSpecSkillIds = jest.fn();
const mockCanAuthorSkillFiles = jest.fn();
const mockGetAgent = jest.fn();
const mockGetMCPServerTools = jest.fn();
@ -18,6 +21,8 @@ jest.mock('@librechat/api', () => ({
initializeAgent: (...args) => mockInitializeAgent(...args),
validateAgentModel: (...args) => mockValidateAgentModel(...args),
loadAddedAgent: (params) => mockLoadAddedAgent(params),
resolveAgentScopedSkillIds: (...args) => mockResolveAgentScopedSkillIds(...args),
resolveModelSpecSkillIds: (...args) => mockResolveModelSpecSkillIds(...args),
}));
jest.mock('~/server/services/Files/permissions', () => ({
@ -28,11 +33,20 @@ jest.mock('~/server/services/Config', () => ({
getMCPServerTools: (...args) => mockGetMCPServerTools(...args),
}));
jest.mock('./skillDeps', () => ({
canAuthorSkillFiles: (...args) => mockCanAuthorSkillFiles(...args),
}));
jest.mock('~/models', () => ({
getAgent: (...args) => mockGetAgent(...args),
getSkillByName: jest.fn(),
listSkillsByAccess: jest.fn(),
listAlwaysApplySkills: jest.fn(),
}));
const { processAddedConvo } = require('./addedConvo');
const db = require('~/models');
const { Constants } = require('librechat-data-provider');
const makeReq = () => ({ user: { id: 'u1', role: 'USER' } });
@ -44,7 +58,7 @@ const makeReq = () => ({ user: { id: 'u1', role: 'USER' } });
* `CodeExecutionToolDefinition` landed in their `toolDefinitions` via the
* registry regardless of any explicit flag.
*/
describe('processAddedConvo — codeEnvAvailable passthrough', () => {
describe('processAddedConvo', () => {
beforeEach(() => {
jest.clearAllMocks();
mockValidateAgentModel.mockResolvedValue({ isValid: true });
@ -53,6 +67,11 @@ describe('processAddedConvo — codeEnvAvailable passthrough', () => {
userMCPAuthMap: undefined,
});
mockLoadAddedAgent.mockResolvedValue({ id: 'added-agent', provider: 'openai' });
mockResolveAgentScopedSkillIds.mockImplementation(
({ accessibleSkillIds }) => accessibleSkillIds,
);
mockResolveModelSpecSkillIds.mockResolvedValue([]);
mockCanAuthorSkillFiles.mockReturnValue(false);
});
const baseParams = (overrides = {}) => ({
@ -105,4 +124,108 @@ describe('processAddedConvo — codeEnvAvailable passthrough', () => {
expect.anything(),
);
});
it('resolves and forwards model-spec skill scope for added ephemeral agents', async () => {
const accessibleSkillId = { toString: () => 'accessible-skill' };
const editableSkillId = { toString: () => 'editable-skill' };
const resolvedSkillId = { toString: () => 'resolved-skill' };
const scopedSkillId = { toString: () => 'scoped-skill' };
const scopedEditableSkillId = { toString: () => 'scoped-editable-skill' };
const skillStates = { 'scoped-skill': true };
mockLoadAddedAgent.mockResolvedValue({
id: Constants.EPHEMERAL_AGENT_ID,
provider: 'openai',
skills_enabled: true,
skills: [],
});
mockResolveModelSpecSkillIds.mockResolvedValue([resolvedSkillId]);
mockResolveAgentScopedSkillIds
.mockReturnValueOnce([scopedSkillId])
.mockReturnValueOnce([scopedEditableSkillId]);
mockCanAuthorSkillFiles.mockReturnValue(true);
await processAddedConvo(
baseParams({
req: {
user: { id: 'u1', role: 'USER' },
config: {
modelSpecs: {
list: [
{
name: 'added-spec',
skills: ['finance-analyst'],
},
],
},
},
},
endpointOption: {
spec: 'primary-spec',
addedConvo: {
endpoint: 'openai',
model: 'gpt-4o',
spec: 'added-spec',
},
},
accessibleSkillIds: [accessibleSkillId],
editableSkillIds: [editableSkillId],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
skillCreateAllowed: true,
skillStates,
defaultActiveOnShare: true,
}),
);
expect(mockResolveModelSpecSkillIds).toHaveBeenCalledWith({
names: ['finance-analyst'],
accessibleSkillIds: [accessibleSkillId],
getSkillByName: db.getSkillByName,
});
expect(mockResolveAgentScopedSkillIds).toHaveBeenNthCalledWith(1, {
agent: expect.objectContaining({
id: Constants.EPHEMERAL_AGENT_ID,
skills_enabled: true,
skills: ['resolved-skill'],
}),
accessibleSkillIds: [accessibleSkillId],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(mockResolveAgentScopedSkillIds).toHaveBeenNthCalledWith(2, {
agent: expect.objectContaining({
id: Constants.EPHEMERAL_AGENT_ID,
skills_enabled: true,
skills: ['resolved-skill'],
}),
accessibleSkillIds: [editableSkillId],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(mockCanAuthorSkillFiles).toHaveBeenCalledWith({
agent: expect.objectContaining({
id: Constants.EPHEMERAL_AGENT_ID,
skills_enabled: true,
skills: ['resolved-skill'],
}),
scopedEditableSkillIds: [scopedEditableSkillId],
skillCreateAllowed: true,
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(mockInitializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
accessibleSkillIds: [scopedSkillId],
skillAuthoringAvailable: true,
skillStates,
defaultActiveOnShare: true,
}),
expect.objectContaining({
listSkillsByAccess: db.listSkillsByAccess,
listAlwaysApplySkills: db.listAlwaysApplySkills,
getSkillByName: db.getSkillByName,
}),
);
});
});

View file

@ -10,6 +10,7 @@ const {
getCustomEndpointConfig,
discoverConnectedAgents,
resolveAgentScopedSkillIds,
resolveModelSpecSkillIds,
buildAgentContextAttachmentsByAgentId,
} = require('@librechat/api');
const {
@ -133,7 +134,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
/** Query accessible skill IDs once per run (shared across all agents).
* Skills activate under strict opt-in semantics see
* `resolveAgentScopedSkillIds` for the per-agent activation predicate:
* - Ephemeral agent per-conversation skills badge toggle (full catalog).
* - Ephemeral agent model-spec `skills` config first, otherwise the
* per-conversation skills badge toggle (full catalog).
* - Persisted agent `agent.skills_enabled === true`. Optional
* `agent.skills` allowlist narrows the catalog; empty/undefined
* allowlist with the toggle on = full accessible catalog. */
@ -288,6 +290,34 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
*/
const manualSkills = extractManualSkills(req.body);
const selectedModelSpec =
endpointOption.spec && Array.isArray(appConfig?.modelSpecs?.list)
? appConfig.modelSpecs.list.find((modelSpec) => modelSpec.name === endpointOption.spec)
: null;
if (
primaryAgent &&
isEphemeralAgentId(primaryAgent.id) &&
selectedModelSpec &&
Object.hasOwn(selectedModelSpec, 'skills')
) {
if (selectedModelSpec.skills === true) {
primaryAgent.skills_enabled = true;
delete primaryAgent.skills;
} else if (selectedModelSpec.skills === false) {
primaryAgent.skills_enabled = false;
primaryAgent.skills = [];
} else if (Array.isArray(selectedModelSpec.skills)) {
const resolvedSkillIds = await resolveModelSpecSkillIds({
names: selectedModelSpec.skills,
accessibleSkillIds,
getSkillByName: db.getSkillByName,
});
primaryAgent.skills_enabled = true;
primaryAgent.skills = resolvedSkillIds.map((id) => id.toString());
}
}
const primaryScopedSkillIds = resolveAgentScopedSkillIds({
agent: primaryAgent,
accessibleSkillIds,
@ -456,6 +486,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
parentMessageId,
allowedProviders,
primaryAgentId: primaryConfig.id,
accessibleSkillIds,
editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
skillCreateAllowed,
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
});

View file

@ -6,6 +6,7 @@ const {
PrincipalModel,
MAX_SUBAGENT_DEPTH,
MAX_SUBAGENT_GRAPH_NODES,
Constants,
} = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
@ -277,6 +278,42 @@ describe('initializeClient — processAgent ACL gate', () => {
expect(initializeParams.accessibleSkillIds.map(String)).toContain(skill._id.toString());
expect(initializeParams.skillAuthoringAvailable).toBe(false);
});
it('enables skill authoring when model specs enable skills for an ephemeral agent', async () => {
const endpointOption = makeEndpointOption();
endpointOption.spec = 'spec-skills';
endpointOption.agent = Promise.resolve({
id: Constants.EPHEMERAL_AGENT_ID,
name: 'Ephemeral Primary',
provider: 'openai',
model: 'gpt-4',
tools: [],
});
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
const req = makeReq();
req.config.endpoints.agents = { capabilities: ['skills'] };
req.config.modelSpecs = {
list: [{ name: 'spec-skills', skills: true }],
};
const canCreateSkillSpy = jest
.spyOn(getSkillToolDeps(), 'canCreateSkill')
.mockResolvedValue(true);
try {
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption,
});
} finally {
canCreateSkillSpy.mockRestore();
}
const initializeParams = mockInitializeAgent.mock.calls[0][0];
expect(initializeParams.agent.skills_enabled).toBe(true);
expect(initializeParams.skillAuthoringAvailable).toBe(true);
});
});
describe('initializeClient — subagent loading', () => {

View file

@ -128,6 +128,12 @@ function isAgentSkillsEnabledForRun({ agent, skillsCapabilityEnabled, ephemeralS
return false;
}
if (isEphemeralAgentId(agent.id)) {
if (agent.skills_enabled === false) {
return false;
}
if (agent.skills_enabled === true) {
return true;
}
return ephemeralSkillsToggle === true;
}
return agent.skills_enabled === true;

View file

@ -25,3 +25,27 @@ endpoints:
fetch: false
titleConvo: false
modelDisplayLabel: 'Mock Provider B'
modelSpecs:
list:
- name: 'e2e-mock-provider-a'
label: 'Mock Provider A'
preset:
endpoint: 'Mock Provider A'
model: 'mock-model-a'
- name: 'e2e-mock-provider-b'
label: 'Mock Provider B'
preset:
endpoint: 'Mock Provider B'
model: 'mock-model-b'
- name: 'e2e-skill-scope'
label: 'E2E Skill Scope'
preset:
endpoint: 'Mock Provider A'
model: 'mock-model-a'
skills:
- 'e2e-model-spec-allowed'
- 'e2e-model-spec-missing'
- 'e2e-model-spec-inaccessible'

View file

@ -14,13 +14,20 @@ const CHUNK_DELAY_MS = Number(process.env.MOCK_LLM_CHUNK_DELAY_MS) || 10;
const CREATE_SKILL_MARKER = 'E2E_CREATE_SKILL:';
const EDIT_SKILL_MARKER = 'E2E_EDIT_SKILL:';
const ASSERT_MODEL_SPEC_SKILLS_MARKER = 'E2E_ASSERT_MODEL_SPEC_SKILLS';
const CREATE_FILE_AUTHORING_FINAL_TEXT = 'E2E file authoring complete';
const EDIT_FILE_AUTHORING_FINAL_TEXT = 'E2E file edit complete';
const MODEL_SPEC_SKILL_ASSERTION_FINAL_TEXT = 'E2E model spec skill assertion passed';
const CREATE_FILE_TOOL_NAME = 'create_file';
const EDIT_FILE_TOOL_NAME = 'edit_file';
const BASH_TOOL_NAME = 'bash_tool';
const SKILL_TOOL_NAME = 'skill';
const CREATE_SKILL_TOOL_CALL_ID = 'call_e2e_create_skill';
const EDIT_SKILL_TOOL_CALL_ID = 'call_e2e_edit_skill';
const MODEL_SPEC_ACCESSIBLE_SKILL = 'e2e-model-spec-allowed';
const MODEL_SPEC_MISSING_SKILL = 'e2e-model-spec-missing';
const MODEL_SPEC_INACCESSIBLE_SKILL = 'e2e-model-spec-inaccessible';
const ALWAYS_APPLY_BODY_MARKER = 'E2E_ALWAYS_APPLY_BODY_MARKER';
const SKILL_DESCRIPTION =
'Use this skill to verify LibreChat skill file authoring in mock end-to-end tests.';
const EDITED_SKILL_DESCRIPTION =
@ -108,6 +115,67 @@ function collectToolNames(agents) {
return names;
}
function collectAdditionalInstructions(agents) {
return (agents ?? [])
.map((agent) =>
typeof agent?.additional_instructions === 'string' ? agent.additional_instructions : '',
)
.filter(Boolean)
.join('\n');
}
function collectSkillPrimeMessages(messages) {
return (messages ?? [])
.filter((message) => message?.additional_kwargs?.source === 'skill')
.map((message) => ({
name: message.additional_kwargs.skillName,
trigger: message.additional_kwargs.trigger,
content: getContentText(message.content),
}));
}
function modelSpecSkillAssertionResponses({ agents, messages, toolNames }) {
const failures = [];
const additionalInstructions = collectAdditionalInstructions(agents);
const skillPrimeMessages = collectSkillPrimeMessages(messages);
const alwaysApplyPrime = skillPrimeMessages.find(
(message) => message.name === MODEL_SPEC_ACCESSIBLE_SKILL && message.trigger === 'always-apply',
);
if (!toolNames.has(SKILL_TOOL_NAME)) {
failures.push(`${SKILL_TOOL_NAME} tool was not advertised`);
}
if (!additionalInstructions.includes(MODEL_SPEC_ACCESSIBLE_SKILL)) {
failures.push(`${MODEL_SPEC_ACCESSIBLE_SKILL} was not present in the model-visible catalog`);
}
if (additionalInstructions.includes(MODEL_SPEC_MISSING_SKILL)) {
failures.push(`${MODEL_SPEC_MISSING_SKILL} leaked into the model-visible catalog`);
}
if (additionalInstructions.includes(MODEL_SPEC_INACCESSIBLE_SKILL)) {
failures.push(`${MODEL_SPEC_INACCESSIBLE_SKILL} leaked into the model-visible catalog`);
}
if (!alwaysApplyPrime) {
failures.push(`${MODEL_SPEC_ACCESSIBLE_SKILL} was not always-apply primed`);
} else if (!alwaysApplyPrime.content.includes(ALWAYS_APPLY_BODY_MARKER)) {
failures.push(`${MODEL_SPEC_ACCESSIBLE_SKILL} always-apply body was missing its marker`);
}
if (skillPrimeMessages.some((message) => message.name === MODEL_SPEC_MISSING_SKILL)) {
failures.push(`${MODEL_SPEC_MISSING_SKILL} was unexpectedly primed`);
}
if (skillPrimeMessages.some((message) => message.name === MODEL_SPEC_INACCESSIBLE_SKILL)) {
failures.push(`${MODEL_SPEC_INACCESSIBLE_SKILL} was unexpectedly primed`);
}
if (failures.length > 0) {
return {
responses: [`E2E model spec skill assertion failed: ${failures.join('; ')}`],
};
}
return {
responses: [`${MODEL_SPEC_SKILL_ASSERTION_FINAL_TEXT}: ${MODEL_SPEC_ACCESSIBLE_SKILL}`],
};
}
function buildSkillBody(skillName) {
return `---
name: ${skillName}
@ -166,7 +234,11 @@ function fileAuthoringResponses(operation, toolNames) {
};
}
function resolveResponses(text, toolNames) {
function resolveResponses({ agents, messages, text, toolNames }) {
if (text.includes(ASSERT_MODEL_SPEC_SKILLS_MARKER)) {
return modelSpecSkillAssertionResponses({ agents, messages, toolNames });
}
const createSkillName = getRequestedSkillName(text, CREATE_SKILL_MARKER);
if (createSkillName) {
return fileAuthoringResponses(
@ -208,6 +280,11 @@ module.exports = function fakeModelHook(run, context) {
const text = getLatestUserText(context?.messages);
const toolNames = collectToolNames(context?.agents);
const { responses, toolCalls } = resolveResponses(text, toolNames);
const { responses, toolCalls } = resolveResponses({
agents: context?.agents,
messages: context?.messages,
text,
toolNames,
});
graph.overrideTestModel(responses, CHUNK_DELAY_MS, toolCalls);
};

View file

@ -14,6 +14,10 @@ export type MockEndpoint = (typeof MOCK_ENDPOINTS)[number];
export const NEW_CHAT_PATH = '/c/new';
type RefreshTokenBody = {
token?: string;
};
export function isAgentsStream(response: Response) {
return response.url().includes('/api/agents') && response.status() === 200;
}
@ -21,12 +25,30 @@ export function isAgentsStream(response: Response) {
const modelSelectorTrigger = (page: Page) =>
page.getByRole('button', { name: 'Select a model' }).first();
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Open the model selector, choose an endpoint, then its model (committed on the model click). */
export async function selectMockEndpoint(page: Page, endpoint: MockEndpoint) {
await modelSelectorTrigger(page).click();
const trigger = modelSelectorTrigger(page);
await trigger.click();
await page.getByRole('option', { name: endpoint.label }).click();
await page.getByRole('option', { name: endpoint.model, exact: true }).click();
await expect(modelSelectorTrigger(page)).not.toHaveText('Select a model');
const modelOption = page.getByRole('option', { name: endpoint.model, exact: true });
if (await modelOption.isVisible({ timeout: 1000 }).catch(() => false)) {
await modelOption.click();
}
await expect(trigger).not.toHaveText('Select a model');
}
/** Open the model selector and choose a configured model spec by label. */
export async function selectModelSpec(page: Page, label: string) {
const trigger = modelSelectorTrigger(page);
await expect(trigger).toBeVisible();
if ((await trigger.textContent())?.includes(label)) {
return;
}
await trigger.click();
await page.getByRole('option', { name: new RegExp(`^${escapeRegExp(label)}\\b`) }).click();
await expect(trigger).toContainText(label);
}
/** Enable the ephemeral Skills capability from the composer tool menu. */
@ -53,3 +75,88 @@ export async function sendMessage(page: Page, text: string): Promise<Response> {
]);
return response;
}
export async function getAccessToken(page: Page): Promise<string> {
const result = await page.evaluate(async () => {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const text = await response.text();
let json: unknown = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: response.ok, status: response.status, text, json };
});
if (!result.ok) {
throw new Error(
`Expected /api/auth/refresh to return 2xx, got ${result.status}: ${result.text}`,
);
}
const body = result.json as RefreshTokenBody | null;
if (!body?.token) {
throw new Error(`Expected /api/auth/refresh to return a token, got: ${result.text}`);
}
return body.token;
}
export async function requestJson<T>(
page: Page,
params: {
path: string;
token: string;
method?: string;
body?: unknown;
},
): Promise<T> {
const result = await page.evaluate(
async ({ accessToken, body, method, urlPath }) => {
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
};
const init: RequestInit = {
method,
credentials: 'include',
headers,
};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
const response = await fetch(urlPath, init);
const text = await response.text();
let json: unknown = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: response.ok, status: response.status, text, json };
},
{
accessToken: params.token,
body: params.body,
method: params.method ?? 'GET',
urlPath: params.path,
},
);
if (!result.ok) {
throw new Error(
`Expected ${params.method ?? 'GET'} ${params.path} to return 2xx, got ${result.status}: ${result.text}`,
);
}
return result.json as T;
}
export async function fetchJson<T>(page: Page, path: string, token: string): Promise<T> {
return requestJson<T>(page, { path, token });
}

View file

@ -0,0 +1,175 @@
import { expect, test } from '@playwright/test';
import { MongoClient, ObjectId } from 'mongodb';
import type { Page } from '@playwright/test';
import { applyRuntimeEnv } from '../../setup/runtimeEnv';
import {
NEW_CHAT_PATH,
fetchJson,
getAccessToken,
requestJson,
selectModelSpec,
sendMessage,
} from './helpers';
const MODEL_SPEC_LABEL = 'E2E Skill Scope';
const ASSERTION_MARKER = 'E2E_ASSERT_MODEL_SPEC_SKILLS';
const ASSERTION_FINAL_TEXT = 'E2E model spec skill assertion passed';
const ACCESSIBLE_SKILL_NAME = 'e2e-model-spec-allowed';
const INACCESSIBLE_SKILL_NAME = 'e2e-model-spec-inaccessible';
const ALWAYS_APPLY_BODY_MARKER = 'E2E_ALWAYS_APPLY_BODY_MARKER';
const INACCESSIBLE_AUTHOR_ID = new ObjectId('64f000000000000000000001');
const SKILL_DESCRIPTION =
'Use this skill to verify model-spec skill scoping and always-apply priming in mock e2e tests.';
type SkillSummary = {
_id: string;
name: string;
description: string;
version: number;
alwaysApply?: boolean;
};
type SkillDetail = SkillSummary & {
body: string;
};
function buildSkillBody(name: string) {
return `---
name: ${name}
description: ${SKILL_DESCRIPTION}
alwaysApply: true
---
# ${name}
${ALWAYS_APPLY_BODY_MARKER}
This body should be injected as an always-apply skill prime.`;
}
async function findSkill(
page: Page,
skillName: string,
token: string,
): Promise<SkillSummary | null> {
const body = await fetchJson<{ skills?: SkillSummary[] }>(
page,
`/api/skills?search=${encodeURIComponent(skillName)}&limit=10`,
token,
);
return body.skills?.find((skill) => skill.name === skillName) ?? null;
}
async function seedAccessibleSkill(page: Page, token: string): Promise<SkillDetail> {
const body = buildSkillBody(ACCESSIBLE_SKILL_NAME);
const payload = {
name: ACCESSIBLE_SKILL_NAME,
description: SKILL_DESCRIPTION,
body,
};
const existing = await findSkill(page, ACCESSIBLE_SKILL_NAME, token);
if (!existing) {
return requestJson<SkillDetail>(page, {
path: '/api/skills',
token,
method: 'POST',
body: payload,
});
}
const detail = await fetchJson<SkillDetail>(
page,
`/api/skills/${encodeURIComponent(existing._id)}`,
token,
);
return requestJson<SkillDetail>(page, {
path: `/api/skills/${encodeURIComponent(existing._id)}`,
token,
method: 'PATCH',
body: {
...payload,
expectedVersion: detail.version,
},
});
}
async function seedInaccessibleSkill() {
applyRuntimeEnv();
if (!process.env.MONGO_URI) {
throw new Error('MONGO_URI must be available for model-spec skill mock e2e tests');
}
const client = new MongoClient(process.env.MONGO_URI);
await client.connect();
try {
const db = client.db();
const skills = db.collection('skills');
const aclEntries = db.collection('aclentries');
const now = new Date();
await skills.updateOne(
{
name: INACCESSIBLE_SKILL_NAME,
author: INACCESSIBLE_AUTHOR_ID,
tenantId: null,
},
{
$set: {
description: SKILL_DESCRIPTION,
body: buildSkillBody(INACCESSIBLE_SKILL_NAME),
authorName: 'Inaccessible E2E User',
updatedAt: now,
},
$setOnInsert: {
displayTitle: INACCESSIBLE_SKILL_NAME,
frontmatter: {},
disableModelInvocation: false,
userInvocable: true,
allowedTools: [],
alwaysApply: true,
source: 'inline',
fileCount: 0,
version: 1,
createdAt: now,
},
},
{ upsert: true },
);
const skill = await skills.findOne({
name: INACCESSIBLE_SKILL_NAME,
author: INACCESSIBLE_AUTHOR_ID,
tenantId: null,
});
if (skill?._id) {
await aclEntries.deleteMany({ resourceType: 'skill', resourceId: skill._id });
}
} finally {
await client.close();
}
}
test.describe('model spec skills', () => {
test('loads accessible configured skills and skips missing or inaccessible names', async ({
page,
}) => {
test.setTimeout(120000);
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
const token = await getAccessToken(page);
const skill = await seedAccessibleSkill(page, token);
expect(skill.alwaysApply).toBe(true);
await seedInaccessibleSkill();
await selectModelSpec(page, MODEL_SPEC_LABEL);
const response = await sendMessage(
page,
`${ASSERTION_MARKER}\nVerify model-spec skill scope and always-apply frontmatter.`,
);
expect(response.ok()).toBeTruthy();
await expect(
page
.getByTestId('messages-view')
.getByText(`${ASSERTION_FINAL_TEXT}: ${ACCESSIBLE_SKILL_NAME}`),
).toBeVisible({ timeout: 30000 });
});
});

View file

@ -4,6 +4,8 @@ import {
MOCK_ENDPOINTS,
NEW_CHAT_PATH,
enableSkills,
fetchJson,
getAccessToken,
selectMockEndpoint,
sendMessage,
} from './helpers';
@ -25,69 +27,8 @@ type SkillDetail = SkillSummary & {
body: string;
};
type RefreshTokenBody = {
token?: string;
};
const uniqueSkillName = () => `e2e-file-authoring-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
async function getAccessToken(page: Page): Promise<string> {
const result = await page.evaluate(async () => {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const text = await response.text();
let json: unknown = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: response.ok, status: response.status, text, json };
});
if (!result.ok) {
throw new Error(
`Expected /api/auth/refresh to return 2xx, got ${result.status}: ${result.text}`,
);
}
const body = result.json as RefreshTokenBody | null;
if (!body?.token) {
throw new Error(`Expected /api/auth/refresh to return a token, got: ${result.text}`);
}
return body.token;
}
async function fetchJson<T>(page: Page, path: string, token: string): Promise<T> {
const result = await page.evaluate(
async ({ accessToken, urlPath }) => {
const response = await fetch(urlPath, {
credentials: 'include',
headers: { Authorization: `Bearer ${accessToken}` },
});
const text = await response.text();
let json: unknown = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: response.ok, status: response.status, text, json };
},
{ accessToken: token, urlPath: path },
);
if (!result.ok) {
throw new Error(`Expected ${path} to return 2xx, got ${result.status}: ${result.text}`);
}
return result.json as T;
}
async function findSkill(
page: Page,
skillName: string,

View file

@ -381,6 +381,10 @@ endpoints:
# # (optional) Minimum relevance score for sources to be included in responses, defaults to 0.45 (45% relevance threshold)
# # Set to 0.0 to show all sources (no filtering), or higher like 0.7 for stricter filtering
# minRelevanceScore: 0.45
# # (optional) Cap the number of active accessible skills shown in the model-visible catalog.
# # Useful for large organizations where many department-specific skills may be available.
# skills:
# maxCatalogSkills: 20
# # (optional) Agent Capabilities available to all users. Omit the ones you wish to exclude. Defaults to list below.
# capabilities: ["deferred_tools", "execute_code", "file_search", "actions", "tools"]
@ -606,6 +610,10 @@ endpoints:
# preset:
# endpoint: "openAI"
# model: "gpt-4o"
# # Skills can be enabled per model spec. Use true for the user's active
# # accessible skill catalog, false to force skills off, or a name list as
# # a strict allowlist for catalog, manual, and always-apply resolution.
# # skills: ["brand-guidelines", "code-review"]
#
# # Example 2: Nested under a custom endpoint (grouped with groq endpoint)
# - name: "llama3-70b-8192"

View file

@ -1,11 +1,16 @@
import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
import { Constants } from 'librechat-data-provider';
import { Constants, FileSources } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { agentSchema, createMethods } from '@librechat/data-schemas';
import type { AgentModelParameters } from 'librechat-data-provider';
import type {
Agent as LibreChatAgent,
AgentModelParameters,
TConversation,
} from 'librechat-data-provider';
import type { LoadAgentParams, LoadAgentDeps } from '../load';
import { loadAgent } from '../load';
import { loadAddedAgent } from '../added';
let Agent: mongoose.Model<unknown>;
let createAgent: ReturnType<typeof createMethods>['createAgent'];
@ -253,6 +258,184 @@ describe('loadAgent', () => {
expect(result?.model_parameters).not.toHaveProperty('promptPrefix');
});
test('should enable full skill scope for ephemeral model spec with skills true', async () => {
const { EPHEMERAL_AGENT_ID } = Constants;
const result = await loadAgent(
{
req: {
user: { id: 'user123' },
body: {},
config: {
config: {},
fileStrategy: FileSources.local,
imageOutputType: 'png',
modelSpecs: {
list: [
{
name: 'skills-on',
label: 'Skills On',
preset: { endpoint: 'openai', model: 'gpt-4' },
skills: true,
},
],
},
},
},
spec: 'skills-on',
agent_id: EPHEMERAL_AGENT_ID as string,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' } as unknown as AgentModelParameters,
},
deps,
);
expect(result?.skills_enabled).toBe(true);
expect(result?.skills).toBeUndefined();
});
test('should initialize an empty allowlist for ephemeral model spec skill names', async () => {
const { EPHEMERAL_AGENT_ID } = Constants;
const result = await loadAgent(
{
req: {
user: { id: 'user123' },
body: {},
config: {
config: {},
fileStrategy: FileSources.local,
imageOutputType: 'png',
modelSpecs: {
list: [
{
name: 'scoped-skills',
label: 'Scoped Skills',
preset: { endpoint: 'openai', model: 'gpt-4' },
skills: ['finance-analyst', 'brand-writer'],
},
],
},
},
},
spec: 'scoped-skills',
agent_id: EPHEMERAL_AGENT_ID as string,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' } as unknown as AgentModelParameters,
},
deps,
);
expect(result?.skills_enabled).toBe(true);
expect(result?.skills).toEqual([]);
});
test('should enable full skill scope for added ephemeral model spec with skills true', async () => {
const result = await loadAddedAgent(
{
req: {
user: { id: 'user123' },
config: {
config: {},
fileStrategy: FileSources.local,
imageOutputType: 'png',
modelSpecs: {
list: [
{
name: 'added-skills-on',
label: 'Added Skills On',
preset: { endpoint: 'openai', model: 'gpt-4' },
skills: true,
},
],
},
},
},
conversation: {
endpoint: 'openai',
model: 'gpt-4',
spec: 'added-skills-on',
} as unknown as TConversation,
},
deps,
);
expect(result?.skills_enabled).toBe(true);
expect(result?.skills).toBeUndefined();
});
test('should initialize an empty allowlist for added ephemeral model spec skill names', async () => {
const result = await loadAddedAgent(
{
req: {
user: { id: 'user123' },
config: {
config: {},
fileStrategy: FileSources.local,
imageOutputType: 'png',
modelSpecs: {
list: [
{
name: 'added-scoped-skills',
label: 'Added Scoped Skills',
preset: { endpoint: 'openai', model: 'gpt-4' },
skills: ['finance-analyst', 'brand-writer'],
},
],
},
},
},
conversation: {
endpoint: 'openai',
model: 'gpt-4',
spec: 'added-scoped-skills',
} as unknown as TConversation,
},
deps,
);
expect(result?.skills_enabled).toBe(true);
expect(result?.skills).toEqual([]);
});
test('should apply model spec skills when added agent mirrors ephemeral primary tools', async () => {
const { EPHEMERAL_AGENT_ID } = Constants;
const result = await loadAddedAgent(
{
req: {
user: { id: 'user123' },
config: {
config: {},
fileStrategy: FileSources.local,
imageOutputType: 'png',
modelSpecs: {
list: [
{
name: 'mirrored-scoped-skills',
label: 'Mirrored Scoped Skills',
preset: { endpoint: 'openai', model: 'gpt-4' },
skills: ['brand-writer'],
},
],
},
},
},
conversation: {
endpoint: 'openai',
model: 'gpt-4',
spec: 'mirrored-scoped-skills',
} as unknown as TConversation,
primaryAgent: { id: EPHEMERAL_AGENT_ID as string, tools: ['web_search'] } as LibreChatAgent,
},
deps,
);
expect(result?.tools).toEqual(['web_search']);
expect(result?.skills_enabled).toBe(true);
expect(result?.skills).toEqual([]);
});
test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => {
const { EPHEMERAL_AGENT_ID } = Constants;

View file

@ -38,6 +38,7 @@ import {
scopeSkillIds,
resolveSkillActive,
resolveAgentScopedSkillIds,
resolveModelSpecSkillIds,
injectSkillCatalog,
buildSkillPrimeMessage,
resolveManualSkills,
@ -312,9 +313,11 @@ describe('resolveAgentScopedSkillIds', () => {
});
const ephemeralAgent = (
skills?: string[],
skills_enabled?: boolean,
): { id: string; skills?: string[]; skills_enabled?: boolean } => ({
id: 'ephemeral_convo_xyz',
skills,
skills_enabled,
});
it('returns [] when the skills capability is disabled, even with every other signal on', () => {
@ -366,14 +369,50 @@ describe('resolveAgentScopedSkillIds', () => {
expect(scoped.map((o) => o.toString()).sort()).toEqual([a.toString(), b.toString()].sort());
});
it('ignores any `skills` field on an ephemeral agent (toggle is the only signal)', () => {
it('returns the full accessible catalog when a model spec enables skills', () => {
const a = makeId();
const b = makeId();
const scoped = resolveAgentScopedSkillIds({
agent: ephemeralAgent(undefined, true),
accessibleSkillIds: [a, b],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(scoped.map((o) => o.toString()).sort()).toEqual([a.toString(), b.toString()].sort());
});
it('returns the model-spec allowlist intersection when configured', () => {
const a = makeId();
const b = makeId();
const scoped = resolveAgentScopedSkillIds({
agent: ephemeralAgent([a.toString()], true),
accessibleSkillIds: [a, b],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(scoped.map((o) => o.toString())).toEqual([a.toString()]);
});
it('treats an empty model-spec allowlist as explicit none', () => {
const a = makeId();
expect(
resolveAgentScopedSkillIds({
agent: ephemeralAgent([a.toString()]),
agent: ephemeralAgent([], true),
accessibleSkillIds: [a],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
ephemeralSkillsToggle: true,
}),
).toEqual([]);
});
it('lets an explicit model-spec skills=false override the badge toggle', () => {
const a = makeId();
expect(
resolveAgentScopedSkillIds({
agent: ephemeralAgent(undefined, false),
accessibleSkillIds: [a],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: true,
}),
).toEqual([]);
});
@ -470,6 +509,95 @@ describe('resolveAgentScopedSkillIds', () => {
});
});
describe('resolveModelSpecSkillIds', () => {
const userObjectId = new Types.ObjectId();
it('resolves configured names against accessible skills and skips misses without failing', async () => {
const knownId = new Types.ObjectId();
const getSkillByName = jest.fn(async (name: string) => {
if (name === 'known-skill') {
return {
_id: knownId,
name,
body: 'body',
author: userObjectId,
};
}
if (name === 'throws') {
throw new Error('lookup failed');
}
return null;
});
const result = await resolveModelSpecSkillIds({
names: [' known-skill ', 'missing-skill', 'throws', 'known-skill'],
accessibleSkillIds: [knownId],
getSkillByName,
});
expect(result.map((id) => id.toString())).toEqual([knownId.toString()]);
expect(getSkillByName).toHaveBeenCalledTimes(3);
expect(getSkillByName).toHaveBeenCalledWith('known-skill', [knownId], {
preferModelInvocable: true,
});
});
it('resolves configured names sequentially to avoid query bursts', async () => {
const firstId = new Types.ObjectId();
const secondId = new Types.ObjectId();
const order: string[] = [];
let releaseFirst!: () => void;
const firstLookup = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const getSkillByName = jest.fn(async (name: string) => {
order.push(`start:${name}`);
if (name === 'first') {
await firstLookup;
order.push(`end:${name}`);
return {
_id: firstId,
name,
body: 'body',
author: userObjectId,
};
}
order.push(`end:${name}`);
return {
_id: secondId,
name,
body: 'body',
author: userObjectId,
};
});
const promise = resolveModelSpecSkillIds({
names: ['first', 'second'],
accessibleSkillIds: [firstId, secondId],
getSkillByName,
});
await Promise.resolve();
expect(order).toEqual(['start:first']);
releaseFirst();
const result = await promise;
expect(result.map((id) => id.toString())).toEqual([firstId.toString(), secondId.toString()]);
expect(order).toEqual(['start:first', 'end:first', 'start:second', 'end:second']);
});
it('returns [] when no skill lookup is available', async () => {
const result = await resolveModelSpecSkillIds({
names: ['known-skill'],
accessibleSkillIds: [new Types.ObjectId()],
getSkillByName: undefined,
});
expect(result).toEqual([]);
});
});
describe('resolveSkillActive', () => {
const makeSkill = (author: Types.ObjectId) => ({ _id: new Types.ObjectId(), author });
@ -744,6 +872,26 @@ describe('injectSkillCatalog', () => {
expect(agent.additional_instructions).toContain('desc-my-skill');
});
it('honors a configured maxCatalogSkills below the default hard limit', async () => {
const first = makeSkill('first-skill', userObjectId);
const second = makeSkill('second-skill', userObjectId);
const third = makeSkill('third-skill', userObjectId);
const listSkillsByAccess = buildPager([[first, second, third]]);
const agent = makeAgent();
const result = await injectSkillCatalog(
baseParams({ listSkillsByAccess, agent, maxCatalogSkills: 2 }),
);
expect(result.skillCount).toBe(2);
expect(result.activeSkillIds.map((id) => id.toString())).toEqual([
first._id.toString(),
second._id.toString(),
]);
expect(agent.additional_instructions).toContain('first-skill');
expect(agent.additional_instructions).toContain('second-skill');
expect(agent.additional_instructions).not.toContain('third-skill');
});
it('fails closed when userId is absent (shared skills drop, owned would need override)', async () => {
const owned = makeSkill('my-skill', userObjectId);
const shared = makeSkill('shared-skill', new Types.ObjectId());

View file

@ -8,13 +8,32 @@ import {
appendAgentIdSuffix,
encodeEphemeralAgentId,
} from 'librechat-data-provider';
import type { Agent, TConversation } from 'librechat-data-provider';
import type { Agent, TConversation, TModelSpec } from 'librechat-data-provider';
import { getCustomEndpointConfig } from '~/app/config';
const { mcp_all, mcp_delimiter } = Constants;
export const ADDED_AGENT_ID = 'added_agent';
function applyModelSpecSkills(
result: Record<string, unknown>,
modelSpec: Pick<TModelSpec, 'skills'> | null | undefined,
): void {
if (!modelSpec || !Object.prototype.hasOwnProperty.call(modelSpec, 'skills')) {
return;
}
if (modelSpec.skills === true) {
result.skills_enabled = true;
delete result.skills;
} else if (modelSpec.skills === false) {
result.skills_enabled = false;
result.skills = [];
} else if (Array.isArray(modelSpec.skills)) {
result.skills_enabled = true;
result.skills = [];
}
}
export interface LoadAddedAgentDeps {
getAgent: (searchParameter: { id: string }) => Promise<Agent | null>;
getMCPServerTools: (
@ -95,8 +114,7 @@ export async function loadAddedAgent(
}
}
const modelSpecs = (appConfig?.modelSpecs as { list?: Array<{ name: string; label?: string }> })
?.list;
const modelSpecs = (appConfig?.modelSpecs as { list?: TModelSpec[] })?.list;
const modelSpec = spec != null && spec !== '' ? modelSpecs?.find((s) => s.name === spec) : null;
const sender =
rest.modelLabel ??
@ -105,14 +123,16 @@ export async function loadAddedAgent(
'';
const ephemeralId = encodeEphemeralAgentId({ endpoint, model, sender, index: 1 });
return {
const result: Record<string, unknown> = {
id: ephemeralId,
instructions: promptPrefix || '',
provider: endpoint,
model_parameters: {},
model,
tools: [...primaryAgent.tools],
} as unknown as Agent;
};
applyModelSpecSkills(result, modelSpec);
return result as unknown as Agent;
}
const ephemeralAgent = rest.ephemeralAgent as
@ -127,18 +147,7 @@ export async function loadAddedAgent(
const mcpServers = new Set<string>(ephemeralAgent?.mcp);
const userId = req.user?.id ?? '';
const modelSpecs = (
appConfig?.modelSpecs as {
list?: Array<{
name: string;
label?: string;
mcpServers?: string[];
executeCode?: boolean;
fileSearch?: boolean;
webSearch?: boolean;
}>;
}
)?.list;
const modelSpecs = (appConfig?.modelSpecs as { list?: TModelSpec[] })?.list;
let modelSpec: (typeof modelSpecs extends Array<infer T> | undefined ? T : never) | null = null;
if (spec != null && spec !== '') {
modelSpec = modelSpecs?.find((s) => s.name === spec) ?? null;
@ -225,6 +234,7 @@ export async function loadAddedAgent(
if (ephemeralAgent?.artifacts != null && ephemeralAgent.artifacts) {
result.artifacts = ephemeralAgent.artifacts;
}
applyModelSpecSkills(result, modelSpec);
return result as unknown as Agent;
}

View file

@ -70,6 +70,13 @@ function appendAdditionalInstructions(agent: Agent, text?: string | null): void
.join('\n\n');
}
function getMaxCatalogSkills(req: ServerRequest): number | undefined {
const endpoints = req.config?.endpoints as
| Record<string, { skills?: { maxCatalogSkills?: number } } | undefined>
| undefined;
return endpoints?.[EModelEndpoint.agents]?.skills?.maxCatalogSkills;
}
function getToolName(tool: unknown): string | undefined {
if (tool == null || typeof tool !== 'object') {
return undefined;
@ -1022,6 +1029,7 @@ export async function initializeAgent(
userId: req.user?.id,
skillStates: params.skillStates,
defaultActiveOnShare: params.defaultActiveOnShare,
maxCatalogSkills: getMaxCatalogSkills(req),
});
toolDefinitions = skillResult.toolDefinitions;
skillCount = skillResult.skillCount;

View file

@ -135,6 +135,17 @@ export async function loadEphemeralAgent(
if (ephemeralAgent?.artifacts) {
result.artifacts = ephemeralAgent.artifacts;
}
if (modelSpec && Object.prototype.hasOwnProperty.call(modelSpec, 'skills')) {
if (modelSpec.skills === true) {
result.skills_enabled = true;
} else if (modelSpec.skills === false) {
result.skills_enabled = false;
result.skills = [];
} else if (Array.isArray(modelSpec.skills)) {
result.skills_enabled = true;
result.skills = [];
}
}
return result as Agent;
}

View file

@ -11,10 +11,13 @@ import { registerCodeExecutionTools } from './tools';
import { logAxiosError } from '~/utils';
const SKILL_CATALOG_LIMIT = 100;
const MIN_SKILL_CATALOG_LIMIT = 1;
/** Max pages scanned per run when filtering out inactive skills. */
const MAX_CATALOG_PAGES = 10;
/** Page size used when paginating to fill the active-skill quota. */
const CATALOG_PAGE_SIZE = 100;
/** Hard ceiling on skill names a model spec can request by config. */
const MAX_MODEL_SPEC_SKILLS = SKILL_CATALOG_LIMIT;
/**
* Hard ceiling on skill names resolved per request via `$` popover or
* `always-apply`. The popover realistically surfaces only a few per turn;
@ -123,6 +126,96 @@ export function scopeSkillIds(
return accessibleSkillIds.filter((oid) => agentSet.has(oid.toString()));
}
function normalizeSkillCatalogLimit(limit: number | undefined): number {
if (typeof limit !== 'number' || !Number.isFinite(limit)) {
return SKILL_CATALOG_LIMIT;
}
return Math.min(SKILL_CATALOG_LIMIT, Math.max(MIN_SKILL_CATALOG_LIMIT, Math.floor(limit)));
}
export interface ResolveModelSpecSkillIdsParams {
/** Skill names configured on a model spec. */
names: string[];
/** Full VIEW-accessible skill IDs for this user before model-spec scoping. */
accessibleSkillIds: Types.ObjectId[];
/** DB lookup: name → skill doc constrained to the user's accessible IDs. */
getSkillByName?: InitializeAgentDbMethods['getSkillByName'];
}
/**
* Resolves model-spec skill names against the current user's accessible skill
* set. Config is advisory: unrecognized, inaccessible, malformed, or errored
* names are skipped with a warning so a stale model-spec entry never blocks
* the actual chat request.
*/
export async function resolveModelSpecSkillIds({
names,
accessibleSkillIds,
getSkillByName,
}: ResolveModelSpecSkillIdsParams): Promise<Types.ObjectId[]> {
if (!names.length || accessibleSkillIds.length === 0 || !getSkillByName) {
return [];
}
const seenNames = new Set<string>();
const uniqueNames: string[] = [];
for (const name of names) {
if (typeof name !== 'string') {
continue;
}
const trimmed = name.trim();
if (!trimmed || trimmed.length > MAX_SKILL_NAME_LENGTH || seenNames.has(trimmed)) {
continue;
}
seenNames.add(trimmed);
uniqueNames.push(trimmed);
}
let boundedNames = uniqueNames;
if (uniqueNames.length > MAX_MODEL_SPEC_SKILLS) {
logger.warn(
`[resolveModelSpecSkillIds] Truncating model spec skill list from ${uniqueNames.length} to ${MAX_MODEL_SPEC_SKILLS}.`,
);
boundedNames = uniqueNames.slice(0, MAX_MODEL_SPEC_SKILLS);
}
const resolved: Array<Types.ObjectId | null> = [];
for (const name of boundedNames) {
try {
const skill = await getSkillByName(name, accessibleSkillIds, {
preferModelInvocable: true,
});
if (!skill) {
logger.warn(
`[resolveModelSpecSkillIds] Skill "${name}" not found or not accessible for this user`,
);
resolved.push(null);
continue;
}
resolved.push(skill._id);
} catch (err) {
logger.warn(
`[resolveModelSpecSkillIds] Failed to resolve skill "${name}":`,
err instanceof Error ? err.message : err,
);
resolved.push(null);
}
}
const seenIds = new Set<string>();
return resolved.filter((id): id is Types.ObjectId => {
if (!id) {
return false;
}
const key = id.toString();
if (seenIds.has(key)) {
return false;
}
seenIds.add(key);
return true;
});
}
export interface ResolveAgentScopedSkillIdsParams {
/** Agent being initialized. Reads `id`, `skills`, and `skills_enabled`. */
agent: Pick<Agent, 'id' | 'skills' | 'skills_enabled'>;
@ -137,8 +230,10 @@ export interface ResolveAgentScopedSkillIdsParams {
/**
* Strict opt-in resolver for per-agent skill scope. Activation requires an
* explicit signal from the user or the agent author:
* - Ephemeral agent the skills badge toggle for this conversation.
* Toggle ON = full accessible catalog; OFF = no skills.
* - Ephemeral agent model-spec `skills` wins when configured:
* `true` = full accessible catalog, string list = scoped allowlist,
* `false` = no skills. Otherwise the skills badge toggle controls
* the full accessible catalog.
* - Persisted agent the builder's `skills_enabled` master switch.
* Enabled + empty allowlist = full catalog; enabled + non-empty
* allowlist = narrow to those ids; disabled (or undefined) = no skills.
@ -158,6 +253,14 @@ export function resolveAgentScopedSkillIds(
return [];
}
if (isEphemeralAgentId(agent.id)) {
if (agent.skills_enabled === false) {
return [];
}
if (agent.skills_enabled === true) {
return Array.isArray(agent.skills)
? scopeSkillIds(accessibleSkillIds, agent.skills)
: scopeSkillIds(accessibleSkillIds, undefined);
}
return ephemeralSkillsToggle ? scopeSkillIds(accessibleSkillIds, undefined) : [];
}
if (agent.skills_enabled !== true) {
@ -217,6 +320,8 @@ export interface InjectSkillCatalogParams {
skillStates?: Record<string, boolean>;
/** Admin-configured default for shared skills. `true` = shared skills auto-activate. */
defaultActiveOnShare?: boolean;
/** Admin-configured cap on the model-visible catalog. Defaults to 100. */
maxCatalogSkills?: number;
}
export interface InjectSkillCatalogResult {
@ -268,7 +373,9 @@ export async function injectSkillCatalog(
userId,
skillStates,
defaultActiveOnShare = false,
maxCatalogSkills,
} = params;
const catalogLimit = normalizeSkillCatalogLimit(maxCatalogSkills);
if (!listSkillsByAccess || accessibleSkillIds.length === 0) {
return {
@ -299,7 +406,7 @@ export async function injectSkillCatalog(
let pages = 0;
let reachedEnd = false;
while (visibleCount < SKILL_CATALOG_LIMIT && pages < MAX_CATALOG_PAGES) {
while (visibleCount < catalogLimit && pages < MAX_CATALOG_PAGES) {
const page = await listSkillsByAccess({
accessibleIds: accessibleSkillIds,
limit: CATALOG_PAGE_SIZE,
@ -307,7 +414,7 @@ export async function injectSkillCatalog(
});
for (const skill of page.skills) {
if (visibleCount >= SKILL_CATALOG_LIMIT) {
if (visibleCount >= catalogLimit) {
break;
}
/**
@ -344,9 +451,9 @@ export async function injectSkillCatalog(
};
}
if (!reachedEnd && visibleCount < SKILL_CATALOG_LIMIT) {
if (!reachedEnd && visibleCount < catalogLimit) {
logger.warn(
`[injectSkillCatalog] Scanned ${MAX_CATALOG_PAGES} pages without filling the ${SKILL_CATALOG_LIMIT}-skill catalog. Some active skills may be excluded.`,
`[injectSkillCatalog] Scanned ${MAX_CATALOG_PAGES} pages without filling the ${catalogLimit}-skill catalog. Some active skills may be excluded.`,
);
}

View file

@ -170,18 +170,24 @@ export function sanitizeModelSpecs<T extends Partial<TSpecsConfig> | null | unde
return {
...modelSpecs,
list: modelSpecs.list.map((modelSpec) => {
const preset = modelSpec?.preset;
if (!preset || typeof preset !== 'object') {
if (!modelSpec || typeof modelSpec !== 'object') {
return modelSpec;
}
const preset = modelSpec?.preset;
const sanitizedModelSpec = { ...modelSpec };
delete (sanitizedModelSpec as { skills?: unknown }).skills;
if (!preset || typeof preset !== 'object') {
return sanitizedModelSpec;
}
const sanitizedPreset = { ...preset };
for (const field of PRIVATE_MODEL_SPEC_PRESET_FIELDS) {
delete sanitizedPreset[field];
}
return {
...modelSpec,
...sanitizedModelSpec,
preset: sanitizedPreset,
};
}),

View file

@ -17,6 +17,7 @@ describe('modelSpecs helpers', () => {
{
name: 'guarded-spec',
label: 'Guarded Spec',
skills: ['private-skill'],
preset: {
endpoint: EModelEndpoint.openAI,
model: 'gpt-4o',
@ -32,11 +33,13 @@ describe('modelSpecs helpers', () => {
],
};
expect(sanitizeModelSpecs(modelSpecs).list[0].preset).toEqual({
const sanitizedModelSpecs = sanitizeModelSpecs(modelSpecs);
expect(sanitizedModelSpecs.list[0].preset).toEqual({
endpoint: EModelEndpoint.openAI,
model: 'gpt-4o',
greeting: 'Hello',
});
expect(sanitizedModelSpecs.list[0]).not.toHaveProperty('skills');
});
it('should restore only private fields for non-enforced model specs', () => {

View file

@ -121,6 +121,37 @@ describe('parseFrontmatter', () => {
});
});
it('extracts alwaysApply: true', () => {
const raw = `---\nname: legal\ndescription: Legal rules.\nalwaysApply: true\n---\n\n# Legal body`;
expect(parseFrontmatter(raw)).toEqual({
name: 'legal',
description: 'Legal rules.',
alwaysApply: true,
invalidBooleans: [],
});
});
it('lets always-apply win when both spellings are present', () => {
const raw = `---\nname: legal\ndescription: Legal rules.\nalways-apply: false\nalwaysApply: true\n---\n\n# Legal body`;
expect(parseFrontmatter(raw).alwaysApply).toBe(false);
});
it('ignores invalid alwaysApply alias when canonical always-apply is valid', () => {
const raw = `---\nname: legal\ndescription: Legal rules.\nalways-apply: true\nalwaysApply: yes\n---\n\n# Legal body`;
const result = parseFrontmatter(raw);
expect(result.alwaysApply).toBe(true);
expect(result.invalidBooleans).toEqual([]);
});
it('ignores invalid alwaysApply alias before a valid canonical always-apply', () => {
const raw = `---\nname: legal\ndescription: Legal rules.\nalwaysApply: yes\nalways-apply: false\n---\n\n# Legal body`;
const result = parseFrontmatter(raw);
expect(result.alwaysApply).toBe(false);
expect(result.invalidBooleans).toEqual([]);
});
it('flags non-boolean always-apply values as invalid (no silent drop)', () => {
const raw = `---\nname: n\ndescription: d\nalways-apply: yes\n---\n\nbody`;
const result = parseFrontmatter(raw);
@ -128,6 +159,13 @@ describe('parseFrontmatter', () => {
expect(result.invalidBooleans).toEqual(['always-apply']);
});
it('flags non-boolean alwaysApply values as invalid (no silent drop)', () => {
const raw = `---\nname: n\ndescription: d\nalwaysApply: yes\n---\n\nbody`;
const result = parseFrontmatter(raw);
expect(result.alwaysApply).toBeUndefined();
expect(result.invalidBooleans).toEqual(['alwaysApply']);
});
it('does not flag always-apply when the key is absent', () => {
const raw = `---\nname: n\ndescription: d\n---\n\nbody`;
expect(parseFrontmatter(raw).invalidBooleans).toEqual([]);

View file

@ -64,11 +64,12 @@ function parseBooleanScalar(value: string): boolean | undefined {
* `packages/data-schemas/src/methods/skill.ts` covers the wire contract;
* this parser only needs to hand `createSkill` the columns it populates.
*
* When a known boolean field (currently just `always-apply`) is present
* When a known boolean field (currently `always-apply` plus the accepted
* `alwaysApply` alias) is present
* with a value that isn't recognizable as `true`/`false`, the parser
* records it on `invalidBooleans[]` so the import handler can surface
* a 400 instead of silently dropping the flag. Without this signal,
* authoring mistakes like `always-apply: yes` would be lossy-converted
* authoring mistakes like `alwaysApply: yes` would be lossy-converted
* to "not always-applied" and the user would never learn their
* frontmatter was malformed.
*
@ -94,19 +95,23 @@ export function parseFrontmatter(raw: string): {
let name = '';
let description = '';
let alwaysApply: boolean | undefined;
const invalidBooleans: string[] = [];
let hasValidCanonicalAlwaysApply = false;
const invalidCanonicalAlwaysApply: string[] = [];
const invalidAliasAlwaysApply: string[] = [];
for (const line of block.split('\n')) {
const colon = line.indexOf(':');
if (colon === -1) {
continue;
}
const key = line.slice(0, colon).trim().toLowerCase();
const rawKey = line.slice(0, colon).trim();
const key = rawKey.toLowerCase();
const rawValue = line.slice(colon + 1).trim();
if (key === 'name') {
name = unquoteYaml(rawValue);
} else if (key === 'description') {
description = unquoteYaml(rawValue);
} else if (key === 'always-apply') {
} else if (key === 'always-apply' || key === 'alwaysapply') {
const isCanonical = key === 'always-apply';
// Operate on the raw post-colon text (no outer `unquoteYaml`): a
// line like `always-apply: "true" # note` must have its comment
// stripped BEFORE unquoting. Running `unquoteYaml` on the whole
@ -122,12 +127,24 @@ export function parseFrontmatter(raw: string): {
}
const parsed = parseBooleanScalar(unquoteYaml(stripped));
if (parsed === undefined) {
invalidBooleans.push(key);
if (isCanonical) {
invalidCanonicalAlwaysApply.push(rawKey);
} else {
invalidAliasAlwaysApply.push(rawKey);
}
} else {
alwaysApply = parsed;
if (isCanonical) {
hasValidCanonicalAlwaysApply = true;
alwaysApply = parsed;
} else if (!hasValidCanonicalAlwaysApply && invalidCanonicalAlwaysApply.length === 0) {
alwaysApply = parsed;
}
}
}
}
const invalidBooleans = hasValidCanonicalAlwaysApply
? invalidCanonicalAlwaysApply
: [...invalidCanonicalAlwaysApply, ...invalidAliasAlwaysApply];
return { name, description, alwaysApply, invalidBooleans };
}

View file

@ -572,6 +572,11 @@ export const agentsEndpointSchema = baseEndpointSchema
.array(z.nativeEnum(AgentCapabilities))
.optional()
.default(defaultAgentCapabilities),
skills: z
.object({
maxCatalogSkills: z.number().int().min(1).max(100).optional(),
})
.optional(),
remoteApi: remoteApiSchema.optional(),
}),
)

View file

@ -39,6 +39,7 @@ export type TModelSpec = {
executeCode?: boolean;
artifacts?: string | boolean;
mcpServers?: string[];
skills?: boolean | string[];
};
export const tModelSpecSchema = z.object({
@ -60,6 +61,7 @@ export const tModelSpecSchema = z.object({
executeCode: z.boolean().optional(),
artifacts: z.union([z.string(), z.boolean()]).optional(),
mcpServers: z.array(z.string()).optional(),
skills: z.union([z.boolean(), z.array(z.string())]).optional(),
});
export const specsConfigSchema = z.object({

View file

@ -197,7 +197,7 @@ export type TCreateSkill = {
body: string;
frontmatter?: Partial<SkillFrontmatter>;
category?: string;
/** When `true`, the skill auto-primes into every turn (mirrors `always-apply` frontmatter). */
/** When `true`, the skill auto-primes into every turn (mirrors always-apply frontmatter). */
alwaysApply?: boolean;
};

View file

@ -283,6 +283,8 @@ describe('skill validation helpers', () => {
it('accepts always-apply as a boolean', () => {
expect(validateSkillFrontmatter({ 'always-apply': true })).toEqual([]);
expect(validateSkillFrontmatter({ 'always-apply': false })).toEqual([]);
expect(validateSkillFrontmatter({ alwaysApply: true })).toEqual([]);
expect(validateSkillFrontmatter({ alwaysApply: false })).toEqual([]);
});
it('rejects always-apply with non-boolean values', () => {
@ -294,6 +296,11 @@ describe('skill validation helpers', () => {
expect(
validateSkillFrontmatter({ 'always-apply': 1 }).some((i) => i.code === 'INVALID_TYPE'),
).toBe(true);
expect(
validateSkillFrontmatter({ alwaysApply: 'yes' }).some(
(i) => i.code === 'INVALID_TYPE' && i.field === 'frontmatter.alwaysApply',
),
).toBe(true);
});
});
@ -1076,6 +1083,35 @@ describe('Skill CRUD methods', () => {
expect(skill.alwaysApply).toBe(true);
});
it('createSkill derives alwaysApply from the camel-case frontmatter alias', async () => {
const { skill } = await methods.createSkill(
makeSkillInput({
name: 'frontmatter-alias-on',
frontmatter: {
name: 'frontmatter-alias-on',
description: 'A small demo skill used in tests.',
alwaysApply: true,
},
}),
);
expect(skill.alwaysApply).toBe(true);
});
it('createSkill lets always-apply win when both frontmatter spellings are present', async () => {
const { skill } = await methods.createSkill(
makeSkillInput({
name: 'frontmatter-canonical-wins',
frontmatter: {
name: 'frontmatter-canonical-wins',
description: 'A small demo skill used in tests.',
'always-apply': false,
alwaysApply: true,
},
}),
);
expect(skill.alwaysApply).toBe(false);
});
it('createSkill prefers explicit top-level alwaysApply over frontmatter', async () => {
const { skill } = await methods.createSkill(
makeSkillInput({
@ -1111,6 +1147,26 @@ describe('Skill CRUD methods', () => {
}
});
it('updateSkill syncs alwaysApply column from the camel-case frontmatter alias', async () => {
const { skill } = await methods.createSkill(makeSkillInput({ name: 'sync-alias-test' }));
expect(skill.alwaysApply).toBe(false);
const result = await methods.updateSkill({
id: skill._id.toString(),
expectedVersion: skill.version,
update: {
frontmatter: {
name: 'sync-alias-test',
description: 'A small demo skill used in tests.',
alwaysApply: true,
},
},
});
expect(result.status).toBe('updated');
if (result.status === 'updated') {
expect(result.skill.alwaysApply).toBe(true);
}
});
it('updateSkill keeps alwaysApply column untouched when the frontmatter update omits always-apply', async () => {
const { skill } = await methods.createSkill(
makeSkillInput({ name: 'no-flip', alwaysApply: true }),
@ -1180,6 +1236,21 @@ describe('Skill CRUD methods', () => {
}
});
it('updateSkill derives alwaysApply=true from a body edit that adds `alwaysApply: true` inline', async () => {
const { skill } = await methods.createSkill(makeSkillInput({ name: 'body-enable-alias' }));
expect(skill.alwaysApply).toBe(false);
const newBody = `---\nname: body-enable-alias\ndescription: now opting in.\nalwaysApply: true\n---\n\n# Body`;
const result = await methods.updateSkill({
id: skill._id.toString(),
expectedVersion: skill.version,
update: { body: newBody },
});
expect(result.status).toBe('updated');
if (result.status === 'updated') {
expect(result.skill.alwaysApply).toBe(true);
}
});
it('updateSkill flips alwaysApply to false when the body removes the `always-apply:` line', async () => {
/* Regression for the durable mismatch case: a previously-
always-apply skill whose SKILL.md body no longer declares the

View file

@ -250,6 +250,7 @@ const ALLOWED_FRONTMATTER_KEYS = new Set<string>([
'user-invocable',
'disable-model-invocation',
'always-apply',
'alwaysApply',
'model',
'effort',
'context',
@ -278,6 +279,7 @@ const FRONTMATTER_KIND: Record<string, FrontmatterKind | FrontmatterKind[]> = {
'user-invocable': 'boolean',
'disable-model-invocation': 'boolean',
'always-apply': 'boolean',
alwaysApply: 'boolean',
model: 'string',
effort: ['string', 'number'],
context: 'string',
@ -597,6 +599,20 @@ export function backfillDerivedFromFrontmatter<
return skill;
}
function getAlwaysApplyFrontmatterValue(
frontmatter: Record<string, unknown> | undefined,
): boolean | undefined {
const canonical = frontmatter?.['always-apply'];
if (typeof canonical === 'boolean') {
return canonical;
}
const camelAlias = frontmatter?.alwaysApply;
if (typeof camelAlias === 'boolean') {
return camelAlias;
}
return undefined;
}
export type UpsertSkillFileInput = {
skillId: Types.ObjectId | string;
relativePath: string;
@ -679,14 +695,14 @@ type BodyAlwaysApplyResult =
| { status: 'invalid' };
/**
* Extractor for the `always-apply` flag sitting inside a SKILL.md body's
* Extractor for the `always-apply` / `alwaysApply` flag sitting inside a SKILL.md body's
* YAML frontmatter block. The REST edit flow lets users rewrite the full
* SKILL.md text via `update.body` without a structured `frontmatter`
* object, so this is the only signal we have for "user flipped
* `always-apply:` inline in their editor".
* `always-apply:` or `alwaysApply:` inline in their editor".
*
* Returns a discriminated union so callers can tell:
* - `absent` no `always-apply:` key (leave column alone; could be
* - `absent` no always-apply key (leave column alone; could be
* "user removed the flag" or "user hasn't written it yet" both
* resolve to no-op). An empty value (`always-apply:` with nothing
* after the colon) is also treated as absent to allow mid-edit
@ -697,7 +713,8 @@ type BodyAlwaysApplyResult =
* recognizable boolean (e.g. `tru`, `yes`, `1`). Validation rejects
* this rather than silently ignoring so `always-apply: tru` typos
* surface as 400s instead of drifting the column from what the
* saved SKILL.md text says.
* saved SKILL.md text says. When both forms are present, the canonical
* `always-apply` form wins because existing files may already rely on it.
*/
function extractAlwaysApplyFromBody(body: string | undefined): BodyAlwaysApplyResult {
if (typeof body !== 'string' || body.length === 0) {
@ -713,13 +730,15 @@ function extractAlwaysApplyFromBody(body: string | undefined): BodyAlwaysApplyRe
return { status: 'absent' };
}
const block = after.slice(0, closingIdx);
let aliasResult: BodyAlwaysApplyResult | undefined;
for (const line of block.split('\n')) {
const colon = line.indexOf(':');
if (colon === -1) {
continue;
}
const key = line.slice(0, colon).trim().toLowerCase();
if (key !== 'always-apply') {
const key = line.slice(0, colon).trim();
const normalizedKey = key.toLowerCase();
if (normalizedKey !== 'always-apply' && normalizedKey !== 'alwaysapply') {
continue;
}
// Strip the YAML inline comment BEFORE unquoting — a line like
@ -728,7 +747,12 @@ function extractAlwaysApplyFromBody(body: string | undefined): BodyAlwaysApplyRe
// the comment-strip would leave `"true"` which parses as invalid.
let value = stripYamlTrailingComment(line.slice(colon + 1).trim()).trim();
if (value === '') {
return { status: 'absent' };
const result: BodyAlwaysApplyResult = { status: 'absent' };
if (normalizedKey === 'always-apply') {
return result;
}
aliasResult = result;
continue;
}
if (
value.length >= 2 &&
@ -739,14 +763,28 @@ function extractAlwaysApplyFromBody(body: string | undefined): BodyAlwaysApplyRe
}
value = value.trim();
if (value === '') {
return { status: 'absent' };
const result: BodyAlwaysApplyResult = { status: 'absent' };
if (normalizedKey === 'always-apply') {
return result;
}
aliasResult = result;
continue;
}
const lowered = value.toLowerCase();
if (lowered === 'true') return { status: 'valid', value: true };
if (lowered === 'false') return { status: 'valid', value: false };
return { status: 'invalid' };
let result: BodyAlwaysApplyResult;
if (lowered === 'true') {
result = { status: 'valid', value: true };
} else if (lowered === 'false') {
result = { status: 'valid', value: false };
} else {
result = { status: 'invalid' };
}
if (normalizedKey === 'always-apply') {
return result;
}
aliasResult = result;
}
return { status: 'absent' };
return aliasResult ?? { status: 'absent' };
}
/**
@ -760,9 +798,9 @@ function extractAlwaysApplyFromBody(body: string | undefined): BodyAlwaysApplyRe
*
* Precedence:
* 1. An explicit top-level `alwaysApply` wins (caller overrides).
* 2. Otherwise, derive from `frontmatter['always-apply']` when it is
* a strict boolean.
* 3. Otherwise, parse `always-apply:` out of the SKILL.md body
* 2. Otherwise, derive from `frontmatter['always-apply']` or the accepted
* alias `frontmatter.alwaysApply` when either is a strict boolean.
* 3. Otherwise, parse `always-apply:` / `alwaysApply:` out of the SKILL.md body
* frontmatter block (covers the UI edit flow that sends only
* `body` without a structured `frontmatter` object).
* 4. Otherwise, return `fallback` (typically `false` on create, or the
@ -782,7 +820,7 @@ function resolveAlwaysApplyFromInput(
if (typeof explicit === 'boolean') {
return explicit;
}
const fromFrontmatter = frontmatter?.['always-apply'];
const fromFrontmatter = getAlwaysApplyFrontmatterValue(frontmatter);
if (typeof fromFrontmatter === 'boolean') {
return fromFrontmatter;
}
@ -807,9 +845,10 @@ export function validateAlwaysApplyInBody(body: string | undefined): ValidationI
if (result.status === 'invalid') {
return [
{
field: 'body.frontmatter.always-apply',
field: 'body.frontmatter.alwaysApply',
code: 'INVALID_TYPE',
message: '"always-apply" in SKILL.md frontmatter must be a boolean (true or false)',
message:
'"always-apply" or "alwaysApply" in SKILL.md frontmatter must be a boolean (true or false)',
},
];
}
@ -885,18 +924,19 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk
higher-precedence source won't override it (see
`resolveAlwaysApplyFromInput` for the cascade). A caller sending
an explicit top-level `alwaysApply` or a structured
`frontmatter['always-apply']` has the body value overridden at
an always-apply value in structured `frontmatter` has the body value overridden at
derivation time, so rejecting them for a typo they aren't relying
on would be user-hostile. */
if (
bodyAlwaysApply?.status === 'invalid' &&
typeof data.alwaysApply !== 'boolean' &&
typeof data.frontmatter?.['always-apply'] !== 'boolean'
getAlwaysApplyFrontmatterValue(data.frontmatter) === undefined
) {
issues.push({
field: 'body.frontmatter.always-apply',
field: 'body.frontmatter.alwaysApply',
code: 'INVALID_TYPE',
message: '"always-apply" in SKILL.md frontmatter must be a boolean (true or false)',
message:
'"always-apply" or "alwaysApply" in SKILL.md frontmatter must be a boolean (true or false)',
});
}
const { errors, warnings } = partitionIssues(issues);
@ -1214,12 +1254,13 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk
if (
bodyAlwaysApply?.status === 'invalid' &&
update.alwaysApply === undefined &&
typeof update.frontmatter?.['always-apply'] !== 'boolean'
getAlwaysApplyFrontmatterValue(update.frontmatter) === undefined
) {
issues.push({
field: 'body.frontmatter.always-apply',
field: 'body.frontmatter.alwaysApply',
code: 'INVALID_TYPE',
message: '"always-apply" in SKILL.md frontmatter must be a boolean (true or false)',
message:
'"always-apply" or "alwaysApply" in SKILL.md frontmatter must be a boolean (true or false)',
});
}
const { errors, warnings } = partitionIssues(issues);
@ -1258,11 +1299,11 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk
/**
* Keep the indexed `alwaysApply` column in sync with whatever the update
* is carrying: an explicit top-level `alwaysApply` always wins; a
* structured `frontmatter` with `always-apply: true/false` is next; and
* a `body` update is scanned last for an inline `always-apply:` line
* structured `frontmatter` with `always-apply: true/false` or the
* accepted `alwaysApply` alias is next; and a `body` update is scanned last for an inline `always-apply:` line
* inside the SKILL.md frontmatter block. The body path is load-bearing
* for the REST edit flow the current UI sends `body` without a
* parallel `frontmatter` object, so inline edits to `always-apply:`
* parallel `frontmatter` object, so inline edits to `always-apply:` / `alwaysApply:`
* would otherwise leave the column stale and auto-priming / pin
* badges would keep using the old value.
*
@ -1277,7 +1318,7 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk
* at each level, not the presence of the parent field. An API caller
* that sends both `body` and an unrelated `frontmatter` bag (e.g.
* editing category + rewriting SKILL.md in one PATCH) still gets the
* body-inline flag respected because `frontmatter['always-apply']`
* body-inline flag respected because no structured always-apply key
* is absent in that payload.
*/
let derivedAlwaysApply: boolean | undefined;
@ -1285,7 +1326,7 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk
derivedAlwaysApply = update.alwaysApply;
}
if (derivedAlwaysApply === undefined && update.frontmatter !== undefined) {
const fromFrontmatter = update.frontmatter['always-apply'];
const fromFrontmatter = getAlwaysApplyFrontmatterValue(update.frontmatter);
if (typeof fromFrontmatter === 'boolean') {
derivedAlwaysApply = fromFrontmatter;
}

View file

@ -211,7 +211,7 @@ const skillSchema: Schema<ISkillDocument> = new Schema(
/**
* When `true`, the skill's SKILL.md body is auto-primed into every turn
* without user `$` invocation or model discretion. Mirrors the
* `always-apply` YAML frontmatter field and is kept as a first-class
* always-apply YAML frontmatter field and is kept as a first-class
* column so the `listAlwaysApplySkills` query at the top of every
* request is an indexed lookup, not a frontmatter scan.
*/