fix(goal): normalize strict-schema fillers
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user