Merge master into codex/session-title
This commit is contained in:
@@ -44,7 +44,7 @@ A goal mutation made during its round supersedes settlement of the older revisio
|
||||
|
||||
Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling.
|
||||
|
||||
Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed.
|
||||
Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` with its typed cause before clearing queues or aborting the turn. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round with the `parent` cause, and awaits the driver plus agent quiescence while its event fence remains installed.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ export function apply(ctx: Context): void {
|
||||
state.competingQueued = true
|
||||
if (attempt?.phase === 'queued') attempt.stale = true
|
||||
})
|
||||
ctx.on('agent/cancel-requested', (agent, reason) => {
|
||||
ctx.on('agent/cancel-requested', (agent, cause) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
state.attempt = undefined
|
||||
@@ -325,7 +325,7 @@ export function apply(ctx: Context): void {
|
||||
return
|
||||
}
|
||||
try {
|
||||
applyOutcome(state, goal, { kind: 'pause', reason })
|
||||
applyOutcome(state, goal, { kind: 'pause', reason: cause.kind })
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
@@ -399,7 +399,7 @@ export function apply(ctx: Context): void {
|
||||
&& source.round === goal.roundsStarted + 1
|
||||
}
|
||||
|
||||
ctx.on('agent/prompt-submit', async (agent, content, source, next): Promise<PromptDecision> => {
|
||||
ctx.on('agent/prompt-submit', async (agent, content, source, _signal, next): Promise<PromptDecision> => {
|
||||
if (!isGoalRoundSource(source)) return next()
|
||||
const state = stateFor(agent)
|
||||
let valid = false
|
||||
@@ -449,7 +449,7 @@ export function apply(ctx: Context): void {
|
||||
if (attempt !== undefined) {
|
||||
attempt.stale = true
|
||||
if (attempt.phase === 'admitted' && state.agent.status === 'running') {
|
||||
state.agent.cancel('goal-session driver disposed')
|
||||
state.agent.cancel({ kind: 'parent' })
|
||||
}
|
||||
waits.push(state.agent.whenIdle())
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal
|
||||
case 'completed':
|
||||
return { kind: 'continue' }
|
||||
case 'aborted':
|
||||
return { kind: 'pause', reason: reason.reason ?? 'cancelled' }
|
||||
return { kind: 'pause', reason: 'cancelled' }
|
||||
case 'error': {
|
||||
const { code, message } = reason.failure ?? reason
|
||||
return code === 'RATE_LIMIT' || code === 'QUOTA'
|
||||
|
||||
@@ -130,7 +130,6 @@ async function waitForRequests(adapter: ScriptedAdapter, count: number): Promise
|
||||
describe('goal-round outcome policy', () => {
|
||||
it.each([
|
||||
[{ kind: 'completed' }, true, { kind: 'continue' }],
|
||||
[{ kind: 'aborted', reason: 'operator stopped' }, true, { kind: 'pause', reason: 'operator stopped' }],
|
||||
[{ kind: 'aborted' }, true, { kind: 'pause', reason: 'cancelled' }],
|
||||
[{ kind: 'error', step: 1, message: 'slow down', code: 'RATE_LIMIT' }, true,
|
||||
{ kind: 'blocked', code: 'usage-limited', message: 'slow down' }],
|
||||
@@ -255,7 +254,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('maps a downstream prompt veto to blocked without admitting the round', async () => {
|
||||
const test = await harness([])
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal'
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, _signal, next) => source.kind === 'goal'
|
||||
? Promise.resolve({ kind: 'block', reason: 'deployment policy' })
|
||||
: next())
|
||||
test.ctx.goals.create(test.agent, { objective: 'respect policy' })
|
||||
@@ -271,7 +270,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('does not reserve again when a stopped-goal observer queues ordinary work', async () => {
|
||||
const test = await harness([textResponse('human follow-up')])
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal'
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, _signal, next) => source.kind === 'goal'
|
||||
? Promise.resolve({ kind: 'block', reason: 'stop this round' })
|
||||
: next())
|
||||
test.ctx.on('goal/changed', (agent, change) => {
|
||||
@@ -291,7 +290,7 @@ describe('same-session goal driving', () => {
|
||||
const cancel = test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
if (agent === test.agent && info.source.kind === 'goal') {
|
||||
cancel()
|
||||
agent.cancel('operator cancelled pending goal')
|
||||
agent.cancel({ kind: 'user' })
|
||||
}
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'do not start yet' })
|
||||
@@ -309,7 +308,7 @@ describe('same-session goal driving', () => {
|
||||
test.ctx.goals.create(test.agent, { objective: 'stop in flight' })
|
||||
await waitForRequests(test.adapter, 1)
|
||||
|
||||
test.agent.cancel('operator stopped active goal')
|
||||
test.agent.cancel({ kind: 'user' })
|
||||
await test.agent.whenIdle()
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
|
||||
|
||||
@@ -401,7 +400,7 @@ describe('same-session goal driving', () => {
|
||||
it('rechecks revision after downstream prompt hooks before admitting', async () => {
|
||||
const test = await harness([textResponse('new revision')])
|
||||
let edited = false
|
||||
test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => {
|
||||
test.ctx.on('agent/prompt-submit', (agent, _content, source, _signal, next) => {
|
||||
if (source.kind === 'goal' && !edited) {
|
||||
edited = true
|
||||
const current = test.ctx.goals.get(agent)
|
||||
@@ -577,7 +576,7 @@ describe('same-session goal driving', () => {
|
||||
it('fails a post-hook read closed before the prompt can enter history', async () => {
|
||||
const test = await harness([])
|
||||
let armed = true
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => {
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, _signal, next) => {
|
||||
if (source.kind === 'goal' && armed) {
|
||||
armed = false
|
||||
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
|
||||
@@ -609,7 +608,7 @@ describe('same-session goal driving', () => {
|
||||
it('does not invent goal state when ordinary queued work is cancelled', async () => {
|
||||
const test = await harness([])
|
||||
test.agent.send([{ type: 'text', text: 'cancel ordinary work' }])
|
||||
test.agent.cancel('ordinary cancellation')
|
||||
test.agent.cancel({ kind: 'user' })
|
||||
await test.agent.whenIdle()
|
||||
|
||||
expect(test.ctx.goals.get(test.agent)).toBeUndefined()
|
||||
@@ -622,7 +621,7 @@ describe('same-session goal driving', () => {
|
||||
await waitForRequests(test.adapter, 1)
|
||||
const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' })
|
||||
|
||||
test.agent.cancel('cancel the inspection')
|
||||
test.agent.cancel({ kind: 'user' })
|
||||
await test.agent.whenIdle()
|
||||
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({
|
||||
@@ -642,7 +641,7 @@ describe('same-session goal driving', () => {
|
||||
vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
|
||||
throw new Error('pause failed')
|
||||
})
|
||||
agent.cancel('cancel the reserved goal round')
|
||||
agent.cancel({ kind: 'user' })
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'fail closed after cancellation' })
|
||||
|
||||
@@ -655,10 +654,10 @@ describe('same-session goal driving', () => {
|
||||
it('blocks admission when downstream cancellation clears the reservation', async () => {
|
||||
const test = await harness([])
|
||||
let cancelled = false
|
||||
test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => {
|
||||
test.ctx.on('agent/prompt-submit', (agent, _content, source, _signal, next) => {
|
||||
if (source.kind === 'goal' && !cancelled) {
|
||||
cancelled = true
|
||||
agent.cancel('cancel from downstream admission policy')
|
||||
agent.cancel({ kind: 'user' })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
interface StubAgent {
|
||||
readonly agent: Agent
|
||||
readonly session: Session
|
||||
@@ -84,6 +86,7 @@ async function execute(
|
||||
initiator: Agent | undefined = agent,
|
||||
): Promise<ToolExecutionResult> {
|
||||
const run = () => ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`call-${Math.random()}`),
|
||||
name,
|
||||
arguments: args,
|
||||
@@ -113,7 +116,7 @@ describe('goal tool registration and presentation', () => {
|
||||
expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name))
|
||||
.toEqual(['create_goal', 'get_goal', 'update_goal'])
|
||||
for (const name of ['create_goal', 'get_goal', 'update_goal']) {
|
||||
expect(ctx.tools.executionMode({ callId: CallId(name), name, arguments: {} }))
|
||||
expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId(name), name, arguments: {} }))
|
||||
.toEqual({ kind: 'exclusive' })
|
||||
}
|
||||
const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal')
|
||||
@@ -197,6 +200,7 @@ describe('goal tool execution authority', () => {
|
||||
|
||||
openTurn(root, { kind: 'user' })
|
||||
const driverless = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('call-driverless'),
|
||||
name: 'get_goal',
|
||||
arguments: {},
|
||||
@@ -326,7 +330,7 @@ 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()
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1, testToolSignal)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => {
|
||||
@@ -337,7 +341,7 @@ describe('goal tool state transitions', () => {
|
||||
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', humanTurn)).toBeUndefined()
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', humanTurn, testToolSignal)).toBeUndefined()
|
||||
const resumed = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: 2, action: 'resume',
|
||||
}, root.agent))
|
||||
@@ -350,8 +354,8 @@ describe('goal tool state transitions', () => {
|
||||
goal_id: created.id, revision: resumed['revision'], action: 'complete',
|
||||
}, root.agent)
|
||||
expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toEqual({ action: 'stop' })
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toBeUndefined()
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn, testToolSignal)).toEqual({ action: 'stop' })
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn, testToolSignal)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rearms a restored active goal only after a new direct human prompt', async () => {
|
||||
|
||||
Reference in New Issue
Block a user