Revert "fix(goal): tolerate strict provider filler fields"

This reverts commit 9a30dbb0f537f512180754c4778c15f9f94f1a95.
This commit is contained in:
Turtle
2026-07-24 13:09:13 +08:00
parent 2e1be7d4d7
commit 0d41349002
22 changed files with 119 additions and 109 deletions

View File

@@ -6,7 +6,7 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal
- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation.
- `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution.
- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. `edit` consumes replacement fields, `blocked` requires and persists `blocked_reason` with the stable code `model-reported`, and every action ignores fields it does not consume so providers that populate every schema property cannot prevent a valid transition.
- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`.
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations.

View File

@@ -248,9 +248,8 @@ export function apply(ctx: Context, config: Config): void {
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 '
+ 'and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured '
+ 'minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and '
+ 'must explain it in blocked_reason.',
+ 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains '
+ 'responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.',
parameters: {
goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' },
revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' },
@@ -260,11 +259,11 @@ export function apply(ctx: Context, config: Config): void {
enum: UPDATE_ACTIONS,
description: 'edit | pause | resume | complete | blocked',
},
objective: { type: 'string', description: 'Replacement objective for action edit; ignored for other actions.' },
max_goal_rounds: { type: 'number', description: 'Replacement round cap for action edit; ignored for other actions.' },
objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' },
max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' },
blocked_reason: {
type: 'string',
description: 'Concrete blocking condition for action blocked; required for blocked, ignored for other actions.',
description: 'Concrete blocking condition; required only with action blocked.',
},
},
output: GOAL_OUTPUT,
@@ -277,12 +276,21 @@ export function apply(ctx: Context, config: Config): void {
}
if (args.action === 'edit') {
requireDirectHuman(ctx, execution)
if (args.blocked_reason !== undefined) {
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
}
const goal = ctx.goals.edit(execution.agent, ref, replacements)
observeMutation(terminalTurns, execution, false)
return Promise.resolve(goalValue(goal))
}
if (args.action === 'pause' || args.action === 'resume') {
requireDirectHuman(ctx, execution)
if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) {
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',
)
}
const goal = args.action === 'pause'
? ctx.goals.pause(execution.agent, ref)
: ctx.goals.resume(execution.agent, ref)
@@ -290,6 +298,15 @@ 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) {
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) {
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
}
if (args.action === 'blocked'
&& (args.blocked_reason === undefined || args.blocked_reason.trim().length === 0)) {
throw new HarnessError('blocked_reason is required with action blocked', 'GOAL_TOOL_INVALID_UPDATE')

View File

@@ -377,12 +377,26 @@ describe('goal tool state transitions', () => {
closeTurn(root, turn)
})
it('returns structured domain and required-argument failures', async () => {
it('returns structured domain and conditional-argument failures', async () => {
const { ctx, root } = await harness()
openTurn(root, { kind: 'user' })
const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent)
expect(invalidCreate.error?.info?.code).toBe('GOAL_INVALID_OBJECTIVE')
const created = ctx.goals.create(root.agent, { objective: 'valid' })
const replacement = await execute(ctx, 'update_goal', {
goal_id: created.id,
revision: created.revision,
action: 'pause',
objective: 'not valid for pause',
}, root.agent)
expect(replacement.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const terminalUpdate = await execute(ctx, 'update_goal', {
goal_id: created.id,
revision: created.revision,
action: 'complete',
max_goal_rounds: 2,
}, root.agent)
expect(terminalUpdate.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const blockedWithoutReason = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'blocked',
}, root.agent)
@@ -391,45 +405,24 @@ describe('goal tool state transitions', () => {
goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ',
}, root.agent)
expect(blockedWithEmptyReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const completeWithReason = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.',
}, root.agent)
expect(completeWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const editWithReason = await execute(ctx, 'update_goal', {
goal_id: created.id,
revision: created.revision,
action: 'edit',
objective: 'still valid',
blocked_reason: 'Not valid for edit.',
}, root.agent)
expect(editWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const malformedRef = await execute(ctx, 'update_goal', {
goal_id: '', revision: 0, action: 'edit', objective: 'x',
max_goal_rounds: 0, blocked_reason: '',
}, root.agent)
expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
})
it('ignores unused action fields emitted by strict-schema providers', async () => {
const { ctx, root } = await harness()
openTurn(root, { kind: 'user' })
let goal = ctx.goals.create(root.agent, { objective: 'valid' })
const strictArgs = {
objective: '',
max_goal_rounds: 0,
blocked_reason: '',
}
const paused = await execute(ctx, 'update_goal', {
goal_id: goal.id, revision: goal.revision, action: 'pause', ...strictArgs,
}, root.agent)
goal = ctx.goals.get(root.agent)!
expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'valid' })
const resumed = await execute(ctx, 'update_goal', {
goal_id: goal.id, revision: goal.revision, action: 'resume', ...strictArgs,
}, root.agent)
goal = ctx.goals.get(root.agent)!
expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'valid' })
const edited = await execute(ctx, 'update_goal', {
goal_id: goal.id, revision: goal.revision, action: 'edit', objective: 'edited',
max_goal_rounds: 7, blocked_reason: '',
}, root.agent)
goal = ctx.goals.get(root.agent)!
expect(resultGoal(edited)).toMatchObject({ objective: 'edited', maxGoalRounds: 7 })
const complete = await execute(ctx, 'update_goal', {
goal_id: goal.id, revision: goal.revision, action: 'complete', ...strictArgs,
}, root.agent)
expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited', maxGoalRounds: 7 })
})
it('allows exact goal rounds to complete but not edit or pause', async () => {
const { ctx, root } = await harness()
const humanTurn = openTurn(root, { kind: 'user' })