Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
644b552
🧬 feat: Persist `disable-model-invocation` / `user-invocable` / `allo…
danny-avila Apr 19, 2026
0d2f03a
πŸ›‘οΈ feat: Enforce frontmatter at runtime (catalog, skill tool, manual …
danny-avila Apr 19, 2026
8dfdb3d
πŸŽ›οΈ feat: `$` popover reads `userInvocable` instead of UI-only `invoca…
danny-avila Apr 19, 2026
fbd0021
πŸ”§ fix: Address Phase 6 review findings
danny-avila Apr 19, 2026
ea09553
πŸ”§ fix: Address codex iter 2 β€” catalog quota + duplicate-name dedup
danny-avila Apr 19, 2026
cc8d820
πŸ”’ fix: Apply `disable-model-invocation` gate to read_file too (codex …
danny-avila Apr 19, 2026
0b00cc7
πŸ”§ fix: Address codex iter 4 β€” manual-prime exception + legacy frontma…
danny-avila Apr 19, 2026
530cf6f
πŸ”§ fix: Address codex iter 5 β€” propagate manualSkillNames + keep read_…
danny-avila Apr 19, 2026
09a20d1
πŸ”§ fix: Address codex iter 6 β€” name-collision consistency via preferIn…
danny-avila Apr 19, 2026
45d5c38
πŸ”§ fix: Address codex iter 7 β€” split preferInvocable into per-axis flags
danny-avila Apr 19, 2026
11b17bc
πŸ”§ fix: TypeScript type-check failure in handlers.spec.ts (CI green)
danny-avila Apr 19, 2026
3cb9ead
πŸ”§ fix: Address codex iter 8 β€” undefined-result fallback + read_file a…
danny-avila Apr 19, 2026
aea9f11
πŸ”§ fix: Address codex iter 9 β€” pin read_file lookup to primed skill _id
danny-avila Apr 19, 2026
809e622
🧹 fix: Address independent reviewer findings (DRY + types + tests + d…
danny-avila Apr 20, 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
9 changes: 8 additions & 1 deletion api/server/controllers/agents/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const { findAccessibleResources } = require('~/server/services/PermissionService
const {
getSkillToolDeps,
enrichWithSkillConfigurable,
buildManualSkillPrimedIdsByName,
} = require('~/server/services/Endpoints/agents/skillDeps');
const db = require('~/models');

Expand Down Expand Up @@ -326,7 +327,13 @@ const OpenAIChatCompletionController = async (req, res) => {
tool_resources: primaryConfig.tool_resources,
actionsEnabled: primaryConfig.actionsEnabled,
});
return enrichWithSkillConfigurable(result, req, primaryConfig.accessibleSkillIds);
return enrichWithSkillConfigurable(
result,
req,
primaryConfig.accessibleSkillIds,
undefined,
buildManualSkillPrimedIdsByName(primaryConfig.manualSkillPrimes),
);
},
toolEndCallback,
...getSkillToolDeps(),
Expand Down
17 changes: 15 additions & 2 deletions api/server/controllers/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const { findAccessibleResources } = require('~/server/services/PermissionService
const {
getSkillToolDeps,
enrichWithSkillConfigurable,
buildManualSkillPrimedIdsByName,
} = require('~/server/services/Endpoints/agents/skillDeps');
const db = require('~/models');

Expand Down Expand Up @@ -509,7 +510,13 @@ const createResponse = async (req, res) => {
tool_resources: primaryConfig.tool_resources,
actionsEnabled: primaryConfig.actionsEnabled,
});
return enrichWithSkillConfigurable(result, req, primaryConfig.accessibleSkillIds);
return enrichWithSkillConfigurable(
result,
req,
primaryConfig.accessibleSkillIds,
undefined,
buildManualSkillPrimedIdsByName(primaryConfig.manualSkillPrimes),
);
},
toolEndCallback,
...getSkillToolDeps(),
Expand Down Expand Up @@ -676,7 +683,13 @@ const createResponse = async (req, res) => {
tool_resources: primaryConfig.tool_resources,
actionsEnabled: primaryConfig.actionsEnabled,
});
return enrichWithSkillConfigurable(result, req, primaryConfig.accessibleSkillIds);
return enrichWithSkillConfigurable(
result,
req,
primaryConfig.accessibleSkillIds,
undefined,
buildManualSkillPrimedIdsByName(primaryConfig.manualSkillPrimes),
);
},
toolEndCallback,
...getSkillToolDeps(),
Expand Down
22 changes: 20 additions & 2 deletions api/server/services/Endpoints/agents/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ const {
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { getSkillToolDeps, enrichWithSkillConfigurable } = require('./skillDeps');
const {
getSkillToolDeps,
enrichWithSkillConfigurable,
buildManualSkillPrimedIdsByName,
} = require('./skillDeps');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { checkPermission, findAccessibleResources } = require('~/server/services/PermissionService');
const AgentClient = require('~/server/controllers/agents/client');
Expand Down Expand Up @@ -185,7 +189,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
});

