fix(goal): stop terminal goal tool turns
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<Agent, number>,
|
||||
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<Agent, number>()
|
||||
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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user