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/fix-goal-and-loop-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Preserve goal completion summaries and show untyped LLM errors without an internal error-code prefix in step interruption events.
9 changes: 5 additions & 4 deletions packages/agent-core-v2/src/agent/goal/goalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
const snapshot = this.toSnapshot(completed);
this.emitCompletion(completed, snapshot, input.reason, actor);
this.trackStatusChanged(completed, actor);
this.clearInternal(actor);
this.clearInternal(actor, { preserveLiveContinuation: true });
return snapshot;
}

Expand Down Expand Up @@ -633,8 +633,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
return status.state === 'idle' && !status.hasPendingRequests;
}

private cancelPendingContinuation(): void {
private cancelPendingContinuation(preserveLiveContinuation = false): void {
const pending = this.pendingContinuation;
if (preserveLiveContinuation && pending?.turnId === this.liveTurnId) return;
this.pendingContinuation = undefined;
pending?.receipt.abort();
}
Expand Down Expand Up @@ -672,10 +673,10 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {

private clearInternal(
actor: GoalActor,
opts: { readonly emit?: boolean; readonly track?: boolean } = {},
opts: { readonly emit?: boolean; readonly track?: boolean; readonly preserveLiveContinuation?: boolean } = {},
): void {
if (this.goalState === null) return;
this.cancelPendingContinuation();
this.cancelPendingContinuation(opts.preserveLiveContinuation === true);
this.wallClockResumedAt = undefined;
this.wire.dispatch(clearGoal({}));
if (opts.emit !== false) this.emitGoalUpdated(null);
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core-v2/src/agent/loop/loopService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,9 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {

private failLoopStep(runtime: LoopRuntime, error: unknown): LoopErrorDisposition {
const reason: LoopInterruptReason = isMaxStepsExceededError(error) ? 'max_steps' : 'error';
this.emitStepInterrupted(runtime.turnId, runtime.current?.number, reason, toErrorMessage(error));
const interruptedError =
isError2(error) && error.code === ErrorCodes.INTERNAL && error.cause !== undefined ? error.cause : error;
this.emitStepInterrupted(runtime.turnId, runtime.current?.number, reason, toErrorMessage(interruptedError));
return { type: 'return', result: { type: 'failed', error, steps: runtime.steps } };
}

Expand Down
51 changes: 49 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 @@ -13,7 +13,7 @@ import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
import { IAgentGoalService } from '#/agent/goal/goal';
import { type AgentGoalService } from '#/agent/goal/goalService';
import { UpdateGoalTool, UpdateGoalToolInputSchema } from '#/agent/goal/tools/update-goal';
import { IAgentLoopService, type AfterStepContext, type Turn } from '#/agent/loop/loop';
import { IAgentLoopService, type AfterStepContext, type EnqueueReceipt, type Step, type Turn } from '#/agent/loop/loop';
import { MessageStepRequest } from '#/agent/loop/stepRequest';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IAgentUsageService } from '#/agent/usage/usage';
Expand Down Expand Up @@ -298,6 +298,7 @@ describe('AgentGoalService', () => {
name: 'UpdateGoal',
arguments: JSON.stringify({ status: 'complete' }),
});
ctx.mockNextResponse({ type: 'text', text: 'Goal completed.' });
const endedTurnIds: number[] = [];
const endedTurnReasons: string[] = [];
const continuationTurnIds: number[] = [];
Expand Down Expand Up @@ -327,7 +328,7 @@ describe('AgentGoalService', () => {
await vi.waitFor(() => {
expect(endedTurnIds).toHaveLength(2);
});
expect(ctx.llmCalls).toHaveLength(2);
expect(ctx.llmCalls).toHaveLength(3);
expect(continuationTurnIds).toEqual(endedTurnIds);
expect(endedTurnReasons).toEqual(['completed', 'completed']);
expect(goals.getGoal().goal).toBeNull();
Expand Down Expand Up @@ -583,6 +584,28 @@ describe('AgentGoalService core workflow hooks', () => {
await ctx?.dispose();
});

async function startLiveContinuation(): Promise<ReturnType<typeof vi.fn<() => boolean>>> {
const abort = vi.fn<() => boolean>(() => true);
const turn: Turn = { ...makeTurn(41), result: new Promise<never>(() => {}) };
const step: Step = {
id: 'goal-continuation',
turnId: turn.id,
state: 'queued',
signal: turn.signal,
result: Promise.resolve({ type: 'completed' }),
cancel: () => true,
};
const receipt: EnqueueReceipt = { assigned: Promise.resolve({ turn, step }), abort };
vi.spyOn(loopService, 'enqueue').mockReturnValue(receipt);

await goals.createGoal({ objective: 'finish the task' });
await goals.markBlocked({ reason: 'need credentials' });
await goals.resumeGoal({ continueIfBlocked: true });
await Promise.resolve();
eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN });
return abort;
}

it('starts a continuation when a user resumes an idle blocked goal', async () => {
await goals.createGoal({ objective: 'finish the task' });
await goals.markBlocked({ reason: 'need credentials' });
Expand All @@ -598,6 +621,30 @@ describe('AgentGoalService core workflow hooks', () => {
});
});

it('aborts a live continuation when the user pauses the goal', async () => {
const abort = await startLiveContinuation();

await goals.pauseGoal();

expect(abort).toHaveBeenCalledOnce();
});

it('aborts a live continuation when the user cancels the goal', async () => {
const abort = await startLiveContinuation();

await goals.cancelGoal();

expect(abort).toHaveBeenCalledOnce();
});

it('aborts a live continuation when the user replaces the goal', async () => {
const abort = await startLiveContinuation();

await goals.createGoal({ objective: 'new task', replace: true });

expect(abort).toHaveBeenCalledOnce();
});

it.each(['turn', 'token', 'wall-clock'] as const)(
'keeps a goal blocked when its %s budget is exhausted before resume',
async (budget) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,26 @@ describe('GoalInjection integration', () => {
expect(await flushedGoalReminderRecords(ctx, persistence)).toHaveLength(2);
});

it('requests a final model response when a continuation completes the goal', async () => {
profile.update({ activeToolNames: ['UpdateGoal'] });
await goals.createGoal({ objective: 'Finish the task' });

ctx.mockNextResponse({ type: 'text', text: 'Working on it.' });
ctx.mockNextResponse({
type: 'function',
id: 'call_complete_goal',
name: 'UpdateGoal',
arguments: JSON.stringify({ status: 'complete' }),
});
ctx.mockNextResponse({ type: 'text', text: 'Finished and verified.' });

await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start.' }] });
await ctx.untilTurnEnd();
await ctx.untilTurnEnd();

expect(ctx.llmCalls).toHaveLength(3);
});

it('writes no goal record when there is no active goal', async () => {
await injectDynamic(injector);

Expand Down
17 changes: 17 additions & 0 deletions packages/agent-core-v2/test/agent/loop/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,23 @@ describe('Agent loop', () => {
);
});

it('reports an untyped LLM error message without an internal-code prefix', async () => {
profile.update({ activeToolNames: [] });

await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
await ctx.untilTurnEnd();

expect(ctx.allEvents).toContainEqual(
expect.objectContaining({
event: 'turn.step.interrupted',
args: expect.objectContaining({
reason: 'error',
message: 'Unexpected generate call #1',
}),
}),
);
});

it('does not run loop error handlers for aborted turns', async () => {
let called = false;
loop.registerLoopErrorHandler({
Expand Down
Loading