logger.debug(`[ON_TOOL_EXECUTE] loaded ${result.loadedTools?.length ?? 0} tools`);
return enrichWithSkillConfigurable(result, req, ctx.accessibleSkillIds, codeApiKey);
return enrichWithSkillConfigurable(
result,
req,
ctx.accessibleSkillIds,
codeApiKey,
ctx.manualSkillPrimedIdsByName,
);
},
toolEndCallback,
...getSkillToolDeps(),
Expand Down Expand Up @@ -288,13 +298,21 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
logger.debug(
`[initializeClient] Storing tool context for ${primaryConfig.id}: ${primaryConfig.toolDefinitions?.length ?? 0} tools, registry size: ${primaryConfig.toolRegistry?.size ?? '0'}`,
);
/** Maps each manually-primed skill name to the `_id` of the exact doc
* that was primed. Plumbed to `enrichWithSkillConfigurable` so the
* read_file handler can pin same-name collision lookups to the
* resolver's chosen doc. */
const manualSkillPrimedIdsByName = buildManualSkillPrimedIdsByName(
primaryConfig.manualSkillPrimes,
);
agentToolContexts.set(primaryConfig.id, {
agent: primaryAgent,
toolRegistry: primaryConfig.toolRegistry,
userMCPAuthMap: primaryConfig.userMCPAuthMap,
tool_resources: primaryConfig.tool_resources,
actionsEnabled: primaryConfig.actionsEnabled,
accessibleSkillIds: primaryConfig.accessibleSkillIds,
manualSkillPrimedIdsByName,
});

const agent_ids = primaryConfig.agent_ids;
Expand Down
37 changes: 35 additions & 2 deletions api/server/services/Endpoints/agents/skillDeps.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { enrichWithSkillConfigurable } = require('@librechat/api');
const db = require('~/models');

/**
* Builds the `manualSkillPrimedIdsByName` map passed through to
* `enrichWithSkillConfigurable`. Centralized here so the four CJS call
* sites (`initialize.js`, `responses.js` x2, `openai.js`) share one
* source of truth β€” if `ResolvedManualSkill` ever renames `_id` or
* gains new identifying fields, only this helper changes.
*
* Returns `undefined` (not `{}`) when there are no primes, so the
* downstream `enrichWithSkillConfigurable` cleanly omits the field
* from `mergedConfigurable` rather than threading an empty object.
*
* @param {Array<{ name: string, _id: { toString(): string } }> | undefined} manualSkillPrimes
* @returns {Record<string, string> | undefined}
*/
function buildManualSkillPrimedIdsByName(manualSkillPrimes) {
if (!manualSkillPrimes?.length) {
return undefined;
}
return Object.fromEntries(manualSkillPrimes.map((p) => [p.name, p._id.toString()]));
}

