diff --git a/.changeset/goal-max-steps-continue.md b/.changeset/goal-max-steps-continue.md new file mode 100644 index 0000000000..ff33430f99 --- /dev/null +++ b/.changeset/goal-max-steps-continue.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix goal pursuit being interrupted when a goal turn reaches the per-turn step limit (`loop_control.max_steps_per_turn`); the limit now splits goal work into more continuation turns instead of pausing the goal. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 128a9878d7..e26afd4492 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1002,7 +1002,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map): boolean { + return ( + result.reason === 'failed' && + normalizeGoalErrorPayload(result.error).code === LoopErrors.codes.LOOP_MAX_STEPS_EXCEEDED + ); +} + function goalFailurePauseReason(error: unknown): string { const payload = normalizeGoalErrorPayload(error); switch (payload.code) { 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 9827a91c7f..8c59fd7931 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -16,7 +16,14 @@ import { IGoalDeadlineScheduler } from '#/agent/goal/goalDeadlineScheduler'; import { type AgentGoalService } from '#/agent/goal/goalService'; import { UpdateGoalToolInputSchema } from '#/agent/tools/goal/update-goal/update-goal'; import { UpdateGoalTool } from '#/agent/tools/goal/update-goal/updateGoalTool'; -import { IAgentLoopService, type AfterStepContext, type EnqueueReceipt, type Step, type Turn } from '#/agent/loop/loop'; +import { + createMaxStepsExceededError, + IAgentLoopService, + type AfterStepContext, + type EnqueueReceipt, + type Step, + type Turn, +} from '#/agent/loop/loop'; import { MessageStepRequest } from '#/agent/loop/stepRequest'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; @@ -1545,6 +1552,26 @@ describe('AgentGoalService core workflow hooks', () => { expect(loopService.launches).toEqual([]); }); + it('continues the goal when a goal turn hits the per-turn step limit', async () => { + await goals.createGoal({ objective: 'finish the task' }); + + const turn = makeTurn(4); + eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN }); + await runGoalStep(loopService, turn); + endTurn(eventBus, turn, { reason: 'failed', error: createMaxStepsExceededError(1) }); + + expect(goals.getGoal().goal).toMatchObject({ status: 'active', turnsUsed: 1 }); + expect(loopService.launches).toHaveLength(1); + expect(loopService.drainNextBatch(context)).toBeDefined(); + expect(context.get().at(-1)?.origin).toEqual({ + kind: 'system_trigger', + name: 'goal_continuation', + }); + const prompt = JSON.stringify(context.get().at(-1)?.content); + expect(prompt).toContain('per-turn step limit'); + expect(prompt).toContain('Pick up where that turn stopped'); + }); + it('blocks active goals when the user prompt hook blocks the turn', async () => { await goals.createGoal({ objective: 'finish the task' }); diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 0e112594eb..11e20b054b 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -121,6 +121,20 @@ const GOAL_CONTINUATION_PROMPT = [ 'leaving the goal active. Do not ask the user for input unless a real blocker prevents progress.', ].join(' '); +/** + * Variant of {@link GOAL_CONTINUATION_PROMPT} used when the previous goal turn + * ended by hitting the per-turn step limit (`loop_control.max_steps_per_turn`). + * The limit fragments goal work into more continuation turns instead of + * pausing the goal; the notice tells the model why, so it can size the next + * slice to fit the limit. + */ +const GOAL_STEP_CAP_CONTINUATION_PROMPT = [ + 'The previous goal turn reached the per-turn step limit before finishing its work,', + 'so a new turn was started for you. Pick up where that turn stopped and keep each', + 'slice of work small enough to fit the limit.', + GOAL_CONTINUATION_PROMPT, +].join(' '); + export class TurnFlow { private steerBuffer: BufferedSteer[] = []; private turnId = -1; @@ -404,11 +418,15 @@ export class TurnFlow { // instead of stopping after the turn that merely started it. (The // already-active case took the early return above.) const goalBecameActive = this.agent.goal.getGoal().goal?.status === 'active'; + // The same per-turn-step-limit exemption as the driver's continuation + // loop: a turn that failed only at the step cap does not block the + // handoff — pursuit starts with a fresh continuation turn (told why). + const hitStepCap = isMaxStepsTurnFailure(end); if ( goalBecameActive && end.event.reason !== 'cancelled' && - end.event.reason !== 'failed' && - end.event.reason !== 'blocked' + end.event.reason !== 'blocked' && + (end.event.reason !== 'failed' || hitStepCap) ) { // The ordinary turn created or resumed the goal, so it counts as the // first active goal turn before the continuation driver takes over. @@ -419,7 +437,12 @@ export class TurnFlow { } return await this.driveGoal( this.allocateTurnId(), - [{ type: 'text', text: GOAL_CONTINUATION_PROMPT }], + [ + { + type: 'text', + text: hitStepCap ? GOAL_STEP_CAP_CONTINUATION_PROMPT : GOAL_CONTINUATION_PROMPT, + }, + ], GOAL_CONTINUATION_ORIGIN, signal, ); @@ -438,7 +461,9 @@ export class TurnFlow { * full turn, then reads the goal status the model set via `UpdateGoal`: * `complete` (the record is cleared) / `blocked` stop the loop; `active` * (the model didn't decide) re-injects the goal reminder and runs the - * next continuation turn. Aborted or failed turns pause the goal. Goal-state + * next continuation turn. Aborted or failed turns pause the goal — except a + * turn that only failed by reaching the per-turn step limit, which just + * fragments goal work into more continuation turns. Goal-state * blockers, such as explicit `UpdateGoal('blocked')`, prompt-hook blocks, and * budget limits, block it (all resumable). Returns the final turn's result. */ @@ -470,7 +495,12 @@ export class TurnFlow { await this.agent.goal.pauseOnInterrupt({ reason: 'Paused after interruption' }); return end; } - if (end.event.reason === 'failed') { + // A turn that failed only by reaching the per-turn step limit ended at a + // clean step boundary, so it is not a goal failure: fall through to the + // normal continuation decision below and keep pursuing the goal. The + // `turn.ended` event still reports the failure (and the limit) to hosts. + const hitStepCap = isMaxStepsTurnFailure(end); + if (end.event.reason === 'failed' && !hitStepCap) { await this.agent.goal.pauseActiveGoal({ reason: goalFailurePauseReason(end.event.error) }); return end; } @@ -495,7 +525,12 @@ export class TurnFlow { } turnId = this.allocateTurnId(); - turnInput = [{ type: 'text', text: GOAL_CONTINUATION_PROMPT }]; + turnInput = [ + { + type: 'text', + text: hitStepCap ? GOAL_STEP_CAP_CONTINUATION_PROMPT : GOAL_CONTINUATION_PROMPT, + }, + ]; turnOrigin = GOAL_CONTINUATION_ORIGIN; } } @@ -1269,6 +1304,18 @@ function hasStepBudgetRemaining(maxSteps: number | undefined, currentStep: numbe return maxSteps === undefined || maxSteps <= 0 || currentStep < maxSteps; } +/** + * True when a turn ended `failed` only because it reached the per-turn step + * limit (`loop_control.max_steps_per_turn`). Such a turn stopped at a clean + * step boundary, so goal pursuit continues instead of pausing. + */ +function isMaxStepsTurnFailure(end: TurnEndResult): boolean { + return ( + end.event.reason === 'failed' && + end.event.error?.code === ErrorCodes.LOOP_MAX_STEPS_EXCEEDED + ); +} + function isTerminalUpdateGoalResult( toolName: string, args: unknown, diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index 5b27ea829e..68be35ec42 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -475,6 +475,141 @@ describe('goal session end-to-end', () => { expect(agent.context.history.at(-1)?.role).toBe('tool'); }); + it('continues the goal when a goal turn hits maxStepsPerTurn', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const { session, agent, scripted } = await setupSession( + sessionDir, + events, + ['GetGoal', 'UpdateGoal'], + undefined, + undefined, + { providers: {}, loopControl: { maxStepsPerTurn: 1 } }, + ); + const api = new SessionAPIImpl(session); + await api.createGoal({ agentId: 'main', objective: 'work' }); + + // Each of the first two turns spends its single allowed step on a tool + // call and is then cut by the step cap; the goal must keep going. The + // third turn completes the goal within the cap. + scripted.mockNextResponse({ + type: 'function', + id: 'check-1', + name: 'GetGoal', + arguments: '{}', + }); + scripted.mockNextResponse({ + type: 'function', + id: 'check-2', + name: 'GetGoal', + arguments: '{}', + }); + scripted.mockNextResponse({ + type: 'function', + id: 'complete', + name: 'UpdateGoal', + arguments: JSON.stringify({ status: 'complete' }), + }); + + agent.turn.prompt([{ type: 'text', text: 'work' }]); + await agent.turn.waitForCurrentTurn(); + + expect(scripted.calls).toHaveLength(3); + const maxStepEnds = events.filter( + (event) => + event['type'] === 'turn.ended' && + event['reason'] === 'failed' && + (event['error'] as Record | undefined)?.['code'] === + ErrorCodes.LOOP_MAX_STEPS_EXCEEDED, + ); + expect(maxStepEnds).toHaveLength(2); + // The cap never pauses or blocks the goal: no stopped status is ever + // emitted, and the goal completes (record cleared) in the third turn. + expect( + events.filter( + (event) => + event['type'] === 'goal.updated' && + ['paused', 'blocked'].includes( + String((event['snapshot'] as Record | null)?.['status']), + ), + ), + ).toEqual([]); + expect((await api.getGoal({ agentId: 'main' })).goal).toBeNull(); + const completion = events.find( + (event) => + event['type'] === 'goal.updated' && + (event['change'] as Record | undefined)?.['kind'] === 'completion', + ); + expect((completion?.['change'] as Record)?.['stats']).toMatchObject({ + turnsUsed: 3, + }); + // Capped continuation turns are told why a new turn was started. + const history = JSON.stringify(agent.context.history); + expect(history).toContain('per-turn step limit'); + expect(history).toContain('Pick up where that turn stopped'); + }); + + it('starts pursuing a goal created mid-turn even when that turn hits maxStepsPerTurn', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const { session, agent, scripted } = await setupSession( + sessionDir, + events, + ['CreateGoal', 'UpdateGoal'], + undefined, + undefined, + { providers: {}, loopControl: { maxStepsPerTurn: 1 } }, + ); + const api = new SessionAPIImpl(session); + + // No goal at turn start: the model creates one on the only allowed step, + // the turn is then cut by the cap, and pursuit must still begin — with a + // continuation turn that explains the cap. + scripted.mockNextResponse({ + type: 'function', + id: 'create', + name: 'CreateGoal', + arguments: JSON.stringify({ objective: 'work' }), + }); + scripted.mockNextResponse({ + type: 'function', + id: 'complete', + name: 'UpdateGoal', + arguments: JSON.stringify({ status: 'complete' }), + }); + + agent.turn.prompt([{ type: 'text', text: 'work' }]); + await agent.turn.waitForCurrentTurn(); + + expect(scripted.calls).toHaveLength(2); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + reason: 'failed', + error: expect.objectContaining({ code: ErrorCodes.LOOP_MAX_STEPS_EXCEEDED }), + }), + ); + expect( + events.filter( + (event) => + event['type'] === 'goal.updated' && + ['paused', 'blocked'].includes( + String((event['snapshot'] as Record | null)?.['status']), + ), + ), + ).toEqual([]); + expect((await api.getGoal({ agentId: 'main' })).goal).toBeNull(); + const completion = events.find( + (event) => + event['type'] === 'goal.updated' && + (event['change'] as Record | undefined)?.['kind'] === 'completion', + ); + expect((completion?.['change'] as Record)?.['stats']).toMatchObject({ + turnsUsed: 2, + }); + expect(JSON.stringify(agent.context.history)).toContain('per-turn step limit'); + }); + it('pauses the goal on provider rate limits', async () => { const sessionDir = await makeTempDir(); const events: Array> = [];