diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8f95bad05b..64cbb27222 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1110,7 +1110,7 @@ export interface Config { } ``` -Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) +Source: [`packages/goal/tool-goal/src/index.ts:27`](../packages/goal/tool-goal/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8f1fd333fe..d48349cdf0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,7 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index 0d66c0c360..e4e0fbf855 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -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 -2026-07-19-model-facing-goal-tools.md: 7a207bbc73e13ca5111d73ee2c58cf05fbe6387e -2026-07-19-model-facing-goal-tools.zh.md: 46ad7671e1ef2a6856df6d1ec893896aab9760b5 +2026-07-19-model-facing-goal-tools.md: cb823aa944d69228005884ac73cc99b67fa00dfb +2026-07-19-model-facing-goal-tools.zh.md: f93ffa3a2eed602d8c7d98faccf09c9e46924f28 diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md index 7a207bbc73..cb823aa944 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -22,6 +22,8 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. +A successful update that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request after pause, block, or completion. A later successful resume in the same turn removes that contribution. + ### Execution authority Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 46ad7671e1..f93ffa3a2e 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -22,6 +22,8 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 +成功更新后若目标处于停止状态,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免在暂停、阻塞或完成后再发起一次不必要的模型请求。同一轮次中后续成功的恢复会移除该贡献。 + ### 执行权限 每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 86def6b65e..a3987e32e1 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -10,6 +10,8 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal 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. +A successful mutation that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn. A later same-turn resume clears the contribution. This avoids an extra model request after pause, block, or completion without changing ordinary loop continuation. + ## Authority Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does. diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 9bfa51095f..29068a4afa 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -6,6 +6,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' @@ -17,6 +18,7 @@ import { goalToolExecution, requireDirectHuman, } from './authority.ts' +import type { GoalToolExecution } from './authority.ts' export const name = 'tool-goal' export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] @@ -105,9 +107,28 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } } } +/** Remember whether one successful mutation makes this turn terminal. */ +function observeMutation( + terminalTurns: WeakMap, + execution: GoalToolExecution, + goal: GoalView, +): void { + if (goal.phase === 'active' && goal.activation === 'armed') { + terminalTurns.delete(execution.agent) + return + } + terminalTurns.set(execution.agent, execution.start.data.turn) +} + /** Register the three Codex-shaped goal tools and their shared policy section. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) + const terminalTurns = new WeakMap() + ctx.on('agent/turn-stop', (agent, turn) => { + if (terminalTurns.get(agent) !== turn) return undefined + terminalTurns.delete(agent) + return { action: 'stop' } + }) ctx.systemPrompt.section({ name: 'tool:goal', order: 114, @@ -149,6 +170,7 @@ export function apply(ctx: Context, config: Config): void { objective: args.objective, ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present('Create goal', 'other', args.objective), @@ -181,9 +203,11 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) + const goal = ctx.goals.edit(execution.agent, ref, replacements) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', - text: renderGoal(ctx.goals.edit(execution.agent, ref, replacements)), + text: renderGoal(goal), }]) } if (args.objective !== undefined || args.max_goal_rounds !== undefined) { @@ -197,6 +221,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) } const authority = completionAuthority(ctx, execution) @@ -211,6 +236,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) : ctx.goals.block(execution.agent, ref) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index c5f1a00593..2532b16a15 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -287,6 +287,19 @@ describe('goal tool state transitions', () => { goal_id: goal['id'], revision: goal['revision'], action: 'resume', }, root.agent)) expect(goal).toMatchObject({ phase: 'active', revision: 4 }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() + }) + + it('stops the current turn after a successful stopped-state mutation', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' }) + const paused = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'pause', + }, root.agent) + expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toEqual({ action: 'stop' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => {