mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
✂️ 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:
parent
d7fc4a73a3
commit
793cbd49f0
3 changed files with 98 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue