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-v2-web-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Resume paused goals when you select Resume.
2 changes: 1 addition & 1 deletion docs/en/guides/goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Write stop conditions into the objective. `/goal` does not have a separate stop-

The web UI shows the current goal in a strip below the conversation. Select the strip to expand or collapse its details. When a token budget is configured, the header shows its progress; goals without a token budget do not show a progress bar.

Use the strip actions to pause an active goal, resume a paused or blocked goal, or cancel the current goal. Cancellation requires confirmation because it cannot be resumed afterwards.
Use the strip actions to pause an active goal, resume a paused or blocked goal, or cancel the current goal. Selecting Resume starts the next goal turn so the agent continues the work. Cancellation requires confirmation because it cannot be resumed afterwards.

## Queue upcoming goals

Expand Down
2 changes: 1 addition & 1 deletion docs/zh/guides/goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Kimi Code 会保存该目标,把它作为下一条用户消息发送,并进

Web 界面会在对话下方显示当前目标条。点击目标条可以展开或收起详细信息。配置 token 预算时,标题栏会显示预算进度;没有配置 token 预算的目标不会显示进度条。

使用目标条中的操作可以暂停进行中的目标、继续已暂停或已阻塞的目标,或取消当前目标。取消操作需要确认,因为取消后无法继续。
使用目标条中的操作可以暂停进行中的目标、继续已暂停或已阻塞的目标,或取消当前目标。点击继续会启动下一轮目标工作,Agent 会继续处理该目标。取消操作需要确认,因为取消后无法继续。

## 安排后续目标

Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/agent/goal/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface GoalReasonInput {
}

export interface ResumeGoalInput extends GoalReasonInput {
readonly continueIfPaused?: boolean;
readonly continueIfBlocked?: boolean;
}

Expand Down
21 changes: 20 additions & 1 deletion packages/agent-core-v2/src/agent/goal/goalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
private readonly wallClockDeadline = this._register(new MutableDisposable<IDisposable>());
private liveWallClockStartedAt?: number;
private pendingContinuation?: PendingContinuation;
private resumeContinuation?: { readonly turnId: number; readonly goalId: string };

