Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/goal-max-steps-continue.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,7 +1002,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2657": undefined;
readonly "__@mediaStripSnapshotBrand@2660": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {
Expand Down
34 changes: 28 additions & 6 deletions packages/agent-core-v2/src/agent/goal/goalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
type EnqueueReceipt,
} from '#/agent/loop/loop';
import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection';
import { LoopErrors } from '#/agent/loop/errors';
import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
Expand Down Expand Up @@ -176,6 +177,13 @@ const GOAL_CONTINUATION_PROMPT = [
'leaving the goal active. Do not ask the user for input unless a real blocker prevents progress.',
].join(' ');

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(' ');

interface GoalForkNoticeState {
readonly goalPresent: boolean;
readonly reminderPending: boolean;
Expand Down Expand Up @@ -840,10 +848,12 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
return;
}
if (goalId === undefined || lifecycleGoalId === undefined) return;
const stepCapped = isMaxStepsTurnFailure(result);
if (
result.reason === 'blocked' ||
result.reason === 'cancelled' ||
result.reason === 'failed'
!stepCapped &&
(result.reason === 'blocked' ||
result.reason === 'cancelled' ||
result.reason === 'failed')
) {
await this.settleAbnormalTurn(result, lifecycleGoalId);
return;
Expand All @@ -853,7 +863,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
const state = this.goalState;
if (state === null || state.status !== 'active' || state.goalId !== lifecycleGoalId) return;
if (this.blockIfBudgetReached(state) !== null) return;
this.launchContinuationTurn(lifecycleGoalId);
this.launchContinuationTurn(lifecycleGoalId, stepCapped);
}

private clearTurnTracking(
Expand Down Expand Up @@ -913,12 +923,17 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
} catch {}
}

private launchContinuationTurn(goalId: string): void {
private launchContinuationTurn(goalId: string, stepCapped = false): void {
if (!this.isActiveGoal(goalId)) return;
if (this.pendingContinuation !== undefined) return;
const message: ContextMessage = {
role: 'user',
content: [{ type: 'text', text: GOAL_CONTINUATION_PROMPT }],
content: [
{
type: 'text',
text: stepCapped ? GOAL_STEP_CAP_CONTINUATION_PROMPT : GOAL_CONTINUATION_PROMPT,
},
],
toolCalls: [],
origin: GOAL_CONTINUATION_ORIGIN,
};
Expand Down Expand Up @@ -1273,6 +1288,13 @@ function isTerminalUpdateGoalResult(
return status === 'complete' || status === 'blocked';
}

function isMaxStepsTurnFailure(result: Pick<TurnEndedEvent, 'reason' | 'error'>): 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) {
Expand Down
29 changes: 28 additions & 1 deletion packages/agent-core-v2/test/agent/goal/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' });

Expand Down
59 changes: 53 additions & 6 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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,
);
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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,
Expand Down
135 changes: 135 additions & 0 deletions packages/agent-core/test/harness/goal-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = [];
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<string, unknown> | 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<string, unknown> | null)?.['status']),
),
),
).toEqual([]);
expect((await api.getGoal({ agentId: 'main' })).goal).toBeNull();
const completion = events.find(
(event) =>
event['type'] === 'goal.updated' &&
(event['change'] as Record<string, unknown> | undefined)?.['kind'] === 'completion',
);
expect((completion?.['change'] as Record<string, unknown>)?.['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<Record<string, unknown>> = [];
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<string, unknown> | null)?.['status']),
),
),
).toEqual([]);
expect((await api.getGoal({ agentId: 'main' })).goal).toBeNull();
const completion = events.find(
(event) =>
event['type'] === 'goal.updated' &&
(event['change'] as Record<string, unknown> | undefined)?.['kind'] === 'completion',
);
expect((completion?.['change'] as Record<string, unknown>)?.['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<Record<string, unknown>> = [];
Expand Down
Loading