diff --git a/AGENTS.md b/AGENTS.md index 02615f53b4..2cfc9b880f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ packages/ Harness packages at packages///, 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 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 8264c76ce6..c6b3e89b6e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -38,6 +38,8 @@ flowchart LR pkg_user_interaction["user-interaction"] svc_userInteraction["ctx.userInteraction
Human question/answer seam"] pkg_stdio_agent["stdio-agent"] + pkg_mode["mode"] + svc_modes["ctx.modes
Session-mode policy state"] svc_agents["ctx.agents
Agent registry"] svc_agentLoop["ctx.agentLoop
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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 85a12a247e..ff68238cd7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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 +} + +/** + * 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 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ae24fb4b41..7d74b3826f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -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). diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5a64e95eed..fdc4b3f055 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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`. diff --git a/docs/module-graph.md b/docs/module-graph.md index eca90ae51d..65b6e4e488 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 917386af9b..84ae7567dc 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -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 diff --git a/packages/README.md b/packages/README.md index 9f6daaabe1..162df6ecff 100644 --- a/packages/README.md +++ b/packages/README.md @@ -18,6 +18,7 @@ Packages are grouped by modular role at `packages///`. 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 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2c8803ebb7..6a90267746 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -125,6 +125,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'stream(options: GenerateOptions): AsyncIterable', ], }, + { + 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.', diff --git a/packages/mode/README.md b/packages/mode/README.md new file mode 100644 index 0000000000..e3ba8fbaf7 --- /dev/null +++ b/packages/mode/README.md @@ -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). diff --git a/packages/mode/mode/README.md b/packages/mode/mode/README.md new file mode 100644 index 0000000000..f3ec88d403 --- /dev/null +++ b/packages/mode/mode/README.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). diff --git a/packages/mode/mode/package.json b/packages/mode/mode/package.json new file mode 100644 index 0000000000..a80ab12bea --- /dev/null +++ b/packages/mode/mode/package.json @@ -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" + } +} diff --git a/packages/mode/mode/src/index.ts b/packages/mode/mode/src/index.ts new file mode 100644 index 0000000000..35196aad23 --- /dev/null +++ b/packages/mode/mode/src/index.ts @@ -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 +} + +/** 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 +} + +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() + 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() + + /** The unknown folded-mode name already narrated per session (once per name). */ + private readonly droppedNoticed = new WeakMap() + + 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 => { + 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 diff --git a/packages/mode/mode/tests/integration.spec.ts b/packages/mode/mode/tests/integration.spec.ts new file mode 100644 index 0000000000..6dfc40af9b --- /dev/null +++ b/packages/mode/mode/tests/integration.spec.ts @@ -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 { + 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 { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +function findEvent( + log: readonly SessionEvent[], + type: T, + position: 'first' | 'last' = 'first', +): Extract { + 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 +} + +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() + }) +}) diff --git a/packages/mode/mode/tests/mode.spec.ts b/packages/mode/mode/tests/mode.spec.ts new file mode 100644 index 0000000000..84da222cee --- /dev/null +++ b/packages/mode/mode/tests/mode.spec.ts @@ -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 { + 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) + }) +}) diff --git a/packages/mode/mode/tsconfig.json b/packages/mode/mode/tsconfig.json new file mode 100644 index 0000000000..349d4a94a5 --- /dev/null +++ b/packages/mode/mode/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index fcd432a9fd..2152a71dcc 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -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:^", diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 4c37f40d37..50f25ca289 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -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 }]) diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 93683d0768..54d58db63b 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -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' }) + }) +}) diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 6f30c1558e..acde0b2cfd 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/ui/stdio-agent/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../user-interaction" }, + { + "path": "../../mode/mode" + }, { "path": "../tool-ask-user" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66ac74fda2..e7da016e94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5240e158d9..92ecc820ce 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -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', diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index dc66dc843e..61f7512a23 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -47,6 +47,7 @@ const GROUP_ORDER = [ 'web', 'timeout', 'todo', + 'mode', 'cordis', 'hooks', 'session-persistence', diff --git a/tsconfig.base.json b/tsconfig.base.json index b4a4e116d8..22d3105e16 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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", diff --git a/tsconfig.build.json b/tsconfig.build.json index 8d850dd50f..d074412b7f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -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" }, diff --git a/tsconfig.json b/tsconfig.json index d69f55bcdc..f555c2fca9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" },