refactor agent pre-step inbox lifecycle

This commit is contained in:
_Kerman
2026-07-31 19:21:16 +08:00
parent c2ff9ddec8
commit fcc2b5e282
267 changed files with 2052 additions and 1546 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/plan/plan-mode/README.md
README.md: 6f0a9ac477b49b96ddfc2ce667e3556dec727569
README.zh.md: 922b153aa1b08e1a6003f63736ea402787bff1dd
README.md: c23dd0c42d8b1d1975b30b08e5be8758a325a9c7
README.zh.md: 88640381d736244ad56aa802cee6ed70c6c75c20

View File

@@ -8,7 +8,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc
`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`.
`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next in-turn request boundary while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths).
`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next in-turn request boundary while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Initial and continuation pre-step boundaries plus request-recovery retries are covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths).
## Model and human surfaces

View File

@@ -8,7 +8,7 @@
`plan/mode`(`{ active: boolean }`)是一个仅写日志、整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。
`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择、等下一个轮内请求边界;返回值说明发生了哪种(`committed`/`queued`)、一次 `cancelled` 反转或 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。提示词提交、常规续行和请求恢复重试都在覆盖范围内;当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。
`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择、等下一个轮内请求边界;返回值说明发生了哪种(`committed`/`queued`)、一次 `cancelled` 反转或 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 边界以及请求恢复重试都在覆盖范围内;当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。
## 模型与人类界面

View File

