diff --git a/packages/agent-core-v2/src/agent/goal/tools/update-goal.md b/packages/agent-core-v2/src/agent/goal/tools/update-goal.md index ee3e0d53e..7b9aa26d8 100644 --- a/packages/agent-core-v2/src/agent/goal/tools/update-goal.md +++ b/packages/agent-core-v2/src/agent/goal/tools/update-goal.md @@ -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. diff --git a/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts b/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts index 05e9efe79..762e747b6 100644 --- a/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts +++ b/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts @@ -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 { 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); diff --git a/packages/agent-core-v2/test/goal/goal-tools.test.ts b/packages/agent-core-v2/test/goal/goal-tools.test.ts index fb704afe6..ea9c84a12 100644 --- a/packages/agent-core-v2/test/goal/goal-tools.test.ts +++ b/packages/agent-core-v2/test/goal/goal-tools.test.ts @@ -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 { const abortController = new AbortController(); eventBus.publish({ type: 'turn.started', turnId, origin: USER_PROMPT_ORIGIN }); diff --git a/packages/agent-core-v2/test/turn/turn.test.ts b/packages/agent-core-v2/test/turn/turn.test.ts index b740ada20..fb42660dc 100644 --- a/packages/agent-core-v2/test/turn/turn.test.ts +++ b/packages/agent-core-v2/test/turn/turn.test.ts @@ -373,8 +373,8 @@ describe('Agent turn flow', () => { [emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Trigger generate failure" } ], "toolCalls": [], "origin": { "kind": "user" } } ] } [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "", "turnId": "0", "step": 1 }, "time": "