Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a77f56a
🎬 feat: Prime Manually-Invoked Skills via $ Popover
danny-avila Apr 17, 2026
480e1fa
🛡️ fix: Scope manual skill primes to single-agent + cap resolver input
danny-avila Apr 17, 2026
df865e2
🧪 refactor: Testable Helpers + Payload Validation for Manual Skill Pr…
danny-avila Apr 17, 2026
1b62e2f
🔖 refactor: Single source for the skill-message source marker
danny-avila Apr 17, 2026
fac0973
♻️ refactor: Drop Multi-Agent Guard + Review Polish
danny-avila Apr 17, 2026
ae0ed41
🧠 fix: Strip Skill Primes from Memory Window + Unbreak CI Mocks
danny-avila Apr 17, 2026
a83fa9d
📜 feat: Show Skill-Loaded Cards for Manually-Invoked Skills
danny-avila Apr 17, 2026
6825378
⚡ feat: Emit Optimistic Skill Cards + Wire Primes in OpenAI/Responses
danny-avila Apr 18, 2026
cff9188
🎯 fix: Route Skill Prime Events to the Real Response + Sparse-Array O…
danny-avila Apr 18, 2026
f659243
🎗️ feat: Replace \$skill-name Text with Pills on the User Message
danny-avila Apr 18, 2026
6939030
🎛️ feat: Manual Skills as Persisted Message Field + Compose-Time Chips
danny-avila Apr 18, 2026
1f81a43
🧪 feat: Assistant-Side Skill-Loading Chips + Pill Padding
danny-avila Apr 18, 2026
2f5347b
🔁 fix: Indicator Visibility + Carry Manual Skills Through Regenerate/…
danny-avila Apr 18, 2026
067a98a
🧭 fix: Drive Mid-Stream Skill Chips from Submission Atom, Not Message…
danny-avila Apr 18, 2026
3bbcb94
🌱 fix: Seed Response manualSkills in createdHandler, Indicator Become…
danny-avila Apr 18, 2026
94f7299
🪞 fix: Render Skill Indicator Inside ContentParts, Adjacent to Parts
danny-avila Apr 18, 2026
9de3a4b
🔎 refactor: Narrow Skill Components to Scalar skills Prop, Kill Memo …
danny-avila Apr 18, 2026
cfba20a
📜 feat: Mid-Stream Skill Cards via SkillCall, Drop Custom Indicator
danny-avila Apr 18, 2026
eb2826c
✂️ fix: Render Interim Skill Cards From manualSkills Only, Leave Cont…
danny-avila Apr 18, 2026
0d2c944
🩹 fix: Codex Review Resolutions — Localization, Guards, Tests, Docs
danny-avila Apr 19, 2026
101d7b0
🔧 fix: Update ParallelContent to Handle Optional Content Prop
danny-avila Apr 19, 2026
49f84fb
🎯 fix: Thread manualSkills Through ContentRender — The Real Renderer
danny-avila Apr 19, 2026
af8265f
🧹 polish: Address Audit Follow-Up (F1/F3/F6)
danny-avila Apr 19, 2026
7dd6bcf
✨ feat: Dedicated PendingSkillCall + Running→Ran Transition on Real C…
danny-avila Apr 19, 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
1 change: 1 addition & 0 deletions api/server/controllers/agents/__tests__/openai.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jest.mock('@librechat/api', () => ({
createErrorResponse: jest.fn(),
getTransactionsConfig: mockGetTransactionsConfig,
recordCollectedUsage: mockRecordCollectedUsage,
extractManualSkills: jest.fn().mockReturnValue(undefined),
buildNonStreamingResponse: jest.fn().mockReturnValue({ id: 'resp-123' }),
createOpenAIStreamTracker: jest.fn().mockReturnValue({
addText: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ jest.mock('@librechat/api', () => ({
getBalanceConfig: mockGetBalanceConfig,
getTransactionsConfig: mockGetTransactionsConfig,
recordCollectedUsage: mockRecordCollectedUsage,
extractManualSkills: jest.fn().mockReturnValue(undefined),
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
// Responses API
writeDone: jest.fn(),
Expand Down
72 changes: 66 additions & 6 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const {
filterMalformedContentParts,
countFormattedMessageTokens,
hydrateMissingIndexTokenCounts,
injectManualSkillPrimes,
isSkillPrimeMessage,
buildSkillPrimeContentParts,
} = require('@librechat/api');
const {
Callback,
Expand Down Expand Up @@ -603,18 +606,27 @@ class AgentClient extends BaseClient {
const memoryConfig = appConfig.memory;
const messageWindowSize = memoryConfig?.messageWindowSize ?? 5;

let messagesToProcess = [...messages];
if (messages.length > messageWindowSize) {
for (let i = messages.length - messageWindowSize; i >= 0; i--) {
const potentialWindow = messages.slice(i, i + messageWindowSize);
/**
* Strip skill-primed meta messages before memory extraction. The primes
* sit next to the latest user message and carry large SKILL.md bodies,
* so letting them into the window would crowd out real chat turns and
* pollute extracted memories with synthetic instruction content the
* user never typed.
*/
const chatMessages = messages.filter((m) => !isSkillPrimeMessage(m));

let messagesToProcess = [...chatMessages];
if (chatMessages.length > messageWindowSize) {
for (let i = chatMessages.length - messageWindowSize; i >= 0; i--) {
const potentialWindow = chatMessages.slice(i, i + messageWindowSize);
if (potentialWindow[0]?.role === 'user') {
messagesToProcess = [...potentialWindow];
break;
}
}

if (messagesToProcess.length === messages.length) {
messagesToProcess = [...messages.slice(-messageWindowSize)];
if (messagesToProcess.length === chatMessages.length) {
messagesToProcess = [...chatMessages.slice(-messageWindowSize)];
}
}

Expand Down Expand Up @@ -759,6 +771,32 @@ class AgentClient extends BaseClient {
`[AgentClient] Boundary token adjustment: ${boundaryTokenAdjustment.original} → ${boundaryTokenAdjustment.adjusted} (${boundaryTokenAdjustment.remainingChars}/${boundaryTokenAdjustment.totalChars} chars)`,
);
}

/**
* Phase 3 manual skill priming — injected by user via `$` popover.
*
* Splice + index-shift logic lives in `injectManualSkillPrimes`
* (packages/api/src/agents/skills.ts) so the delicate position math
* can be unit-tested in TS without standing up AgentClient. Runs for
* both single-agent and multi-agent runs; how primes interact with
* handoff / added-convo agents' per-agent state is an agents-SDK
* concern, not this layer's to gate.
*/
const manualSkillPrimes = this.options.agent?.manualSkillPrimes;
if (manualSkillPrimes && manualSkillPrimes.length > 0) {
const primeResult = injectManualSkillPrimes({
initialMessages,
indexTokenCountMap,
manualSkillPrimes,
Comment on lines +785 to +790
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict manual skill priming to primary-agent runs

This injects manualSkillPrimes into the shared initialMessages array before runAgents fans out to [primary, ...agentConfigs], so added/handoff agents receive the primary agent’s SKILL.md bodies too. In multi-agent or added-convo runs, that leaks/bleeds skill instructions across agent boundaries even when those agents were never scoped to or selected for the skill.

Useful? React with 👍 / 👎.

});
Comment on lines +787 to +791
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude manual skill primes from memory extraction window

Injecting manualSkillPrimes into initialMessages here means those synthetic HumanMessages are passed to runMemory(messages) later in the same chatCompletion flow, so SKILL.md bodies are treated as user chat when memory extraction is enabled. In practice, selecting several skills (or a large skill body) can crowd out real conversation turns in the memory window and increase memory-model token/cost overhead, producing polluted memories unrelated to the user’s intent.

Useful? React with 👍 / 👎.

indexTokenCountMap = primeResult.indexTokenCountMap;
if (primeResult.inserted > 0) {
logger.debug(
`[AgentClient] Primed ${primeResult.inserted} manual skill(s) at message index ${primeResult.insertIdx}: ${manualSkillPrimes.map((p) => p.name).join(', ')}`,
);
}
}

if (indexTokenCountMap && isEnabled(process.env.AGENT_DEBUG_LOGGING)) {
const entries = Object.entries(indexTokenCountMap);
const perMsg = entries.map(([idx, count]) => {
Expand Down Expand Up @@ -875,6 +913,28 @@ class AgentClient extends BaseClient {
const hideSequentialOutputs = config.configurable.hide_sequential_outputs;
await runAgents(initialMessages);

/**
* Surface a completed `skill` tool_call content part per manually-
* invoked skill so the existing `SkillCall` frontend renderer shows
* a "Skill X loaded" card on the assistant response. Applied after
* the graph finishes to avoid clashing with the aggregator's own
* per-step content indexing. Prepended (not appended) so the cards
* render ahead of the model's output — matching the turn semantics:
* priming ran first, the model's reply followed.
*
* Persistence and final-event reconcile piggyback on the existing
* pipeline: `sendCompletion` reads `this.contentParts` verbatim, so
* the cards land in the saved response message and the frontend
* picks them up via the final SSE event.
*/
const primedSkills = this.options.agent?.manualSkillPrimes;
if (primedSkills && primedSkills.length > 0) {
const primeParts = buildSkillPrimeContentParts(primedSkills, {
runId: this.responseMessageId ?? 'manual-skill',
});
this.contentParts.unshift(...primeParts);
Comment on lines +933 to +938
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid persisting manual primes as synthetic skill tool_calls

Prepending these synthetic skill tool_call parts causes manual selections to be stored in assistant history, and extractInvokedSkillsFromPayload later treats them as previously invoked skills. That makes a one-turn manual $ selection sticky across future turns (auto-re-primed without re-selection), which increases prompt bloat/cost and changes turn-scoped behavior.

Useful? React with 👍 / 👎.

}

/** @deprecated Agent Chain */
if (hideSequentialOutputs) {
this.contentParts = this.contentParts.filter((part, index) => {
Expand Down
5 changes: 5 additions & 0 deletions api/server/controllers/agents/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
validateRequest,
initializeAgent,
getBalanceConfig,
extractManualSkills,
createErrorResponse,
recordCollectedUsage,
getTransactionsConfig,
Expand Down Expand Up @@ -237,6 +238,8 @@ const OpenAIChatCompletionController = async (req, res) => {
accessibleSkillIds,
});

const manualSkills = extractManualSkills(req.body);

const primaryConfig = await initializeAgent(
{
req,
Expand All @@ -256,6 +259,7 @@ const OpenAIChatCompletionController = async (req, res) => {
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
skillStates,
defaultActiveOnShare,
manualSkills,
},
Comment on lines 260 to 264
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply manual skill primes in OpenAI-compatible runs

This path now resolves manualSkills via initializeAgent, but the controller never injects primaryConfig.manualSkillPrimes into formattedMessages before the run starts. The /agents/openai flow therefore does the extra DB resolution work but still ignores manual priming behavior, creating endpoint-inconsistent results versus the main AgentClient path.

Useful? React with 👍 / 👎.

{
getConvoFiles: db.getConvoFiles,
Expand All @@ -268,6 +272,7 @@ const OpenAIChatCompletionController = async (req, res) => {
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
listSkillsByAccess: db.listSkillsByAccess,
getSkillByName: db.getSkillByName,
},
);

Expand Down
5 changes: 5 additions & 0 deletions api/server/controllers/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const {
getBalanceConfig,
recordCollectedUsage,
getTransactionsConfig,
extractManualSkills,
createToolExecuteHandler,
// Responses API
writeDone,
Expand Down Expand Up @@ -377,6 +378,8 @@ const createResponse = async (req, res) => {
accessibleSkillIds,
});

const manualSkills = extractManualSkills(req.body);

const primaryConfig = await initializeAgent(
{
req,
Expand All @@ -396,6 +399,7 @@ const createResponse = async (req, res) => {
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
skillStates,
defaultActiveOnShare,
manualSkills,
},
{
getConvoFiles: db.getConvoFiles,
Expand All @@ -408,6 +412,7 @@ const createResponse = async (req, res) => {
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
listSkillsByAccess: db.listSkillsByAccess,
getSkillByName: db.getSkillByName,
},
);

Expand Down
13 changes: 13 additions & 0 deletions api/server/services/Endpoints/agents/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
validateAgentModel,
createEdgeCollector,
filterOrphanedEdges,
extractManualSkills,
GenerationJobManager,
getCustomEndpointConfig,
createSequentialChainEdges,
Expand Down Expand Up @@ -237,6 +238,15 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
const conversationId = req.body.conversationId;
/** @type {string | undefined} */
const parentMessageId = req.body.parentMessageId;
/**
* Skill names the user invoked via the `$` popover for this turn. Only flows
* to the primary agent — handoff agents are follow-up turns that don't see
* the user's per-submission `$` selections. `extractManualSkills` also
* drops non-string / empty elements so a crafted payload can't reach the
* `getSkillByName` DB query with nonsense values.
* @type {string[] | undefined}
*/
const manualSkills = extractManualSkills(req.body);

const primaryConfig = await initializeAgent(
{
Expand All @@ -257,6 +267,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
skillStates,
defaultActiveOnShare,
manualSkills,
},
{
getFiles: db.getFiles,
Expand All @@ -270,6 +281,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
listSkillsByAccess: db.listSkillsByAccess,
getSkillByName: db.getSkillByName,
},
);

Expand Down Expand Up @@ -359,6 +371,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
listSkillsByAccess: db.listSkillsByAccess,
getSkillByName: db.getSkillByName,
},
);

Expand Down
36 changes: 35 additions & 1 deletion client/src/hooks/Chat/useChatFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { v4 } from 'uuid';
import { cloneDeep } from 'lodash';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useSetRecoilState, useResetRecoilState, useRecoilValue } from 'recoil';
import { useSetRecoilState, useResetRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
import {
Constants,
QueryKeys,
Expand Down Expand Up @@ -76,6 +76,30 @@ export default function useChatFunctions({
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));

/**
* Atomically read + reset the per-conversation queue of manually-invoked
* skills from the `$` popover. Reading and resetting in a single Recoil
* snapshot guarantees that if the user selects more skills between here and
* the next submission, their picks are never silently lost into a reset atom.
*
* The `hasValue` guard is defensive: this atom has a synchronous default of
* `[]` so `.contents` is always the resolved value in practice, but reading
* `.contents` on a loading/errored loadable yields a Promise/Error, which
* would make the `string[]` cast unsound.
*/
const drainPendingManualSkills = useRecoilCallback(
({ snapshot, reset }) =>
(convoId: string): string[] => {
const loadable = snapshot.getLoadable(store.pendingManualSkillsByConvoId(convoId));
const skills = loadable.state === 'hasValue' ? (loadable.contents as string[]) : [];
if (skills.length > 0) {
reset(store.pendingManualSkillsByConvoId(convoId));
}
return skills;
},
[],
);

const ask: TAskFunction = (
{
text,
Expand Down Expand Up @@ -124,6 +148,15 @@ export default function useChatFunctions({
}

const ephemeralAgent = getEphemeralAgent(conversationId ?? Constants.NEW_CONVO);
/**
* Regenerate reuses a prior user message verbatim — it's not a fresh
* invocation from the textarea, so any skill the user queued up for a NEW
* turn shouldn't be drained or attached. Leave the atom alone.
*/
const manualSkills =
isRegenerate || isContinued || isEdited
? []
: drainPendingManualSkills(conversationId ?? Constants.NEW_CONVO);
const isEditOrContinue = isEdited || isContinued;

let currentMessages: TMessage[] | null = overrideMessages ?? getMessages() ?? [];
Expand Down Expand Up @@ -332,6 +365,7 @@ export default function useChatFunctions({
ephemeralAgent,
editedContent,
addedConvo,
manualSkills: manualSkills.length > 0 ? manualSkills : undefined,
};

if (isRegenerate) {
Expand Down
Loading
Loading