✂️ fix: Deduplicate Skill Bodies Across Fresh Primes and History (#13610)

When a skill is primed fresh this turn (manual $-popover or always-apply) AND
also appears in history as a `skill` tool_call, its SKILL.md body was injected
twice — once by injectSkillPrimes and once reconstructed by formatAgentMessages.

- add `collectFreshSkillPrimeNames` helper (packages/api) — union of manual +
  always-apply prime names
- client.js: pass the set as `skipSkillBodyNames` to formatAgentMessages for
  both the initialMessages and memoryMessages paths so the body reconstructs
  once. Names not primed this turn still reconstruct (sticky manual re-prime).

Requires `@librechat/agents` with `skipSkillBodyNames` support; the published
dist silently ignores the unknown option until upgraded.
This commit is contained in:
Danny Avila 2026-06-09 17:16:24 -04:00 committed by GitHub
parent d7fc4a73a3
commit 793cbd49f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 98 additions and 3 deletions

View file

@ -33,6 +33,7 @@ const {
prependFileContext,
hydrateMissingIndexTokenCounts,
injectSkillPrimes,
collectFreshSkillPrimeNames,
isSkillPrimeMessage,
collectFileIds,
processTextWithTokenLimit,
@ -953,7 +954,30 @@ class AgentClient extends BaseClient {
hasDeepSeekAgent(this.options.agent) ||
(this.agentConfigs != null &&
Array.from(this.agentConfigs.values()).some(hasDeepSeekAgent));
const formatOptions = needsDeepSeekFormat ? { provider: Providers.DEEPSEEK } : undefined;
/**
* Skills primed fresh this turn manual ($ popover) and always-apply
* (frontmatter). `injectSkillPrimes` (below) splices their SKILL.md
* bodies in, so `formatAgentMessages` must NOT also reconstruct the
* same names from a historical `skill` tool_call otherwise the body
* lands twice and a prompt-cache marker can pin to the duplicated
* synthetic prefix. Names NOT primed this turn still reconstruct from
* history, preserving sticky manual re-priming across turns.
*/
const manualSkillPrimes = this.options.agent?.manualSkillPrimes;
const alwaysApplySkillPrimes = this.options.agent?.alwaysApplySkillPrimes;
const freshSkillPrimeNames = collectFreshSkillPrimeNames({
manualSkillPrimes,
alwaysApplySkillPrimes,
});
const formatOptions =
needsDeepSeekFormat || freshSkillPrimeNames.size > 0
? {
...(needsDeepSeekFormat ? { provider: Providers.DEEPSEEK } : {}),
...(freshSkillPrimeNames.size > 0
? { skipSkillBodyNames: freshSkillPrimeNames }
: {}),
}
: undefined;
let {
messages: initialMessages,
indexTokenCountMap,
@ -984,9 +1008,11 @@ class AgentClient extends BaseClient {
* 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.
*
* `manualSkillPrimes` / `alwaysApplySkillPrimes` are resolved above
* (used to build `freshSkillPrimeNames` for dedupe against historical
* skill reconstruction).
*/
const manualSkillPrimes = this.options.agent?.manualSkillPrimes;
const alwaysApplySkillPrimes = this.options.agent?.alwaysApplySkillPrimes;
if (
(manualSkillPrimes && manualSkillPrimes.length > 0) ||
(alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0)

View file

@ -45,6 +45,7 @@ import {
resolveAlwaysApplySkills,
injectManualSkillPrimes,
injectSkillPrimes,
collectFreshSkillPrimeNames,
extractManualSkills,
isSkillPrimeMessage,
buildSkillPrimeContentParts,
@ -2247,3 +2248,45 @@ describe('injectSkillPrimes', () => {
expect(result.inserted).toBe(2);
});
});
describe('collectFreshSkillPrimeNames', () => {
it('returns the union of manual + always-apply prime names', () => {
const names = collectFreshSkillPrimeNames({
manualSkillPrimes: [{ name: 'pdf-analyzer' }, { name: 'code-review' }],
alwaysApplySkillPrimes: [{ name: 'clickhouse-best-practices' }],
});
expect(names).toEqual(new Set(['pdf-analyzer', 'code-review', 'clickhouse-best-practices']));
});
it('dedupes a skill that is both manual and always-apply', () => {
const names = collectFreshSkillPrimeNames({
manualSkillPrimes: [{ name: 'clickhouse-best-practices' }],
alwaysApplySkillPrimes: [{ name: 'clickhouse-best-practices' }],
});
expect(names.size).toBe(1);
expect(names.has('clickhouse-best-practices')).toBe(true);
});
it('handles undefined / empty inputs', () => {
expect(collectFreshSkillPrimeNames({}).size).toBe(0);
expect(
collectFreshSkillPrimeNames({
manualSkillPrimes: [],
alwaysApplySkillPrimes: [],
}).size,
).toBe(0);
});
it('collects names from only one side when the other is absent', () => {
expect(
collectFreshSkillPrimeNames({
alwaysApplySkillPrimes: [{ name: 'only-always' }],
}),
).toEqual(new Set(['only-always']));
expect(
collectFreshSkillPrimeNames({
manualSkillPrimes: [{ name: 'only-manual' }],
}),
).toEqual(new Set(['only-manual']));
});
});

View file

@ -1087,6 +1087,32 @@ export function injectManualSkillPrimes(
return { initialMessages, indexTokenCountMap, inserted: numPrimes, insertIdx };
}
/**
* Collects the set of skill names primed fresh this turn the union of manual
* ($-popover) and always-apply (frontmatter) primes. Passed to
* `formatAgentMessages` as `skipSkillBodyNames` so a skill that is BOTH primed
* this turn AND present in history (as a `skill` tool_call) has its SKILL.md
* body injected exactly once by the fresh prime via `injectSkillPrimes` and
* is NOT also reconstructed from history. Names absent from this set still
* reconstruct from history, preserving sticky manual re-priming across turns.
*/
export function collectFreshSkillPrimeNames({
manualSkillPrimes,
alwaysApplySkillPrimes,
}: {
manualSkillPrimes?: Pick<ResolvedManualSkill, 'name'>[];
alwaysApplySkillPrimes?: Pick<ResolvedAlwaysApplySkill, 'name'>[];
}): Set<string> {
const names = new Set<string>();
for (const prime of manualSkillPrimes ?? []) {
names.add(prime.name);
}
for (const prime of alwaysApplySkillPrimes ?? []) {
names.add(prime.name);
}
return names;
}
export interface InjectSkillPrimesParams {
/** Formatted LangChain messages produced by `formatAgentMessages`. Mutated in place. */
initialMessages: BaseMessage[];