@@ -8,8 +8,8 @@
*
* The state in force is folded from the session log (`plan/mode`, last one
* wins), so resume and fork restore it without a live mirror. User selections
* are held as pending intent until an in-turn request boundary because every
* session event is turn-enclosed. The service flushes at `agent/step` before
* are held as pending intent until an in-turn step boundary because every
* session event is turn-enclosed. The service flushes on `step/start` before
* the affected request assembly, including retry turns.
*
* The exit tool remains registered while plan mode is inactive so crossing a
@@ -24,9 +24,9 @@
import { Context, Service } from 'cordis'
import { z as zod } from 'zod'
import type { ZodType } from 'zod'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
@@ -197,21 +197,32 @@ export class PlanModeService extends Service {
this.section = resolveConfig(config).section
let disposed = false
// The boundary flush uses the loop's `agent/step` interception seam, not
// post-commit `session/event` observation. `agent/step` runs inside the
// open turn before every request derivation (including turn 1 step 1), so
// it is the sole flush point: prompt admission happens pre-turn, where a
// `plan/mode` append would land outside any open turn. Failures are
// contained so policy cannot block a turn; a failed append remains
// pending for a later boundary.
ctx.on('agent/step', (agent) => {
if (disposed) return
// Pre-step runs before the turn opens, so the turn-enclosed mode event
// commits from the immediately following step/start observer. Request
// assembly happens afterward. A failed append remains pending for a later
// boundary, and policy cannot block the turn.
ctx.on('session/event', (session, event) => {
if (disposed || event.type !== 'step/start') return
try {
this.onBoundary(agent)
this.onBoundary(session)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
}, { prepend: true })
ctx.on('agent/pre-step', async (
agent,
_messages,
_signal,
next,
): Promise<PreStepDecision> => {
const decision = await next()
const pending = this.pendingIntents.get(agent.session)
if (decision.kind === 'reject' || pending?.narrate !== true) return decision
const narration = this.narration(agent.session, pending.active)
return narration === undefined
? decision
: { ...decision, messages: [...decision.messages, narration] }
})
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime')
ctx.systemPrompt.section({
@@ -424,13 +435,13 @@ export class PlanModeService extends Service {
}
session.append('plan/mode', { active })
this.pendingIntents.delete(session)
this.narrate(session, active)
const narration = this.narration(session, active)
if (narration !== undefined) agent.inject(narration)
return 'committed'
}
/** Flush one pending selection before the next request assembly. */
private onBoundary(agent: Agent): void {
const session = agent.session
private onBoundary(session: Session): void {
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
const target = pending.active
@@ -442,20 +453,19 @@ export class PlanModeService extends Service {
// Delete only after append succeeds so a later boundary can retry a failed
// durable write.
this.pendingIntents.delete(session)
if (pending.narrate) this.narrate(session, target)
}
/** Tell the model about a user switch when the last logged header described the other mode. */
private narrate(session: Session, target: boolean): void {
/** Build a user-switch notice when the last logged header described the other mode. */
private narration(session: Session, target: boolean): UserMessage | undefined {
const told = planModeAtLastHeader(session.events)
if (told === undefined || told === target) return
const text = target
? 'The user switched this session to plan mode.'
: 'The user switched this session back to the default mode.'
session.append('user/message', createUserMessage({
return createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'plan-mode' },
}), { surfaceOp: 'append' })
})
}
}

View File

@@ -71,8 +71,7 @@ describe('plan mode through the agent loop', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' })
// Selected while idle: the pending intent flushes at the first
// in-turn agent/step seam, before the first assembly.
// Selected while idle: the mode commits immediately, before the first assembly.
ctx.planMode.set(agent, true)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } }))

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import UserInteractionService, {
@@ -21,15 +21,22 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
* Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
* `ToolRegistry` services, with fake Agents carrying real `Session`s and a
* real scoped `agent.ctx` minted through `createScope`.
* Request boundaries are simulated by dispatching the real prompt-admission
* and between-step seams used by the loop.
* Request boundaries are simulated by dispatching the real pre-step waterfall
* and the following `step/start` session event used by the loop.
*/
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
// A live store session when a store is mounted (the command executor logs
// lifecycle events through it); bare otherwise (fold/tool-only benches).
const session = new Session(SessionId(id))
const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
const agent = {
id: SessionId(id),
session,
options: {},
inject(message: UserMessage) {
session.append('user/message', message, { surfaceOp: 'append' })
},
} as unknown as Agent & { session: Session }
let scoped!: Context
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
inject: ['tools'],
@@ -56,24 +63,30 @@ async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
}
/**
* Dispatch either prompt admission or the between-step checkpoint.
* Dispatch pre-step processing and optionally its following step-start commit.
*/
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> {
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'pre-step' | 'step-start'): Promise<void> {
const events = agentEvents(ctx, agent)
if (type === 'turn/start') {
const message = createUserMessage({
content: [{ type: 'text', text: 'boundary probe' }],
source: { kind: 'user' },
})
await events.waterfall(
'agent/prompt-submit',
[message],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow', messages: [message] }),
)
return
const message = createUserMessage({
content: [{ type: 'text', text: 'boundary probe' }],
source: { kind: 'user' },
})
const signal = new AbortController().signal
const decision = await events.waterfall(
'agent/pre-step',
[message],
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [message] }),
)
if (decision.kind === 'enter') {
for (const message of decision.messages.slice(1)) {
agent.session.append('user/message', message, { surfaceOp: 'append' })
}
}
if (type === 'step-start') {
const event = agent.session.append('step/start', { turn: 1, step: 1 })
ctx.emit('session/event', agent.session, event)
}
await events.serial('agent/step', 1, 2, new AbortController().signal)
}
/** Open a turn so a selection queues for the boundary flush (the mid-turn shape). */
@@ -210,7 +223,7 @@ describe('ctx.planMode: get/set', () => {
expect(ctx.planMode.set(agent, false)).toBe('committed')
expect(foldPlanMode(agent.session.events)).toBe(false)
// A later boundary finds nothing pending — no double append.
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2)
})
@@ -236,23 +249,22 @@ describe('ctx.planMode: get/set', () => {
})
describe('the boundary flush', () => {
it('does not flush at prompt admission — the seam is pre-turn, so the first step boundary lands it', async () => {
it('does not flush during pre-step and commits from the following step/start', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
openTurn(agent.session)
ctx.planMode.set(agent, true)
// Prompt admission runs before any turn opens; a plan/mode appended there
// would sit outside the turn. The pending intent survives admission and
// the in-turn agent/step boundary flushes it before the request derives.
await boundary(ctx, agent, 'turn/start')
// Pre-step only composes narration. The pending intent survives until the
// turn-enclosed step/start event commits it before request assembly.
await boundary(ctx, agent, 'pre-step')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('skips the flush after the plugin fiber is disposed (a captured wrapper must not write into a dead service)', async () => {
it('removes the step/start flush when the plugin fiber is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -260,32 +272,9 @@ describe('the boundary flush', () => {
const agent = await agentWithSession(ctx)
openTurn(agent.session)
ctx.planMode.set(agent, true)
// A listener captured in the same dispatch snapshot keeps the plan-mode
// callback alive across the unload; the resumed wrapper must not append
// through the disposed service. Registered prepended AFTER the plugin so
// it runs before plan-mode's own prepended flush.
ctx.on('agent/step', async () => {
await fiber.dispose()
}, { prepend: true })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('skips the step-seam flush after the plugin fiber is disposed (a captured listener must not write into a dead service)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx)
openTurn(agent.session)
ctx.planMode.set(agent, true)
// Serial dispatch captures its listener list up front; prepending after
// the plugin puts this listener ahead of the plugin's own prepended one,
// so the plugin's captured callback still runs after the disposal below.
ctx.on('agent/step', async () => {
await fiber.dispose()
}, { prepend: true })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
await fiber.dispose()
const event = agent.session.append('step/start', { turn: 1, step: 1 })
ctx.emit('session/event', agent.session, event)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
@@ -293,7 +282,7 @@ describe('the boundary flush', () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(true)
})
@@ -304,7 +293,7 @@ describe('the boundary flush', () => {
openTurn(agent.session)
ctx.planMode.set(agent, true)
ctx.planMode.set(agent, false)
await boundary(ctx, agent, 'turn/start')
await boundary(ctx, agent, 'pre-step')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -313,7 +302,7 @@ describe('the boundary flush', () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start')
await boundary(ctx, agent, 'pre-step')
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -322,9 +311,9 @@ describe('the boundary flush', () => {
const agent = await agentWithSession(ctx)
header(agent.session)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
})
@@ -334,7 +323,7 @@ describe('the boundary flush', () => {
agent.session.append('plan/mode', { active: true })
header(agent.session)
ctx.planMode.set(agent, false)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
})
@@ -345,7 +334,7 @@ describe('the boundary flush', () => {
header(agent.session)
agent.session.append('plan/mode', { active: false })
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -365,19 +354,19 @@ describe('the boundary flush', () => {
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(warn).toHaveBeenCalledOnce()
// The failed flush re-parks the intent (cleared only after a landed
// append), so the next healthy boundary converges the log with the
// picker's optimistic state instead of dropping the switch forever.
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
agent.session.append = original
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent).pending).toBeUndefined()
})
it('prompt admission never appends, so a broken backend surfaces only at the step boundary', async () => {
it('pre-step never appends, so a broken backend surfaces only at step/start', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
@@ -389,9 +378,9 @@ describe('the boundary flush', () => {
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'turn/start')
await boundary(ctx, agent, 'pre-step')
expect(warn).not.toHaveBeenCalled()
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(warn).toHaveBeenCalledOnce()
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
@@ -752,7 +741,7 @@ describe('exit_plan_mode', () => {
// step's end, so the plan policy covers any remaining call of the SAME batch.
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(false)
expect(asked).toHaveLength(1)
expect(asked[0]?.agent).toBe(agent)
@@ -820,7 +809,7 @@ describe('exit_plan_mode', () => {
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(false)
const afterExit = await ctx.systemPrompt.assemble({ agent })
expect(afterExit.tools).toEqual(assembly.tools)
@@ -831,7 +820,7 @@ describe('exit_plan_mode', () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
header(agent.session)
await callExit(ctx, agent)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -1024,7 +1013,7 @@ describe('HMR disposal', () => {
expect(ctx.get('planMode')).toBeUndefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy')
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
})