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

Prevent autonomous goals from being paused by model-reported status updates.
9 changes: 5 additions & 4 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,8 +389,8 @@
* Drives an active goal as a sequence of ordinary turns — the autonomous
* equivalent of the user repeatedly typing "continue". Each iteration runs one
* full turn, then reads the goal status the model set via `UpdateGoal`:
* `complete` (the record is cleared) / `blocked` / `paused` stop the loop;
* `active` (the model didn't decide) re-injects the goal reminder and runs the
* `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
* 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 @@ -437,8 +437,9 @@
}

// The model decides via UpdateGoal: a cleared record means `complete`;
// anything non-active means it stopped (blocked / paused). Only a still
// `active` goal continues to another turn.
// `blocked` remains as a non-active record. Runtime failures and user
// interrupts can still leave the goal paused. Only a still `active` goal
// continues to another turn.
const goal = this.agent.goal.getGoal().goal;
if (goal === null || goal.status !== 'active') {
return end;
Expand Down Expand Up @@ -841,39 +842,39 @@
authorizeToolExecution: async (ctx) => {
return this.agent.permission.beforeToolCall(ctx);
},
finalizeToolResult: async (ctx) => {
// Resolve dedup BEFORE firing the PostToolUse hook so same-step
// dups (whose ctx.result is the dedup placeholder) report the
// original's real outcome, not an empty success.
const finalResult = await deduper.finalizeResult(
ctx.toolCall.id,
ctx.toolCall.name,
ctx.args,
ctx.result,
);
const { isError, output } = finalResult;
const event = isError === true ? 'PostToolUseFailure' : 'PostToolUse';
void this.agent.hooks?.fireAndForgetTrigger(event, {
matcherValue: ctx.toolCall.name,
inputData: {
toolName: ctx.toolCall.name,
toolInput: toolInputRecord(ctx.args),
toolCallId: ctx.toolCall.id,
error: isError === true ? toKimiErrorPayload(toolOutputText(output)) : undefined,
toolOutput: isError === true ? undefined : toolOutputText(output).slice(0, 2000),
},
});
const modelResult = await budgetToolResultForModel({
homedir: this.agent.homedir,
toolName: ctx.toolCall.name,
toolCallId: ctx.toolCall.id,
result: finalResult,
});
if (isTerminalUpdateGoalResult(ctx.toolCall.name, ctx.args, finalResult)) {
goalOutcomeToolResultPending = true;
}
return modelResult;
},

Check warning on line 877 in packages/agent-core/src/agent/turn/index.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-loop-func)

Function declared in a loop contains unsafe references to variable(s)
},
});

Expand Down
3 changes: 1 addition & 2 deletions packages/agent-core/src/tools/builtin/goal/update-goal.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
Set the status of the current goal. This is how you resume, end, or yield an autonomous goal.
Set the status of the current goal. This is how you resume, complete, or block an autonomous goal.

- `active` — resume a paused or blocked goal when the user explicitly asks you to work on that goal.
- `complete` — the objective is satisfied and any stated validation has passed. The goal ends and a completion summary is recorded.
- `blocked` — a genuine impasse prevents useful progress: an external condition, required user input, missing credentials or permissions, a persistent technical failure, or an impossible, unsafe, or contradictory objective. The goal stops but can be resumed later. Do not use `blocked` because the work is large, hard, slow, uncertain, partial, still needs validation, or needs more goal turns.
- `paused` — set the goal aside for now (e.g. to hand control back to the user). It can be resumed later.

Most active goal turns should not call this tool. If you complete one useful slice of work and material work remains, end the turn normally without calling UpdateGoal; the runtime will prompt you to continue in the next goal turn. Call `complete` only when all required work is done, any stated validation has passed, and there is no useful next action. Before calling `complete`, check that every required part of the objective is done, completion criteria are satisfied, requested or expected validation passed or has been reported as impossible, and no known material task remains. Do not call `complete` after only producing a plan, summary, first pass, or partial result. If you call `blocked`, you will be prompted to explain the blocker in your next message. Setting the status is the machine-readable signal; the completion summary or blocker explanation is yours to write in the following message.
36 changes: 23 additions & 13 deletions packages/agent-core/src/tools/builtin/goal/update-goal.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/**
* UpdateGoalTool — the model's single lever over the goal lifecycle. It updates
* the goal's status directly; the turn driver reads the status at each turn
* boundary and stops (`complete` / `blocked` / `paused`) or keeps going
* (`active`).
* boundary and stops (`complete` / `blocked`) or keeps going (`active`).
*
* The argument is intentionally just a status enum — no reason or evidence. The
* model explains itself in its own reply; the status is the machine-readable
Expand All @@ -25,7 +24,7 @@ import DESCRIPTION from './update-goal.md?raw';
export const UpdateGoalToolInputSchema = z
.object({
status: z
.enum(['active', 'complete', 'paused', 'blocked'])
.enum(['active', 'complete', 'blocked'])
.describe('The lifecycle status to set for the current goal.'),
})
.strict();
Expand All @@ -40,23 +39,31 @@ export class UpdateGoalTool implements BuiltinTool<UpdateGoalToolInput> {
constructor(private readonly agent: Agent) {}

resolveExecution(args: UpdateGoalToolInput): ToolExecution {
if (!isUpdateGoalStatus(args.status)) {
return {
isError: true,
output: 'Invalid goal status. Use `active`, `complete`, or `blocked`.',
};
}

const status = args.status;
const goal = this.agent.goal;
const currentGoal = goal.getGoal().goal;
const goalIsActive = currentGoal?.status === 'active';

return {
description: `Setting goal status: ${args.status}`,
stopBatchAfterThis: args.status !== 'active' && goalIsActive,
description: `Setting goal status: ${status}`,
stopBatchAfterThis: status !== 'active' && goalIsActive,
approvalRule: this.name,
execute: async () => {
if (args.status === 'active') {
if (status === 'active') {
if (currentGoal === null) {
return { output: 'Goal not resumed: no current goal.' };
}
await goal.resumeGoal({}, 'model');
return { output: 'Goal resumed.' };
}
if (args.status === 'complete') {
if (status === 'complete') {
const completed = await goal.markComplete({}, 'model');
if (completed === null) {
return { output: 'Goal not completed: no active goal.' };
Expand All @@ -65,7 +72,7 @@ export class UpdateGoalTool implements BuiltinTool<UpdateGoalToolInput> {
buildGoalCompletionSummaryPrompt(completed);
return { output, stopTurn: true };
}
if (args.status === 'blocked') {
if (status === 'blocked') {
const blocked = await goal.markBlocked({}, 'model');
if (blocked === null) {
return { output: 'Goal not blocked: no active goal.' };
Expand All @@ -74,12 +81,15 @@ export class UpdateGoalTool implements BuiltinTool<UpdateGoalToolInput> {
buildGoalBlockedReasonPrompt(blocked);
return { output, stopTurn: true };
}
if (currentGoal === null) {
return { output: 'Goal not paused: no current goal.' };
}
await goal.pauseGoal({}, 'model');
return { output: 'Goal paused.', stopTurn: true };
return {
isError: true,
output: 'Invalid goal status. Use `active`, `complete`, or `blocked`.',
};
},
};
}
}

function isUpdateGoalStatus(status: unknown): status is UpdateGoalToolInput['status'] {
return status === 'active' || status === 'complete' || status === 'blocked';
}
34 changes: 19 additions & 15 deletions packages/agent-core/test/tools/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,16 +325,32 @@ describe('UpdateGoalTool', () => {
} as unknown as Agent;
}

it('accepts only active / complete / paused / blocked', () => {
for (const status of ['active', 'complete', 'paused', 'blocked']) {
it('accepts only active / complete / blocked', () => {
for (const status of ['active', 'complete', 'blocked']) {
expect(UpdateGoalToolInputSchema.safeParse({ status }).success).toBe(true);
}
expect(UpdateGoalToolInputSchema.safeParse({ status: 'blocked', reason: 'x' }).success).toBe(false);
for (const status of ['impossible', 'cancelled', '']) {
for (const status of ['paused', 'impossible', 'cancelled', '']) {
expect(UpdateGoalToolInputSchema.safeParse({ status }).success).toBe(false);
}
});

it('forbids model-driven goal pauses', async () => {
const store = makeStore();
await store.createGoal({ objective: 'work' });
const tool = new UpdateGoalTool(agentWithContext(store));
const validator = compileToolArgsValidator(tool.parameters);

expect(validateToolArgs(validator, { status: 'paused' })).not.toBeNull();

const execution = tool.resolveExecution({ status: 'paused' } as never);
expect(execution).toMatchObject({
isError: true,
output: 'Invalid goal status. Use `active`, `complete`, or `blocked`.',
});
expect(store.getGoal().goal?.status).toBe('active');
});

it('`complete` marks the goal complete and clears it (transient)', async () => {
const store = makeStore();
const reminders: Array<{ readonly content: string; readonly origin: unknown }> = [];
Expand Down Expand Up @@ -367,17 +383,6 @@ describe('UpdateGoalTool', () => {
expect(reminders).toHaveLength(0);
});

it('`paused` marks the goal paused', async () => {
const store = makeStore();
await store.createGoal({ objective: 'work' });
const result = await executeTool(
new UpdateGoalTool(agentWithContext(store)),
ctx({ status: 'paused' }),
);
expect(result.stopTurn).toBe(true);
expect(store.getGoal().goal?.status).toBe('paused');
});

it('`active` resumes a paused goal', async () => {
const store = makeStore();
await store.createGoal({ objective: 'work' });
Expand All @@ -392,7 +397,6 @@ describe('UpdateGoalTool', () => {
['active', 'Goal not resumed: no current goal.'],
['complete', 'Goal not completed: no active goal.'],
['blocked', 'Goal not blocked: no active goal.'],
['paused', 'Goal not paused: no current goal.'],
] as const)('reports a no-goal result for `%s` without stopping the turn', async (status, output) => {
const tool = new UpdateGoalTool(agentWithContext(makeStore()));
const execution = tool.resolveExecution({ status });
Expand Down
Loading