refactor agent pre-step inbox lifecycle
This commit is contained in:
@@ -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' })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user