mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix: forbid model-driven goal pauses (#1476)
This commit is contained in:
parent
ebd25a4a55
commit
d1a964fba9
5 changed files with 53 additions and 34 deletions
5
.changeset/forbid-model-goal-pauses.md
Normal file
5
.changeset/forbid-model-goal-pauses.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Prevent autonomous goals from being paused by model-reported status updates.
|
||||
|
|
@ -389,8 +389,8 @@ export class TurnFlow {
|
|||
* 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.
|
||||
|
|
@ -437,8 +437,9 @@ export class TurnFlow {
|
|||
}
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
|
|
@ -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.' };
|
||||
|
|
@ -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.' };
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }> = [];
|
||||
|
|
@ -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' });
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue