mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+27
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -47,6 +47,7 @@ const GROUP_ORDER = [
|
||||
'web',
|
||||
'timeout',
|
||||
'todo',
|
||||
'mode',
|
||||
'cordis',
|
||||
'hooks',
|
||||
'session-persistence',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user