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:
kingwl
2026-07-10 01:38:39 +08:00
parent ab8dc1ec72
commit 63ced3e0e2
26 changed files with 1179 additions and 5 deletions
+1
View File
@@ -19,6 +19,7 @@ packages/ Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
compact/ compaction seam + basic backend
subagent/ subagent seam + spawn/fork/ACP backends + delegation tool
todo/ the todo_write tool
mode/ session modes: plan mode as logged per-agent policy state
guard/ loop-hygiene plugins
cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime
hooks/ Claude Code / Codex hook bridges + shared wire-protocol library
+5
View File
@@ -38,6 +38,8 @@ flowchart LR
pkg_user_interaction["user-interaction"]
svc_userInteraction["ctx.userInteraction<br/>Human question/answer seam"]
pkg_stdio_agent["stdio-agent"]
pkg_mode["mode"]
svc_modes["ctx.modes<br/>Session-mode policy state"]
svc_agents["ctx.agents<br/>Agent registry"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_agent_core["agent-core"]
@@ -82,6 +84,7 @@ flowchart LR
pkg_llm_deepseek --> svc_llm
pkg_llm_pi_ai --> svc_llm
pkg_llm_replay --> svc_llm
pkg_mode --> svc_modes
pkg_session --> svc_sessions
pkg_session_persistence --> svc_sessionPersistence
pkg_session_persistence_jsonl --> svc_sessionPersistence
@@ -114,6 +117,7 @@ flowchart LR
svc_fs --> pkg_tool_fs
svc_llm --> pkg_agent_loop
svc_llm --> pkg_compact_basic
svc_modes --> pkg_stdio_agent
svc_sessionPersistence --> pkg_acp
svc_sessionPersistence --> pkg_agent_loop
svc_sessions --> pkg_agent
@@ -150,6 +154,7 @@ flowchart LR
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. |
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
| `ctx.modes` | `core` | [`mode`](../packages/mode/mode) | - | [`stdio-agent`](../packages/ui/stdio-agent) | - | Folds the logged per-agent mode (mode/set), flushes user flips at turn boundaries, and enforces the mode through the assemble filter and the tools/pre-execute gate. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local. |
+29
View File
@@ -398,6 +398,35 @@ export interface Config {
Source: [`packages/support/llm-replay/src/index.ts:429`](../packages/support/llm-replay/src/index.ts)
## `@deepseek-ai/dsh-mode`
Requires: `tools` · `systemPrompt`
```ts config-catalog
/**
* 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>
}
/**
* 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[]
}
```
Source: [`packages/mode/mode/src/index.ts:94`](../packages/mode/mode/src/index.ts)
## `@deepseek-ai/dsh-repeat-tool-guard`
```ts config-catalog
+14
View File
@@ -147,6 +147,20 @@ Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../co
Source: [`packages/llm/llm/src/index.ts:88`](../../packages/llm/llm/src/index.ts)
## `ctx.modes` — `ModesService`
`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.
```ts cordis-catalog
list(): string[]
get(agent: Agent): { current: string, pending?: string }
set(agent: Agent, mode: string): void
```
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/mode/mode/src/index.ts:179`](../../packages/mode/mode/src/index.ts)
## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
+4 -4
View File
@@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`mode`](../packages/mode/mode), [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:476`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:357`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
@@ -24,17 +24,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`mode`](../packages/mode/mode), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`mode`](../packages/mode/mode) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:132`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:111`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:127`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:91`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:91`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`mode`](../packages/mode/mode) |
Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`.
+10 -1
View File
@@ -61,6 +61,9 @@ flowchart TD
subgraph group_todo["packages/todo"]
pkg_tool_todo["tool-todo"]
end
subgraph group_mode["packages/mode"]
pkg_mode["mode"]
end
subgraph group_cordis["packages/cordis"]
pkg_tool_cordis["tool-cordis"]
end
@@ -173,6 +176,10 @@ flowchart TD
pkg_tool_todo --> pkg_agent
pkg_tool_todo --> pkg_session
pkg_tool_todo --> pkg_tools
pkg_mode --> pkg_agent
pkg_mode --> pkg_session
pkg_mode --> pkg_system_prompt
pkg_mode --> pkg_tools
pkg_tool_cordis --> pkg_tools
pkg_hooks_codex --> pkg_agent
pkg_hooks_codex --> pkg_hook_protocol
@@ -236,6 +243,7 @@ flowchart TD
pkg_stdio_agent --> pkg_agent_core
pkg_stdio_agent --> pkg_app_boot
pkg_stdio_agent --> pkg_llm
pkg_stdio_agent --> pkg_mode
pkg_stdio_agent --> pkg_session
pkg_stdio_agent --> pkg_session_persistence_jsonl
pkg_stdio_agent --> pkg_tool_ask_user
@@ -284,6 +292,7 @@ flowchart TD
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`mode`](../packages/mode/mode) | `mode` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`tools`](../packages/core/tools) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
@@ -298,4 +307,4 @@ flowchart TD
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`mode`](../packages/mode/mode), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
+12
View File
@@ -107,6 +107,18 @@ A hook command's outcome — log-only, paired with a prior `hook/invoked` (same
Source: [`packages/hooks/hook-protocol/src/types.ts:45`](../packages/hooks/hook-protocol/src/types.ts)
### `mode/*`
#### `mode/set` — log-only
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 foldMode). A log with none folds to DEFAULT_MODE.
```ts persistence-catalog
'mode/set': { mode: string }
```
Source: [`packages/mode/mode/src/index.ts:39`](../packages/mode/mode/src/index.ts)
### `prompt/*`
#### `prompt/blocked` — log-only
+1
View File
@@ -18,6 +18,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
| [`mode/`](mode/README.md) | Session-mode policy family: plan mode as logged per-agent state with soft/hard enforcement | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
@@ -125,6 +125,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
],
},
{
key: 'modes',
summary: '`ctx.modes`: the session-mode service.',
methods: [
'list(): string[]',
'get(agent: Agent): { current: string, pending?: string }',
'set(agent: Agent, mode: string): void',
],
},
{
key: 'sessionPersistence',
summary: 'Abstract durable session-persistence service.',
+9
View File
@@ -0,0 +1,9 @@
# mode/ — session-mode policy family
Session modes: named, logged, per-agent policy states, with **plan mode** as the first shipped definition. A single **product** package — there is no interface/implementation seam here, because a mode's variable parts are config values (allowlist, section text), not swappable implementations.
| Package | Role | ctx key |
|---|---|---|
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the soft layer (assemble filter + `mode:policy` section) and the hard layer (`tools/pre-execute` deny-by-default gate) | `ctx.modes` |
The mode in force is a pure function of the session log (`SessionEventMap['mode/set']`, last one wins), so resume and fork restore it with no extra machinery; the default mode is the absence of policy, keeping the plugin invisible until a mode is set. UIs read flips off `session/event`: the [stdio app](../ui/stdio-agent) exposes `/mode`, the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker. RFC: [plan mode](../../docs/rfc/proposed/feature/2026-07-07-plan-mode.md).
+38
View File
@@ -0,0 +1,38 @@
# @deepseek-ai/dsh-mode
Session modes: named, logged, per-agent policy states. **Plan mode** is the first shipped definition — the agent explores and designs under a read-only tool policy, produces a reviewable plan, and crosses back into full authority through an explicit review.
## The mode state is a session event
`mode/set` (`{ mode: string }`) is a log-only, non-surface `SessionEventMap` member with whole-value-replace semantics; the pure `foldMode(events)` returns the mode in force (the last `mode/set`, else `default`). Because the log is the fact channel, resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event` — there is no live mirror.
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.
## Two layers of enforcement
**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header-delta` on the next step. The `exit_plan_mode` tool is visible IFF the folded mode is `plan`.
**Hard — what can run.** A `tools/pre-execute` listener denies, deny-by-default against the same allowlist, any call the mode does not permit — a hallucinated call to a still-registered (or freshly re-widened) tool cannot run. Agent-less executions and the default mode pass through; the gate judges by the LOGGED mode only, never a pending intent.
## `ctx.modes`
`list()` returns the selectable vocabulary (`default` first, then the configured definitions); `get(agent)` returns the folded mode (a folded name the config no longer defines reads as `default`) plus any pending intent; `set(agent, mode)` validates against `list()` (loud on unknown; `default` is always a valid target) and records a pending intent — every session event is turn-enclosed and an idle agent has no open turn, so the service flushes the intent at the next `turn/start`/`step/end` and, when the flushed mode differs from what the last logged request header told the model, appends one coalesced `context/message` notice in the same frame. A net-zero flip sequence appends nothing.
`AgentOptions.mode` (declaration-merged) seeds a child's initial mode through the same pending-intent flush; explicit options beat the logged baseline on create AND resume. A fork child needs no mechanism — the parent's `mode/set` is inside the seeded prefix.
## Config
```yaml
- id: mode
name: '@deepseek-ai/dsh-mode'
config:
modes:
plan:
section: |
You are in plan mode: ...
tools: [read, todo_write, web_search, web_fetch, exit_plan_mode]
```
Definitions are validated at load (`resolveConfig`): the built-in `plan` (read-only allowlist, `bash`/`subagent` excluded) merges unless overridden, `default` is rejected as a key, and allowlists may name not-yet-registered tools (registration is dynamic). An unknown name fails loudly at `set()` time.
RFC: [plan mode](../../../docs/rfc/proposed/feature/2026-07-07-plan-mode.md).
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@deepseek-ai/dsh-mode",
"description": "Session modes for the DeepSeek Harness: plan mode as logged per-agent policy state with soft (prompt) and hard (execution) enforcement",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+335
View File
@@ -0,0 +1,335 @@
/**
* 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/proposed/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 type { PreToolDecision } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
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 your plan is ready, present it with the exit_plan_mode '
+ 'tool and wait for the user\'s review. 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.'
const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', EXIT_PLAN_MODE]
/**
* 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 user-selected mode per session, awaiting its turn-boundary flush. */
private readonly pendingIntents = new WeakMap<Session, string>()
/** 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 ?? ''),
})
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)
result.tools = result.tools.filter(tool =>
allowed.has(tool.name) && (tool.name !== EXIT_PLAN_MODE || active.name === PLAN_MODE))
return result
})
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()
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 })
})
}
/**
* 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 }
}
/**
* 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) ?? this.get(agent).current
if (mode === target) return
this.pendingIntents.set(session, mode)
}
/** 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 target = this.pendingIntents.get(session)
if (target === undefined) return
this.pendingIntents.delete(session)
if (target === foldMode(session.events)) return
session.append('mode/set', { mode: target })
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
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import ModesService, { PLAN_MODE, foldMode } from '@deepseek-ai/dsh-mode'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
* Full-loop integration: a scripted mock model drives the REAL mode plugin
* through the agent loop — the pending-intent flush at the turn boundary, the
* assembly the soft layer filters, the `request/header`/`request/header-delta`
* trail every transition leaves, and the hard gate's deny. Only the model is
* mocked; the loop, the session log, and the plugin are real.
*/
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(ModesService)
ctx.llm.registerAdapter(['mock'], adapter)
for (const name of ['read', 'write']) {
ctx.tools.register(defineTool({
name,
description: `test tool ${name}`,
parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
}))
}
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function findEvent<T extends SessionEvent['type']>(
log: readonly SessionEvent[],
type: T,
position: 'first' | 'last' = 'first',
): Extract<SessionEvent, { type: T }> {
const found = position === 'first'
? log.find(event => event.type === type)
: log.findLast(event => event.type === type)
if (!found) throw new Error(`no ${type} event in the session log`)
return found as Extract<SessionEvent, { type: T }>
}
describe('plan mode through the agent loop', () => {
it('seeds plan mode from AgentOptions: the FIRST header is already plan-shaped and the gate denies a write', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'write', {}, 'Trying to write anyway.'),
textResponse('Back to planning.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-plan-seed'), { model: 'mock', mode: PLAN_MODE })
agent.send([{ type: 'text', text: 'explore the repo' }])
await waitForIdle(ctx, agent)
const log = agent.session.events
const modeSet = findEvent(log, 'mode/set')
const header = findEvent(log, 'request/header')
expect(modeSet.seq).toBeLessThan(header.seq)
expect(header.data.reason).toBe('initial')
expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['read'])
expect(header.data.header.system).toContain('plan mode')
const result = findEvent(log, 'tool/result')
expect(result.data.isError).toBe(true)
expect(foldMode(log)).toBe(PLAN_MODE)
expect(log.some(event => event.type === 'context/message')).toBe(false)
})
it('a user flip between turns lands at the boundary: one notice and the widening header delta', async () => {
const adapter = new MockAdapter([
textResponse('First turn, default mode.'),
textResponse('Second turn, plan mode.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-plan-flip'), { model: 'mock' })
agent.send([{ type: 'text', text: 'hello' }])
await waitForIdle(ctx, agent)
expect(foldMode(agent.session.events)).toBe('default')
ctx.modes.set(agent, PLAN_MODE)
agent.send([{ type: 'text', text: 'now plan' }])
await waitForIdle(ctx, agent)
const log = agent.session.events
expect(foldMode(log)).toBe(PLAN_MODE)
const notices = log.filter(event => event.type === 'context/message')
expect(notices).toHaveLength(1)
expect(findEvent(log, 'context/message').data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
const delta = findEvent(log, 'request/header-delta')
expect(delta.data.tools).toBeDefined()
expect(delta.data.system).toBeDefined()
})
})
+384
View File
@@ -0,0 +1,384 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
import type { ModeConfig } from '../src/index.ts'
/**
* Drives the REAL plugin: mounts `dsh-mode` beside real `SystemPrompt` and
* `ToolRegistry` services, with fake Agents carrying real `Session`s (the
* tool-todo test shape). Turn boundaries are simulated by appending the real
* boundary events and emitting `session/event` by hand — exactly the feed the
* store wires in production.
*/
function agentWithSession(id = 'agent-1', options: { mode?: string } = {}): Agent & { session: Session } {
const session = new Session(SessionId(id))
return { id: AgentId(id), session, options } as unknown as Agent & { session: Session }
}
async function setup(config?: ModeConfig): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ModesService, config)
return ctx
}
/** Append a boundary event and hand-emit the `session/event` feed the store would. */
function boundary(ctx: Context, session: Session, type: 'turn/start' | 'step/end'): void {
const event = type === 'turn/start'
? session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
: session.append('step/end', { turn: 1, step: 1 })
ctx.emit('session/event', session, event as SessionEvent)
}
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
function header(session: Session): void {
session.append('request/header', { header: { config: { model: 'test-model' } }, reason: 'initial' })
}
function noticeTexts(session: Session): string[] {
return session.events
.filter(event => event.type === 'context/message')
.map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join(''))
}
function registerNamedTools(ctx: Context, names: string[]): void {
for (const name of names) {
ctx.tools.register(defineTool({
name,
description: `test tool ${name}`,
parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
}))
}
}
let callCounter = 0
function execute(ctx: Context, name: string, agent?: Agent) {
return ctx.tools.execute({
callId: CallId(`call-${++callCounter}`),
name,
arguments: {},
...agent ? { agent } : {},
})
}
describe('resolveConfig', () => {
it('merges the built-in plan definition with the read-only allowlist', () => {
const resolved = resolveConfig({})
const plan = resolved.definitions.get(PLAN_MODE)
expect(plan?.tools).toEqual(['read', 'todo_write', 'web_search', 'web_fetch', EXIT_PLAN_MODE])
expect(plan?.section).toContain('plan mode')
})
it('lets config override plan and add further modes', () => {
const resolved = resolveConfig({ modes: {
plan: { section: 'custom plan', tools: ['read'] },
review: { section: 'review', tools: ['read', 'write'] },
} })
expect(resolved.definitions.get(PLAN_MODE)).toEqual({ section: 'custom plan', tools: ['read'] })
expect(resolved.definitions.get('review')).toEqual({ section: 'review', tools: ['read', 'write'] })
})
it('rejects the reserved default key loudly', () => {
expect(() => resolveConfig({ modes: { default: { section: '', tools: [] } } }))
.toThrow('"default" is reserved')
})
it('rejects a malformed definition loudly', () => {
expect(() => resolveConfig({ modes: { bad: { section: 5, tools: [] } as unknown as { section: string; tools: string[] } } }))
.toThrow('needs a string `section`')
expect(() => resolveConfig({ modes: { bad: { section: '', tools: 'read' } as unknown as { section: string; tools: string[] } } }))
.toThrow('needs a `tools` array')
expect(() => resolveConfig({ modes: { bad: { section: '', tools: [7] } as unknown as { section: string; tools: string[] } } }))
.toThrow('needs a `tools` array')
})
})
describe('foldMode', () => {
it('folds an empty log to the default mode and takes the last mode/set otherwise', () => {
const session = new Session(SessionId('fold'))
expect(foldMode(session.events)).toBe(DEFAULT_MODE)
session.append('mode/set', { mode: 'plan' })
session.append('mode/set', { mode: 'default' })
session.append('mode/set', { mode: 'plan' })
expect(foldMode(session.events)).toBe('plan')
})
it('folds a prefix when `end` is given', () => {
const session = new Session(SessionId('fold-prefix'))
session.append('mode/set', { mode: 'plan' })
session.append('mode/set', { mode: 'default' })
expect(foldMode(session.events, 1)).toBe('plan')
expect(foldMode(session.events, 0)).toBe(DEFAULT_MODE)
})
})
describe('ctx.modes: list/get/set', () => {
it('lists default first, then the configured definitions', async () => {
const ctx = await setup({ modes: { review: { section: 's', tools: [] } } })
expect(ctx.modes.list()).toEqual([DEFAULT_MODE, PLAN_MODE, 'review'])
})
it('reads the folded mode, mapping a dropped definition to default', async () => {
const ctx = await setup()
const agent = agentWithSession()
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
agent.session.append('mode/set', { mode: PLAN_MODE })
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
agent.session.append('mode/set', { mode: 'retired' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
})
it('rejects an unknown mode name loudly, naming the vocabulary', async () => {
const ctx = await setup()
const agent = agentWithSession()
expect(() => { ctx.modes.set(agent, 'nope') }).toThrow('unknown mode "nope" — available modes: default, plan')
})
it('accepts default as a target (exit-to-default is a valid write)', async () => {
const ctx = await setup()
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
ctx.modes.set(agent, DEFAULT_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE })
})
it('drops a no-op set (target equals pending, else the current fold)', async () => {
const ctx = await setup()
const agent = agentWithSession()
ctx.modes.set(agent, DEFAULT_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
ctx.modes.set(agent, PLAN_MODE)
ctx.modes.set(agent, PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
})
it('seeds the initial mode from AgentOptions.mode on agent/created', async () => {
const ctx = await setup()
const agent = agentWithSession('seeded', { mode: PLAN_MODE })
ctx.emit('agent/created', agent)
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
const bare = agentWithSession('unseeded')
ctx.emit('agent/created', bare)
expect(ctx.modes.get(bare)).toEqual({ current: DEFAULT_MODE })
})
})
describe('the boundary flush', () => {
it('flushes the pending intent as a mode/set at turn/start', async () => {
const ctx = await setup()
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
boundary(ctx, agent.session, 'turn/start')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
})
it('flushes at step/end too (a mid-turn flip lands on the following step)', async () => {
const ctx = await setup()
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
boundary(ctx, agent.session, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
const ctx = await setup()
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
ctx.modes.set(agent, DEFAULT_MODE)
boundary(ctx, agent.session, 'turn/start')
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
it('narrates nothing before the first request header (the section is the state statement)', async () => {
const ctx = await setup()
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
boundary(ctx, agent.session, 'turn/start')
expect(noticeTexts(agent.session)).toEqual([])
})
it('narrates once when the flushed mode differs from what the last header told the model', async () => {
const ctx = await setup()
const agent = agentWithSession()
header(agent.session)
ctx.modes.set(agent, PLAN_MODE)
boundary(ctx, agent.session, 'turn/start')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
boundary(ctx, agent.session, 'step/end')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
})
it('narrates a switch back to the default mode with the default wording', async () => {
const ctx = await setup()
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
header(agent.session)
ctx.modes.set(agent, DEFAULT_MODE)
boundary(ctx, agent.session, 'step/end')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
})
it('stays silent when the header already reflects the flushed mode', async () => {
const ctx = await setup()
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
header(agent.session)
agent.session.append('mode/set', { mode: DEFAULT_MODE })
ctx.modes.set(agent, PLAN_MODE)
boundary(ctx, agent.session, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(noticeTexts(agent.session)).toEqual([])
})
it('narrates a folded mode the config no longer defines, once, at turn starts', async () => {
const ctx = await setup()
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
boundary(ctx, agent.session, 'turn/start')
boundary(ctx, agent.session, 'turn/start')
expect(noticeTexts(agent.session)).toEqual([
'Mode "retired" is no longer defined in this deployment\'s configuration; the session continues in the default mode.',
])
boundary(ctx, agent.session, 'step/end')
expect(noticeTexts(agent.session)).toHaveLength(1)
})
it('ignores non-boundary session events on the feed', async () => {
const ctx = await setup()
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
const event = agent.session.append('mode/set', { mode: DEFAULT_MODE })
ctx.emit('session/event', agent.session, event as SessionEvent)
expect(ctx.modes.get(agent).pending).toBe(PLAN_MODE)
})
it('contains an append failure instead of killing the session feed', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
const original = agent.session.append.bind(agent.session)
agent.session.append = (() => { throw new Error('backend gone') })
expect(() => { boundary({ emit: ctx.emit.bind(ctx) } as never as Context, agent.session, 'step/end') }).toThrow('backend gone')
agent.session.append = original
const event = agent.session.append('step/end', { turn: 1, step: 2 })
agent.session.append = (() => { throw new Error('backend gone') })
ctx.emit('session/event', agent.session, event as SessionEvent)
expect(warn).toHaveBeenCalledOnce()
})
})
describe('the soft layer', () => {
it('keeps a default-mode assembly identical to a no-dsh-mode deployment (exit tool dropped)', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
const agent = agentWithSession()
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
})
it('leaves an agent-less assembly untouched', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', EXIT_PLAN_MODE])
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
})
it('filters plan-mode tools to the allowlist and renders the mode section', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'todo_write', EXIT_PLAN_MODE])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read', 'todo_write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toContain('plan mode')
})
it('drops exit_plan_mode outside plan mode even when a custom allowlist names it', async () => {
const ctx = await setup({ modes: { review: { section: 'reviewing', tools: ['read', EXIT_PLAN_MODE] } } })
registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'review' })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['read'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('reviewing')
})
it('treats a dropped folded definition as the default mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', EXIT_PLAN_MODE])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
})
})
describe('the hard layer', () => {
it('passes agent-less and default-mode executions through', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['write'])
const agentless = await execute(ctx, 'write')
expect(agentless.isError).toBe(false)
const agent = agentWithSession()
const defaulted = await execute(ctx, 'write', agent)
expect(defaulted.isError).toBe(false)
})
it('passes allowlisted calls and denies the rest with the plan-mode reason', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const allowed = await execute(ctx, 'read', agent)
expect(allowed.isError).toBe(false)
const denied = await execute(ctx, 'write', agent)
expect(denied.isError).toBe(true)
expect(denied.content).toEqual([{
type: 'text',
text: 'Error: tool "write" is not available in plan mode; continue planning and present your plan with exit_plan_mode when ready',
}])
})
it('denies with the generic reason in a custom mode', async () => {
const ctx = await setup({ modes: { review: { section: 's', tools: ['read'] } } })
registerNamedTools(ctx, ['write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'review' })
const denied = await execute(ctx, 'write', agent)
expect(denied.isError).toBe(true)
expect(denied.content).toEqual([{ type: 'text', text: 'Error: tool "write" is not available in "review" mode' }])
})
it('judges by the logged mode only — a pending intent does not gate', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['write'])
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
const result = await execute(ctx, 'write', agent)
expect(result.isError).toBe(false)
})
it('treats a dropped folded definition as the default mode (no gate)', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
const result = await execute(ctx, 'write', agent)
expect(result.isError).toBe(false)
})
})
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/tools"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../core/system-prompt"
}
]
}
+2
View File
@@ -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
View File
@@ -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' })
})
})
+3
View File
@@ -35,6 +35,9 @@
{
"path": "../user-interaction"
},
{
"path": "../../mode/mode"
},
{
"path": "../tool-ask-user"
},
+27
View File
@@ -605,6 +605,30 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/mode/mode:
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/session-persistence/session-persistence:
devDependencies:
'@deepseek-ai/dsh-session':
@@ -1081,6 +1105,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-mode':
specifier: workspace:^
version: link:../../mode/mode
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+9
View File
@@ -75,6 +75,7 @@ const GROUP_ORDER = [
'subagent',
'web',
'todo',
'mode',
'cordis',
'hooks',
'session-persistence',
@@ -134,6 +135,14 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-ask-user', 'stdio-agent', 'acp'],
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
},
{
key: 'modes',
pkg: 'mode',
title: 'Session-mode policy state',
mode: 'core',
consumers: ['stdio-agent'],
note: 'Folds the logged per-agent mode (mode/set), flushes user flips at turn boundaries, and enforces the mode through the assemble filter and the tools/pre-execute gate.',
},
{
key: 'agents',
pkg: 'agent',
+1
View File
@@ -47,6 +47,7 @@ const GROUP_ORDER = [
'web',
'timeout',
'todo',
'mode',
'cordis',
'hooks',
'session-persistence',
+1
View File
@@ -47,6 +47,7 @@
"./packages/fs/*/src",
"./packages/compact/*/src",
"./packages/guard/*/src",
"./packages/mode/*/src",
"./packages/subagent/*/src",
"./packages/web/*/src",
"./packages/timeout/*/src",
+1
View File
@@ -59,6 +59,7 @@
{ "path": "./packages/subagent/subagent-fork" },
{ "path": "./packages/subagent/subagent-acp" },
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/mode/mode" },
{ "path": "./packages/guard/repeat-tool-guard" },
{ "path": "./packages/cordis/tool-cordis" },
{ "path": "./packages/hooks/hook-protocol" },
+1
View File
@@ -70,6 +70,7 @@
{ "path": "./packages/subagent/subagent-fork" },
{ "path": "./packages/subagent/subagent-acp" },
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/mode/mode" },
{ "path": "./packages/guard/repeat-tool-guard" },
{ "path": "./packages/cordis/tool-cordis" },
{ "path": "./packages/hooks/hook-protocol" },