diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml index c01497007..0ca7df5c7 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.puml +++ b/packages/agent-core-v2/docs/di-scope-domains.puml @@ -307,6 +307,7 @@ fullCompaction --> llmRequester #34495E fullCompaction --> profile #34495E fullCompaction --> toolRegistry #34495E fullCompaction --> telemetry #34495E +fullCompaction --> log #34495E fullCompaction --> wireRecord #34495E fullCompaction --> turn #34495E fullCompaction --> loop #34495E diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 3e44df319..b233136f3 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -1,6 +1,7 @@ import { Disposable } from "#/_base/di/lifecycle"; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; import { renderPrompt } from "#/_base/utils/render-prompt"; import { estimateTokens, @@ -132,6 +133,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull @IAgentWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, @IAgentTurnService private readonly turn: IAgentTurnService, + @ILogService private readonly log: ILogService, @IAgentLoopService loopService: IAgentLoopService, ) { super(); @@ -421,6 +423,11 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull try { const result = await this.compactionRound(active, data); if (this._compacting !== active) throw compactionCancelledReason(active); + try { + await this.profile.refreshSystemPrompt(); + } catch (error) { + this.log.error('failed to refresh system prompt after compaction', { error }); + } this.lastCompactedTokenCount = result.tokensAfter; if (!this.markCompleted(active)) { throw compactionCancelledReason(active); diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index b36085559..3e4fac170 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -153,6 +153,11 @@ export interface IAgentProfileService { * {@link getAgentsMdWarning} / `getSessionWarnings`. */ applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise; + /** + * Re-render the active profile's system prompt from freshly gathered runtime + * context without changing the active tool set. + */ + refreshSystemPrompt(): Promise; /** * The AGENTS.md size warning produced by the most recent {@link applyProfile}, * if the combined AGENTS.md content exceeded the recommended soft budget. diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 63e8aa762..15cebeee6 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -110,6 +110,8 @@ export class AgentProfileService implements IAgentProfileService { ); } + private activeProfile: ResolvedAgentProfile | undefined; + constructor( @IAgentWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, @@ -137,6 +139,12 @@ export class AgentProfileService implements IAgentProfileService { update(changed: ProfileUpdateData): void { const { activeToolNames, ...configChanged } = changed; + if ( + changed.profileName !== undefined && + this.activeProfile?.name !== changed.profileName + ) { + this.activeProfile = undefined; + } if (Object.keys(configChanged).length > 0) { this.wire.dispatch(configUpdate(this.resolveConfigPayload(configChanged))); this.afterConfigDispatch(configChanged); @@ -157,8 +165,8 @@ export class AgentProfileService implements IAgentProfileService { const context = await this.buildSystemPromptContext(input.cwd); const systemPrompt = profile.systemPrompt(context); - const { agentsMdWarning } = context; - this.agentsMdWarning = agentsMdWarning; + this.activeProfile = profile; + this.cacheAgentsMdWarning(context); const thinkingLevel = resolveThinkingEffort( input.thinking, @@ -175,13 +183,7 @@ export class AgentProfileService implements IAgentProfileService { this.wire.dispatch(configUpdate({ modelAlias: input.model, thinkingEffort: thinkingLevel })); this.afterConfigDispatch({ modelAlias: input.model, thinkingLevel }); - if (agentsMdWarning !== undefined) { - this.eventBus.publish({ - type: 'warning', - message: agentsMdWarning, - code: 'agents-md-oversized', - }); - } + this.publishAgentsMdWarning(); } async setModel(alias: string): Promise { @@ -213,6 +215,7 @@ export class AgentProfileService implements IAgentProfileService { } useProfile(profile: ResolvedAgentProfile, context: SystemPromptContext): void { + this.activeProfile = profile; this.update({ profileName: profile.name, systemPrompt: profile.systemPrompt(context), @@ -221,24 +224,24 @@ export class AgentProfileService implements IAgentProfileService { } async applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise { - const context = await prepareSystemPromptContext( - { fs: this.fs, homeDir: this.env.homeDir }, - this.sessionContext.cwd, - this.bootstrap.homeDir, - { - additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs, - }, - ); + const context = await this.buildSystemPromptContext(undefined, options); this.useProfile(profile, context); - const { agentsMdWarning } = context; - this.agentsMdWarning = agentsMdWarning; - if (agentsMdWarning !== undefined) { - this.eventBus.publish({ - type: 'warning', - message: agentsMdWarning, - code: 'agents-md-oversized', - }); - } + this.cacheAgentsMdWarning(context); + this.publishAgentsMdWarning(); + } + + async refreshSystemPrompt(): Promise { + const profile = this.resolveActiveProfile(); + if (profile === undefined) return; + + const context = await this.buildSystemPromptContext(this.cwd); + this.activeProfile = profile; + this.update({ + profileName: profile.name, + systemPrompt: profile.systemPrompt(context), + }); + this.cacheAgentsMdWarning(context); + this.publishAgentsMdWarning(); } getAgentsMdWarning(): string | undefined { @@ -490,13 +493,37 @@ export class AgentProfileService implements IAgentProfileService { } } - private async buildSystemPromptContext(cwd?: string): Promise { + private resolveActiveProfile(): ResolvedAgentProfile | undefined { + if (this.activeProfile !== undefined) return this.activeProfile; + const profileName = this.profileName; + if (profileName === undefined) return undefined; + return this.catalog.get(profileName); + } + + private cacheAgentsMdWarning(context: Pick): void { + this.agentsMdWarning = context.agentsMdWarning; + } + + private publishAgentsMdWarning(): void { + const warning = this.agentsMdWarning; + if (warning === undefined) return; + this.eventBus.publish({ + type: 'warning', + message: warning, + code: 'agents-md-oversized', + }); + } + + private async buildSystemPromptContext( + cwd?: string, + options?: ApplyProfileOptions, + ): Promise { const effectiveCwd = cwd ?? this.sessionContext.cwd; const base = await prepareSystemPromptContext( { fs: this.fs, homeDir: this.env.homeDir }, effectiveCwd, this.bootstrap.homeDir, - { additionalDirs: this.workspace.additionalDirs }, + { additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs }, ); const skills = await this.resolveSkillListing(); return { diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index 5e9895fc1..f97cdae78 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; @@ -19,14 +19,16 @@ import { MASTER_ENV } from '#/app/flag/flagService'; import { estimateTokensForMessages } from '#/_base/utils/tokens'; import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../harness'; -import { appServices, createCommandRunner, execEnvServices, sessionServices, testAgent } from '../harness'; +import { appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, sessionServices, testAgent } from '../harness'; import { IAgentFullCompactionService, IOAuthService, IAgentProfileService, ISessionTodoService, + type ResolvedAgentProfile, } from '#/index'; import { IAgentTurnService } from '#/agent/turn/turn'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; type GenerateFn = NonNullable; @@ -53,6 +55,19 @@ const SNAPSHOT_VISIBLE_TOOLS = [ 'EnterPlanMode', 'ExitPlanMode', ] as const; +const EXACT_COMPACTION_REFRESH_PROFILE: ResolvedAgentProfile = { + name: 'exact-compaction-refresh', + systemPrompt: (context) => + [ + `cwd:${context.cwd ?? ''}`, + `os:${context.osKind ?? ''}`, + `shell:${context.shellName ?? ''}:${context.shellPath ?? ''}`, + `agents:${context.agentsMd ?? ''}`, + `ls:${context.cwdListing ?? ''}`, + `extra:${context.additionalDirsInfo ?? ''}`, + ].join('\n'), + tools: ['Read', 'Write', 'Skill'], +}; describe('FullCompaction', () => { it('keeps an oversized trailing user message as recent', () => { @@ -278,6 +293,46 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('refreshes the active profile system prompt after compaction without resetting active tools', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'kimi-compact-refresh-home-')); + const workDir = mkdtempSync(join(tmpdir(), 'kimi-compact-refresh-work-')); + try { + writeFileSync(join(workDir, 'AGENTS.md'), 'old project instructions', 'utf-8'); + const ctx = testAgent( + execEnvServices({ hostFs: new HostFileSystem() }), + hostEnvironmentServices(homeDir), + { autoConfigure: false, cwd: workDir }, + ); + ctx.configureRuntimeModel(CATALOGUED_PROVIDER, CATALOGUED_MODEL_CAPABILITIES); + const profile = ctx.get(IAgentProfileService); + await profile.applyProfile(EXACT_COMPACTION_REFRESH_PROFILE); + profile.update({ activeToolNames: ['Read'] }); + + expect(profile.data().systemPrompt).toBe( + exactCompactionRefreshPrompt(workDir, 'old project instructions'), + ); + + const refreshSpy = vi.spyOn(profile, 'refreshSystemPrompt'); + writeFileSync(join(workDir, 'AGENTS.md'), 'new project instructions', 'utf-8'); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80); + const completed = ctx.once('compaction.completed'); + + ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' }); + await ctx.rpc.beginCompaction({}); + await completed; + + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(profile.data().systemPrompt).toBe( + exactCompactionRefreshPrompt(workDir, 'new project instructions'), + ); + expect(profile.getActiveToolNames()).toEqual(['Read']); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + rmSync(workDir, { recursive: true, force: true }); + } + }); + it('rejects a manual compaction while a turn is active', async () => { const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('should-not-run') })); ctx.configure({ @@ -2441,6 +2496,17 @@ function countEvents(events: ReturnType, type: st }).length; } +function exactCompactionRefreshPrompt(workDir: string, agentsMd: string): string { + return [ + `cwd:${workDir}`, + 'os:Linux', + 'shell:bash:/bin/bash', + `agents:\n${agentsMd}`, + 'ls:\u2514\u2500\u2500 AGENTS.md', + 'extra:', + ].join('\n'); +} + function oauthTestAgentOptions( getAccessToken: (options?: { readonly force?: boolean }) => Promise, ): { diff --git a/packages/agent-core-v2/test/profile/apply-profile.test.ts b/packages/agent-core-v2/test/profile/apply-profile.test.ts index 7cd4c5975..6bcffb502 100644 --- a/packages/agent-core-v2/test/profile/apply-profile.test.ts +++ b/packages/agent-core-v2/test/profile/apply-profile.test.ts @@ -16,6 +16,20 @@ const profile: ResolvedAgentProfile = { tools: [], }; +const exactProfile: ResolvedAgentProfile = { + name: 'exact-profile', + systemPrompt: (context) => + [ + `cwd:${context.cwd ?? ''}`, + `os:${context.osKind ?? ''}`, + `shell:${context.shellName ?? ''}:${context.shellPath ?? ''}`, + `agents:${context.agentsMd ?? ''}`, + `ls:${context.cwdListing ?? ''}`, + `extra:${context.additionalDirsInfo ?? ''}`, + ].join('\n'), + tools: ['Read', 'Write'], +}; + describe('AgentProfileService.applyProfile', () => { let ctx: TestAgentContext; let homeDir: string; @@ -56,6 +70,29 @@ describe('AgentProfileService.applyProfile', () => { expect(svc.getAgentsMdWarning()).toBeUndefined(); }); + it('renders the complete runtime context exactly', async () => { + await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8'); + const { profile: svc } = buildContext(); + + await svc.applyProfile(exactProfile); + + expect(svc.data().systemPrompt).toBe(exactSystemPrompt(workDir, 'project instructions')); + }); + + it('refreshes the active profile system prompt exactly without resetting active tools', async () => { + await writeFile(join(workDir, 'AGENTS.md'), 'old instructions', 'utf-8'); + const { profile: svc } = buildContext(); + svc.update({ cwd: workDir }); + await svc.applyProfile(exactProfile); + svc.update({ activeToolNames: ['Read'] }); + await writeFile(join(workDir, 'AGENTS.md'), 'new instructions', 'utf-8'); + + await svc.refreshSystemPrompt(); + + expect(svc.data().systemPrompt).toBe(exactSystemPrompt(workDir, 'new instructions')); + expect(svc.getActiveToolNames()).toEqual(['Read']); + }); + it('caches an agents-md warning when the content exceeds the 32 KB soft budget', async () => { const largeContent = 'x'.repeat(40 * 1024); await writeFile(join(workDir, 'AGENTS.md'), largeContent, 'utf-8'); @@ -88,3 +125,14 @@ describe('AgentProfileService.applyProfile', () => { expect(svc.getAgentsMdWarning()).toBeUndefined(); }); }); + +function exactSystemPrompt(workDir: string, agentsMd: string): string { + return [ + `cwd:${workDir}`, + 'os:Linux', + 'shell:bash:/bin/bash', + `agents:\n${agentsMd}`, + 'ls:\u2514\u2500\u2500 AGENTS.md', + 'extra:', + ].join('\n'); +}