diff --git a/.changeset/file-history-subagent-idle.md b/.changeset/file-history-subagent-idle.md new file mode 100644 index 00000000000..589cba3ba23 --- /dev/null +++ b/.changeset/file-history-subagent-idle.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": patch +--- + +Keep file-history snapshots on the parent turn when a background subagent writes after that turn ends. diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts index f38289ce770..c7604e333a0 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts @@ -44,7 +44,8 @@ export interface IAgentFileHistoryService { history(): FileHistoryState; settled(): Promise; - captureForActiveTurn(path: string): Promise; + captureTurnId(): number | undefined; + captureForActiveTurn(path: string, turnId?: number): Promise; changes(turnId: number): Promise; turnRecorded(turnId: number): Promise; contentAt( diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index 5f21ecbda1c..86e915b802a 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -8,7 +8,7 @@ import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import type { WillExecuteToolEvent } from '#/agent/toolExecutor/toolHooks'; +import type { ToolDidExecuteContext, WillExecuteToolEvent } from '#/agent/toolExecutor/toolHooks'; import { TurnStarted } from '#/agent/loop/turnEvents'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -48,6 +48,9 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor private queue: Promise = Promise.resolve(); private activeTurnId: number | undefined; + private lastEndedTurnId: number | undefined; + private parentTurnId: number | undefined; + private pendingLateCaptures: { path: string; turnId: number }[] = []; private orphanSweepDone = false; constructor( @@ -67,9 +70,15 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor super(); this.agentState.contributeState(fileHistoryKey); if (this.agentCtx.agentId !== MAIN_AGENT_ID) { + this.parentTurnId = this.lookupParentTurnId(); this._register( toolExecutor.onWillExecuteTool((event) => this.onSubagentWillExecuteTool(event)), ); + this._register( + toolExecutor.hooks.onDidExecuteTool.register('fileHistory', (ctx, next) => + this.onSubagentDidExecuteTool(ctx, next), + ), + ); return; } @@ -86,6 +95,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor eventBus.subscribe(TurnEnded, (event) => { if (event.agentId !== this.agentCtx.agentId) return; if (this.activeTurnId === event.turnId) this.activeTurnId = undefined; + this.lastEndedTurnId = event.turnId; void this.enqueue(() => this.endCheckpoint(event.turnId)); }), ); @@ -256,18 +266,52 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor event.waitUntil(this.enqueue(() => this.capture(path, event.turnId))); } + private lookupParentTurnId(): number | undefined { + const main = this.agentLifecycle.handleOf(MAIN_AGENT_ID); + if (main === undefined) return undefined; + return main.accessor.get(IAgentFileHistoryService).captureTurnId(); + } + private onSubagentWillExecuteTool(event: WillExecuteToolEvent): void { const path = editTargetPath(event.execution.display); if (path === undefined) return; const main = this.agentLifecycle.handleOf(MAIN_AGENT_ID); if (main === undefined) return; - event.waitUntil(main.accessor.get(IAgentFileHistoryService).captureForActiveTurn(path)); + const history = main.accessor.get(IAgentFileHistoryService); + const turnId = this.parentTurnId ?? history.captureTurnId(); + if (turnId === undefined) return; + this.pendingLateCaptures.push({ path, turnId }); + event.waitUntil(history.captureForActiveTurn(path, turnId)); } - captureForActiveTurn(path: string): Promise { - const turnId = this.activeTurnId; - if (turnId === undefined) return Promise.resolve(); - return this.enqueue(() => this.capture(path, turnId)); + private async onSubagentDidExecuteTool( + _ctx: ToolDidExecuteContext, + next: (context?: ToolDidExecuteContext) => Promise, + ): Promise { + const pending = this.pendingLateCaptures.shift(); + if (pending !== undefined) { + const main = this.agentLifecycle.handleOf(MAIN_AGENT_ID); + if (main !== undefined) { + await main.accessor + .get(IAgentFileHistoryService) + .captureForActiveTurn(pending.path, pending.turnId); + } + } + await next(); + } + + captureTurnId(): number | undefined { + return this.activeTurnId ?? this.lastEndedTurnId; + } + + captureForActiveTurn(path: string, turnId?: number): Promise { + const resolved = turnId ?? this.activeTurnId ?? this.lastEndedTurnId; + if (resolved === undefined) return Promise.resolve(); + const needsFinalize = this.activeTurnId !== resolved; + return this.enqueue(async () => { + await this.capture(path, resolved); + if (needsFinalize) await this.endCheckpoint(resolved, [this.pathKey(path)]); + }); } private enqueue(op: () => Promise): Promise { @@ -328,11 +372,12 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor ); } - private async endCheckpoint(turnId: number): Promise { + private async endCheckpoint(turnId: number, onlyPaths?: readonly string[]): Promise { const state = this.history(); - if (state.checkpoints.some((c) => c.turnId === turnId && checkpointPhaseOf(c) === 'end')) { - return; - } + const existingEnd = state.checkpoints.find( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'end', + ); + if (existingEnd !== undefined && onlyPaths === undefined) return; const start = state.checkpoints.find( (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'start', ); @@ -350,13 +395,23 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor string, FileBackupEntry >; - for (const [pathKey, before] of Object.entries(start.entries)) { + if (existingEnd !== undefined) { + for (const [path, entry] of Object.entries(existingEnd.entries)) { + entries[path] = entry; + } + } + const pathKeys = onlyPaths ?? Object.keys(start.entries); + for (const pathKey of pathKeys) { + const before = Object.hasOwn(start.entries, pathKey) ? start.entries[pathKey] : undefined; + if (before === undefined) continue; const nextVersion = maxVersion(state.checkpoints, pathKey) + 1; const current = await this.readCurrent(pathKey); if (current === 'unreadable') continue; if (current === 'missing') { if (before.key !== null || before.oversize === true) { entries[pathKey] = { key: null, version: nextVersion }; + } else { + delete entries[pathKey]; } continue; } @@ -373,11 +428,16 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor size: current.oversizeBytes, mtimeMs: current.mtimeMs, }; + } else { + delete entries[pathKey]; } continue; } const contentHash = sha256(current); - if (before.contentHash === contentHash) continue; + if (before.contentHash === contentHash) { + delete entries[pathKey]; + continue; + } entries[pathKey] = await this.backup(pathKey, nextVersion, current, contentHash); } diff --git a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts index af7c1b9a6a3..0b7c4a46eba 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -19,6 +19,7 @@ import { IAgentFileHistoryService } from '#/features/fileHistory/fileHistory'; import { AgentFileHistoryService, countLineDiff } from '#/features/fileHistory/fileHistoryService'; import { displacedCheckpoints } from '#/features/fileHistory/fileHistoryOps'; import type { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import type { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolCall } from '#human/llm/message'; import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -110,7 +111,14 @@ describe('AgentFileHistoryService', () => { }); } - function createService(agentId = 'main'): AgentFileHistoryService { + function createService( + agentId = 'main', + options?: { + executor?: IAgentToolExecutorService; + lifecycle?: IAgentLifecycleService; + state?: IAgentStateService; + }, + ): AgentFileHistoryService { const ctx = agentId === scopeCtx.agentId ? scopeCtx @@ -129,8 +137,8 @@ describe('AgentFileHistoryService', () => { return disposables.add( new AgentFileHistoryService( ctx, - ix.get(IAgentStateService), - executorEvents.executor, + options?.state ?? ix.get(IAgentStateService), + options?.executor ?? executorEvents.executor, eventBus, ix.get(IEventDispatcher), stubRuntime(), @@ -153,7 +161,7 @@ describe('AgentFileHistoryService', () => { readdir: async () => [], remove: async () => {}, } as unknown as IHostFileSystem, - { handleOf: () => undefined } as unknown as IAgentLifecycleService, + options?.lifecycle ?? ({ handleOf: () => undefined } as unknown as IAgentLifecycleService), ), ); } finally { @@ -369,6 +377,198 @@ describe('AgentFileHistoryService', () => { expect(service.history().checkpoints).toEqual([]); }); + function createLinkedSubagent(main: AgentFileHistoryService): ToolExecutorEventStubs { + const subEvents = stubToolExecutorEvents(); + const subState = { + contributeState: () => {}, + get: () => ({ checkpoints: [], tracked: [] }), + has: () => false, + replayableKeys: () => [], + onDidContributeReplayable: () => ({ dispose() {} }), + onDidWithdrawReplayable: () => ({ dispose() {} }), + } as unknown as IAgentStateService; + createService('agent-1', { + executor: subEvents.executor, + state: subState, + lifecycle: { + handleOf: (id: string) => + id === 'main' + ? { + accessor: { + get: (token: unknown) => + token === IAgentFileHistoryService ? main : undefined, + }, + } + : undefined, + } as unknown as IAgentLifecycleService, + }); + return subEvents; + } + + async function fireSubagentWrite( + events: ToolExecutorEventStubs, + path: string, + afterCapture?: () => void, + ): Promise { + const toolCall: ToolCall = { + type: 'function', + id: 'call-sub-0', + name: 'Write', + arguments: null, + }; + const execution: RunnableToolExecution = { + approvalRule: 'Write', + display: { kind: 'file_io', operation: 'write', path }, + execute: async () => ({ output: '' }), + }; + const signal = new AbortController().signal; + await events.fireWillExecute({ turnId: 0, toolCall, execution, args: {} }, signal); + afterCapture?.(); + await events.didExecuteSlot.run({ + turnId: 0, + signal, + toolCall, + toolCalls: [toolCall], + args: {}, + outcome: 'executed', + result: { output: '' }, + }); + } + + it('keeps a captureForActiveTurn after the main turn has ended', async () => { + const service = createService(); + setFile('/ws/late.txt', 'from-background-subagent\n'); + + startTurn(1); + endTurn(1); + await service.settled(); + expect(service.history().checkpoints).toEqual([]); + + await service.captureForActiveTurn('/ws/late.txt'); + await service.settled(); + + const start = service + .history() + .checkpoints.find((c) => c.turnId === 1 && c.phase === 'start'); + expect(start?.entries['late.txt']?.version).toBe(1); + expect(await blobText(start!.entries['late.txt']!.key!)).toBe('from-background-subagent\n'); + expect(await service.turnRecorded(1)).toBe(true); + }); + + it('finalizes an ended turn after a late capture so changes() includes the write', async () => { + const service = createService(); + + startTurn(1); + endTurn(1); + await service.settled(); + expect(await service.turnRecorded(1)).toBe(false); + expect(await service.changes(1)).toEqual([]); + + await service.captureForActiveTurn('/ws/late.txt'); + setFile('/ws/late.txt', 'from-background-subagent\n'); + await service.captureForActiveTurn('/ws/late.txt'); + await service.settled(); + + expect(await service.turnRecorded(1)).toBe(true); + expect(await service.changes(1)).toEqual([ + { path: 'late.txt', status: 'added', additions: 1, deletions: 0 }, + ]); + }); + + it('merges a late capture into an ended turn that already has an end checkpoint', async () => { + const service = createService(); + setFile('/ws/a.txt', 'one\n'); + + startTurn(1); + await fireEdit(service, '/ws/a.txt', 1); + setFile('/ws/a.txt', 'two\n'); + endTurn(1); + await service.settled(); + expect(await service.changes(1)).toEqual([ + { path: 'a.txt', status: 'modified', additions: 1, deletions: 1 }, + ]); + + await service.captureForActiveTurn('/ws/late.txt'); + setFile('/ws/late.txt', 'after-end\n'); + await service.captureForActiveTurn('/ws/late.txt'); + await service.settled(); + + expect(await service.changes(1)).toEqual([ + { path: 'a.txt', status: 'modified', additions: 1, deletions: 1 }, + { path: 'late.txt', status: 'added', additions: 1, deletions: 0 }, + ]); + expect(await service.turnRecorded(1)).toBe(true); + }); + + it('forwards a subagent edit onto the last ended main turn', async () => { + const main = createService(); + startTurn(3); + const subEvents = createLinkedSubagent(main); + endTurn(3); + await main.settled(); + + await fireSubagentWrite(subEvents, '/ws/from-sub.txt', () => { + setFile('/ws/from-sub.txt', 'written-after-parent-ended\n'); + }); + await main.settled(); + + const start = main + .history() + .checkpoints.find((c) => c.turnId === 3 && c.phase === 'start'); + expect(start?.entries['from-sub.txt']).toEqual({ key: null, version: 1 }); + expect(await main.turnRecorded(3)).toBe(true); + expect(await main.changes(3)).toEqual([ + { path: 'from-sub.txt', status: 'added', additions: 1, deletions: 0 }, + ]); + }); + + it('keeps a subagent capture on its parent turn after a later main turn starts', async () => { + const main = createService(); + startTurn(1); + const subEvents = createLinkedSubagent(main); + endTurn(1); + startTurn(2); + await main.settled(); + + await fireSubagentWrite(subEvents, '/ws/from-turn-1.txt', () => { + setFile('/ws/from-turn-1.txt', 'still-belongs-to-turn-1\n'); + }); + await main.settled(); + + expect(await main.changes(1)).toEqual([ + { path: 'from-turn-1.txt', status: 'added', additions: 1, deletions: 0 }, + ]); + expect(await main.changes(2)).toEqual([]); + expect(await main.turnRecorded(1)).toBe(true); + expect( + main.history().checkpoints.find((c) => c.turnId === 2)?.entries['from-turn-1.txt'], + ).toBeUndefined(); + }); + + it('pins a captureForActiveTurn to the main turn that is active when it arrives', async () => { + const service = createService(); + setFile('/ws/spawned.txt', 'during-turn-1\n'); + startTurn(1); + await service.captureForActiveTurn('/ws/spawned.txt'); + endTurn(1); + await service.settled(); + + startTurn(2); + setFile('/ws/late.txt', 'during-turn-2\n'); + await service.captureForActiveTurn('/ws/late.txt'); + await service.settled(); + + expect( + service.history().checkpoints.find((c) => c.turnId === 1)?.entries['spawned.txt'], + ).toBeDefined(); + expect( + service.history().checkpoints.find((c) => c.turnId === 2)?.entries['late.txt'], + ).toBeDefined(); + expect( + service.history().checkpoints.find((c) => c.turnId === 1)?.entries['late.txt'], + ).toBeUndefined(); + }); + it('drops turns outside the retention window and re-baselines returning files', async () => { const service = createService(); setFile('/ws/w.txt', 'v1\n');