/** Skill-related properties for ToolExecuteOptions (stable references, allocated once). */
const skillToolDeps = {
getSkillByName: db.getSkillByName,
Expand All @@ -28,16 +49,28 @@ function getSkillToolDeps() {
* @param {object} req - The Express request object
* @param {Array} accessibleSkillIds - Pre-computed accessible skill IDs
* @param {string} [preResolvedCodeApiKey] - Pre-resolved code API key (skips redundant lookup)
* @param {Record<string, string>} [manualSkillPrimedIdsByName] - Map of name β†’ skill id for skills manually invoked this turn via the `$` popover. Pins same-name collision lookups in `read_file`.
* @returns {Promise<object>} Augmented result with skill configurable
*/
function enrichConfigurable(result, req, accessibleSkillIds, preResolvedCodeApiKey) {
function enrichConfigurable(
result,
req,
accessibleSkillIds,
preResolvedCodeApiKey,
manualSkillPrimedIdsByName,
) {
return enrichWithSkillConfigurable(
result,
req,
accessibleSkillIds,
loadAuthValues,
preResolvedCodeApiKey,
manualSkillPrimedIdsByName,
);
}

module.exports = { getSkillToolDeps, enrichWithSkillConfigurable: enrichConfigurable };
module.exports = {
getSkillToolDeps,
enrichWithSkillConfigurable: enrichConfigurable,
buildManualSkillPrimedIdsByName,
};
14 changes: 5 additions & 9 deletions client/src/components/Chat/Input/SkillsCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { memo, useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { ScrollText } from 'lucide-react';
import { AutoSizer, List } from 'react-virtualized';
import { Spinner, useCombobox } from '@librechat/client';
import { InvocationMode } from 'librechat-data-provider';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import type { TSkillSummary } from 'librechat-data-provider';
import type { MentionOption } from '~/common';
Expand All @@ -22,16 +21,13 @@ const skillIcon = <ScrollText className="icon-md text-cyan-500" />;

/**
* Determines whether a skill should appear in the `$` command popover.
* `manual` and `both` are user-invocable. `auto` is model-only and hidden.
* Skills without an explicit mode (undefined) default to visible for
* backward compatibility until the backend persists `invocationMode`.
* Reads the persisted `userInvocable` field (mirrors the `user-invocable`
* frontmatter). Defaults to visible when the field is absent so older
* skills authored before Phase 6 stay user-invocable without a migration;
* only an explicit `false` hides them.
*/
export function isUserInvocable(skill: TSkillSummary): boolean {
const mode = skill.invocationMode;
if (mode == null || mode === InvocationMode.both) {
return true;
}
return mode === InvocationMode.manual;
return skill.userInvocable !== false;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import React from 'react';
import { act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import { InvocationMode } from 'librechat-data-provider';
import type { TSkillSummary } from 'librechat-data-provider';

const CONVO_ID = 'convo-1';
Expand Down Expand Up @@ -412,7 +411,7 @@ describe('filterSkillsForPopover', () => {
const inactive = () => false;
const s1 = makeSkill({ _id: '1', name: 'a' });
const s2 = makeSkill({ _id: '2', name: 'b' });
const s3 = makeSkill({ _id: '3', name: 'c', invocationMode: InvocationMode.auto });
const s3 = makeSkill({ _id: '3', name: 'c', userInvocable: false });

it('passes everything through when agentSkillIds is undefined', () => {
const out = filterSkillsForPopover([s1, s2], { agentSkillIds: undefined, isActive: active });
Expand Down Expand Up @@ -440,7 +439,7 @@ describe('filterSkillsForPopover', () => {
expect(out.map((s) => s._id)).toEqual(['2']);
});

it('excludes auto-only skills via isUserInvocable', () => {
it('excludes skills with userInvocable: false via isUserInvocable', () => {
const out = filterSkillsForPopover([s1, s3], { agentSkillIds: null, isActive: active });
expect(out.map((s) => s._id)).toEqual(['1']);
});
Expand All @@ -456,8 +455,8 @@ describe('filterSkillsForPopover', () => {
agentSkillIds: ['1', '2', '3'],
isActive,
});
/* s1 passes (active, manual-by-default, scoped), s2 drops (inactive),
s3 drops (auto-only invocation mode). */
/* s1 passes (active, user-invocable by default, scoped), s2 drops (inactive),
s3 drops (userInvocable: false). */
expect(out.map((s) => s._id)).toEqual(['1']);
});

Expand Down
Loading
Loading