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

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/goal/tool-goal/README.md
README.md: aaed61dd517aeb2f94efa22c34c64d1068155d46
README.zh.md: 5365b64ef65fb3d3f00e19357327479ebd8285a8
README.md: 2fa80c2e5fa3d675a48fc18506635fd811ac8f80
README.zh.md: c6c39e3cc739fb39a4a36080db5246e7c7349147

View File

@@ -14,7 +14,7 @@ All calls are exclusive, so a model-ordered batch observes earlier mutations and
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.
An autonomous goal round that successfully reports `complete` or `blocked` marks that tool execution with `concludeTurn()` so the physical turn stops after the step. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
An autonomous goal round that successfully reports `complete` or `blocked` defers one wrap-up context onto that tool result: an injected instruction telling the model to write a final closing message to the user and call no more tools, after which the turn ends through the ordinary no-tool-calls stop. Direct-human mutations receive no instruction: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
## Authority
@@ -61,11 +61,11 @@ Prefix-stable while the plugin scope, configured threshold, and guidance text ar
#### What the model sees
The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `<goal_state>` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority.
The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `<goal_state>` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. A goal-round `complete` or `blocked` result additionally injects one `<goal_complete>`/`<goal_blocked>` wrap-up instruction that asks for a grounded closing message to the user without further tool calls.
#### Token effect
Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction.
Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. A goal-round terminal update adds the injected wrap-up instruction and one further model request for the closing message — once per goal lifecycle, not per round.
#### KV Cache effect

View File

@@ -14,7 +14,7 @@
3 个规范值都与已经渲染给 Native 调用方的紧凑 JSON 一致:`{ goal: null }``{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`。因此,编程消费方无需解析渲染后的 JSON即可收到相同领域结构。
自主 Goal Round 成功报告 `complete``blocked` 时,会`concludeTurn()` 标记该次工具执行,使物理轮次在该步骤后停止。人类直接变更不会导致这种停止assistant 可以确认变更,循环仍可接收并发的人类 steering中途引导
自主 Goal Round 成功报告 `complete``blocked` 时,会在该次工具结果上附带一条收尾注入指令,要求模型面向用户写出最终收尾消息、不再调用工具,之后轮次经由常规的无工具调用停止路径结束。人类直接变更不会收到这条指令assistant 可以确认变更,循环仍可接收并发的人类 steering中途引导
## 权限
@@ -61,11 +61,11 @@ Use goal tools for one long-running completion objective in the current session.
#### 模型看到的内容
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `<goal_state>` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `<goal_state>` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。Goal Round 的 `complete`/`blocked` 结果还会额外注入一条 `<goal_complete>`/`<goal_blocked>` 收尾指令,要求模型向用户写出有依据的收尾消息且不再调用工具。
#### Token 影响
固定 schema 成本加上每次调用的一条紧凑结果。变更还会保留领域快照直到压缩compaction
固定 schema 成本加上每次调用的一条紧凑结果。变更还会保留领域快照直到压缩compactionGoal Round 的终态更新会增加注入的收尾指令和一次额外的模型请求用于收尾消息——每个 goal 生命周期一次,而非每轮一次。
#### KV Cache 影响

View File

@@ -8,7 +8,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import { GoalId } from '@deepseek-ai/dsh-goal'
import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -17,6 +17,7 @@ import {
goalToolExecution,
requireDirectHuman,
} from './authority.ts'
import { renderWrapupContext } from './wrapup.ts'
export const name = 'tool-goal'
export const inject = ['agents', 'goals', 'tools', 'systemPrompt']
@@ -309,7 +310,14 @@ export function apply(ctx: Context, config: Config): void {
code: 'model-reported',
message: args.blocked_reason as string,
})
if (authority.kind === 'goal-round') exec.concludeTurn()
if (authority.kind === 'goal-round') {
exec.deferContext(createUserMessage({
content: args.action === 'complete'
? renderWrapupContext(goal.objective)
: renderWrapupContext(goal.objective, args.blocked_reason as string),
source: { kind: 'plugin', plugin: 'tool-goal' },
}))
}
return Promise.resolve(goalValue(goal))
},
presentCall: args => present(

View File

@@ -0,0 +1,40 @@
/** Model-visible wrap-up instruction for a terminal autonomous goal update. */
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
const GROUNDING =
'Report only what earlier rounds and tool results in this session actually establish; '
+ 'when a detail is not in the session, say so instead of inventing it. '
/**
* Render the closing-message instruction injected after an autonomous goal
* round reports `complete` or `blocked`, replacing the former hard turn stop
* so the model still addresses the user once before the turn ends.
* @param objective - the terminal goal's objective, echoed for grounding.
* @param blockedReason - the validated report for `blocked`; omitted for `complete`.
* @returns a fresh one-block context for `ToolRunContext.deferContext()`.
*/
export function renderWrapupContext(objective: string, blockedReason?: string): ContentBlock[] {
const heading = `Objective: ${JSON.stringify(objective)}\n`
const text = blockedReason === undefined
? '<goal_complete>\n'
+ heading
+ 'The goal is marked complete and this autonomous run is ending. Write the closing '
+ 'message to the user now: state the outcome, summarize what was done and how it was '
+ 'verified, and point to the concrete results (files, commits, or other artifacts). '
+ GROUNDING
+ 'Note anything the user should review or do next. Address the user directly. Do not '
+ 'call any more tools.\n'
+ '</goal_complete>'
: '<goal_blocked>\n'
+ heading
+ `Blocked: ${JSON.stringify(blockedReason)}\n`
+ 'The goal is marked blocked and this autonomous run is ending. Write the closing '
+ 'message to the user now: state what has been completed so far, describe the concrete '
+ 'blocking condition and what you tried, and say exactly what you need from the user to '
+ 'continue. '
+ GROUNDING
+ 'Address the user directly. Do not call any more tools.\n'
+ '</goal_blocked>'
return [{ type: 'text', text }]
}

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 () => {