fix(goal): normalize strict-schema fillers

This commit is contained in:
Tianyi Cui
2026-07-24 20:45:23 +08:00
parent 145ba169f6
commit 9e691d54c0
10 changed files with 101 additions and 49 deletions

View File

@@ -6,9 +6,9 @@ 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`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`.
- `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`. Strict-schema empty-string and zero fillers count as omitted, while meaningful values remain limited to their action.
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.
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. Mutation cards select the first meaningful action value and otherwise show the goal id, so accepted fillers never produce blank input.
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.

View File

@@ -132,13 +132,13 @@ function resolveConfig(config: Config): ResolvedConfig {
return { blockedAfterConsecutiveRounds: blockedAfter }
}
/** Whether an optional string carries a meaningful action-specific value. */
function hasText(value: string | undefined): boolean {
/** Whether optional text is meaningful rather than a strict-schema empty filler. */
function hasText(value: string | undefined): value is string {
return value !== undefined && value !== ''
}
/** Whether an optional round cap carries a meaningful action-specific value. */
function hasRoundCap(value: number | undefined): boolean {
/** Whether an optional round cap is meaningful rather than a strict-schema zero filler. */
function hasRoundCap(value: number | undefined): value is number {
return value !== undefined && value !== 0
}
@@ -281,13 +281,11 @@ export function apply(ctx: Context, config: Config): void {
const execution = goalToolExecution(ctx, exec)
const ref = goalRef(args.goal_id, args.revision)
const replacements = {
...args.objective === undefined ? {} : { objective: args.objective },
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
...hasText(args.objective) ? { objective: args.objective } : {},
...hasRoundCap(args.max_goal_rounds) ? { maxGoalRounds: args.max_goal_rounds } : {},
}
if (args.action === 'edit') {
requireDirectHuman(ctx, execution)
// 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')
}
@@ -343,7 +341,11 @@ export function apply(ctx: Context, config: Config): void {
presentCall: args => present(
`${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`,
'other',
args.blocked_reason ?? args.objective ?? args.goal_id,
hasText(args.blocked_reason)
? args.blocked_reason
: hasText(args.objective)
? args.objective
: hasRoundCap(args.max_goal_rounds) ? args.max_goal_rounds : args.goal_id,
),
}))
}

View File

@@ -145,8 +145,17 @@ describe('goal tool registration and presentation', () => {
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.',
})).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' })
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'edit',
objective: 'ship', max_goal_rounds: 0, blocked_reason: '',
})).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 'ship' })
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'edit',
objective: '', max_goal_rounds: 8, blocked_reason: '',
})).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 8 })
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'resume',
objective: '', max_goal_rounds: 0, blocked_reason: '',
})).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' })
expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined()
})
@@ -438,11 +447,23 @@ describe('goal tool state transitions', () => {
revision: goal.revision,
action: 'edit',
objective: 'edited',
max_goal_rounds: 0,
blocked_reason: '',
}, root.agent)
expect(resultGoal(edited)).toMatchObject({ objective: 'edited' })
goal = ctx.goals.get(root.agent)!
const capped = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'edit',
objective: '',
max_goal_rounds: 8,
blocked_reason: '',
}, root.agent)
expect(resultGoal(capped)).toMatchObject({ objective: 'edited', maxGoalRounds: 8 })
goal = ctx.goals.get(root.agent)!
const paused = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,