From d436074cc23f2b304ae97198b968f25e7fb14c86 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 13:15:39 +0800 Subject: [PATCH] fix(goal): accept empty update fillers --- packages/goal/tool-goal/src/index.ts | 35 ++++++++++- .../goal/tool-goal/tests/tool-goal.spec.ts | 59 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 009a00376f..91ca254b2d 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -132,6 +132,32 @@ function resolveConfig(config: Config): ResolvedConfig { return { blockedAfterConsecutiveRounds: blockedAfter } } +/** Remove only empty provider fillers from fields unused by the selected action. */ +function normalizeUpdateArgs(args: Record): Record { + const normalized = { ...args } + const empty = (value: unknown): boolean => value === undefined || value === null || value === '' || value === 0 + const removeEmpty = (key: string): void => { + if (empty(normalized[key])) normalized[key] = undefined + } + switch (normalized['action']) { + case 'edit': + removeEmpty('blocked_reason') + break + case 'blocked': + removeEmpty('objective') + removeEmpty('max_goal_rounds') + break + case 'pause': + case 'resume': + case 'complete': + removeEmpty('objective') + removeEmpty('max_goal_rounds') + removeEmpty('blocked_reason') + break + } + return normalized +} + /** Build the exact compare-and-set ref from model arguments. */ function goalRef(goalId: string, revision: number): GoalRef { if (goalId.length === 0 || goalId !== goalId.trim() @@ -244,7 +270,7 @@ export function apply(ctx: Context, config: Config): void { presentCall: args => present('Create goal', 'other', args.objective), })) - ctx.tools.register(defineTool({ + const updateGoal = defineTool({ name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' @@ -333,5 +359,10 @@ export function apply(ctx: Context, config: Config): void { 'other', args.blocked_reason ?? args.objective ?? args.goal_id, ), - })) + }) + // Object-literal execute methods do not use `this`; retaining the reference is safe. + // eslint-disable-next-line @typescript-eslint/unbound-method + const executeUpdate = updateGoal.execute + updateGoal.execute = (args, exec) => executeUpdate(normalizeUpdateArgs(args as Record), exec) + ctx.tools.register(updateGoal) } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 7b2ccdc347..a1261d3cb8 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -423,6 +423,65 @@ describe('goal tool state transitions', () => { expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') }) + it('accepts only empty fillers in fields unused by the selected action', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + let goal = ctx.goals.create(root.agent, { objective: 'valid' }) + + const edited = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'edit', + objective: 'edited', + blocked_reason: '', + }, root.agent) + expect(resultGoal(edited)).toMatchObject({ objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const paused = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'pause', + objective: '', + max_goal_rounds: 0, + blocked_reason: null, + }, root.agent) + expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const resumed = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'resume', + objective: null, + max_goal_rounds: '', + blocked_reason: '', + }, root.agent) + expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const blocked = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'blocked', + objective: '', + max_goal_rounds: null, + blocked_reason: 'actual blocker', + }, root.agent) + expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked' }) + goal = ctx.goals.resume(root.agent, { id: goal.id, revision: goal.revision + 1 }) + + const complete = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'complete', + objective: '', + max_goal_rounds: 0, + blocked_reason: '', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited' }) + }) + it('allows exact goal rounds to complete but not edit or pause', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' })