feat(mode): the session-mode core — logged per-agent policy state (@deepseek-ai/dsh-mode)
Plan mode's stage 1 (RFC 2026-07-07-plan-mode): a new packages/mode/ group
with one product package owning the mode/set SessionEventMap vocabulary
(log-only, non-surface, whole-value replace), the pure foldMode, and the
ctx.modes service (list/get/set). User flips are pending intents flushed
at turn/start / step/end — turn enclosure makes an idle append illegal —
with one coalesced context/message notice when the flushed mode differs
from what the last logged request header told the model; a folded mode
the config no longer defines reads as default plus one boundary notice.
Enforcement is two covering layers: a system-prompt/assemble wrapper
filters the RETURNED assembly's tools to the mode's allowlist (and shows
exit_plan_mode IFF the folded mode is plan) beside the mode:policy
section at order 50, and a tools/pre-execute gate denies deny-by-default
against the same allowlist, judging by the logged mode only. The default
mode is the absence of policy — assemblies stay byte-identical to a
no-dsh-mode deployment.
AgentOptions.mode (declaration-merged) seeds a child's initial mode
through the same flush on agent/created; the stdio app gains /mode
(print/switch, never sent to the model) over an opportunistic
ctx.get('modes'). Config is an explicit resolve step: the built-in plan
definition (read-only allowlist; bash/subagent excluded until the
sandbox family lands) merges unless overridden, 'default' as a key
throws at load, unknown names throw at set() time.
This commit is contained in:
@@ -37,6 +37,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-core": "^0.0.1",
|
||||
"@deepseek-ai/dsh-mode": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
@@ -53,6 +54,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
||||
"@deepseek-ai/dsh-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
type AskUserQuestionOption,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
// Type-only edge: makes `ctx.get('modes')` resolve the ModesService type when
|
||||
// @deepseek-ai/dsh-mode is composed; the runtime read stays opportunistic.
|
||||
import type {} from '@deepseek-ai/dsh-mode'
|
||||
|
||||
export const name = 'ui-stdio'
|
||||
export const inject = ['agents', 'userInteraction']
|
||||
@@ -357,6 +360,31 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
ctx.logger.error('ui-stdio: agent "%s" is not running', agentId)
|
||||
return
|
||||
}
|
||||
if (text === '/mode' || text.startsWith('/mode ')) {
|
||||
// A command line, never sent to the model: print or switch the session
|
||||
// mode. The switch is a pending intent the mode service flushes at the
|
||||
// next turn boundary (dsh-mode's turn-enclosure contract).
|
||||
const modes = ctx.get('modes')
|
||||
if (modes === undefined) {
|
||||
output.write('session modes are not composed in this deployment\n> ')
|
||||
return
|
||||
}
|
||||
const target = text.slice('/mode'.length).trim()
|
||||
if (target === '') {
|
||||
const { current, pending } = modes.get(agent)
|
||||
const pendingNote = pending === undefined ? '' : ` (pending: ${pending})`
|
||||
output.write(`mode: ${current}${pendingNote} — available: ${modes.list().join(', ')}\n> `)
|
||||
return
|
||||
}
|
||||
try {
|
||||
modes.set(agent, target)
|
||||
output.write(`mode → ${target} (applies from the next turn)\n> `)
|
||||
} catch (error) {
|
||||
// ModesService.set throws only Error (its unknown-name validation).
|
||||
output.write(`${(error as Error).message}\n> `)
|
||||
}
|
||||
return
|
||||
}
|
||||
submittedWork = true
|
||||
if (agent.status === 'running') {
|
||||
agent.steer([{ type: 'text', text }])
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { Session as RealSession, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import ModesService, { PLAN_MODE } from '@deepseek-ai/dsh-mode'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts'
|
||||
|
||||
@@ -803,3 +807,67 @@ describe('createStdioChat disposal (HMR safety)', () => {
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createStdioChat /mode command', () => {
|
||||
/** An agent fake carrying a REAL session, so `ctx.modes` folds a genuine log. */
|
||||
function makeModeAgent(id: string): Agent & { sent: ContentBlock[][] } {
|
||||
const sent: ContentBlock[][] = []
|
||||
return {
|
||||
id: id as Agent['id'],
|
||||
status: 'idle',
|
||||
options: {},
|
||||
sent,
|
||||
session: new RealSession(SessionId(`${id}-session`)),
|
||||
send: (content: ContentBlock[]) => void sent.push(content),
|
||||
steer: () => {},
|
||||
} as never
|
||||
}
|
||||
|
||||
async function setupWithModes() {
|
||||
const bundle = await setup()
|
||||
await bundle.ctx.plugin(SystemPrompt)
|
||||
await bundle.ctx.plugin(ToolRegistry)
|
||||
await bundle.ctx.plugin(ModesService)
|
||||
const agent = makeModeAgent('main')
|
||||
bundle.ctx.agents.register(agent)
|
||||
return { ...bundle, agent }
|
||||
}
|
||||
|
||||
it('reports when session modes are not composed', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const agent = makeAgent('main', 'idle')
|
||||
ctx.agents.register(agent)
|
||||
input.feed('/mode')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('session modes are not composed in this deployment')
|
||||
expect(agent.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('prints the current and available modes, never sending the line to the model', async () => {
|
||||
const { input, out, agent } = await setupWithModes()
|
||||
input.feed('/mode')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('mode: default — available: default, plan')
|
||||
expect(agent.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('switches the mode as a pending intent and echoes the banner', async () => {
|
||||
const { ctx, input, out, agent } = await setupWithModes()
|
||||
input.feed('/mode plan')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('mode → plan (applies from the next turn)')
|
||||
expect(ctx.modes.get(agent)).toEqual({ current: 'default', pending: PLAN_MODE })
|
||||
input.feed('/mode')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('mode: default (pending: plan) — available: default, plan')
|
||||
expect(agent.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('prints the validation error for an unknown mode name', async () => {
|
||||
const { ctx, input, out, agent } = await setupWithModes()
|
||||
input.feed('/mode nope')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('unknown mode "nope" — available modes: default, plan')
|
||||
expect(ctx.modes.get(agent)).toEqual({ current: 'default' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../mode/mode"
|
||||
},
|
||||
{
|
||||
"path": "../tool-ask-user"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user