diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index a0df65a6ed..96e6389f85 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -132,14 +132,14 @@ 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 action = args['action'] - const empty = (value: unknown): boolean => value === undefined || value === null || value === '' || value === 0 - const unused = (key: string): boolean => key === 'blocked_reason' - ? action !== 'blocked' - : (key === 'objective' || key === 'max_goal_rounds') && action !== 'edit' - return Object.fromEntries(Object.entries(args).filter(([key, value]) => !unused(key) || !empty(value))) +/** Whether an optional string carries a meaningful action-specific value. */ +function hasText(value: string | undefined): boolean { + return value !== undefined && value !== '' +} + +/** Whether an optional round cap carries a meaningful action-specific value. */ +function hasRoundCap(value: number | undefined): boolean { + return value !== undefined && value !== 0 } /** Build the exact compare-and-set ref from model arguments. */ @@ -254,7 +254,7 @@ export function apply(ctx: Context, config: Config): void { presentCall: args => present('Create goal', 'other', args.objective), })) - const updateGoal = defineTool({ + ctx.tools.register(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 ' @@ -286,7 +286,9 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) - if (args.blocked_reason !== undefined) { + // Some strict-schema providers emit empty placeholders for every declared + // optional field. Reject only values that could change another action. + if (hasText(args.blocked_reason)) { throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') } const goal = ctx.goals.edit(execution.agent, ref, replacements) @@ -295,7 +297,7 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) { + if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds) || hasText(args.blocked_reason)) { throw new HarnessError( 'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE', @@ -308,13 +310,13 @@ export function apply(ctx: Context, config: Config): void { return Promise.resolve(goalValue(goal)) } const authority = completionAuthority(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds)) { throw new HarnessError( 'objective and max_goal_rounds are valid only with action edit', 'GOAL_TOOL_INVALID_UPDATE', ) } - if (args.action === 'complete' && args.blocked_reason !== undefined) { + if (args.action === 'complete' && hasText(args.blocked_reason)) { throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') } if (args.action === 'blocked' @@ -343,10 +345,5 @@ 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 a1261d3cb8..cf48b3dcbc 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -444,7 +444,7 @@ describe('goal tool state transitions', () => { action: 'pause', objective: '', max_goal_rounds: 0, - blocked_reason: null, + blocked_reason: '', }, root.agent) expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'edited' }) goal = ctx.goals.get(root.agent)! @@ -453,8 +453,8 @@ describe('goal tool state transitions', () => { goal_id: goal.id, revision: goal.revision, action: 'resume', - objective: null, - max_goal_rounds: '', + objective: '', + max_goal_rounds: 0, blocked_reason: '', }, root.agent) expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'edited' }) @@ -465,7 +465,7 @@ describe('goal tool state transitions', () => { revision: goal.revision, action: 'blocked', objective: '', - max_goal_rounds: null, + max_goal_rounds: 0, blocked_reason: 'actual blocker', }, root.agent) expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked' })