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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。Goal Round 的终态更新会增加注入的收尾指令和一次额外的模型请求用于收尾消息——每个 goal 生命周期一次,而非每轮一次。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
40
packages/goal/tool-goal/src/wrapup.ts
Normal file
40
packages/goal/tool-goal/src/wrapup.ts
Normal 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 }]
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user