mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(agent-core-v2): forbid model goal pauses
This commit is contained in:
parent
6ad2edca53
commit
2e2ca0362f
4 changed files with 70 additions and 19 deletions
|
|
@ -1,7 +1,6 @@
|
|||
Set the status of the current goal. This is how you resume, pause, complete, or block 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.
|
||||
- `paused` — set the goal aside for now, for example when the user explicitly asks you to stop goal-driven work but keep the goal available to resume later.
|
||||
- `complete` — the objective is satisfied and any stated validation has passed. The goal ends and a completion summary is recorded. Before using this, verify the current state against the actual objective and every explicit requirement. Treat weak or indirect evidence as not complete. Do not use `complete` merely because a budget is nearly exhausted or you want to stop.
|
||||
- `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. For non-terminal blockers, do not use `blocked` the first time you hit the blocker. The same blocking condition must repeat for at least 3 consecutive goal turns before you call `blocked`, counting the original/user-triggered turn and automatic continuations. If a previously blocked goal is resumed, treat the resumed run as a fresh blocked audit. If the objective itself is impossible, unsafe, or contradictory, call `blocked` in the same turn instead of running more goal turns. Do not use `blocked` because the work is large, hard, slow, uncertain, incomplete, still needs validation, would benefit from clarification, or needs more goal turns. Once the 3-turn threshold is met and you cannot make meaningful progress without user input or an external-state change, call `blocked` instead of leaving the goal active.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -21,7 +20,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. Use `blocked` for impossible, unsafe, or contradictory objectives, or after the same non-terminal blocking condition repeats for at least 3 consecutive goal turns.',
|
||||
),
|
||||
|
|
@ -38,28 +37,45 @@ export class UpdateGoalTool implements BuiltinTool<UpdateGoalToolInput> {
|
|||
constructor(@IAgentGoalService private readonly goal: IAgentGoalService) {}
|
||||
|
||||
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 currentGoal = this.goal.getGoal().goal;
|
||||
const goalIsActive = currentGoal?.status === 'active';
|
||||
|
||||
return {
|
||||
description: `Setting goal status: ${args.status}`,
|
||||
stopBatchAfterThis: args.status !== 'active',
|
||||
description: `Setting goal status: ${status}`,
|
||||
stopBatchAfterThis: status !== 'active' && goalIsActive,
|
||||
approvalRule: this.name,
|
||||
execute: async () => {
|
||||
if (args.status === 'active') {
|
||||
if (status === 'active') {
|
||||
await this.goal.resumeGoal({}, 'model');
|
||||
return { output: 'Goal resumed.' };
|
||||
}
|
||||
if (args.status === 'complete') {
|
||||
if (status === 'complete') {
|
||||
await this.goal.markComplete({}, 'model');
|
||||
return { output: 'Goal marked complete.', stopTurn: true };
|
||||
}
|
||||
if (args.status === 'blocked') {
|
||||
if (status === 'blocked') {
|
||||
await this.goal.markBlocked({}, 'model');
|
||||
return { output: 'Goal marked blocked.', stopTurn: true };
|
||||
}
|
||||
await this.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';
|
||||
}
|
||||
|
||||
registerTool(UpdateGoalTool);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
compileToolArgsValidator,
|
||||
validateToolArgs,
|
||||
} from '#/_base/tools/args-validator';
|
||||
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
|
||||
import { IAgentGoalService } from '#/agent/goal/goal';
|
||||
import { SetGoalBudgetTool } from '#/agent/goal/tools/set-goal-budget';
|
||||
import {
|
||||
UpdateGoalTool,
|
||||
UpdateGoalToolInputSchema,
|
||||
} from '#/agent/goal/tools/update-goal';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentTurnService } from '#/agent/turn/turn';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
|
|
@ -17,7 +25,8 @@ describe('goal tools', () => {
|
|||
let goals: IAgentGoalService;
|
||||
let loopService: IAgentLoopService;
|
||||
let eventBus: IEventBus;
|
||||
let tool: SetGoalBudgetTool;
|
||||
let setGoalBudgetTool: SetGoalBudgetTool;
|
||||
let updateGoalTool: UpdateGoalTool;
|
||||
|
||||
beforeEach(() => {
|
||||
loopService = stubLoopWithHooks();
|
||||
|
|
@ -27,7 +36,8 @@ describe('goal tools', () => {
|
|||
);
|
||||
goals = ctx.get(IAgentGoalService);
|
||||
eventBus = ctx.get(IEventBus);
|
||||
tool = new SetGoalBudgetTool(goals);
|
||||
setGoalBudgetTool = new SetGoalBudgetTool(goals);
|
||||
updateGoalTool = new UpdateGoalTool(goals);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -35,7 +45,7 @@ describe('goal tools', () => {
|
|||
});
|
||||
|
||||
it('SetGoalBudget reports no current goal without failing', async () => {
|
||||
const execution = tool.resolveExecution({ value: 20, unit: 'turns' });
|
||||
const execution = setGoalBudgetTool.resolveExecution({ value: 20, unit: 'turns' });
|
||||
if (execution.isError === true) throw new Error('execution should not be an error');
|
||||
|
||||
const result = await execution.execute({ turnId: 0, toolCallId: 'call_1', signal });
|
||||
|
|
@ -49,7 +59,7 @@ describe('goal tools', () => {
|
|||
await goals.createGoal({ objective: 'work' });
|
||||
await countGoalTurn(1);
|
||||
|
||||
const execution = tool.resolveExecution({ value: 1, unit: 'turns' });
|
||||
const execution = setGoalBudgetTool.resolveExecution({ value: 1, unit: 'turns' });
|
||||
if (execution.isError === true) throw new Error('execution should not be an error');
|
||||
|
||||
expect(execution.stopBatchAfterThis).toBe(true);
|
||||
|
|
@ -67,7 +77,7 @@ describe('goal tools', () => {
|
|||
await goals.createGoal({ objective: 'work' });
|
||||
await countGoalTurn(2);
|
||||
|
||||
const execution = tool.resolveExecution({ value: 5, unit: 'turns' });
|
||||
const execution = setGoalBudgetTool.resolveExecution({ value: 5, unit: 'turns' });
|
||||
if (execution.isError === true) throw new Error('execution should not be an error');
|
||||
|
||||
expect(execution.stopBatchAfterThis).toBeFalsy();
|
||||
|
|
@ -81,6 +91,32 @@ describe('goal tools', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('UpdateGoal accepts only active / complete / blocked statuses', () => {
|
||||
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 ['paused', 'impossible', 'cancelled', '']) {
|
||||
expect(UpdateGoalToolInputSchema.safeParse({ status }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('UpdateGoal forbids model-driven goal pauses', async () => {
|
||||
await goals.createGoal({ objective: 'work' });
|
||||
const validator = compileToolArgsValidator(updateGoalTool.parameters);
|
||||
|
||||
expect(validateToolArgs(validator, { status: 'paused' })).not.toBeNull();
|
||||
|
||||
const execution = updateGoalTool.resolveExecution({ status: 'paused' } as never);
|
||||
expect(execution).toMatchObject({
|
||||
isError: true,
|
||||
output: 'Invalid goal status. Use `active`, `complete`, or `blocked`.',
|
||||
});
|
||||
expect(goals.getGoal().goal?.status).toBe('active');
|
||||
});
|
||||
|
||||
async function countGoalTurn(turnId: number): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
eventBus.publish({ type: 'turn.started', turnId, origin: USER_PROMPT_ORIGIN });
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue