fix(tool-goal): let the model deliver a wrap-up message after a goal-round complete/blocked

A goal round reporting complete or blocked used to conclude the turn at
the tool result, so the model never spoke after the call and sessions
ended on a bare update_goal card. The terminal update now defers one
plugin-sourced <goal_complete>/<goal_blocked> instruction onto its
result asking for a grounded closing message without further tool
calls; the turn then ends through the ordinary no-tool-calls stop.
Direct-human mutations stay uninstructed. Wording chosen by A/B
sampling on deepseek-v4-pro; one extra request per goal lifecycle.

New keyless ACP snapshot goal-wrapup drives the shipped app through
create -> round one -> autonomous complete and pins the injection, the
same-turn closing message, and the completed turn end.
This commit is contained in:
ZiyaZhang
2026-08-02 05:58:45 -07:00
parent 04d43e3b19
commit 666ef95f81
19 changed files with 334 additions and 19 deletions

View File

@@ -347,7 +347,7 @@ describe('goal tool state transitions', () => {
expect(goal).toMatchObject({ phase: 'active', revision: 4 })
})
it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => {
it('injects one wrap-up instruction for an autonomous completion but leaves a human pause interactive', async () => {
const { ctx, root } = await harness()
const humanTurn = openTurn(root, { kind: 'user' })
const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' })
@@ -356,6 +356,7 @@ describe('goal tool state transitions', () => {
}, root.agent)
expect(resultGoal(paused)).toMatchObject({ phase: 'paused' })
expect(paused.concludesTurn).toBeUndefined()
expect(paused.additionalContexts).toBeUndefined()
const resumed = resultGoal(await execute(ctx, 'update_goal', {
goal_id: created.id, revision: 2, action: 'resume',
}, root.agent))
@@ -368,7 +369,27 @@ describe('goal tool state transitions', () => {
goal_id: created.id, revision: resumed['revision'], action: 'complete',
}, root.agent)
expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
expect(complete.concludesTurn).toBe(true)
expect(complete.concludesTurn).toBeUndefined()
const contexts = complete.additionalContexts ?? []
expect(contexts).toHaveLength(1)
expect(contexts[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-goal' })
const block = contexts[0]?.content[0]
if (block?.type !== 'text') throw new Error('expected one text wrap-up block')
expect(block.text).toContain('<goal_complete>')
expect(block.text).toContain('"pause cleanly"')
expect(block.text).toContain('Do not call any more tools.')
})
it('completes without a wrap-up instruction under direct human authority', async () => {
const { ctx, root } = await harness()
openTurn(root, { kind: 'user' })
const created = ctx.goals.create(root.agent, { objective: 'finish now' })
const complete = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'complete',
}, root.agent)
expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
expect(complete.concludesTurn).toBeUndefined()
expect(complete.additionalContexts).toBeUndefined()
})
it('rearms a restored active goal only after a new direct human prompt', async () => {
@@ -550,6 +571,14 @@ describe('goal tool state transitions', () => {
blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' },
roundsStarted: 3,
})
expect(blocked.concludesTurn).toBeUndefined()
const contexts = blocked.additionalContexts ?? []
expect(contexts).toHaveLength(1)
const block = contexts[0]?.content[0]
if (block?.type !== 'text') throw new Error('expected one text wrap-up block')
expect(block.text).toContain('<goal_blocked>')
expect(block.text).toContain('The required credential is still unavailable.')
expect(block.text).toContain('Do not call any more tools.')
})
it('lets direct human authority block before the model threshold', async () => {