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

@@ -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' })
})
}
}