constructor(
@IWireService private readonly wire: IWireService,
Expand Down Expand Up @@ -417,8 +418,11 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
`Cannot resume a goal in status "${state.status}"`,
);
}
const continuePaused =
actor === 'user' && state.status === 'paused' && input.continueIfPaused === true;
const shouldContinue =
state.status === 'blocked' && input.continueIfBlocked === true && actor === 'user';
continuePaused ||
(actor === 'user' && state.status === 'blocked' && input.continueIfBlocked === true);
const snapshot = this.applyLifecycle(state, 'active', input.reason, actor);
if (!shouldContinue) return snapshot;
const budgetBlocked = this.blockIfBudgetReached(this.requireState());
Expand All @@ -430,6 +434,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
await this.settleGoalAfterContinuationFailure(error, state.goalId);
throw error;
}
} else if (continuePaused && this.liveTurnId !== undefined) {
this.resumeContinuation = { turnId: this.liveTurnId, goalId: state.goalId };
}
return snapshot;
}
Expand Down Expand Up @@ -670,6 +676,17 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
result: Pick<TurnEndedEvent, 'reason' | 'error'>,
): Promise<void> {
const { goalId, lifecycleGoalId, starterTurn } = this.clearTurnTracking(turnId);
const resumeContinuation = this.resumeContinuation;
if (resumeContinuation?.turnId === turnId) this.resumeContinuation = undefined;
if (resumeContinuation?.turnId === turnId && result.reason === 'cancelled') {
const state = this.goalState;
if (state === null || state.status !== 'active' || state.goalId !== resumeContinuation.goalId) {
return;
}
if (this.blockIfBudgetReached(state) !== null) return;
this.launchContinuationTurn(resumeContinuation.goalId);
return;
}
if (goalId === undefined || lifecycleGoalId === undefined) return;
if (
result.reason === 'blocked' ||
Expand Down Expand Up @@ -856,6 +873,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
opts: { readonly emit?: boolean; readonly track?: boolean; readonly preserveLiveContinuation?: boolean } = {},
): void {
if (this.goalState === null) return;
this.resumeContinuation = undefined;
this.cancelPendingContinuation(opts.preserveLiveContinuation === true);
this.wallClockDeadline.clear();
this.liveWallClockStartedAt = undefined;
Expand All @@ -879,6 +897,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
if (status === 'active') {
this.liveWallClockStartedAt = this.deadlineScheduler.now();
} else if (state.status === 'active') {
this.resumeContinuation = undefined;
this.cancelPendingContinuation(
opts.preserveLiveContinuation === true,
opts.cancellationReason,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export class SessionLegacyService implements ISessionLegacyService {
await goal.pauseGoal({});
break;
case 'resume':
await goal.resumeGoal({ continueIfBlocked: true });
await goal.resumeGoal({ continueIfPaused: true, continueIfBlocked: true });
break;
case 'cancel':
await goal.cancelGoal({});
Expand Down
27 changes: 25 additions & 2 deletions packages/agent-core-v2/test/agent/goal/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1111,16 +1111,39 @@ describe('AgentGoalService core workflow hooks', () => {
expect(loopService.launches).toHaveLength(1);
});

it('does not launch a continuation when a paused goal resumes', async () => {
it('does not launch a continuation when a paused goal resumes by default', async () => {
await goals.createGoal({ objective: 'finish the task' });
await goals.pauseGoal();

const resumed = await goals.resumeGoal({ continueIfBlocked: true });
const resumed = await goals.resumeGoal();

expect(resumed.status).toBe('active');
expect(loopService.launches).toEqual([]);
});

it('starts one continuation when a caller opts to resume a paused goal', async () => {
await goals.createGoal({ objective: 'finish the task' });
await goals.pauseGoal();

const resumed = await goals.resumeGoal({ continueIfPaused: true });

expect(resumed.status).toBe('active');
expect(loopService.launches).toHaveLength(1);
});

it('starts a continuation after an opted paused resume waits for a cancelled turn', async () => {
await startLiveContinuation();
const enqueue = vi.mocked(loopService.enqueue);

await goals.pauseGoal();
const resumed = await goals.resumeGoal({ continueIfPaused: true });
endTurn(eventBus, makeTurn(41), { reason: 'cancelled' });

await vi.waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
expect(resumed.status).toBe('active');
expect(goals.getGoal().goal?.status).toBe('active');
});

it('counts an active goal turn and launches the next continuation', async () => {
await goals.createGoal({ objective: 'finish the task' });

Expand Down
29 changes: 24 additions & 5 deletions packages/kap-server/test/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ describe('server-v2 /api/v1/sessions', () => {
expect(body.details?.[0]?.path).toBe('web_log');
});

async function createBlockedGoalRig() {
async function createStoppedGoalRig(status: 'paused' | 'blocked') {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const id = created.body.data.id;
Expand All @@ -237,10 +237,11 @@ describe('server-v2 /api/v1/sessions', () => {
const events: DomainEvent[] = [];
const subscription = eventBus.subscribe((event) => events.push(event));

const blocked = await postJson<{ status: string }>(agentRpc(IAgentGoalService, 'markBlocked', id), {
reason: 'need credentials',
});
if (blocked.body.data.status !== 'blocked') throw new Error('expected a blocked goal');
const stopped = await postJson<{ status: string }>(
agentRpc(IAgentGoalService, status === 'blocked' ? 'markBlocked' : 'pauseGoal', id),
status === 'blocked' ? { reason: 'need credentials' } : {},
);
if (stopped.body.data.status !== status) throw new Error(`expected a ${status} goal`);

return {
id,
Expand All @@ -255,6 +256,10 @@ describe('server-v2 /api/v1/sessions', () => {
};
}

async function createBlockedGoalRig() {
return createStoppedGoalRig('blocked');
}

it('creates a session from metadata.cwd', async () => {
const cwd = home as string;
const { status, body } = await postJson<SessionWire>('/api/v1/sessions', {
Expand Down Expand Up @@ -519,6 +524,20 @@ describe('server-v2 /api/v1/sessions', () => {
}
});

it('starts one continuation when the Web profile resumes a paused goal', async () => {
const rig = await createStoppedGoalRig('paused');
try {
const resumed = await postJson<SessionWire>(`/api/v1/sessions/${rig.id}/profile`, {
agent_config: { goal_control: 'resume' },
});

expect(resumed.body.code).toBe(0);
expect(goalContinuationStarts(rig.events)).toHaveLength(1);
} finally {
await rig.cancel();
}
});

it('returns the active goal when the Web refreshes after blocked-goal resume', async () => {
const rig = await createBlockedGoalRig();
try {
Expand Down
Loading