fix(agent-core-v2): refresh system prompt after compaction

This commit is contained in:
_Kerman 2026-07-09 13:14:26 +08:00
parent 14626234a4
commit b159946fc9
6 changed files with 184 additions and 30 deletions

View file

@ -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

View file

@ -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);

View file

@ -153,6 +153,11 @@ export interface IAgentProfileService {
* {@link getAgentsMdWarning} / `getSessionWarnings`.
*/
applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise<void>;
/**
* Re-render the active profile's system prompt from freshly gathered runtime
* context without changing the active tool set.
*/
refreshSystemPrompt(): Promise<void>;
/**
* The AGENTS.md size warning produced by the most recent {@link applyProfile},
* if the combined AGENTS.md content exceeded the recommended soft budget.

View file

@ -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<ProfileSetModelResult> {
@ -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<void> {
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<void> {
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<SystemPromptContext> {
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<SystemPromptContext, 'agentsMdWarning'>): 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<SystemPromptContext> {
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 {

View file

@ -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<TestAgentOptions['generate']>;
@ -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<TestAgentContext['newEvents']>, type: st
}).length;
}
function exactCompactionRefreshPrompt(workDir: string, agentsMd: string): string {
return [
`cwd:${workDir}`,
'os:Linux',
'shell:bash:/bin/bash',
`agents:<!-- From: ${join(workDir, 'AGENTS.md')} -->\n${agentsMd}`,
'ls:\u2514\u2500\u2500 AGENTS.md',
'extra:',
].join('\n');
}
function oauthTestAgentOptions(
getAccessToken: (options?: { readonly force?: boolean }) => Promise<string>,
): {

View file

@ -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:<!-- From: ${join(workDir, 'AGENTS.md')} -->\n${agentsMd}`,
'ls:\u2514\u2500\u2500 AGENTS.md',
'extra:',
].join('\n');
}