diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index abef2061a734..f62a1d007a8d 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -33,6 +33,7 @@ const { prependFileContext, hydrateMissingIndexTokenCounts, injectSkillPrimes, + collectFreshSkillPrimeNames, isSkillPrimeMessage, collectFileIds, buildAgentScopedContext, @@ -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 } + : {}), + } + : undefined; let { messages: initialMessages, indexTokenCountMap, @@ -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) diff --git a/packages/api/src/agents/__tests__/skills.test.ts b/packages/api/src/agents/__tests__/skills.test.ts index 976c09eb85bd..87c849fc8f64 100644 --- a/packages/api/src/agents/__tests__/skills.test.ts +++ b/packages/api/src/agents/__tests__/skills.test.ts @@ -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'])); + }); +}); diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts index 8fba2ba83f31..b074daad5f6f 100644 --- a/packages/api/src/agents/skills.ts +++ b/packages/api/src/agents/skills.ts @@ -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[]; + alwaysApplySkillPrimes?: Pick[]; +}): Set { + const names = new Set(); + 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[];