Files
deepseek-harness/packages/mode/mode/src/index.ts
T
kingwl e2628442fa fix(mode): the Code Mode SDK section is re-rendered under the mode's visibility rule
Review follow-up on the residual the previous commit accepted — and the
acceptance was wrong, because the fix is clean: in Code Mode the SDK
section IS the soft surface (the wire carries only run_code), section
text resolves in assemble's base, and renderToolsSdk is an exported
pure renderer. The outermost wrapper therefore re-renders tools:sdk
from the same visibility predicate the wire filter applies (allowlist,
exit-IFF-plan, minus run_code mirroring the registry's own exclusion):
a plan-mode program is documented exactly the callable bindings — read
and the exit, never the denied write. The default mode leaves the
section untouched (absence of policy), both pinned by tests.

The soft layer's promise — the model is never encouraged toward a tool
the gate denies — now holds in Code Mode too; the only remaining
prompt-honesty residual is a prepend-after-load assemble listener,
where the gate still covers execution.
2026-07-10 21:09:28 +08:00

472 lines
21 KiB
TypeScript

/**
* Session modes: named, logged, per-agent policy states, with **plan mode** as
* the first shipped definition. A mode names which tools stay visible (the
* soft layer, a `system-prompt/assemble` filter plus a guidance section) and
* which may run (the hard layer, a deny-by-default `tools/pre-execute` gate);
* the mode IN FORCE for an agent is session state, folded from its log
* (`mode/set`, last one wins), so resume and fork restore it for free.
*
* The default mode is the absence of policy: no section, no filtering, no
* gate. An agent that never sees a `mode/set` behaves byte-identically to a
* deployment that never loads this plugin, so it is safe to compose
* unconditionally.
*
* User flips go through {@link ModesService.set}: every session event is
* turn-enclosed and an idle agent has no open turn, so `set()` records a
* pending intent and the service flushes it at the next boundary
* (`turn/start` / `step/end` — both outside the step's tool-execution window).
* A flush that changes what the last logged request header told the model
* appends one coalesced `context/message` notice in the same frame.
*
* RFC: docs/rfc/implemented/feature/2026-07-07-plan-mode.md
*
* @module @deepseek-ai/dsh-mode
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool, renderToolsSdk, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* The session mode in force from this point on: log-only, non-surface,
* whole-value replace — the last `mode/set` in the log wins (see
* {@link foldMode}). A log with none folds to {@link DEFAULT_MODE}.
*/
'mode/set': { mode: string }
}
}
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/**
* Initial session mode for this agent. Applied as a pending intent flushed
* at the first turn boundary; an explicit option beats the logged baseline
* on create AND resume. An unknown name throws at agent creation.
*/
mode?: string
}
}
declare module 'cordis' {
interface Context {
modes: ModesService
}
}
/**
* The mode a log with no `mode/set` folds to: the absence of policy. Reserved —
* {@link resolveConfig} rejects it as a definition key, and {@link ModesService.set}
* always accepts it as a target (a picker's exit-to-default is a valid write).
*/
export const DEFAULT_MODE = 'default'
/** The one shipped mode definition's name. */
export const PLAN_MODE = 'plan'
/**
* The model-facing exit tool's name. The assemble filter shows the tool IFF the
* folded mode is {@link PLAN_MODE}, which keeps a default-mode assembly
* byte-identical to a deployment without this plugin even though the tool is
* always registered.
*/
export const EXIT_PLAN_MODE = 'exit_plan_mode'
/**
* One mode's deployment-configured policy: the guidance section the model sees
* and the allowlist of tool names that stay visible and executable.
*/
export interface ModeDefinition {
/** Guidance text rendered as the `mode:policy` prompt section while the mode is in force. */
section: string
/** Allowlist of tool NAMES; names may reference not-yet-registered tools (registration is dynamic). */
tools: string[]
}
/**
* Plugin config: mode definitions by name. The built-in {@link PLAN_MODE}
* definition is merged in unless overridden; {@link DEFAULT_MODE} is rejected
* as a key ({@link resolveConfig} throws at load).
*/
export interface ModeConfig {
/** Mode definitions by name, overriding or extending the built-in `plan`. */
modes?: Record<string, ModeDefinition>
}
/** Validated mode definitions: the built-in `plan` merged with (or replaced by) the configured ones. */
export interface ResolvedModes {
/** Definitions by mode name; never contains {@link DEFAULT_MODE}. */
definitions: ReadonlyMap<string, ModeDefinition>
}
const PLAN_SECTION
= 'You are in plan mode: a read-only planning state. Explore, analyze, and design; '
+ 'do not attempt to modify anything — mutating tools are not available and calls '
+ 'to them are denied. When a decision or a missing detail blocks the plan, ask the '
+ 'user through the ask_user_question tool where it is available. A finished plan '
+ 'is delivered by calling exit_plan_mode — that call is what puts it in front of '
+ 'the user for review, so prefer it over pasting the plan as a plain reply or '
+ 'asking the user to switch modes themselves. If exit_plan_mode is unavailable or '
+ 'its review fails, ask the user to switch the session out of plan mode instead '
+ 'of retrying denied tools.'
// 'structured_output' is a structured subagent child's result channel (pure
// reporting, the ask/exit class of read-only-safe): its runtime re-injects the
// schema into the FINAL assembly from an outermost per-spawn listener, so
// allowlisting is what keeps the soft filter, that re-injection, and the hard
// gate telling one consistent story when such a child runs in plan mode.
const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', 'ask_user_question', 'structured_output', EXIT_PLAN_MODE]
/** The review question's approve option label — the answer item is matched by it. */
const APPROVE_LABEL = 'Approve'
/** The review question's keep-planning option label. */
const KEEP_PLANNING_LABEL = 'Keep planning'
const EXIT_DESCRIPTION
= 'Present your plan for the user\'s review and, on approval, leave plan mode. '
+ 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
+ 'The user may approve (the full toolset returns on your next step) or keep '
+ 'planning — their feedback comes back in the tool result; revise and present again.'
/** The plan's first markdown heading (any level), or `undefined` when it has none. */
function firstHeading(plan: string): string | undefined {
for (const line of plan.split('\n')) {
const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
if (match) return match[1]
}
return undefined
}
/**
* Validate the config and merge the built-in `plan` definition (explicit
* resolve step — the `dsh-bash` request/spec template). Fail-loud: a
* {@link DEFAULT_MODE} key or a malformed definition throws at load.
*
* @param config Raw plugin config.
* @returns The validated definitions, `plan` included unless overridden.
*/
export function resolveConfig(config: ModeConfig): ResolvedModes {
const definitions = new Map<string, ModeDefinition>()
definitions.set(PLAN_MODE, { section: PLAN_SECTION, tools: [...PLAN_TOOLS] })
for (const [name, definition] of Object.entries(config.modes ?? {})) {
if (name === DEFAULT_MODE) {
throw new Error(`ModeConfig: "${DEFAULT_MODE}" is reserved (the absence of policy) and cannot be defined`)
}
if (typeof definition.section !== 'string') {
throw new Error(`ModeConfig: mode "${name}" needs a string \`section\``)
}
if (!Array.isArray(definition.tools) || definition.tools.some(tool => typeof tool !== 'string')) {
throw new Error(`ModeConfig: mode "${name}" needs a \`tools\` array of tool names`)
}
definitions.set(name, { section: definition.section, tools: [...definition.tools] })
}
return { definitions }
}
/**
* The mode in force after the first `end` events: the last `mode/set` wins,
* a prefix with none folds to {@link DEFAULT_MODE}. Pure — exported for
* reconstructors and tests.
*
* @param events The session log (or any prefix of it).
* @param end Fold `events[0, end)`; defaults to the whole log.
* @returns The folded mode name.
*/
export function foldMode(events: readonly SessionEvent[], end = events.length): string {
let mode = DEFAULT_MODE
let index = 0
for (const event of events) {
if (index >= end) break
index++
if (event.type === 'mode/set') mode = event.data.mode
}
return mode
}
/** The mode the last logged request header shipped under, or `undefined` before the first header. */
function modeAtLastHeader(events: readonly SessionEvent[]): string | undefined {
let lastHeader = -1
let index = 0
for (const event of events) {
if (event.type === 'request/header' || event.type === 'request/header-delta') lastHeader = index
index++
}
if (lastHeader < 0) return undefined
return foldMode(events, lastHeader + 1)
}
/**
* `ctx.modes`: the session-mode service. Owns the `mode/set` vocabulary, the
* pending-intent flush, the boundary narration, and both policy layers (the
* assemble filter + `mode:policy` section, and the `tools/pre-execute` gate).
* UIs read mode flips off `session/event`; there is no live mirror.
*/
export class ModesService extends Service {
static inject = ['tools', 'systemPrompt']
/** Validated definitions (built-in `plan` merged unless overridden). */
readonly resolved: ResolvedModes
/**
* The latest selected mode per session, awaiting its turn-boundary flush.
* `narrate` is true for user selections (the flush appends the coalesced
* notice when the header disagrees) and false for the exit tool's own
* switch, which narrates through its tool result instead.
*/
private readonly pendingIntents = new WeakMap<Session, { mode: string; narrate: boolean }>()
/** The unknown folded-mode name already narrated per session (once per name). */
private readonly droppedNoticed = new WeakMap<Session, string>()
constructor(ctx: Context, config: ModeConfig = {}) {
super(ctx, 'modes')
this.resolved = resolveConfig(config)
ctx.on('session/event', (session, event) => {
if (event.type !== 'turn/start' && event.type !== 'step/end') return
try {
this.onBoundary(session, event.type === 'turn/start')
} catch (error) {
// Contained (a policy plugin must never kill the session feed): the only
// throw path in onBoundary is session.append rejecting mid-teardown.
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
})
ctx.on('agent/created', (agent) => {
const seed = agent.options.mode
if (seed === undefined) return
this.set(agent, seed)
})
ctx.systemPrompt.section({
name: 'mode:policy',
order: 50,
text: context => (context.agent === undefined ? '' : this.activeDefinition(context.agent.session)?.definition.section ?? ''),
})
// prepend: the filter wraps OUTSIDE every append-registered listener
// regardless of load order, so their post-next() additions are filtered
// too. A listener that ALSO prepends after this plugin loads (the
// structured runtime's per-spawn wrapper) still wins the wrap — for that
// one the allowlist carries the contract, and the hard gate covers
// execution either way.
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
const result = await next()
const agent = context.agent
if (agent === undefined) return result
const active = this.activeDefinition(agent.session)
if (active === undefined) {
result.tools = result.tools.filter(tool => tool.name !== EXIT_PLAN_MODE)
return result
}
const allowed = new Set(active.definition.tools)
const visible = (name: string): boolean =>
allowed.has(name) && (name !== EXIT_PLAN_MODE || active.name === PLAN_MODE)
// run_code is a TRANSPORT, not a capability: under the registry's Code
// Mode it is the only wire tool (filtering it would leave the model
// with nothing, not even the exit), and every bridged sub-call
// re-enters tools/pre-execute with the same agent, where the allowlist
// governs each capability individually.
result.tools = result.tools.filter(tool => visible(tool.name) || tool.name === RUN_CODE_NAME)
// Code Mode's soft surface is the SDK section, not the wire schemas —
// section text resolves in assemble's base, so the outermost wrapper
// can re-render it here from the same visibility rule the wire filter
// applies (minus run_code, mirroring the registry's own exclusion).
// Without this the prompt would document bindings the gate denies.
const sdkIndex = result.sections.findIndex(section => section.name === 'tools:sdk')
if (sdkIndex >= 0) {
const sdkText = renderToolsSdk(ctx.tools.schemas().filter(schema =>
visible(schema.name) && schema.name !== RUN_CODE_NAME))
result.sections = result.sections.map((section, index) =>
index === sdkIndex ? { ...section, text: sdkText } : section)
}
return result
}, { prepend: true })
ctx.on('tools/pre-execute', (exec, next): Promise<PreToolDecision> => {
if (exec.agent === undefined) return next()
const active = this.activeDefinition(exec.agent.session)
if (active === undefined) return next()
// Transport pass-through: a run_code program's every tool call is
// serialized back through ToolRegistry.execute() with the same agent,
// so each sub-call is judged here individually — gating the wrapper
// would only remove the vehicle, not widen or narrow any capability.
if (exec.name === RUN_CODE_NAME) return next()
if (active.definition.tools.includes(exec.name)) return next()
const reason = active.name === PLAN_MODE
? `tool "${exec.name}" is not available in plan mode; continue planning and present your plan with ${EXIT_PLAN_MODE} when ready`
: `tool "${exec.name}" is not available in "${active.name}" mode`
return Promise.resolve({ kind: 'deny', reason })
})
ctx.tools.register(defineTool({
name: EXIT_PLAN_MODE,
description: EXIT_DESCRIPTION,
parameters: {
plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
},
execute: async (_args, exec) => {
const agent = exec.agent
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
if (this.activeDefinition(agent.session)?.name !== PLAN_MODE) {
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
}
const interaction = ctx.get('userInteraction')
if (interaction === undefined) {
throw new Error('no user-interaction channel is available to review the plan; ask the user to switch the session mode instead')
}
const answer = await interaction.ask({
questions: [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
options: [
{ label: APPROVE_LABEL, description: 'Leave plan mode; the full toolset returns on the next step.' },
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
],
}],
agent,
...exec.signal ? { signal: exec.signal } : {},
})
const item = answer.answers.find(entry => entry.id === 'plan-review')
if (!item?.selected.includes(APPROVE_LABEL)) {
// A custom-text-only answer is feedback, not consent — approval is
// exactly the approve option (an unknown selection never exits).
const feedback = item?.custom ?? ''
throw new Error(feedback === ''
? 'The user chose to keep planning; revise the plan and present it again.'
: `The user chose to keep planning; their feedback: ${feedback}`)
}
// A boundary-applied switch, NOT a direct append: the loop may still
// execute further tool calls from the SAME assistant response after
// this one, and they were requested under the plan-shaped header — a
// same-batch exit_plan_mode + write pair must not smuggle the write
// past the gate. The flush at this step's end appends the mode/set
// (still in-turn), so the next step's assembly widens; narrate: false —
// this result IS the narration.
this.pendingIntents.set(agent.session, { mode: DEFAULT_MODE, narrate: false })
const note = item.custom === undefined || item.custom === '' ? '' : ` User note: ${item.custom}`
return [{ type: 'text', text: `Plan approved — plan mode exited; the full toolset returns on your next step.${note}` }]
},
presentCall: args => ({
card: 'generic',
title: firstHeading(args.plan) ?? 'Plan',
kind: 'other',
content: [{ type: 'text', text: args.plan }],
}),
presentResult: (_args, result) => ({
card: 'generic',
title: 'Plan review',
content: result.content,
}),
}))
}
/**
* The selectable mode vocabulary: {@link DEFAULT_MODE} first, then the
* configured definitions — the list a mode picker advertises.
*
* @returns Mode names, `default` first.
*/
list(): string[] {
return [DEFAULT_MODE, ...this.resolved.definitions.keys()]
}
/**
* The agent's mode state: the folded mode in force (a folded name the config
* no longer defines reads as {@link DEFAULT_MODE}) plus the pending
* user-selected intent awaiting its boundary flush, when one exists.
*
* @param agent The agent to read.
* @returns The current (effective) mode and the pending intent, if any.
*/
get(agent: Agent): { current: string; pending?: string } {
const current = this.activeDefinition(agent.session)?.name ?? DEFAULT_MODE
const pending = this.pendingIntents.get(agent.session)
return pending === undefined ? { current } : { current, pending: pending.mode }
}
/**
* Select the agent's mode. Validates the name against {@link list} (loud on
* unknown; `default` is always a valid target), drops a no-op (target equals
* the pending intent, else the current fold), and otherwise records a
* pending intent flushed as a `mode/set` at the next turn boundary.
*
* @param agent The agent to switch.
* @param mode The target mode name.
*/
set(agent: Agent, mode: string): void {
if (mode !== DEFAULT_MODE && !this.resolved.definitions.has(mode)) {
throw new Error(`unknown mode "${mode}" — available modes: ${this.list().join(', ')}`)
}
const session = agent.session
const target = this.pendingIntents.get(session)?.mode ?? this.get(agent).current
if (mode === target) return
this.pendingIntents.set(session, { mode, narrate: true })
}
/** The folded mode's definition, or `undefined` for the default mode and for a folded name the config no longer defines. */
private activeDefinition(session: Session): { name: string; definition: ModeDefinition } | undefined {
const name = foldMode(session.events)
if (name === DEFAULT_MODE) return undefined
const definition = this.resolved.definitions.get(name)
if (definition === undefined) return undefined
return { name, definition }
}
/**
* One turn-boundary pass: narrate a folded mode the config dropped (once per
* name, turn starts only), then flush the pending intent — append the
* `mode/set` (skipped when the fold already matches: a net-zero flip
* sequence) and the one coalesced notice when the flushed mode differs from
* what the last logged request header told the model.
*/
private onBoundary(session: Session, turnStart: boolean): void {
if (turnStart) this.noticeDroppedDefinition(session)
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
const target = pending.mode
if (target === foldMode(session.events)) {
this.pendingIntents.delete(session)
return
}
session.append('mode/set', { mode: target })
// Clear the intent only AFTER the append landed: if a backend rejects the
// write, the intent stays parked and the next boundary retries — the UI's
// optimistic picker state and the log re-converge instead of diverging
// forever on a swallowed one-shot.
this.pendingIntents.delete(session)
if (!pending.narrate) return
const told = modeAtLastHeader(session.events)
if (told === undefined || told === target) return
const text = target === DEFAULT_MODE
? 'The user switched this session back to the default mode.'
: `The user switched this session to ${target} mode.`
session.append('context/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'mode' },
}, { surfaceOp: 'append' })
}
/** Narrate a folded mode name the current config no longer defines — the session reads as default plus this one notice. */
private noticeDroppedDefinition(session: Session): void {
const name = foldMode(session.events)
if (name === DEFAULT_MODE || this.resolved.definitions.has(name)) return
if (this.droppedNoticed.get(session) === name) return
this.droppedNoticed.set(session, name)
session.append('context/message', {
content: [{ type: 'text', text: `Mode "${name}" is no longer defined in this deployment's configuration; the session continues in the default mode.` }],
source: { kind: 'plugin', plugin: 'mode' },
}, { surfaceOp: 'append' })
}
}
export default ModesService