Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 29 additions & 3 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const {
prependFileContext,
hydrateMissingIndexTokenCounts,
injectSkillPrimes,
collectFreshSkillPrimeNames,
isSkillPrimeMessage,
collectFileIds,
buildAgentScopedContext,
Expand Down Expand Up @@ -912,7 +913,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 }

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 Update agents dependency for skipSkillBodyNames

In deployments built from this repo/lockfile, npm ci still installs @librechat/agents 3.2.31 (package-lock.json resolves that exact version), and the commit notes that version silently ignores skipSkillBodyNames. When a fresh always-apply/manual skill is also present in historical skill tool calls, this option therefore has no effect and the duplicate SKILL.md body problem remains until the companion agents build is actually pulled into the lockfile.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Correct, and intentional — this is the cross-repo sequencing noted under Dependency in the PR description. The runtime behavior is gated on the companion SDK PR (danny-avila/agents#231), which adds the skipSkillBodyNames option. Until that ships and @librechat/agents is bumped in package.json + the lockfile, the published 3.2.31 dist safely ignores the unknown option (no regression, no effect).

Sequencing: merge & release agents#231 → bump @librechat/agents here → this PR's dedupe takes effect. I'll push the dependency bump as a follow-up commit once the SDK version is published; happy to convert this to a draft until then if you'd prefer it not merge ahead of the bump.

: {}),
}
: undefined;
let {
messages: initialMessages,
indexTokenCountMap,
Expand Down Expand Up @@ -943,9 +967,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)
Expand Down
43 changes: 43 additions & 0 deletions packages/api/src/agents/__tests__/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
resolveAlwaysApplySkills,
injectManualSkillPrimes,
injectSkillPrimes,
collectFreshSkillPrimeNames,
extractManualSkills,
isSkillPrimeMessage,
buildSkillPrimeContentParts,
Expand Down Expand Up @@ -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']));
});
});
26 changes: 26 additions & 0 deletions packages/api/src/agents/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
Loading