diff --git a/.changeset/preserve-goal-continuation-status.md b/.changeset/preserve-goal-continuation-status.md new file mode 100644 index 0000000000..f10f6541be --- /dev/null +++ b/.changeset/preserve-goal-continuation-status.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve final status messages when automatic goal continuations reach a budget or report a blocker. diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 92ad9022aa..a6274ce5d7 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -433,7 +433,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { ): Promise { const state = this.goalState; if (state === null || state.status !== 'active') return null; - const snapshot = this.applyLifecycle(state, 'blocked', input.reason, actor); + const snapshot = this.applyLifecycle(state, 'blocked', input.reason, actor, { + preserveLiveContinuation: true, + }); return snapshot; } @@ -735,7 +737,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { const pending = this.pendingContinuation; if (preserveLiveContinuation && pending?.turnId === this.liveTurnId) return; this.pendingContinuation = undefined; - pending?.receipt.abort(); + if (pending !== undefined && !pending.receipt.abort() && pending.turnId !== undefined) { + this.loopService.cancel(pending.turnId); + } } private normalizeAfterReplay(): void { @@ -786,12 +790,13 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { status: GoalStatus, reason: string | undefined, actor: GoalActor, + opts: { readonly preserveLiveContinuation?: boolean } = {}, ): GoalSnapshot { const wallClockMs = this.settleWallClock(state); if (status === 'active') { this.wallClockResumedAt = Date.now(); } else if (state.status === 'active') { - this.cancelPendingContinuation(); + this.cancelPendingContinuation(opts.preserveLiveContinuation === true); this.wallClockResumedAt = undefined; } this.wire.dispatch(updateGoal({ status, reason, wallClockMs, actor })); @@ -866,7 +871,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { if (state.status !== 'active') return null; const reason = goalBudgetBlockReason(this.toSnapshot(state).budget); if (reason === undefined) return null; - return this.applyLifecycle(state, 'blocked', reason, 'runtime'); + return this.applyLifecycle(state, 'blocked', reason, 'runtime', { + preserveLiveContinuation: true, + }); } } diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index e98b51c06f..572f590751 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -605,8 +605,10 @@ describe('AgentGoalService core workflow hooks', () => { await ctx?.dispose(); }); - async function startLiveContinuation(): Promise boolean>>> { - const abort = vi.fn<() => boolean>(() => true); + async function startLiveContinuation( + abortResult = true, + ): Promise boolean>>> { + const abort = vi.fn<() => boolean>(() => abortResult); const turn: Turn = { ...makeTurn(41), result: new Promise(() => {}) }; const step: Step = { id: 'goal-continuation', @@ -916,6 +918,16 @@ describe('AgentGoalService core workflow hooks', () => { expect(loopService.launches).toHaveLength(1); }); + it('cancels a preserved continuation turn after its original receipt settles', async () => { + const abort = await startLiveContinuation(false); + const cancel = vi.spyOn(loopService, 'cancel').mockReturnValue(true); + await goals.markBlocked({ reason: 'still need credentials' }, 'model'); + await goals.cancelGoal(); + + expect(abort).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledWith(41); + }); + it.each(['turn', 'token', 'wall-clock'] as const)( 'keeps a goal blocked when its %s budget is exhausted before resume', async (budget) => { @@ -1448,6 +1460,54 @@ describe('AgentGoalService mid-turn budget stop', () => { } }); + it('lets an automatic continuation report final status after crossing its token budget', async () => { + const ctx = createTestAgent(); + try { + ctx.configure({ tools: ['GetGoal'] }); + const goals = ctx.get(IAgentGoalService); + await goals.createGoal({ objective: 'work' }); + await goals.markBlocked({ reason: 'ready for a fresh continuation' }); + await goals.setBudgetLimits({ budgetLimits: { tokenBudget: 1 } }, 'model'); + + ctx.mockNextResponse({ + type: 'function', + id: 'g1', + name: 'GetGoal', + arguments: JSON.stringify({}), + }); + ctx.mockNextResponse({ type: 'text', text: 'Final status: budget exhausted.' }); + ctx.mockNextResponse({ type: 'text', text: 'This step should never run.' }); + + const turnEnd = ctx.untilTurnEnd(); + await goals.resumeGoal({ continueIfBlocked: true }); + const events = await turnEnd; + + expect(ctx.llmCalls).toHaveLength(2); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ reason: 'completed' }), + }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ reason: 'cancelled' }), + }), + ); + + const history = ctx.get(IAgentContextMemoryService).get(); + expect(JSON.stringify(history)).toContain('Final status: budget exhausted.'); + expect(JSON.stringify(history)).not.toContain('This step should never run.'); + expect(goals.getGoal().goal).toMatchObject({ + status: 'blocked', + budget: { tokenBudgetReached: true }, + }); + } finally { + await ctx.dispose(); + } + }); + it('rejects tool calls made during the budget grace step without executing them', async () => { const ctx = createTestAgent(); try { @@ -1544,6 +1604,42 @@ describe('AgentGoalService mid-turn budget stop', () => { }); describe('AgentGoalService goal outcome tool result flow', () => { + it('lets an automatic continuation explain the blocker after UpdateGoal blocks the goal', async () => { + const ctx = createTestAgent(); + try { + ctx.configure({ tools: ['UpdateGoal'] }); + const goals = ctx.get(IAgentGoalService); + await goals.createGoal({ objective: 'work' }); + await goals.markBlocked({ reason: 'ready for a fresh continuation' }); + + ctx.mockNextResponse({ + type: 'function', + id: 'blocked', + name: 'UpdateGoal', + arguments: JSON.stringify({ status: 'blocked' }), + }); + ctx.mockNextResponse({ type: 'text', text: 'Blocked because credentials are unavailable.' }); + + const turnEnd = ctx.untilTurnEnd(); + await goals.resumeGoal({ continueIfBlocked: true }); + const events = await turnEnd; + + expect(ctx.llmCalls).toHaveLength(2); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ reason: 'completed' }), + }), + ); + const history = ctx.get(IAgentContextMemoryService).get(); + expect(JSON.stringify(history)).toContain('Blocked because credentials are unavailable.'); + expect(history.at(-1)?.role).toBe('assistant'); + expect(goals.getGoal().goal?.status).toBe('blocked'); + } finally { + await ctx.dispose(); + } + }); + it('does not force a goal outcome summary after maxStepsPerTurn is exhausted', async () => { const ctx = createTestAgent({ initialConfig: { providers: {}, loopControl: { maxStepsPerTurn: 1 } },