fix(agent-core-v2): forbid model goal pauses

This commit is contained in:
_Kerman 2026-07-09 16:56:31 +08:00
parent 6ad2edca53
commit 2e2ca0362f
4 changed files with 70 additions and 19 deletions

View file

@ -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.

View file

@ -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);

View file

@ -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