Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
16 changes: 16 additions & 0 deletions api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,22 @@ class BaseClient {
}
delete userMessage.image_urls;
}
/**
* Persist the user's manual skill picks onto the user message so the
* frontend `ManualSkillPills` component can render them in history
* after reload. UI-only metadata — the runtime skill resolution
* pipeline reads the top-level `req.body.manualSkills` separately.
* Filter is defense-in-depth on top of Mongoose schema validation:
* keeps the DB row free of empty/non-string entries even if a
* crafted payload slips past schema checks upstream.
*/
const rawManualSkills = this.options.req?.body?.manualSkills;
if (Array.isArray(rawManualSkills) && rawManualSkills.length > 0) {
const skills = rawManualSkills.filter((s) => typeof s === 'string' && s.length > 0);
if (skills.length > 0) {
Comment on lines +504 to +507
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 Enforce manual-skill limits before persisting message metadata

This persistence path only filters manualSkills to non-empty strings, so a crafted request can still attach arbitrarily many and arbitrarily long skill names to userMessage.manualSkills. Unlike the runtime resolver path (which caps count/length), this can bloat stored message documents, inflate history payloads/UI rendering, and in extreme cases trigger Mongo document-size save failures for otherwise valid messages. Reuse the same bounded sanitizer when writing manualSkills to the message record.

Useful? React with 👍 / 👎.

userMessage.manualSkills = skills;
}
}
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user).catch(
(err) => {
logger.error('[BaseClient] Failed to save user message:', err);
Expand Down
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
75 changes: 69 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,31 @@ 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 cards sit
* above the model's output — priming ran before the turn, the
* reply follows.
*
* Live streaming display of cards is handled on the user side via
* `ManualSkillPills` reading the message's `manualSkills` field;
* no separate SSE emit is needed here, and trying to stream a
* mid-run tool_call at index 0 collided with the LLM's first text
* content, while emitting at a sparse offset pushed the card below
* the reply on finalize. Post-run unshift keeps the final
* responseMessage.content in the right order.
*/
const primedSkills = this.options.agent?.manualSkillPrimes;
if (primedSkills && primedSkills.length > 0) {
const primeParts = buildSkillPrimeContentParts(primedSkills, {
runId: this.responseMessageId ?? 'manual-skill',
});
this.contentParts.unshift(...primeParts);
}

/** @deprecated Agent Chain */
if (hideSequentialOutputs) {
this.contentParts = this.contentParts.filter((part, index) => {
Expand Down
31 changes: 26 additions & 5 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 All @@ -27,6 +28,7 @@ const {
buildNonStreamingResponse,
createOpenAIStreamTracker,
createOpenAIContentAggregator,
injectManualSkillPrimes,
isChatCompletionValidationFailure,
} = require('@librechat/api');
const {
Expand Down Expand Up @@ -237,6 +239,8 @@ const OpenAIChatCompletionController = async (req, res) => {
accessibleSkillIds,
});

const manualSkills = extractManualSkills(req.body);

const primaryConfig = await initializeAgent(
{
req,
Expand All @@ -256,6 +260,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 +273,7 @@ const OpenAIChatCompletionController = async (req, res) => {
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
listSkillsByAccess: db.listSkillsByAccess,
getSkillByName: db.getSkillByName,
},
);

Expand Down Expand Up @@ -331,11 +337,26 @@ const OpenAIChatCompletionController = async (req, res) => {
const openaiMessages = convertMessages(request.messages);

const toolSet = buildToolSet(primaryConfig);
const {
messages: formattedMessages,
indexTokenCountMap,
summary: initialSummary,
} = formatAgentMessages(openaiMessages, {}, toolSet);
const formatted = formatAgentMessages(openaiMessages, {}, toolSet);
const formattedMessages = formatted.messages;
const initialSummary = formatted.summary;
let indexTokenCountMap = formatted.indexTokenCountMap;

/**
* Inject manual skill primes so the model sees SKILL.md bodies for this
* turn — parity with AgentClient's chat path. OpenAI-compatible streaming
* uses its own tracker/aggregator shape, so the LibreChat-style card SSE
* events don't apply here; only the message-context part carries over.
*/
const manualSkillPrimes = primaryConfig.manualSkillPrimes;
if (manualSkillPrimes && manualSkillPrimes.length > 0) {
const primeResult = injectManualSkillPrimes({
initialMessages: formattedMessages,
indexTokenCountMap,
manualSkillPrimes,
});
indexTokenCountMap = primeResult.indexTokenCountMap;
}

/**
* Create a simple handler that processes data
Expand Down
31 changes: 26 additions & 5 deletions api/server/controllers/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const {
getBalanceConfig,
recordCollectedUsage,
getTransactionsConfig,
extractManualSkills,
injectManualSkillPrimes,
createToolExecuteHandler,
// Responses API
writeDone,
Expand Down Expand Up @@ -377,6 +379,8 @@ const createResponse = async (req, res) => {
accessibleSkillIds,
});

const manualSkills = extractManualSkills(req.body);

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

Expand All @@ -431,11 +437,26 @@ const createResponse = async (req, res) => {
const allMessages = [...previousMessages, ...inputMessages];

const toolSet = buildToolSet(primaryConfig);
const {
messages: formattedMessages,
indexTokenCountMap,
summary: initialSummary,
} = formatAgentMessages(allMessages, {}, toolSet);
const formatted = formatAgentMessages(allMessages, {}, toolSet);
const formattedMessages = formatted.messages;
const initialSummary = formatted.summary;
let indexTokenCountMap = formatted.indexTokenCountMap;

/**
* Inject manual skill primes so the model sees SKILL.md bodies for this
* turn — parity with AgentClient's chat path. The Responses API uses its
* own response-builder shape, so LibreChat-style card SSE events don't
* apply; only the message-context part carries over.
*/
const manualSkillPrimes = primaryConfig.manualSkillPrimes;
if (manualSkillPrimes && manualSkillPrimes.length > 0) {
const primeResult = injectManualSkillPrimes({
initialMessages: formattedMessages,
indexTokenCountMap,
manualSkillPrimes,
});
indexTokenCountMap = primeResult.indexTokenCountMap;
}

// Create tracker for streaming or aggregator for non-streaming
const tracker = actuallyStreaming ? createResponseTracker() : null;
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
8 changes: 8 additions & 0 deletions client/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,14 @@ export type TOptions = {
isResubmission?: boolean;
/** Currently only utilized when `isResubmission === true`, uses that message's currently attached files */
overrideFiles?: t.TMessage['files'];
/**
* Carry forward a user message's manually-invoked skills when the caller
* is resubmitting / regenerating that same message — the compose-time
* atom has already been drained on the original submit, so without this
* the second turn would run without any manual priming even though the
* pills are still visible on the user bubble.
*/
overrideManualSkills?: string[];
/** Added conversation for multi-convo feature - sent to server as part of submission payload */
addedConvo?: t.TConversation;
};
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/Chat/Input/ChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import AttachFileChat from './Files/AttachFileChat';
import FileFormChat from './Files/FileFormChat';
import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader';
import PendingManualSkillsChips from './PendingManualSkillsChips';
import SkillsCommand from './SkillsCommand';
import PromptsCommand from './PromptsCommand';
import AudioRecorder from './AudioRecorder';
Expand Down Expand Up @@ -272,6 +273,7 @@ const ChatForm = memo(function ChatForm({
)}
>
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<PendingManualSkillsChips conversationId={conversationId} />
{/* WIP */}
<EditBadges
isEditingChatBadges={isEditingBadges}
Expand Down
Loading
Loading