From 897dc82d9b47adea3af5134bb409cb9a8fa2f756 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:13:59 +0800 Subject: [PATCH] fix(mode): stabilize plan-mode model experience --- .../feature/2026-07-07-plan-mode.md | 74 +++-- .../2026-07-04-fold-stdio-ui-helper.md | 2 +- docs/capability-seams.md | 4 +- docs/config-catalog.md | 15 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/cordis-catalog/services.md | 6 +- docs/core-data-structures/core.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/tool-catalog.md | 6 +- examples/plan-acp-agent/README.md | 8 +- examples/plan-acp-agent/cordis.yml | 21 +- examples/plan-acp-agent/tests/acp.snapshot.ts | 11 +- .../plan-acp-agent/tests/plan-mode.e2e.ts | 84 ++++++ .../plan-mode-reject/stdout.expected.jsonl | 2 +- .../tests/snapshots/plan-mode/session.jsonl | 2 +- .../snapshots/plan-mode/stdout.expected.jsonl | 2 +- .../plan-mode/system-prompt.expected.md | 13 +- .../plan-mode/tool-schemas.expected.json | 18 +- packages/README.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/llm/llm-deepseek/src/serialize.ts | 13 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 20 +- packages/mode/README.md | 2 +- packages/mode/mode/README.md | 84 +++--- packages/mode/mode/package.json | 4 +- packages/mode/mode/src/index.ts | 153 +++++----- packages/mode/mode/tests/integration.spec.ts | 57 +++- packages/mode/mode/tests/mode.spec.ts | 266 ++++++++++++++---- packages/ui/acp/src/index.ts | 9 +- packages/ui/acp/tests/bridge.spec.ts | 3 + packages/ui/acp/tests/harness.ts | 2 +- packages/ui/tui/src/index.ts | 4 + packages/ui/tui/tests/tui.spec.ts | 3 +- packages/ui/user-interaction/README.md | 2 +- packages/ui/user-interaction/src/index.ts | 2 + pnpm-lock.yaml | 2 +- scripts/gen-doc-graphs.ts | 4 +- scripts/gen-tool-catalog.ts | 4 +- 40 files changed, 620 insertions(+), 298 deletions(-) create mode 100644 examples/plan-acp-agent/tests/plan-mode.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-07-07-plan-mode.md b/.agents/notes/implemented/feature/2026-07-07-plan-mode.md index e605e8c9ec..b90a199ee7 100644 --- a/.agents/notes/implemented/feature/2026-07-07-plan-mode.md +++ b/.agents/notes/implemented/feature/2026-07-07-plan-mode.md @@ -4,35 +4,35 @@ Status: implemented ## Problem -The harness has no way to put an agent into a distinct working stance. The canonical feature that needs one is plan mode — the agent explores and designs under a planning stance, produces a reviewable plan, and crosses back through an explicit approval. [The extension cookbook](../../../../docs/cookbook/extension-cookbook.md) already reserves the row ("Plan mode — `tools/pre-execute` (deny writes) + a mode prompt section"), and [the ACP feature matrix](../../../../packages/ui/acp/acp-feature-support.md) records session modes as a known gap both reference adapters ship. Neither says where the mode STATE lives, how it survives resume and fork, or how its model-visible consequences stay honest with the session log. +Before this change, the harness had no durable way to put one agent into a distinct working stance. Plan mode needs the agent to explore and design under planning guidance, produce a reviewable artifact, cross an explicit approval boundary, and restore that state across resume and fork without making the model-visible request diverge from the session log. -Every shipped plan mode decomposes into the same five parts — a low-authority tool policy, a plan artifact, an approval moment, an execution-state switch, and durable state ([Prior art](#prior-art) carries the survey). Four of the five already exist here as gated infrastructure: what the model is TOLD it can do is shaped per step at [`system-prompt/assemble`](../../../../packages/core/system-prompt/README.md) and whatever ships is logged as `request/header*` events ([reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md)); what can RUN is gated at `tools/pre-execute` with typed decisions ([interception seams](../../implemented/feature/2026-06-30-interception-seams.md)); the approval moment is a human answer over the user-interaction seam ([`ctx.userInteraction`](../../../../packages/ui/user-interaction/README.md), the [ask-user precedent](../../implemented/feature/2026-06-25-ask-user-question.md)); durable per-agent facts are `SessionEventMap` members ([the `todo/write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)). The missing fifth is the mode itself: a named, durable, per-agent state its listeners can read. +The extension seams already supplied the surrounding pieces: [`system-prompt/assemble`](../../../../packages/core/system-prompt/README.md) shapes guidance per step and the shipped request is logged in `request/header*` events ([reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md)); [`ctx.userInteraction`](../../../../packages/ui/user-interaction/README.md) carries the approval question and corrective feedback ([ask-user precedent](../../implemented/feature/2026-06-25-ask-user-question.md)); `SessionEventMap` carries durable per-agent facts ([the `todo/write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)). The missing piece was the named session state that joins those seams while leaving execution enforcement on the independent sandbox and approval axes. ## Decision -The deliverable is **plan mode**. It ships as the first **session mode** — a named, logged, per-agent COLLABORATION state: a mode definition is the guidance section the model sees, deployment config; the mode IN FORCE for an agent is session state, folded from its log. Modes are one axis and the enforcement knobs — the sandbox mode, the approval policy — are others: they never read or write each other, matching how Codex keeps its Plan/Default collaboration presets separate from its sandbox and approval settings. One new product package, `@deepseek-ai/dsh-mode` at `packages/mode/mode/` (a new top-level group, the `packages/approval/` shape), owns the event vocabulary, a thin `ctx.modes` service, and every listener; the loop does not change. `plan` is the only shipped definition — the mode-shaped vocabulary exists so a second mode never renames durable event types, not because more modes ship now. +The deliverable is **plan mode**. It ships as the first **session mode** — a named, logged, per-agent COLLABORATION state: a mode definition is deployment-configured guidance the model sees, while the mode IN FORCE for an agent is session state folded from its log. Modes are one axis and the enforcement knobs — the sandbox mode, the approval policy — are others: they never read or write each other, matching how Codex keeps its Plan/Default collaboration presets separate from its sandbox and approval settings. One new product package, `@deepseek-ai/dsh-mode` at `packages/mode/mode/`, owns the event vocabulary, a thin `ctx.modes` service, and every listener; the loop does not change. `plan` is the only required definition — the mode-shaped vocabulary exists so a second mode never renames durable event types, not because more modes ship now. -The state is one `SessionEventMap` member: **`mode/set`**, a log-only, non-surface event carrying `{ mode: string }` with whole-value-replace semantics, plus a pure `foldMode(events)` that returns the mode in force — the last `mode/set`, or the default mode when none exists. Because [the log is the fact channel](../../implemented/architecture/2026-06-30-event-domain-semantics.md), resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event`. The default mode is the absence of policy — no section, no filtering, no gate — so an agent that never sees a `mode/set` behaves byte-identically to a deployment that never loads `dsh-mode`, which keeps every existing snapshot golden stable and makes the plugin safe to compose unconditionally. +The state is one `SessionEventMap` member: **`mode/set`**, a log-only, non-surface event carrying `{ mode: string }` with whole-value-replace semantics, plus a pure `foldMode(events)` that returns the mode in force — the last `mode/set`, or the default mode when none exists. Because [the log is the fact channel](../../implemented/architecture/2026-06-30-event-domain-semantics.md), resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event`. The default mode is the absence of mode guidance — no section, filtering, or gate. Loading `dsh-mode` still contributes one stable `exit_plan_mode` schema in every mode; that fixed cost avoids tool-catalog churn at mode boundaries. -A mode's whole surface is soft: a `system-prompt/assemble` listener renders the mode's guidance section and shows the `exit_plan_mode` tool IFF the folded mode is `plan` — every transition therefore surfaces as an attributable complete `request/header` event on the next step, keeping the [reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) invariant green by construction. A mode deliberately enforces NOTHING: no execution gate, no tool filtering, no reach into the sandbox or approval knobs — a user who wants a hard read-only floor while planning switches the sandbox-mode option beside the mode picker, in either order, and neither axis disturbs the other. There is likewise NO per-mode tool allow/deny list — which tools a mode admits is an effects question, parked until tool definitions declare their effects ([Deferred](#deferred)); a mode's restraint is its section's guidance plus the exit review. +A mode's whole surface is soft: a `mode:policy` prompt section renders the active definition's guidance, while `exit_plan_mode` remains in the registered tool catalog across every mode and rejects at execution unless the folded mode is `plan`. A transition therefore changes only the system-prompt portion of the attributable `request/header` on the next step, keeping [reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) green without changing native schemas or Code Mode's SDK. A mode deliberately enforces NOTHING: no execution gate, no tool filtering, no reach into the sandbox or approval knobs — a user who wants a hard read-only floor while planning switches the sandbox-mode option beside the mode picker, in either order, and neither axis disturbs the other. There is likewise NO per-mode tool allow/deny list — which tools a mode admits is an effects question, parked until tool definitions declare their effects ([Deferred](#deferred)); a mode's restraint is its section's guidance plus the exit review. -The model leaves plan mode through the **`exit_plan_mode`** tool: its single argument is the plan text, which makes the plan a durable log artifact, and the tool conducts the review itself through the user-interaction seam — a question with options and a free-text channel, not a bare permission — so an approval flips the logged mode back to the default, and a rejection becomes the corrective error carrying the user's feedback verbatim, which keeps the model planning with direction. A user flips the mode from any surface through `ctx.modes.set()`; the flip is applied at the next turn boundary (session events are turn-enclosed) and narrated to the model once, only when the model-visible state actually changed. +The model leaves plan mode through the **`exit_plan_mode`** tool: its single argument is the plan text, which makes the plan reconstructable from the log, and the tool conducts the review itself through the user-interaction seam — a question whose supporting detail carries the exact plan, with options and a free-text channel, not a bare permission — so an approval flips the logged mode back to the default, and a rejection becomes the corrective error carrying the user's feedback verbatim, which keeps the model planning with direction. A user flips the mode from any surface through `ctx.modes.set()`; the flip is applied at the next turn boundary (session events are turn-enclosed) and narrated to the model once, only when the model-visible state actually changed. ## High-level API ### A plan-mode session end to end -The user switches the session to plan mode — the ACP mode picker or the stdio `/mode plan` — and from the next turn every request ships the plan guidance section and the `exit_plan_mode` tool. +The user switches the session to plan mode through the ACP mode picker, and from the next turn every request ships the configured plan guidance section. The `exit_plan_mode` schema was already present in default and remains byte-identical. The model explores and designs; the section's guidance is what defers changes into the plan. The sandbox and approval knobs keep whatever the user set them to — a deployment (or user) that wants kernel-enforced read-only during planning pairs plan mode with the independent sandbox-mode option. -When ready, the model calls `exit_plan_mode` with the plan markdown as its argument; the UI renders the plan as the call card and the review question arrives through the user-interaction channel — approve, or keep planning, with free-text feedback welcome — so what the human reviews is exactly the logged artifact. +When ready, the model calls `exit_plan_mode` with the plan markdown as its argument; the review question carries that exact markdown as supporting detail — approve, or keep planning, with free-text feedback welcome. A native call also renders the plan card; a Code Mode nested dispatch has no native card, so the review detail is the common presentation surface. -On approve, the tool flips the logged mode back to the default: the next step drops the plan section and the exit tool (the header event is in the log), and execution tracking from there is already `todo_write`'s job. On keep-planning, the model receives a corrective error carrying the user's feedback text, revises, and re-presents. +On approve, the tool flips the logged mode back to the default: the next step drops the plan section while retaining the same tool catalog (the changed header is in the log), and execution tracking from there is already `todo_write`'s job. On keep-planning, the model receives a corrective error carrying the user's feedback text, revises, and re-presents. ### Deployment configuration -Mode definitions are validated plugin Config — per repo convention, changeable from `cordis.yml` with no code edit. The shipped `plan` definition works with zero config; overriding it, or adding a mode, is a config entry: +Mode definitions are validated plugin Config — per repo convention, changeable from `cordis.yml` with no code edit. The deployment must provide the complete `plan` section; the package embeds no model instructions. Additional modes use the same config map: ```yaml - id: mode @@ -47,13 +47,9 @@ Mode definitions are validated plugin Config — per repo convention, changeable A definition is exactly `{ section }` — there is deliberately no per-mode tool list and no enforcement field ([FAQ](#faq)). `default` is reserved (the absence of policy) and rejected as a key; any unknown definition key — a `tools` list or an `access` cap included — fails validation at load; an unknown mode name fails loudly at `set()` time. -### In the terminal - -The stdio app gains `/mode` (print the current and available modes) and `/mode ` (switch + banner — a command line, never sent to the model, and reserved even while a question prompt is active: a command is never recorded as an answer). The exit review prompts right in the terminal with no new machinery: it is an ordinary user-interaction question, so it rides the stdio provider's one-prompt-owns-stdin queue that `ask_user_question` already uses. - ### Over ACP -The mode PICKER is this package's surface: `session/new`/`session/load` advertise `availableModes`/`currentModeId` from `ctx.modes` (consumed opportunistically via `ctx.get`, the `tool-bash` pattern), `session/set_mode` calls `set()` and notifies `current_mode_update` optimistically (the pending mode IS the user's selection; the logged `mode/set` follows at the boundary), and a `session/event` listener re-notifies on each logged flip that differs from the last sent. The exit tool's review needs no new ACP work at all — it rides the elicitation flow the user-interaction ACP provider already drives, beside the already-streamed plan card. Individual environment knobs — sandbox mode, approval policy, the model — are NOT modes and belong to `session/set_config_option` ([FAQ](#faq)). +The mode PICKER is this package's surface: `session/new`/`session/load` advertise `availableModes`/`currentModeId` from `ctx.modes` (consumed opportunistically via `ctx.get`, the `tool-bash` pattern), `session/set_mode` calls `set()` and notifies `current_mode_update` optimistically (the pending mode IS the user's selection; the logged `mode/set` follows at the boundary), and a `session/event` listener re-notifies on each logged flip that differs from the last sent. The exit tool reuses the user-interaction ACP provider's elicitation flow; its ACP mapping carries the review `detail` because Code Mode nested dispatches have no native plan card, while native calls may additionally stream the plan card. Individual environment knobs — sandbox mode, approval policy, the model — are NOT modes and belong to `session/set_config_option` ([FAQ](#faq)). ### For agent creators @@ -69,44 +65,44 @@ The mode PICKER is this package's surface: `session/new`/`session/load` advertis DEFAULT_MODE = 'default' // the fold of a log with no mode/set; reserved, not definable ``` -The payload carries no reason/provenance field: a tool-driven flip sits next to its `tool/call` in the log and a user flip sits at its turn boundary, so the cause is log-adjacent — the same "narrative fields are derivable" call the [reconstructability Agent Note](../architecture/2026-07-05-reconstructable-requests.md) made for header deltas (the in-flight `env/state` event carries a `source` precisely because its drift variant has NO log-adjacent cause — a contrast, not a conflict). Mode names are config-declared vocabulary, not opaque cross-boundary ids, so they stay bare strings (no `Branded`). +The payload carries no reason/provenance field: a tool-driven flip sits next to its `tool/call` in the log and a user flip sits at its turn boundary, so the cause is log-adjacent — the same "narrative fields are derivable" call the [reconstructability Agent Note](../architecture/2026-07-05-reconstructable-requests.md) made for request-header facts (the in-flight `env/state` event carries a `source` precisely because its drift variant has NO log-adjacent cause — a contrast, not a conflict). Mode names are config-declared vocabulary, not opaque cross-boundary ids, so they stay bare strings (no `Branded`). ### Config and the resolve step ```text interface ModeDefinition { section: string } // prompt text — a mode's whole vocabulary -interface ModeConfig { modes?: Record } // plan's built-in definition merged unless overridden +interface ModeConfig { modes: Record } // plan is required and owns its complete prompt resolveConfig(config): ResolvedModes // explicit resolve (the dsh-bash template), fail-loud: - // 'default' as a key rejected; any unknown key rejected + // missing plan, 'default', blank sections, and unknown keys rejected ``` The one-field shape is deliberate minimalism, not the final vocabulary: a per-tool policy dimension returns as effects metadata on tool definitions ([Deferred](#deferred)), read here rather than re-declared per mode — the config shape must not need a migration when it arrives. ### The fold, the service, and the flush -`foldMode(events)` is pure (exported for reconstructors and tests); the service tracks it per session with a lazy cursor in a `WeakMap` — O(new events) per read, never invalidated, because the log is append-only and `mode/set` is not a surface node (compaction cannot rewrite it). `set(agent, mode)` validates the name against `list()`'s vocabulary — the configured definitions plus the reserved `default`, which is rejected as a config KEY but always accepted as a `set()` TARGET (a picker's exit-to-default must be a valid write) — drops a no-op (target equals pending ?? current), and otherwise records the intent in a `WeakMap` — it cannot append immediately, because [every session event is turn-enclosed](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) and an idle agent has no open turn. +`foldMode(events)` is pure (exported for reconstructors and tests) and folds the append-only session log directly; `mode/set` is not a surface node, so compaction cannot shadow it. `set(agent, mode)` validates the name against `list()`'s vocabulary — the configured definitions plus the reserved `default`, which is rejected as a config KEY but always accepted as a `set()` TARGET — drops a no-op (target equals pending, else current), and otherwise records `{ mode, narrate }` in a `WeakMap` pending-intent slot. It cannot append immediately because [every session event is turn-enclosed](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) and an idle agent has no open turn. -Contained listeners on the loop's interception seams ([defensive patterns](../../../../docs/defensive-patterns.md): a policy plugin must not block a prompt or a turn) flush the pending intent as a `mode/set` append — `agent/prompt-submit` fires inside the just-opened turn before its first assembly, `agent/turn-continuation` after each step closed before the next assembly, and both sit outside the step's tool-execution window AND outside any log emit (post-commit `session/event` observers are observe-only; an append from one would re-enter the publishing append), so the executions of a step always run under the mode its assembly folded. When the flushed mode differs from the fold at the last `request/header`, the flush appends one coalesced `context/message` notice in the same frame ("The user switched this session to plan mode."); the user-visible narration cases are enumerated in the [FAQ](#faq). Seeding rides `agent/created`: `AgentOptions.mode` becomes a pending intent, so explicit options beat the logged baseline on create AND resume — the same precedence the call-config seed follows. +Contained listeners on the loop's interception seams ([defensive patterns](../../../../docs/defensive-patterns.md): a policy plugin must not block a prompt or a turn) flush the pending intent as a `mode/set` append — `agent/prompt-submit` fires inside the just-opened turn before its first assembly, and `agent/turn-continuation` fires after an ordinary step closes before its successor. Automatic request recovery bypasses continuation, so a prepended `agent/request-error` wrapper delegates through the composed policy and asynchronous backoff, then flushes only a `retry` decision before the waterfall returns to the loop; an effect-scoped lifetime guard suppresses a captured wrapper that resumes after plugin disposal. All three paths sit outside tool execution and log publication (post-commit `session/event` observers are observe-only), so every step runs under the mode its assembly folded. When the flushed mode differs from the fold at the last `request/header`, the flush appends one coalesced `context/message` notice in the same frame ("The user switched this session to plan mode."); the user-visible narration cases are enumerated in the [FAQ](#faq). Seeding rides `agent/created`: `AgentOptions.mode` becomes a pending intent, so explicit options beat the logged baseline on create AND resume — the same precedence the call-config seed follows. -### The soft layer: a computed section and a narrow post-`next()` filter +### The soft layer: a computed section and a stable exit schema -A `system-prompt/assemble` waterfall listener reads the calling agent's mode (the `AssembleContext` carries `agent`) and, in a non-default mode, appends the mode's guidance section. The loop already renders per step and logs the result: entering or leaving a mode surfaces on the next step as a complete changed `request/header`, so every mode transition is an attributable log fact. The section is static per mode and the plan itself stays in the conversation (messages and tool args, already in context), so a mode does not add per-step prompt churn — re-injecting plan state into every request ([Prior art](#prior-art)'s compaction-survival hack) is unnecessary and would only burn prefix cache. +The registered prompt section reads the calling agent's mode from `AssembleContext.agent` and resolves to the active definition's guidance or `''`. The loop renders per step and logs a complete `request/header` whenever the rendered header changes, so entering or leaving a mode is attributable. The section is static per mode and the plan itself stays in the conversation as messages and tool arguments; re-injecting separate plan state on every request ([Prior art](#prior-art)'s compaction-survival hack) is unnecessary prompt churn. -The guidance section is an ordinary registered section, `{ name: 'mode:policy', order: 50, text: context => … }` — order 50 sits after the persona (0) and before tool guidance (100–199); it resolves to the folded mode's configured text and to `''` (dropped at render) for the default mode or an agent-less assembly. The same listener carries the ONE tool rule this plugin has, deliberately narrow (`prepend: true`, so it wraps outside every append-registered listener's post-`next()` mutation): `exit_plan_mode` is visible IFF the agent's folded mode is `plan` — which keeps a default-mode assembly byte-identical to a no-`dsh-mode` deployment even though the tool is always registered. Everything else passes through untouched: which tools a mode admits is not this plugin's decision. Code Mode's soft surface is the `tools:sdk` section rather than the wire schemas, and section text resolves in assemble's base, so the same wrapper re-renders that section under the same visibility rule; the default mode re-renders it too, hiding only the exit binding, keeping a default-mode Code Mode assembly byte-identical to a deployment that never loaded `dsh-mode`. There is NO `tools/pre-execute` listener: a mode gates nothing (the exit tool's own folded-mode recheck rejects an out-of-plan call), and the registry's `ask` vocabulary stays free for genuine permission gating — the exit review is a question with options and feedback, not a permission, so it lives inside the tool's own execution over the user-interaction seam. +The guidance contribution is `{ name: 'mode:policy', order: 50, text: context => … }`: after persona (0), before tool guidance (100–199), and empty for default or agent-less assembly. `exit_plan_mode` is registered once through `ctx.tools` and never filtered, so native schemas and Code Mode's generated SDK remain byte-identical across mode switches; a deployment without `dsh-mode` lacks that one binding. There is NO `tools/pre-execute` listener: a mode gates nothing, while the exit tool's own folded-mode check rejects out-of-plan calls. The exit review is a question with options and feedback, not a permission, so it lives inside the tool's execution over the user-interaction seam. ### `exit_plan_mode` -`defineTool` with one required `plan: string` argument — the plan is thereby a durable, replayable log artifact riding the ordinary `tool/call` event. `execute` rejects an agent-less call (the [`todo_write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)), re-checks the folded mode as defense in depth, then conducts the review: one single-select `ctx.userInteraction.ask()` question — approve, or keep planning — with the free-text channel open for feedback. Approve records the switch back to `default` as a SILENT boundary-applied pending intent (flushed at this step's end, still in-turn) and returns a short confirmation; the plan surface — the section, the exit tool's visibility — therefore keeps holding for every remaining call of the SAME assistant response (they were requested under the plan-shaped header), and the next step reflects the exit, logging the pure-removal header-delta. Every other outcome — keep-planning (the user's feedback text carried verbatim), an aborted question, a missing provider — returns the corrective `isError` that tells the model to revise and re-present, and the mode stays `plan`. +`defineTool` has one required `plan: string` argument. Native execution records it in the ordinary `tool/call`; Code Mode records the outer `run_code` source before execution and appends the normalized nested arguments in `tool/code-dispatch` after the dispatch settles. `execute` rejects an agent-less call (the [`todo_write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)), rejects any folded mode other than `plan`, rejects an empty or heading-less plan before asking the reviewer, then conducts one single-select `ctx.userInteraction.ask()` review whose `detail` is the exact plan — approve or keep planning — with free-text feedback open. Only exactly one `Approve` selection consents; every other shape fails closed. Approval records a SILENT boundary-applied intent to switch to `default` and returns a short confirmation. The deployment guidance tells the model to make this the only and final tool call in its response; if a model violates that rule, the runtime still holds plan guidance for the rest of the batch, and the next step logs a changed header with the guidance removed and tool schemas unchanged. Every non-approval outcome returns a corrective `isError` and leaves the mode in `plan`. -Its [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md), decided up front: `presentCall` is a `generic` card titled by the plan's first heading with the plan markdown as content, plus a `generic` result card — the review question arrives beside this already-streamed card, so what the human reviews is exactly the logged artifact. The seam is consumed opportunistically (`ctx.get('userInteraction')`), so `dsh-mode` composes without it and degrades to the manual exit pinned in the [FAQ](#faq). +Its [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md), decided up front: `presentCall` is a `generic` card titled by the plan's first heading with the plan markdown as content, plus a `generic` result card. Native front doors show that card before the question; Code Mode nested dispatches do not produce native call-card events, so the user-interaction `detail` independently carries the same plan on every provider. The seam is consumed opportunistically (`ctx.get('userInteraction')`), so `dsh-mode` composes without it and degrades to the manual exit pinned in the [FAQ](#faq). ### Dependencies and surfaces -`dsh-mode` is one product package, not a capability-seam trio ([Alternatives considered](#alternatives-considered)): it peers on `cordis`, `dsh-session`, `dsh-agent`, `dsh-tools`, and `dsh-system-prompt`, injects `['tools', 'systemPrompt']`, reads `ctx.userInteraction` opportunistically at execute time (a type-only peer edge on `dsh-user-interaction`), and depends on no UI package. Beyond the `ctx.modes` call surface everything participates through listeners, so dropping the package gracefully removes modes rather than breaking a consumer. The stdio app adds a `/mode [name]` line-handler branch — the exit review needs nothing there, because the stdio user-interaction provider already owns the prompt queue. The ACP wire mapping is pinned in [High-level API](#over-acp); package-wise the bridge takes a type-only peer edge on `dsh-mode` and reads the service opportunistically, so a bridge without the plugin behaves exactly as today. +`dsh-mode` is one product package, not a capability-seam trio ([Alternatives considered](#alternatives-considered)): it peers on `cordis`, `dsh-session`, `dsh-agent`, `dsh-tools`, and `dsh-system-prompt`, injects `['tools', 'systemPrompt']`, reads `ctx.userInteraction` opportunistically at execute time (a type-only peer edge on `dsh-user-interaction`), and depends on no UI package. Beyond the `ctx.modes` call surface everything participates through listeners, so dropping the package gracefully removes modes rather than breaking a consumer. The ACP wire mapping is pinned in [High-level API](#over-acp); package-wise the bridge takes a type-only peer edge on `dsh-mode` and reads the service opportunistically, so a bridge without the plugin behaves exactly as today. ### The recorded scenario and the harness op -`input.json` gains one step op, `{ "op": "setMode", "modeId": "plan" }`, driven through the real `session/set_mode` RPC, and a scripted `elicitationAnswers` queue (FIFO, consumed by the harness client's elicitation callback — the review question's answer). The `plan-mode` scenario: initialize → newSession → setMode(plan) → a prompt the model answers by running a real `cat` through the bash tool INSIDE plan (the composition's sandbox applies exactly as in default mode; on replay the command re-executes under the host runner — Seatbelt on macOS, bwrap on Linux CI) and presenting the plan via `exit_plan_mode` → a scripted approve → the same turn's next step edits for real. Because the mode is set before turn 1, the FIRST `request/header` snapshot is already in plan shape (the full toolset plus the exit tool, the mode section, reason `initial`); the approved exit logs a complete changed `request/header`, and the scenario pins both, plus the `mode/set` pair. A sibling `plan-mode-reject` scenario scripts the keep-planning answer with feedback text and pins the corrective result. Both need a with-key recording session; the sandbox-denial marker, whose recorded stderr would be the backend's dialect and replay only where it was recorded, stays pinned at `dsh-tool-bash`'s unit tier. +`input.json` gains one step op, `{ "op": "setMode", "modeId": "plan" }`, driven through the real `session/set_mode` RPC, and a scripted `elicitationAnswers` queue. The `plan-mode` scenario enters plan before turn 1, runs a real `cat` under the independently configured sandbox, presents a plan through `exit_plan_mode`, receives scripted approval, then edits on the next step. The first `request/header` contains the full stable toolset plus the configured mode section; the post-approval changed header retains byte-identical tool schemas and removes only that section. `plan-mode-reject` pins corrective free-text feedback and the unchanged plan state. Both recordings replay host commands under Seatbelt or bwrap; backend-specific sandbox denial stays at the bash-tool unit tier. ### The mechanical tail @@ -116,17 +112,17 @@ No new cordis event is declared (`mode/set` rides `session/event`; the listeners Each behind its own decision: subagent mode inheritance via a forwarded `AgentOptions.mode` (the option field itself ships), preset modes beyond `plan` (read-only, accept-edits), the idle-record primitive if pending-intent loss proves real, and — the big one — **effects self-declaration on tool definitions**: a per-tool read-only/mutating classification (the MCP `ToolAnnotations` vocabulary — `readOnlyHint`/`destructiveHint` — is the natural template, with its untrusted-hint caveat implying trust tiers). That item is what a general per-mode tool policy waits on: this Agent Note first shipped an interim per-mode name allowlist and removed it before release — a hand-maintained list mislabels the effects question, must track every tool a deployment composes, and rots silently as tools arrive — so mode-scoped tool availability (and per-tool `ask` policies) returns as a CONSUMER of declared effects, which is its restart trigger. -The recorded snapshot scenarios are landed: `plan-mode` (the pinned-header arc — plan-shaped initial header, a real `cat` inside plan, scripted elicitation approve, the pure-removal header-delta, a real edit) and `plan-mode-reject` (keep-planning feedback carried verbatim in the corrective `isError`), beside the keyless `modes-advertise` wire golden. +The recorded snapshot scenarios pin the initial plan header, real read, scripted approval, stable tool schemas across the changed header, subsequent edit, rejection feedback, and the keyless mode wire. A self-skipping real-API smoke boots the shipped leaf, verifies the file before approving the review, and verifies the approved implementation in the world afterward. ## FAQ Behavioral clarifications of the chosen design; rejected designs live in [Alternatives considered](#alternatives-considered), accepted costs in [Consequences](#consequences). -**When does a user's mode flip take effect?** At the next turn boundary: `set()` records a pending intent, the service flushes it as the first append after the next `turn/start`, and the loop assembles the prompt after the turn opens and before each step — so step 1 already folds it. A mid-turn flip lands at the next boundary and takes effect on the following step. This is the "applies to subsequent requests" semantics every product in [Prior art](#prior-art) ships. +**When does a user's mode flip take effect?** At the next pre-assembly boundary: `agent/prompt-submit` covers the first step, `agent/turn-continuation` covers a normal successor, and the post-composed `agent/request-error` retry decision covers automatic recovery. A mode selected while a request or retry backoff is in flight therefore shapes the following model request. This is the "applies to subsequent requests" semantics every product in [Prior art](#prior-art) ships. **When is a mode change narrated to the model?** Only when the model-visible state actually changed: the flush compares the flushed mode against the fold at the last `request/header` and narrates once, coalesced. A net-zero flip sequence (plan then back, all before the boundary) narrates nothing; a tool-driven exit narrates through its own tool result instead; a mode set before the first turn narrates nothing — the section is the state statement. The principle is the in-flight env-state proposal's boundary narration: a silently flipped prompt surface leaves the transcript arguing from a state the header no longer has. -**What happens on resume when the config no longer defines the folded mode?** One read-path rule closes the gap: a folded mode name the current config no longer defines behaves as the default mode plus one boundary notice naming the dropped definition — never a silent substitute restriction, never a bricked session. `set()`'s loud validation covers only the write path; a resumed log answers to the config it finds. +**What happens on resume when the config no longer defines the folded mode?** One read-path rule closes the gap: a folded mode name the current config no longer defines behaves as the default mode plus one boundary notice naming the dropped definition — never a silent substitute restriction, never a bricked session. The notice itself is found in the durable log on resume, so later turns and process restarts do not duplicate it. `set()`'s loud validation covers only the write path; a resumed log answers to the config it finds. **What if a deployment composes no user-interaction provider?** Plan mode stays safe but manual: `ctx.userInteraction.ask()` throws `NO_PROVIDER` (and an absent seam never resolves at all), the tool returns the corrective `isError`, and the exit degrades to the user toggling modes — never to an unreviewed exit. The mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user in prose if that fails — so it keeps presenting instead of stalling. @@ -144,6 +140,8 @@ A survey of shipped plan modes (Claude Code, Cursor, Copilot, OpenCode, Gemini C The mode surface is a LIST everywhere it is advertised, never a boolean: Claude Code's picker offers `plan` beside `acceptEdits` (plus an auto-mode entry into plan), and Codex exposes `Plan` beside `Default` as collaboration-mode presets while keeping approval and sandbox settings separate. This is the surface [the ACP feature matrix](../../../../packages/ui/acp/acp-feature-support.md) records as the gap, and what sizes the vocabulary as named modes rather than a flag. +The deployment-owned example prompt borrows the instrumental behavior, not product-specific mechanics. From Codex: remain in plan mode despite imperative implementation language, explore before asking, distinguish repository facts from user-owned choices, and make the plan decision-complete across APIs, data flow, failures, tests, and assumptions. From Claude Code: prohibit mutations and commits, prefer existing patterns, use questions only for requirements or approach choices, and finish through the exit tool rather than a prose approval request. It deliberately omits Codex protocol tags and Claude's plan-file or phased-subagent machinery because those belong to their runtimes, not this plugin contract. + The ecosystems that leave modes to convention show the failure shapes to avoid. Pi-style mode extensions fight over a last-wins global active-tool list, enforce "read-only" by prompt text alone (a hallucinated call to a still-registered tool executes), and re-inject plan state into every request to survive compaction. The contested global list and the re-injection hack close structurally here — per-agent folded state, and a log-only non-surface event compaction cannot shadow. The prompt-only shape, by contrast, is deliberately KEPT — it is what Codex ships for Plan, and it is why the mode axis composes freely with the enforcement axes: a deployment that wants a hard floor pairs the mode with the independent sandbox knob instead of the mode carrying its own enforcement ([FAQ](#faq)). ## Alternatives considered @@ -166,7 +164,7 @@ The ecosystems that leave modes to convention show the failure shapes to avoid. **A boolean `planMode` instead of named modes.** Too narrow for the surface the repo already tracks: ACP advertises a mode LIST and the shipped pickers fill it with more than plan ([Prior art](#prior-art)); generalizing later would rename durable event vocabulary. The string-shaped mechanism costs nothing extra now; only `plan` ships as a definition. -**A tool-policy-stack service (the Pi-critique remedy).** A dedicated composition service for tool policies is premature: waterfall listeners compose by construction, and the narrow surface this plugin filters (exactly the exit tool) makes filter-order races non-exploitable. Formalize only if real conflicts appear. +**A tool-policy-stack service (the Pi-critique remedy).** A dedicated composition service for tool policies is premature: this implementation performs no mode-scoped tool filtering, and future effect policies can compose through the existing guarded execution seams. Formalize only when declared tool effects create a concrete composition requirement. **Exit approval through the approval seam (a `{ kind: 'ask' }` gate decision).** The original sketch, natural while the approval seam was the only asking machinery in flight — but it seats a review in a permission chair: the seam's outcome vocabulary is deliberately closed and one-shot (`allowed-once`/`rejected`), so a rejection carries no feedback and an approval can never grow options (approve-and-accept-edits). The exit moment is a question, not a permission — the user-interaction seam gives it options plus the free-text channel, and the rejection feedback reaches the model verbatim. The approval seam remains the right seat for genuine permission gates (the sandbox escalation), and the registry's `ask` vocabulary stays available to deployments that want one there. @@ -174,15 +172,15 @@ The ecosystems that leave modes to convention show the failure shapes to avoid. ## Consequences -What holds now, pinned by the unit, protocol, and snapshot tiers: +What holds now, pinned by the unit, protocol, snapshot, and real-API tiers: -- The mode in force is a pure function of the session log: resume and fork restore it with no extra machinery, and a `mode/set` is followed by the matching complete `request/header` event on the next step with the dev invariant green throughout. +- The mode in force is a pure function of the session log: resume and fork restore it with no extra machinery, and a `mode/set` is followed by a matching complete `request/header` on the next changed step. - A user-driven flip narrates exactly once at the next boundary and a net-zero flip sequence narrates nothing; a tool-driven exit narrates only through its tool result. -- In the default mode the plugin is invisible: assemblies are byte-identical with and without `dsh-mode` loaded, and every pre-existing snapshot golden is unchanged. -- In plan mode the guidance section and the `exit_plan_mode` tool reach both the wire request and the logged header; entering and leaving plan each log a complete header snapshot. +- In default mode the plugin contributes no mode section but does contribute the stable `exit_plan_mode` schema; a deployment without `dsh-mode` lacks that binding. +- Native tool schemas and Code Mode's SDK stay byte-identical across default, plan, and custom-mode transitions; only the configured guidance section changes. - Plan mode changes nothing on the enforcement axes: the toolset, the sandbox mode, escalation, and the approval policy behave identically in plan and default — pairing the mode with the independent sandbox/approval knobs is how a deployment hardens planning. -- Mode definitions (section text) are changeable from `cordis.yml` with no code edit; an unknown mode name fails validation loudly at `set()` time, an unknown definition key at load. -- `exit_plan_mode`'s approve path flips the mode and drops the plan surface on the next step; the keep-planning path returns the corrective `isError` carrying the user's feedback and stays in plan mode; the ACP `session/set_mode` round-trip updates `current_mode_update`, and the exit review prompts through each surface's user-interaction provider. +- Mode definitions are changeable from `cordis.yml` with no code edit; the complete plan instructions are required there, while missing plan config, malformed definitions, and unknown keys fail at load and unknown mode names fail at `set()`. +- `exit_plan_mode` is always advertised, rejects outside plan, drops only plan guidance after approval, and carries keep-planning feedback in a corrective `isError`; ACP mode updates and each surface's user-interaction provider carry the human side. - The docs tail shipped with the landing: READMEs, regenerated catalogs (persistence log, config, cordis services, tools), the packages map and architecture rows, and the cookbook row. -The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). Every mode transition is a logged header change and therefore a prefix-cache reset at the provider — inherent, visible in per-step usage, and an argument against mode-flapping UIs, not against the design. **A mode restrains by guidance alone**: a model that ignores the section CAN mutate during plan — the review moment, the session log (every call attributable under the plan-shaped header), and the independent enforcement axes (the sandbox mode, the approval policy, the fs stack's own policies) are the containment surface, and hardening planning means setting those knobs, not widening the mode; the two removed enforcement shapes and their restart trigger (effects self-declaration) are archived in [Alternatives considered](#alternatives-considered) and [Deferred](#deferred). The mode filter prepends and hides only the exit tool; a listener that ALSO prepends after `dsh-mode` loads could re-show it — a re-shown exit tool self-rejects outside plan, so the residual is cosmetic. The ACP mode surface carries both this stack's picker and the sandbox stack's config options under the picker-to-modes / knobs-to-config-options division pinned in the [FAQ](#faq), recorded in [the feature matrix](../../../../packages/ui/acp/acp-feature-support.md). The ACP spec's draft v2 direction reportedly slates session modes for removal in favor of config options; if that lands, the picker migrates to a config-option select mechanically — the mode state and every policy surface are wire-agnostic — accepted. +The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). A mode transition changes the system prompt at order 50, so the cache path from that point onward changes, but the tool schemas and Code Mode SDK no longer churn. **A mode restrains by guidance alone**: a model that ignores the section CAN mutate during plan — the review moment, the session log, and independent sandbox, approval, and filesystem policies are the containment surface. Hardening planning means setting those knobs, not widening the mode; the removed enforcement shapes and their effects-declaration restart trigger remain in [Alternatives considered](#alternatives-considered) and [Deferred](#deferred). The ACP mode surface carries the picker while sandbox, approval, and model selectors remain config options under the division pinned in the [FAQ](#faq) and [feature matrix](../../../../packages/ui/acp/acp-feature-support.md). If ACP removes session modes in favor of config options, the picker mapping can migrate without changing the logged mode state or model surface. diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index c44201b4e6..2484abe660 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -12,7 +12,7 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## Decision -The helper lives in `@deepseek-ai/dsh-stdio` as the terminal-channel plugin (`packages/ui/stdio/src/index.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio/tests/stdio.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/repl-agent` keep proving the composed tree boots through the real Loader (the stdio package's plugin-shape unit suite pins the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). +The helper moved into the now-removed `@deepseek-ai/dsh-stdio` package as its terminal-channel plugin: `createStdioChat`, its `StdioRuntime` test seam, and the `stdio.spec.ts` and `readline.spec.ts` unit suites moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stayed unit-covered under the per-file coverage gate without hijacking process globals. The module retained the named `name`/`inject`/`Config`/`apply` export shape consumed by the app's `ctx.plugin(uiStdio, …)` mount, while keyless Loader-path smokes proved the composed tree booted through the real Loader; the plugin-shape unit suite pinned the explicit `unwrapExports` assertion because a bundle without `inject` could otherwise boot past a stray default rather than crash. The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. diff --git a/docs/capability-seams.md b/docs/capability-seams.md index f109abe5b1..b46735410e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -50,7 +50,6 @@ flowchart LR pkg_tui["tui"] pkg_mode["mode"] svc_modes["ctx.modes
Session-mode policy state"] - pkg_stdio_agent["stdio-agent"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] @@ -174,7 +173,6 @@ flowchart LR svc_llm --> pkg_agent_loop svc_llm --> pkg_compact_basic svc_modes --> pkg_acp - svc_modes --> pkg_stdio_agent svc_permission --> pkg_acp svc_sandbox --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_bash_sandbox @@ -233,7 +231,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-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`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`, [`acp`](../packages/ui/acp) | - | 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.modes` | `core` | [`mode`](../packages/mode/mode) | - | [`acp`](../packages/ui/acp) | - | Folds logged per-agent mode/set state, flushes user flips at turn boundaries, renders deployment-owned guidance, and keeps the plan-exit schema stable across modes. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0dfa62e77c..a9602bd551 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:213`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:216`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -646,13 +646,14 @@ 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). + * Plugin config: mode definitions by name. The deployment must define + * {@link PLAN_MODE}, including its complete model instructions; + * {@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 + /** Mode definitions by name; `plan` is required and owns its full prompt text. */ + modes: Record } /** @@ -667,7 +668,7 @@ export interface ModeDefinition { } ``` -Source: [`packages/mode/mode/src/index.ts:104`](../packages/mode/mode/src/index.ts) +Source: [`packages/mode/mode/src/index.ts:103`](../packages/mode/mode/src/index.ts) ## `@deepseek-ai/dsh-permission` diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index a061eec1f6..755c324bf0 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: caa90f5d8dca940fef5986fbc14dc383a0bf8f08 -extension-cookbook.zh.md: b37687585b0be2a01c20f9b68d579259012880ab +extension-cookbook.md: 95ea32de7b65845395c7a340e71a3c17c3cdd8c8 +extension-cookbook.zh.md: 37c5ac8a5b7849520dace2bee2813558cf8e5535 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index caa90f5d8d..95ea32de7b 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Four runnable leaves load their plugin trees from `cordis.yml`: [`examples/tui-agent`](../../examples/tui-agent) (DeepSeek coding tools through the full-screen TUI, `pnpm run demo:tui`), [`examples/headless-agent`](../../examples/headless-agent) (the coding capabilities behind a one-shot task and DSH-native output, `pnpm run demo:headless "task"`), [`examples/cordis-agent`](../../examples/cordis-agent) (self-inspection and dynamic plugin mounting through the TUI, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an ACP server over JSON-RPC stdio, `pnpm run demo:acp`). Interactive leaves load [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), non-interactive leaves load [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), the ACP leaf loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and all three app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). +Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. Interactive leaves use [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), non-interactive leaves use [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and the app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index b37687585b..37c5ac8a5b 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -四个可运行叶子从 `cordis.yml` 加载各自的插件树:[`examples/tui-agent`](../../examples/tui-agent)(通过全屏 TUI 运行的 DeepSeek coding 工具,`pnpm run demo:tui`)、[`examples/headless-agent`](../../examples/headless-agent)(通过单次任务和 DSH 原生输出运行的 coding 能力,`pnpm run demo:headless "task"`)、[`examples/cordis-agent`](../../examples/cordis-agent)(通过 TUI 进行自我检查和动态插件挂载,`pnpm run demo:cordis`)与 [`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露的 ACP 服务器,`pnpm run demo:acp`)。交互式叶子加载 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo),非交互式叶子加载 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),三个 app 包都通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 +可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。交互式叶子使用 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo),非交互式叶子使用 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),应用包共享 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo)。 ## 功能→机制映射 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9ddea2dea5..e261e0b706 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -535,7 +535,7 @@ Source: [`packages/llm/llm/src/index.ts:137`](../../packages/llm/llm/src/index.t ## `ctx.modes` — `ModesService` -`ctx.modes`: the session-mode service. Owns the `mode/set` vocabulary, the pending-intent flush, the boundary narration, the `mode:policy` section, and the exit tool's visibility rule. UIs read mode flips off `session/event`; there is no live mirror. +`ctx.modes`: the session-mode service. Owns the `mode/set` vocabulary, the pending-intent flush, the boundary narration, the `mode:policy` section, and the stable exit tool. UIs read mode flips off `session/event`; there is no live mirror. ```ts cordis-catalog /** @@ -570,7 +570,7 @@ set(agent: Agent, mode: string): void Types: [Agent](../core-data-structures/core.md) -Source: [`packages/mode/mode/src/index.ts:216`](../../packages/mode/mode/src/index.ts) +Source: [`packages/mode/mode/src/index.ts:217`](../../packages/mode/mode/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1298,7 +1298,7 @@ async ask(request: AskUserQuestionRequest): Promise Types: [AskUserQuestionAnswer](../core-data-structures/user-interaction.md) · [AskUserQuestionRequest](../core-data-structures/user-interaction.md) · [UserInteractionProvider](../core-data-structures/user-interaction.md) -Source: [`packages/ui/user-interaction/src/index.ts:82`](../../packages/ui/user-interaction/src/index.ts) +Source: [`packages/ui/user-interaction/src/index.ts:84`](../../packages/ui/user-interaction/src/index.ts) ## `ctx.web` — `WebService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 71a593da7a..82f655b1e1 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -401,7 +401,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `running` describes the driver-wide drain interval, which can span turn close, its durability checkpoint, and consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `running` describes the driver-wide drain interval, which can span turn close, its durability checkpoint, and consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible: core declares `provider?` and `model?` (dispatch requires both after `agent/request`), while `dsh-mode` adds `mode?` as a boundary-applied initial session-mode intent. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 14456295ba..03c78f6f68 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../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:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../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:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp), [`mode`](../packages/mode/mode) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 32554c2fad..aa86dc3a15 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -17,7 +17,7 @@ This table connects model-visible tool names to the plugin package and service s | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | -| `@deepseek-ai/dsh-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `mode/set back to default on an approved review`, `tool/result` | - | exit_plan_mode presents the plan for the user's review over the user-interaction seam (approve / keep planning with feedback); approval flips the logged session mode back to default at the step boundary (same-batch calls stay plan-gated). The assemble filter shows it only while the folded mode is plan. | +| `@deepseek-ai/dsh-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `mode/set back to default on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema in every session mode so transitions add no tool-catalog churn on top of the mode-section change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval flips the logged session mode back to default at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | @@ -128,7 +128,7 @@ Owned by the tool registry as a reserved transport outside filterable capability ### `exit_plan_mode` -Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again. +Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again. ```json { @@ -147,7 +147,7 @@ Present your plan for the user's review and, on approval, leave plan mode. Send Source: [`packages/mode/mode/src/index.ts`](../packages/mode/mode/src/index.ts) -exit_plan_mode presents the plan for the user's review over the user-interaction seam (approve / keep planning with feedback); approval flips the logged session mode back to default at the step boundary (same-batch calls stay plan-gated). The assemble filter shows it only while the folded mode is plan. +exit_plan_mode stays in the model-facing schema in every session mode so transitions add no tool-catalog churn on top of the mode-section change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval flips the logged session mode back to default at the step boundary. ## `@deepseek-ai/dsh-tool-bash` diff --git a/examples/plan-acp-agent/README.md b/examples/plan-acp-agent/README.md index 667fd76a95..be3beceb07 100644 --- a/examples/plan-acp-agent/README.md +++ b/examples/plan-acp-agent/README.md @@ -4,7 +4,9 @@ The coding agent as an ACP server with **session modes** composed — the live c ## What it demonstrates -`session/new` advertises the mode picker (`default` / `plan`) plus the sandbox-mode and approval config options — two independent axes on one session, the composition this example exists to demonstrate. Plan mode adds the plan guidance section and the `exit_plan_mode` tool and touches nothing else: the sandbox keeps whatever mode its own knob says (workspace-write here by default), escalation prompts work in plan exactly as in default, and switching either axis never disturbs the other, in any order. A user who wants a hard read-only floor while planning flips the sandbox-mode option to read-only alongside the mode picker. There is deliberately no per-mode tool list either: `write`/`edit`/`bash` stay present in plan and the section's guidance is what defers changes to after the review (the effects-based generalization is the RFC's deferred item). A blocking decision goes to the user through `ask_user_question`. The model leaves by presenting its plan through `exit_plan_mode`: the plan markdown renders as the tool's call card, the review question arrives as an elicitation form (approve / keep planning, free text welcome), and a keep-planning answer returns the feedback to the model verbatim. +`session/new` advertises the mode picker (`default` / `plan`) plus the sandbox-mode and approval config options — independent axes on one session. The composition owns its full plan instructions in [`cordis.yml`](cordis.yml): persist in the selected mode, inspect before asking, avoid mutations, resolve discoverable facts from the repository, and produce a decision-complete plan through `exit_plan_mode`. These are the most instrumental behaviors shared by the local Codex and Claude Code plan-mode references without importing their product-specific plan files, phase machinery, or protocol tags. + +Plan mode adds only the configured guidance section. Every other tool, including `exit_plan_mode`, has the same schema in `default` and `plan`; the exit tool describes itself as plan-only and rejects if called outside plan mode. Keeping both native schemas and Code Mode's SDK stable avoids tool-catalog churn at the transition. The sandbox retains its own mode (workspace-write here by default), escalation prompts work identically, and a user who wants a hard read-only floor selects read-only separately. A blocking user-owned choice goes through `ask_user_question`. In plan mode, `exit_plan_mode` renders the submitted markdown as a call card and asks for approval or corrective feedback through ACP elicitation. ## Run @@ -12,8 +14,8 @@ The coding agent as an ACP server with **session modes** composed — the live c pnpm run demo:plan-acp # needs DEEPSEEK_API_KEY (repo-root .env works) ``` -Drive it from Zed or any ACP client; the mode picker appears on the session beside the sandbox/approval selects. Switching back to `default` (or an approved `exit_plan_mode`) drops the plan section and the exit tool on the next step; the sandbox and approval knobs stay exactly where the user left them. +Drive it from Zed or any ACP client; the mode picker appears beside the sandbox and approval selects. Switching back to `default`, directly or through an approved `exit_plan_mode`, drops only the plan section on the next step. The tool catalog and the independent knobs stay unchanged. ## Tests -`pnpm run test:snapshot` replays three scenarios keyless (the recorded bash re-executes for real under the host's sandbox runner — Seatbelt on macOS, bwrap on Linux CI). `modes-advertise` (authored): the `modes` advertisement and both config options on `session/new`, both `session/set_mode` round-trips with their optimistic `current_mode_update`, and the loud rejection of an unknown mode id, as committed wire bytes. `plan-mode` (recorded, the header pin): the full arc — setMode(plan), the plan-shaped initial header (full toolset + exit tool + section), a real `cat` run inside plan (under the sandbox's own workspace-write default — the mode does not change it), the plan presented via `exit_plan_mode`, a scripted elicitation approve, the boundary-flushed `mode/set` back with a complete changed `request/header`, then a real edit mid-turn. `plan-mode-reject` (recorded): the keep-planning branch, whose corrective `isError` carries the reviewer's free-text feedback verbatim and leaves the session in plan mode. The sandbox-denial marker stays pinned at the unit tier (`packages/bash/tool-bash/tests` — a recorded denial's stderr would be the backend's dialect and replay only where it was recorded). +`pnpm run test:snapshot` replays the ACP mode and plan-review surfaces keyless, including stable schemas across approval and real filesystem calls under Seatbelt on macOS or bwrap on Linux. `pnpm run test:e2e` adds a self-skipping live-model smoke that verifies the file is unchanged when review appears and changed only after approval. Sandbox denial remains covered at the `dsh-tool-bash` unit tier because recorded backend stderr is platform-specific. diff --git a/examples/plan-acp-agent/cordis.yml b/examples/plan-acp-agent/cordis.yml index 13e7eb350b..226e8dcdee 100644 --- a/examples/plan-acp-agent/cordis.yml +++ b/examples/plan-acp-agent/cordis.yml @@ -25,10 +25,24 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# Session modes: dsh-mode owns logged state, built-in plan guidance, and the -# plan-only exit tool. +# Deployment-owned plan guidance; dsh-mode owns state and the stable exit tool. - id: mode name: '@deepseek-ai/dsh-mode' + config: + modes: + plan: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. # One shared sandbox policy serves bash and filesystem tools in every mode. - id: sandbox @@ -66,6 +80,7 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' -# Model-facing task tracking remains composed in both modes. +# Kept loaded across both modes for a stable catalog; todo_write tracks +# implementation after approval, while exit_plan_mode owns the review plan. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/examples/plan-acp-agent/tests/acp.snapshot.ts b/examples/plan-acp-agent/tests/acp.snapshot.ts index f9161cab7c..4291d55e22 100644 --- a/examples/plan-acp-agent/tests/acp.snapshot.ts +++ b/examples/plan-acp-agent/tests/acp.snapshot.ts @@ -45,13 +45,10 @@ const SCENARIOS: Scenario[] = [ // class to carry a pin. { name: 'modes-advertise', hasModelTurn: false, recorded: false, headerClass: 'plan' }, // The full plan-mode arc, and NECESSARILY the pinned-header scenario for - // the 'plan' class: the first request ships the plan-shaped header (reason - // initial) — the full toolset plus exit_plan_mode and the mode section — - // and the approved exit narrows it back by exactly that tool and section, - // a pure removal the delta encoding CAN express (one header-delta; the - // ENTERING flip is a non-tail insertion the append-only tools delta cannot - // express, so it falls back to a snapshot — pinned at the unit tier). The - // arc: setMode(plan) → the model runs a real `cat` inside plan and + // the 'plan' class: the first request ships the configured mode section and + // the full toolset, including exit_plan_mode. Approval removes only the + // section; the following changed header carries byte-identical tool schemas. + // The arc: setMode(plan) → the model runs a real `cat` inside plan and // presents the plan via exit_plan_mode → the scripted elicitation approves // → the very next step already edits for real, mid-turn. { name: 'plan-mode', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'plan', expectedHeaderChanges: 1 }, diff --git a/examples/plan-acp-agent/tests/plan-mode.e2e.ts b/examples/plan-acp-agent/tests/plan-mode.e2e.ts new file mode 100644 index 0000000000..79427c2f61 --- /dev/null +++ b/examples/plan-acp-agent/tests/plan-mode.e2e.ts @@ -0,0 +1,84 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { + PROTOCOL_VERSION, + type CreateElicitationRequest, + type CreateElicitationResponse, +} from '@agentclientprotocol/sdk' +import { + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' + +/** The shipped plan-mode ACP leaf exercised through its real subprocess entry. */ +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), +} + +let spawned: LaunchedAcpTestAgent | undefined +let workdir: string | undefined + +afterEach(async () => { + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined + workdir = undefined + try { + if (ownedSpawned !== undefined) { + await ownedSpawned.close('SIGKILL').catch((error: unknown) => { + throw new Error(`plan ACP cleanup failed; child stderr:\n${ownedSpawned.stderr()}`, { cause: error }) + }) + } + } finally { + if (ownedWorkdir !== undefined) await rm(ownedWorkdir, { recursive: true, force: true }) + } +}) + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('plan-acp-agent e2e: approval gates implementation (real model)', () => { + it('keeps the file unchanged through review, then applies the approved plan', async () => { + workdir = await mkdtemp(join(tmpdir(), 'plan-acp-e2e-')) + const proofPath = join(workdir, 'proof.txt') + await writeFile(proofPath, 'BEFORE\n') + + const reviews: CreateElicitationRequest[] = [] + let contentAtReview: string | undefined + const createElicitation = async (request: CreateElicitationRequest): Promise => { + if (request.mode !== 'form' || request.requestedSchema.title !== 'Plan review') return { action: 'cancel' } + reviews.push(request) + contentAtReview = await readFile(proofPath, 'utf8') + return { action: 'accept', content: { choice: 'Approve' } } + } + + spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir, createElicitation }) + const { client, updates } = spawned + const rpc = async (stage: string, operation: Promise): Promise => operation.catch((error: unknown) => { + throw new Error(`plan ACP ${stage} failed; child stderr:\n${spawned?.stderr() ?? ''}`, { cause: error }) + }) + await rpc('initialize', client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })) + const created = await rpc('session/new', client.newSession({ cwd: workdir, mcpServers: [] })) + expect(created.modes?.availableModes.map(mode => mode.id)).toEqual(['default', 'plan']) + await rpc('session/set_mode', client.setSessionMode({ sessionId: created.sessionId, modeId: 'plan' })) + + const result = await rpc('prompt', client.prompt({ + sessionId: created.sessionId, + prompt: [{ + type: 'text', + text: 'Inspect proof.txt and plan the smallest change that replaces its contents with exactly AFTER followed by one newline. Present the complete plan through exit_plan_mode. After I approve it, implement the change with the filesystem tools, verify the exact file contents, and stop. Do not ask questions.', + }], + })) + + expect(['end_turn', 'max_tokens']).toContain(result.stopReason) + expect(reviews).toHaveLength(1) + expect(contentAtReview).toBe('BEFORE\n') + expect(await readFile(proofPath, 'utf8')).toBe('AFTER\n') + expect(updates + .filter(update => update.sessionUpdate === 'current_mode_update') + .map(update => update.currentModeId)).toEqual(['plan', 'default']) + }, 240_000) +}) diff --git a/examples/plan-acp-agent/tests/snapshots/plan-mode-reject/stdout.expected.jsonl b/examples/plan-acp-agent/tests/snapshots/plan-mode-reject/stdout.expected.jsonl index e19f26cea3..c03e48350a 100644 --- a/examples/plan-acp-agent/tests/snapshots/plan-mode-reject/stdout.expected.jsonl +++ b/examples/plan-acp-agent/tests/snapshots/plan-mode-reject/stdout.expected.jsonl @@ -82,7 +82,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"o"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_ucsiIH3qJOCb1B6d3dmm4387","title":"Fix the greeting typo","kind":"other","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"# Fix the greeting typo\n\n**Issue:** The greeting message currently displays \"helo wrld\" instead of the correct \"Hello World\".\n\n**Plan:**\n\n1. **Find the source file** — Locate the file that contains the greeting string \"helo wrld\".\n2. **Fix the typo** — Replace \"helo wrld\" with \"Hello World\" in that source file.\n3. **Verify** — Confirm the fix is correct by reading the changed file."}}]}}} -{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"sessionId":"{{sessionId}}","mode":"form","message":"Approve this plan and leave plan mode?","requestedSchema":{"type":"object","title":"Plan review","properties":{"choice":{"type":"string","title":"Approve this plan and leave plan mode?","description":"Choose one option, or fill a custom answer below.","oneOf":[{"const":"Approve","title":"Approve: Leave plan mode; the plan is carried out from the next step."},{"const":"Keep planning","title":"Keep planning: Stay in plan mode; feedback goes back to the model."}]},"custom":{"type":"string","title":"Custom answer","description":"Optional free-form answer. Leave empty to use the selected option."}},"required":[]}}} +{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"sessionId":"{{sessionId}}","mode":"form","message":"Approve this plan and leave plan mode?\n\n# Fix the greeting typo\n\n**Issue:** The greeting message currently displays \"helo wrld\" instead of the correct \"Hello World\".\n\n**Plan:**\n\n1. **Find the source file** — Locate the file that contains the greeting string \"helo wrld\".\n2. **Fix the typo** — Replace \"helo wrld\" with \"Hello World\" in that source file.\n3. **Verify** — Confirm the fix is correct by reading the changed file.","requestedSchema":{"type":"object","title":"Plan review","properties":{"choice":{"type":"string","title":"Approve this plan and leave plan mode?","description":"Choose one option, or fill a custom answer below.","oneOf":[{"const":"Approve","title":"Approve: Leave plan mode; the plan is carried out from the next step."},{"const":"Keep planning","title":"Keep planning: Stay in plan mode; feedback goes back to the model."}]},"custom":{"type":"string","title":"Custom answer","description":"Optional free-form answer. Leave empty to use the selected option."}},"required":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_ucsiIH3qJOCb1B6d3dmm4387","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: The user chose to keep planning; their feedback: Also add a verification step that re-reads the file after the fix."}}],"title":"Plan review"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/plan-acp-agent/tests/snapshots/plan-mode/session.jsonl b/examples/plan-acp-agent/tests/snapshots/plan-mode/session.jsonl index 9401f552da..f15753e76d 100644 --- a/examples/plan-acp-agent/tests/snapshots/plan-mode/session.jsonl +++ b/examples/plan-acp-agent/tests/snapshots/plan-mode/session.jsonl @@ -229,7 +229,7 @@ {"type":"step/end","seq":227,"time":1784525378329,"data":{"turn":1,"step":2}} {"type":"mode/set","seq":228,"time":1784525378329,"data":{"mode":"default"}} {"type":"step/start","seq":229,"time":1784525378330,"data":{"turn":1,"step":3}} -{"type":"request/header","seq":230,"time":1784560735611,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} +{"type":"request/header","seq":230,"time":1784553020470,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} {"type":"assistant/chunk","seq":231,"time":1784525378723,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":232,"time":1784525378723,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":233,"time":1784525378807,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" plan"}}} diff --git a/examples/plan-acp-agent/tests/snapshots/plan-mode/stdout.expected.jsonl b/examples/plan-acp-agent/tests/snapshots/plan-mode/stdout.expected.jsonl index 38a16f95f0..45083a4c48 100644 --- a/examples/plan-acp-agent/tests/snapshots/plan-mode/stdout.expected.jsonl +++ b/examples/plan-acp-agent/tests/snapshots/plan-mode/stdout.expected.jsonl @@ -122,7 +122,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plan"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_6RLxuiGtAFswvfLnWdt63143","title":"Fix the greeting typo","kind":"other","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"# Fix the greeting typo\n\n## Single step\n1. **Edit line 2 of `notes.txt`** — Replace the content of line 2 (`- the greeting message still says \"helo wrld\"`) with `hello world`."}}]}}} -{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"sessionId":"{{sessionId}}","mode":"form","message":"Approve this plan and leave plan mode?","requestedSchema":{"type":"object","title":"Plan review","properties":{"choice":{"type":"string","title":"Approve this plan and leave plan mode?","description":"Choose one option, or fill a custom answer below.","oneOf":[{"const":"Approve","title":"Approve: Leave plan mode; the plan is carried out from the next step."},{"const":"Keep planning","title":"Keep planning: Stay in plan mode; feedback goes back to the model."}]},"custom":{"type":"string","title":"Custom answer","description":"Optional free-form answer. Leave empty to use the selected option."}},"required":[]}}} +{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"sessionId":"{{sessionId}}","mode":"form","message":"Approve this plan and leave plan mode?\n\n# Fix the greeting typo\n\n## Single step\n1. **Edit line 2 of `notes.txt`** — Replace the content of line 2 (`- the greeting message still says \"helo wrld\"`) with `hello world`.","requestedSchema":{"type":"object","title":"Plan review","properties":{"choice":{"type":"string","title":"Approve this plan and leave plan mode?","description":"Choose one option, or fill a custom answer below.","oneOf":[{"const":"Approve","title":"Approve: Leave plan mode; the plan is carried out from the next step."},{"const":"Keep planning","title":"Keep planning: Stay in plan mode; feedback goes back to the model."}]},"custom":{"type":"string","title":"Custom answer","description":"Optional free-form answer. Leave empty to use the selected option."}},"required":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_6RLxuiGtAFswvfLnWdt63143","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Plan approved — plan mode exited; carry out the plan starting with your next step."}}],"title":"Plan review"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"current_mode_update","currentModeId":"default"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} diff --git a/examples/plan-acp-agent/tests/snapshots/plan-mode/system-prompt.expected.md b/examples/plan-acp-agent/tests/snapshots/plan-mode/system-prompt.expected.md index 664450b7bf..26e8f40afd 100644 --- a/examples/plan-acp-agent/tests/snapshots/plan-mode/system-prompt.expected.md +++ b/examples/plan-acp-agent/tests/snapshots/plan-mode/system-prompt.expected.md @@ -5,7 +5,18 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -You are in plan mode: a planning state. Explore, analyze, and design; reading files and running read-only commands is fine, but hold off on changes — edits and other side effects belong in the plan and run after its approval, not in this mode. When a decision or a missing detail blocks the plan, ask the user through the ask_user_question tool where it is available. A finished plan is delivered by calling exit_plan_mode — that call is what puts it in front of the user for review, so prefer it over pasting the plan as a plain reply or asking the user to switch modes themselves. If exit_plan_mode is unavailable or its review fails, ask the user to switch the session out of plan mode instead of pressing on. +You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. + +Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + +The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + +Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + +Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + +When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. diff --git a/examples/plan-acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json b/examples/plan-acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json index d8b4640366..d25438e601 100644 --- a/examples/plan-acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json +++ b/examples/plan-acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json @@ -150,7 +150,7 @@ }, { "name": "exit_plan_mode", - "description": "Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", "parameters": { "type": "object", "properties": { @@ -478,6 +478,22 @@ ] } }, + { + "name": "exit_plan_mode", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "parameters": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The complete plan, as markdown, starting with a # heading that names it." + } + }, + "required": [ + "plan" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", diff --git a/packages/README.md b/packages/README.md index 1b15e17195..154fefa188 100644 --- a/packages/README.md +++ b/packages/README.md @@ -23,7 +23,7 @@ Packages live at `packages///`; groups are containers, while names r | [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | | [`spill/`](spill/README.md) | Spill capability family: the storage seam, a local impl, and the tool-result spill policy | 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 | +| [`mode/`](mode/README.md) | Session-mode policy family: plan mode as logged per-agent guidance with a user-reviewed exit | Product — stable surface | | [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | 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](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 933e0f0210..d29bb9424a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -987,7 +987,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AskUserQuestionItem', - declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}', + declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n detail?: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}', }, { name: 'AskUserQuestionOption', diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index f463a4e30e..6c43772dbc 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -40,15 +40,10 @@ function serializeAssistant(message: Message): WireMessage { return { role: 'assistant', - // Text-less turns send "" — NEVER null. Pure tool-call turns: the - // official samples replay message.content verbatim (which is "") and - // some gateways reject null outright. Reasoning-ONLY turns (the model - // can answer entirely in the reasoning channel, e.g. a v4-flash - // greeting): the live API rejects null-content/no-tool_calls assistant - // messages with a 400 ("content or tool_calls must be set"), and since - // the message sits durably in the session log, a null here bricks every - // later turn of that session. - content: text, + // Tool-call turns send "" rather than null: the live API answers both, + // but the official samples replay message.content verbatim (which is "" + // for pure tool-call responses) and some gateways reject null outright. + content: text.length > 0 ? text : toolCalls.length > 0 ? '' : null, // Official passback rule (guides/thinking_mode.mdx): reasoning_content // must return on tool-call turns; it is ignored on plain turns, so we // drop it there to save tokens. diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index e84909fc20..8d3b190ed1 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -187,22 +187,12 @@ describe('serializeRequest', () => { }) }) -describe('review fixes: assistant content shapes', () => { - it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => { - // Aborted/empty assistant turns: no text, no calls → "". The earlier - // null shape was live-falsified: the API 400s a null-content assistant - // message without tool_calls ("content or tool_calls must be set"). +describe('assistant empty and tool-call content shapes', () => { + it('serializes a content-less, tool-call-less assistant message as null content', () => { + // Aborted/empty assistant turns: no text, no calls → null (the wire + // accepts it; "" is reserved for tool-call turns per the samples). const wire = serializeMessages([{ role: 'assistant', content: [] }]) - expect(wire).toEqual([{ role: 'assistant', content: '' }]) - }) - - it('serializes a reasoning-ONLY assistant message as "" content with the reasoning dropped', () => { - // The model can answer entirely in the reasoning channel (a v4-flash - // greeting did, live). The passback rule keeps reasoning_content off - // plain turns, and content must still be SET — a null here poisoned the - // session log and bricked every later turn of that session. - const wire = serializeMessages([{ role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }] }]) - expect(wire).toEqual([{ role: 'assistant', content: '' }]) + expect(wire).toEqual([{ role: 'assistant', content: null }]) }) it('serializes tool-call turns with empty string content, not null', () => { diff --git a/packages/mode/README.md b/packages/mode/README.md index 4024ba0833..8cf7c7eee3 100644 --- a/packages/mode/README.md +++ b/packages/mode/README.md @@ -6,4 +6,4 @@ Session modes: named, logged, per-agent collaboration states, with **plan mode** |---|---|---| | `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the `mode:policy` guidance section, and the model-facing `exit_plan_mode` review tool | `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 [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md). +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 deployment supplies the plan instructions through Cordis config, while the model-facing `exit_plan_mode` schema remains registered in every mode to keep the request tool catalog stable. UIs read flips off `session/event`; the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md). diff --git a/packages/mode/mode/README.md b/packages/mode/mode/README.md index 71d14b61eb..bc2489936c 100644 --- a/packages/mode/mode/README.md +++ b/packages/mode/mode/README.md @@ -1,31 +1,35 @@ # @deepseek-ai/dsh-mode -Session modes: named, logged, per-agent COLLABORATION states. **Plan mode** is the first shipped definition — the agent explores and designs under a planning stance, produces a reviewable plan, and crosses back through an explicit review. Modes are one axis; enforcement knobs (the sandbox mode, the approval policy) are others — they never read or write each other, matching how Codex keeps its Plan/Default collaboration presets separate from its sandbox and approval settings. +Session modes are named, logged, per-agent collaboration states. **Plan mode** is the required first definition: the agent explores and designs under deployment-owned instructions, presents a reviewable plan, and crosses back through an explicit review. Modes are independent from enforcement knobs such as sandbox mode and approval policy. ## 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. +`mode/set` (`{ mode: string }`) is a log-only, non-surface `SessionEventMap` member with whole-value-replace semantics; the pure `foldMode(events)` returns the last logged mode or `default`. Resume, fork, and compaction therefore restore the mode from the log, and UIs observe flips through `session/event`. -The `default` mode is the absence of policy: no section, no extra tool. An agent that never sees a `mode/set` behaves byte-identically to a deployment that never loads this plugin. +The `default` mode means no mode guidance. Loading this plugin still contributes one stable `exit_plan_mode` tool schema in every mode; that fixed schema is the cost that avoids changing the tool catalog at a mode boundary. ## What a mode carries -**The guidance section.** A `system-prompt/assemble` listener renders the mode's `section` text as the `mode:policy` section (order 50) while the mode is in force, and shows the `exit_plan_mode` tool IFF the folded mode is `plan` — on the wire and, under the registry's Code Mode, in the `tools:sdk` section alike. Every transition therefore surfaces as an attributable complete `request/header` event on the next step; entering or leaving plan changes both the section and the exit-tool catalog. +The plugin registers one `mode:policy` prompt section at order 50. It renders the active definition's deployment-configured `section` text and renders empty in `default`, for an agent-less assembly, or when a logged definition no longer exists. -**Deliberately absent: enforcement.** A mode never gates execution, filters the toolset, or touches the sandbox/approval knobs — those are independent axes the user switches separately (a deployment that wants a hard read-only floor while planning flips the sandbox-mode option beside the mode picker, in either order; neither disturbs the other). A per-mode tool allow/deny list is likewise out: which tools a mode admits is an effects question — a per-tool read-only/mutating classification the harness does not yet have — parked until tool definitions declare their effects (the plan-mode RFC's deferred item). The config vocabulary is exactly `{ section }`, and an unknown key (a `tools` list or an `access` cap included) fails loud at load. +A mode does not gate execution, filter tools, or change sandbox or approval settings. A deployment that wants a hard read-only floor while planning combines plan mode with the independent sandbox and approval controls. The config vocabulary is exactly `{ section }`; unknown keys fail at load. ## `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 on the loop's interception seams (`agent/prompt-submit` inside the just-opened turn, `agent/turn-continuation` after each step closed — both outside any log emit, where a post-commit `session/event` observer could not append) 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. +`list()` returns `default` followed by the configured definitions. `get(agent)` returns the folded mode, treating a removed definition as `default`, plus any pending intent. `set(agent, mode)` validates against that vocabulary and records a pending intent. The service flushes the intent on `agent/prompt-submit` before the first assembly, `agent/turn-continuation` before a normal successor step, or after a composed `agent/request-error` decision authorizes a retry. Each append is turn-enclosed and precedes the affected prompt assembly, including an automatic recovery step after asynchronous backoff. A changed user selection adds one coalesced `context/message` notice when the last logged request header described a different mode; a net-zero selection sequence adds 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. +`AgentOptions.mode` seeds an initial mode through the same pending-intent path. Forked sessions inherit mode state through their logged prefix. ## `exit_plan_mode` -The model-facing exit tool. Its single required argument is the plan text — a durable, replayable log artifact riding the ordinary `tool/call` event. `execute` re-checks the folded mode, then conducts the review over the user-interaction seam (`ctx.get('userInteraction')`, opportunistic): one single-select question — Approve, or Keep planning — with the free-text channel open. Approve records the switch back to `default` as a silent boundary-applied pending intent (flushed at this step's end — the plan surface keeps holding for any remaining call of the same assistant response) and the next step reflects the exit; every other outcome (keep-planning with the user's feedback verbatim, an aborted question, no provider) returns the corrective `isError` and the mode stays `plan`. `presentCall` renders a `generic` card titled by the plan's first heading with the plan markdown as content; over ACP the review rides the same elicitation flow as `ask_user_question`, in the terminal the stdio provider's prompt queue. +[`exit_plan_mode`](../../../docs/tool-catalog.md#deepseek-aidsh-mode) is registered in every mode so native tool schemas and Code Mode's generated SDK remain byte-identical across a mode switch. Its description says it is plan-only, and execution rechecks the folded mode and rejects outside `plan`. + +In plan mode, the required `plan` argument makes the review artifact durable: native dispatch records it in `tool/call`; Code Mode records the outer `run_code` source before execution and the extracted arguments in `tool/code-dispatch` when the nested dispatch settles. The review request also carries the exact plan as supporting detail, so ACP and TUI show what is being approved even when Code Mode has no nested native call card. The tool asks the user to Approve or Keep planning through `ctx.userInteraction`, with optional free-text rejection feedback. Approval schedules a silent switch to `default` at the step boundary; every non-approval outcome returns a corrective `isError` and keeps plan mode. Native presentation additionally renders the markdown as a generic plan card. ## Config +The deployment must provide the complete plan-mode instructions in Cordis config; the package has no embedded plan prompt. See the [plan ACP example](../../../examples/plan-acp-agent/cordis.yml) for the maintained production-shaped instructions. + ```yaml - id: mode name: '@deepseek-ai/dsh-mode' @@ -33,51 +37,65 @@ The model-facing exit tool. Its single required argument is the plan text — a modes: plan: section: | - You are in plan mode: ... + You are in plan mode. Explore first, make no changes, and present a decision-complete plan through exit_plan_mode. ``` -Definitions are validated at load (`resolveConfig`): the built-in `plan` (the shipped guidance section) merges unless overridden, `default` is rejected as a key, and any other key — a `tools` list or an `access` cap included — fails loud. An unknown mode name fails loudly at `set()` time. +`resolveConfig` requires `modes.plan.section`, rejects `default` as a definition key, rejects blank sections and unknown definition keys, and preserves any further named modes. `set()` rejects an unknown mode name. ## Model Experience -### System prompt and mode tool +### Mode guidance section #### What the model sees -In `default`, no `mode:policy` text appears and the registered `exit_plan_mode` tool is filtered from native schemas and the Code Mode SDK, making the request identical to a deployment without this plugin. A configured non-default mode renders its exact section at order 50; `plan` also exposes the [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-mode). A user-driven transition whose prior header described another mode appends one coalesced notice naming the new mode. +In `default`, no `mode:policy` text appears. In a configured mode, that definition's exact `section` text appears after persona and before tool guidance. The package does not own a stable prompt literal; the [example Cordis config](../../../examples/plan-acp-agent/cordis.yml) owns the plan instructions used by the shipped composition. -##### Plan-mode policy section +#### Token effect + +`default` adds no section tokens. Plan mode adds the configured section on each request; the text is static until config or mode changes. + +#### KV Cache effect + +Within one mode, the section is stable. Entering or leaving a non-default mode changes the system prompt at order 50, so bytes from that section onward need a new cache path; the stable prefix before it can still be reused where the provider supports prefix caching. No tool-schema or Code Mode SDK churn accompanies the transition. + +### Mode transition notices + +#### What the model sees + +A user-driven change whose previous request header described another mode appends either `The user switched this session to mode.` or `The user switched this session back to the default mode.` A logged mode removed from config reads as default and appends `Mode "" is no longer defined in this deployment's configuration; the session continues in the default mode.` once per removed name. Initial selection before the first header, net-zero selections, and the tool-driven exit add no notice. + +#### Token effect + +Each qualifying transition adds one short conversation message once. The dynamic mode name is the only data-dependent part. + +#### KV Cache effect + +The notice itself is append-only conversation growth. A real mode transition also changes the earlier order-50 section, so that section remains the limiting cache boundary; a dropped-definition notice with no section change preserves the prior request prefix and only extends it. + +### Exit tool schema and review exchange + +#### What the model sees + +The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-mode) is present in every mode. Outside plan mode, a call returns `Error: exit_plan_mode is only available in plan mode`. In plan mode, an empty or heading-less argument returns `Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading` before review. A valid call carries the complete plan both as the tool argument and as review detail; exactly one `Approve` selection returns `Plan approved — plan mode exited; carry out the plan starting with your next step.`, while every other answer returns `Error: The user chose to keep planning; revise the plan and present it again.` or `Error: The user chose to keep planning; their feedback: `. An unavailable review channel returns its fail-closed error and keeps the mode unchanged. + +##### Stable literal ```markdown -You are in plan mode: a planning state. Explore, analyze, and design; reading files and running read-only commands is fine, but hold off on changes — edits and other side effects belong in the plan and run after its approval, not in this mode. When a decision or a missing detail blocks the plan, ask the user through the ask_user_question tool where it is available. A finished plan is delivered by calling exit_plan_mode — that call is what puts it in front of the user for review, so prefer it over pasting the plan as a plain reply or asking the user to switch modes themselves. If exit_plan_mode is unavailable or its review fails, ask the user to switch the session out of plan mode instead of pressing on. +Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again. ``` #### Token effect -`default` adds no tokens. Plan mode adds the policy section and one tool schema on each request; each qualifying user transition adds one short conversation notice. +The stable cost depends on ToolRegistry mode: `native` adds the tool schema, `code` adds the generated SDK binding inside the `run_code` surface instead of a native schema, and `both` adds both representations. The plan markdown is paid once as tool-call arguments and remains in context. Each rejection adds its feedback result, and the next revision adds another complete plan tool call. #### KV Cache effect -Within one mode, the section and catalog are stable. Entering or leaving plan changes the system prompt at order 50 and adds or removes the exit-tool schema, so the request takes a different cache path; bytes before the section remain a reusable prefix where the provider supports prefix caching. - -### Exit review - -#### What the model sees - -The call carries the complete plan markdown as ordinary tool arguments. Approval returns `Plan approved — plan mode exited; carry out the plan starting with your next step.`; every non-approval returns a corrective error containing the reviewer's feedback when provided. - -#### Token effect - -The plan is paid once as tool arguments and remains in the conversation. Each rejection adds its feedback result, and a later revision adds another complete plan call. - -#### KV Cache effect - -The review call and result extend the conversation normally. An approved exit changes the next request's earlier mode section and removes the exit-tool schema, so that request follows the default-mode cache path rather than the plan-mode path. +The tool schema and generated SDK binding are byte-identical in `default`, `plan`, and custom modes, so a mode change adds no tool-catalog diff. The earlier order-50 section change still moves the cache path as described above; schema stability avoids a second source of request-shape churn and keeps subsequent requests within the new mode on one catalog shape. Loading or unloading the plugin itself changes that catalog. Review arguments and results extend the conversation normally. ## Known Limitations and Deferred Work -- **A mode restrains by guidance only** — nothing gates execution while a mode holds; a user who wants a hard floor pairs the mode with the independent sandbox/approval knobs. The [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) archives the two removed enforcement shapes (the interim allowlist, the `access` sandbox cap) and their restart trigger (effects self-declaration on tool definitions). -- **A pending flip set while idle dies with the process** — the UI re-applies; the idle-record primitive is the escape hatch if this bites. -- **Subagent mode inheritance is deferred** — a fork child inherits via the seeded prefix; a spawn child starts default unless its creator seeds `AgentOptions.mode`. +- A mode restrains by guidance only; pair it with independent enforcement knobs when a hard boundary is required. The [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) records the rejected enforcement shapes and the effects-metadata restart trigger. +- A pending flip selected while idle is lost if the process exits before the next boundary; the UI must reapply it. +- Forked children inherit the logged mode, while spawned children start in `default` unless their creator seeds `AgentOptions.mode`. Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md). diff --git a/packages/mode/mode/package.json b/packages/mode/mode/package.json index 7582c137dc..7c8e184605 100644 --- a/packages/mode/mode/package.json +++ b/packages/mode/mode/package.json @@ -27,7 +27,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -38,6 +38,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/mode/mode/src/index.ts b/packages/mode/mode/src/index.ts index b7374c401a..9a51e83397 100644 --- a/packages/mode/mode/src/index.ts +++ b/packages/mode/mode/src/index.ts @@ -8,13 +8,13 @@ * Plan/Default collaboration presets from its sandbox and approval settings. * There is likewise NO per-mode tool allow/deny list: which tools a mode * admits is an effects question, parked until tool definitions can declare - * their effects (the plan-mode RFC's deferred item). The mode IN FORCE for an + * their effects (the plan-mode Agent Note's deferred item). 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 extra tool. 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. + * The default mode is the absence of mode guidance. The `exit_plan_mode` tool + * remains registered in every mode so request tool schemas never change at a + * mode boundary; its execute path rejects calls outside plan mode. * * 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 @@ -35,7 +35,7 @@ import { Context, Service } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { defineTool, renderToolsSdk, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-user-interaction' @@ -74,14 +74,12 @@ declare module 'cordis' { */ export const DEFAULT_MODE = 'default' -/** The one shipped mode definition's name. */ +/** The required plan 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. + * The model-facing exit tool's name. It stays registered in every mode so the + * request tool catalog is stable; execution outside {@link PLAN_MODE} rejects. */ export const EXIT_PLAN_MODE = 'exit_plan_mode' @@ -97,33 +95,22 @@ export interface ModeDefinition { } /** - * 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). + * Plugin config: mode definitions by name. The deployment must define + * {@link PLAN_MODE}, including its complete model instructions; + * {@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 + /** Mode definitions by name; `plan` is required and owns its full prompt text. */ + modes: Record } -/** Validated mode definitions: the built-in `plan` merged with (or replaced by) the configured ones. */ +/** Validated deployment-owned mode definitions, including `plan`. */ export interface ResolvedModes { /** Definitions by mode name; never contains {@link DEFAULT_MODE}. */ definitions: ReadonlyMap } -const PLAN_SECTION - = 'You are in plan mode: a planning state. Explore, analyze, and design; reading ' - + 'files and running read-only commands is fine, but hold off on changes — edits ' - + 'and other side effects belong in the plan and run after its approval, not in ' - + 'this mode. When a decision or a missing detail blocks the plan, ask the ' - + 'user through the ask_user_question tool where it is available. A finished plan ' - + 'is delivered by calling exit_plan_mode — that call is what puts it in front of ' - + 'the user for review, so prefer it over pasting the plan as a plain reply or ' - + 'asking the user to switch modes themselves. If exit_plan_mode is unavailable or ' - + 'its review fails, ask the user to switch the session out of plan mode instead ' - + 'of pressing on.' - /** The review question's approve option label — the answer item is matched by it. */ const APPROVE_LABEL = 'Approve' @@ -131,11 +118,16 @@ const APPROVE_LABEL = 'Approve' const KEEP_PLANNING_LABEL = 'Keep planning' const EXIT_DESCRIPTION - = 'Present your plan for the user\'s review and, on approval, leave plan mode. ' + = 'Use only in plan mode. Present your plan for the user\'s review and, on approval, leave plan mode. ' + 'Send the COMPLETE plan as markdown, starting with a # heading that names it. ' + 'The user may approve (carry out the plan from your next step) or keep ' + 'planning — their feedback comes back in the tool result; revise and present again.' +/** Durable notice text for a folded mode the current deployment no longer defines. */ +function droppedDefinitionNotice(name: string): string { + return `Mode "${name}" is no longer defined in this deployment's configuration; the session continues in the default mode.` +} + /** The plan's first markdown heading (any level), or `undefined` when it has none. */ function firstHeading(plan: string): string | undefined { for (const line of plan.split('\n')) { @@ -146,23 +138,29 @@ function firstHeading(plan: string): string | undefined { } /** - * Validate the config and merge the built-in `plan` definition (explicit - * resolve step — the `dsh-bash` request/spec template). Fail-loud: a - * {@link DEFAULT_MODE} key or a malformed definition throws at load. + * Validate the deployment-owned mode definitions (explicit resolve step — the + * `dsh-bash` request/spec template). Fail-loud: a missing {@link PLAN_MODE}, 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. + * @returns The validated definitions, including deployment-configured `plan`. */ export function resolveConfig(config: ModeConfig): ResolvedModes { const definitions = new Map() - definitions.set(PLAN_MODE, { section: PLAN_SECTION }) - for (const [name, definition] of Object.entries(config.modes ?? {})) { + // Cordis can invoke the constructor with omitted runtime config even though + // the public TypeScript contract requires `modes`; keep that invalid shape + // inside validation so it gets the actionable missing-plan error below. + const modes = (config as Partial).modes ?? {} + for (const [name, definition] of Object.entries(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 (definition.section.trim() === '') { + throw new Error(`ModeConfig: mode "${name}" needs a non-empty \`section\``) + } // Unknown keys fail loud rather than silently shaping nothing — the // definition vocabulary is exactly { section }: a tool allow/deny list // and enforcement knobs are deliberately NOT part of it (module doc). @@ -172,6 +170,9 @@ export function resolveConfig(config: ModeConfig): ResolvedModes { } definitions.set(name, { section: definition.section }) } + if (!definitions.has(PLAN_MODE)) { + throw new Error(`ModeConfig: mode "${PLAN_MODE}" is required; put its model instructions in modes.${PLAN_MODE}.section`) + } return { definitions } } @@ -210,13 +211,13 @@ function modeAtLastHeader(events: readonly SessionEvent[]): string | undefined { /** * `ctx.modes`: the session-mode service. Owns the `mode/set` vocabulary, the * pending-intent flush, the boundary narration, the `mode:policy` section, - * and the exit tool's visibility rule. UIs read mode flips off - * `session/event`; there is no live mirror. + * and the stable exit tool. 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). */ + /** Validated deployment-owned definitions, including `plan`. */ readonly resolved: ResolvedModes /** @@ -227,10 +228,10 @@ export class ModesService extends Service { */ private readonly pendingIntents = new WeakMap() - /** The unknown folded-mode name already narrated per session (once per name). */ - private readonly droppedNoticed = new WeakMap() + /** Mode-plugin notice texts already present in each live session; hydrated once from the durable log. */ + private readonly noticeTexts = new WeakMap>() - constructor(ctx: Context, config: ModeConfig = {}) { + constructor(ctx: Context, config: ModeConfig = { modes: {} }) { super(ctx, 'modes') this.resolved = resolveConfig(config) @@ -274,52 +275,21 @@ export class ModesService extends Service { text: context => (context.agent === undefined ? '' : this.activeDefinition(context.agent.session)?.definition.section ?? ''), }) - // prepend: the filter wraps OUTSIDE every append-registered listener - // regardless of load order, so their post-next() additions are filtered - // too. It hides exactly ONE thing: the always-registered exit tool, wherever - // the folded mode is not plan — which keeps a default-mode assembly - // byte-identical to a no-dsh-mode deployment (whose registry never saw the - // tool) and keeps custom modes from advertising a binding that only errors. - ctx.on('system-prompt/assemble', async (_assembly, context, next) => { - const result = await next() - const agent = context.agent - if (agent === undefined) return result - if (this.activeDefinition(agent.session)?.name === PLAN_MODE) return result - result.tools = result.tools.filter(tool => tool.name !== EXIT_PLAN_MODE) - // Code Mode's soft surface is the SDK section, not the wire schemas — - // section text resolves in assemble's base, so the outermost wrapper - // re-renders it under the same visibility rule the wire filter applies. - rerenderSdk(result, name => name !== EXIT_PLAN_MODE) - return result - }, { prepend: true }) - - /** - * Re-render the `tools:sdk` section (present only under the registry's - * Code Mode) from the registry schemas the given rule admits — minus - * `run_code` itself, mirroring the registry's own exclusion. A no-op when - * the section is absent (native mode). - */ - function rerenderSdk(result: { sections: { name: string; text: string }[] }, include: (name: string) => boolean): void { - const sdkIndex = result.sections.findIndex(section => section.name === 'tools:sdk') - if (sdkIndex < 0) return - const sdkText = renderToolsSdk(ctx.tools.schemas().filter(schema => - include(schema.name) && schema.name !== RUN_CODE_NAME)) - result.sections = result.sections.map((section, index) => - index === sdkIndex ? { ...section, text: sdkText } : section) - } - ctx.tools.register(defineTool({ name: EXIT_PLAN_MODE, description: EXIT_DESCRIPTION, parameters: { plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' }, }, - execute: async (_args, exec) => { + execute: async (args, exec) => { const agent = exec.agent if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`) if (this.activeDefinition(agent.session)?.name !== PLAN_MODE) { throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`) } + if (!/^#\s+\S/.test(args.plan.trim())) { + throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`) + } const interaction = ctx.get('userInteraction') if (interaction === undefined) { throw new Error('no user-interaction channel is available to review the plan; ask the user to switch the session mode instead') @@ -329,6 +299,7 @@ export class ModesService extends Service { id: 'plan-review', header: 'Plan review', question: 'Approve this plan and leave plan mode?', + detail: args.plan, options: [ { label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' }, { label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' }, @@ -337,8 +308,9 @@ export class ModesService extends Service { agent, ...exec.signal ? { signal: exec.signal } : {}, }) - const item = answer.answers.find(entry => entry.id === 'plan-review') - if (!item?.selected.includes(APPROVE_LABEL)) { + const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review') + const item = reviewItems.length === 1 ? reviewItems[0] : undefined + if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) { // A custom-text-only answer is feedback, not consent — approval is // exactly the approve option (an unknown selection never exits). const feedback = item?.custom ?? '' @@ -349,13 +321,12 @@ export class ModesService extends Service { // A boundary-applied switch, NOT a direct append: the loop may still // execute further tool calls from the SAME assistant response after // this one, and they were requested under the plan-shaped header — so - // the plan surface (the section, the exit tool's visibility) keeps - // holding for that whole batch. The flush at this step's end appends + // the plan guidance keeps holding for that whole batch. The flush at + // this step's end appends // the mode/set (still in-turn), so the next step's assembly reflects // the exit; narrate: false — this result IS the narration. this.pendingIntents.set(agent.session, { mode: DEFAULT_MODE, narrate: false }) - const note = item.custom === undefined || item.custom === '' ? '' : ` User note: ${item.custom}` - return [{ type: 'text', text: `Plan approved — plan mode exited; carry out the plan starting with your next step.${note}` }] + return [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }] }, presentCall: args => ({ card: 'generic', @@ -464,12 +435,24 @@ export class ModesService extends Service { 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) + const text = droppedDefinitionNotice(name) + let noticed = this.noticeTexts.get(session) + if (noticed === undefined) { + noticed = new Set(session.events.flatMap(event => event.type === 'context/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'mode' + && event.data.content.length === 1 + && event.data.content[0]?.type === 'text' + ? [event.data.content[0].text] + : [])) + this.noticeTexts.set(session, noticed) + } + if (noticed.has(text)) return 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.` }], + content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'mode' }, }, { surfaceOp: 'append' }) + noticed.add(text) } } diff --git a/packages/mode/mode/tests/integration.spec.ts b/packages/mode/mode/tests/integration.spec.ts index 1ceca88828..81d7cbca61 100644 --- a/packages/mode/mode/tests/integration.spec.ts +++ b/packages/mode/mode/tests/integration.spec.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import LlmService, { type StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, 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, { type Agent } from '@deepseek-ai/dsh-agent' @@ -10,6 +9,8 @@ import AgentLoop 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' +const PLAN_CONFIG = { modes: { plan: { section: 'Test plan mode instructions.' } } } + /** * 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 @@ -26,7 +27,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(ModesService) + await ctx.plugin(ModesService, PLAN_CONFIG) ctx.llm.registerAdapter(['mock'], adapter) for (const name of ['read', 'write']) { ctx.tools.register(defineTool({ @@ -91,7 +92,7 @@ describe('plan mode through the agent loop', () => { expect(log.some(event => event.type === 'context/message')).toBe(false) }) - it('a user flip between turns lands at the boundary: one notice and the changed plan header', async () => { + it('a user flip between turns lands at the boundary: one notice and a changed header with stable tool schemas', async () => { const adapter = new MockAdapter([ textResponse('First turn, default mode.'), textResponse('Second turn, plan mode.'), @@ -102,6 +103,8 @@ describe('plan mode through the agent loop', () => { agent.send([{ type: 'text', text: 'hello' }]) await waitForIdle(ctx, agent) expect(foldMode(agent.session.events)).toBe('default') + const first = findEvent(agent.session.events, 'request/header') + expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write']) ctx.modes.set(agent, PLAN_MODE) agent.send([{ type: 'text', text: 'now plan' }]) @@ -114,11 +117,51 @@ describe('plan mode through the agent loop', () => { expect(findEvent(log, 'context/message').data.content).toEqual([ { type: 'text', text: 'The user switched this session to plan mode.' }, ]) - // Entering plan changes both the section and tool catalog, so the next - // request logs a complete changed header. + // The changed request is logged as a complete snapshot. const second = findEvent(log, 'request/header', 'last') expect(second.data.reason).toBe('change') expect(second.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write']) + expect(second.data.header.tools).toEqual(first.data.header.tools) expect(second.data.header.system).toContain('plan mode') }) + + it('a mode flip during request recovery shapes the retry before its assembly', async () => { + const failedRequest = [{ + type: 'finish', + reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } }, + }] satisfies StreamChunk[] + const adapter = new MockAdapter([failedRequest, textResponse('Recovered in plan mode.')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) + const recoveryEntered = Promise.withResolvers() + const releaseRecovery = Promise.withResolvers() + ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, _history, _signal, next) => { + if (subject !== agent) return next() + recoveryEntered.resolve(true) + await releaseRecovery.promise + return { action: 'retry' } + }) + + const idle = waitForIdle(ctx, agent) + agent.send([{ type: 'text', text: 'plan after the transient failure' }]) + await recoveryEntered.promise + ctx.modes.set(agent, PLAN_MODE) + releaseRecovery.resolve(true) + await idle + + expect(adapter.requests).toHaveLength(2) + expect(adapter.requests[0]?.system).not.toContain(PLAN_CONFIG.modes.plan.section) + expect(adapter.requests[1]?.system).toContain(PLAN_CONFIG.modes.plan.section) + expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools) + const log = agent.session.events + const modeSet = findEvent(log, 'mode/set') + const firstEnd = log.find(event => event.type === 'step/end' && event.data.step === 1) + const retryStart = log.find(event => event.type === 'step/start' && event.data.step === 2) + expect(firstEnd?.seq).toBeLessThan(modeSet.seq) + expect(modeSet.seq).toBeLessThan(retryStart?.seq ?? 0) + expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.modes.plan.section) + expect(findEvent(log, 'context/message').data.content).toEqual([ + { type: 'text', text: 'The user switched this session to plan mode.' }, + ]) + }) }) diff --git a/packages/mode/mode/tests/mode.spec.ts b/packages/mode/mode/tests/mode.spec.ts index 24a8947248..565f4e88b0 100644 --- a/packages/mode/mode/tests/mode.spec.ts +++ b/packages/mode/mode/tests/mode.spec.ts @@ -2,7 +2,7 @@ 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 ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' import { Session, SessionId } from '@deepseek-ai/dsh-session' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction' @@ -10,6 +10,9 @@ import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek- import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts' import type { ModeConfig } from '../src/index.ts' +const TEST_PLAN_SECTION = 'Test plan mode instructions.' +const PLAN_CONFIG = { modes: { plan: { section: TEST_PLAN_SECTION } } } satisfies ModeConfig + /** * Drives the REAL plugin: mounts `dsh-mode` beside real `SystemPrompt` and * `ToolRegistry` services, with fake Agents carrying real `Session`s (the @@ -24,7 +27,7 @@ function agentWithSession(id = 'agent-1', options: { mode?: string } = {}): Agen return { id: SessionId(id), session, options } as unknown as Agent & { session: Session } } -async function setup(config?: ModeConfig): Promise { +async function setup(config: ModeConfig = PLAN_CONFIG): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -82,36 +85,38 @@ function execute(ctx: Context, name: string, agent?: Agent) { } describe('resolveConfig', () => { - it('merges the built-in plan definition: a guidance section, nothing else', () => { - const resolved = resolveConfig({}) - const plan = resolved.definitions.get(PLAN_MODE) - expect(plan).toEqual({ section: plan?.section }) - expect(plan?.section).toContain('plan mode') + it('requires the deployment to configure the plan instructions', () => { + expect(() => resolveConfig({ modes: {} })) + .toThrow('mode "plan" is required; put its model instructions in modes.plan.section') + expect(() => resolveConfig({} as ModeConfig)) + .toThrow('mode "plan" is required; put its model instructions in modes.plan.section') }) - it('lets config override plan and add further modes', () => { + it('loads the configured plan instructions and further modes verbatim', () => { const resolved = resolveConfig({ modes: { - plan: { section: 'custom plan' }, + plan: { section: TEST_PLAN_SECTION }, review: { section: 'review' }, } }) - expect(resolved.definitions.get(PLAN_MODE)).toEqual({ section: 'custom plan' }) + expect(resolved.definitions.get(PLAN_MODE)).toEqual({ section: TEST_PLAN_SECTION }) expect(resolved.definitions.get('review')).toEqual({ section: 'review' }) }) it('rejects the reserved default key loudly', () => { - expect(() => resolveConfig({ modes: { default: { section: '' } } })) + expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, default: { section: 'policy' } } })) .toThrow('"default" is reserved') }) it('rejects a malformed definition loudly', () => { - expect(() => resolveConfig({ modes: { bad: { section: 5 } as unknown as { section: string } } })) + expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 5 } as unknown as { section: string } } })) .toThrow('needs a string `section`') + expect(() => resolveConfig({ modes: { plan: { section: ' ' } } })) + .toThrow('needs a non-empty `section`') // Unknown keys fail loud — a tool allow/deny list and enforcement knobs // are deliberately not part of the vocabulary, and a config still // carrying one must not be silently accepted as if it shaped anything. - expect(() => resolveConfig({ modes: { bad: { section: '', tools: ['read'] } as unknown as { section: string } } })) + expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 'bad', tools: ['read'] } as unknown as { section: string } } })) .toThrow('unknown key(s) tools — a definition is { section }') - expect(() => resolveConfig({ modes: { bad: { section: '', access: 'read-only' } as unknown as { section: string } } })) + expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 'bad', access: 'read-only' } as unknown as { section: string } } })) .toThrow('unknown key(s) access — a definition is { section }') }) }) @@ -137,7 +142,7 @@ describe('foldMode', () => { describe('ctx.modes: list/get/set', () => { it('lists default first, then the configured definitions', async () => { - const ctx = await setup({ modes: { review: { section: 's' } } }) + const ctx = await setup({ modes: { ...PLAN_CONFIG.modes, review: { section: 's' } } }) expect(ctx.modes.list()).toEqual([DEFAULT_MODE, PLAN_MODE, 'review']) }) @@ -268,6 +273,46 @@ describe('the boundary flush', () => { expect(noticeTexts(agent.session)).toHaveLength(1) }) + it('does not repeat a dropped-definition notice after the mode service restarts', async () => { + const first = await setup() + const original = agentWithSession('dropped-resume') + original.session.append('mode/set', { mode: 'retired' }) + await boundary(first, original, 'turn/start') + + const resumed = agentWithSession('dropped-resume') + resumed.session = new Session(SessionId('dropped-resume'), original.session.events) + const second = await setup() + await boundary(second, resumed, 'turn/start') + + expect(noticeTexts(resumed.session)).toEqual([ + 'Mode "retired" is no longer defined in this deployment\'s configuration; the session continues in the default mode.', + ]) + }) + + it('retries a dropped-definition notice when its append fails', async () => { + const ctx = await setup() + const warn = vi.fn() + ctx.logger.warn = warn as never + const agent = agentWithSession() + agent.session.append('mode/set', { mode: 'retired' }) + const original = agent.session.append.bind(agent.session) + agent.session.append = (((type: string, ...rest: unknown[]) => { + if (type === 'context/message') throw new Error('backend gone') + return (original as (...args: unknown[]) => unknown)(type, ...rest) + }) as unknown) as typeof agent.session.append + + await boundary(ctx, agent, 'turn/start') + expect(warn).toHaveBeenCalledOnce() + expect(noticeTexts(agent.session)).toEqual([]) + + agent.session.append = original + await boundary(ctx, agent, 'turn/start') + await boundary(ctx, agent, '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.', + ]) + }) + it('contains an append failure instead of blocking the prompt or the turn', async () => { const ctx = await setup() const warn = vi.fn() @@ -311,13 +356,18 @@ describe('the boundary flush', () => { }) describe('the soft layer', () => { - it('keeps a default-mode assembly identical to a no-dsh-mode deployment (exit tool dropped)', async () => { + it('keeps the tool schemas identical across default and plan mode', async () => { const ctx = await setup() registerNamedTools(ctx, ['read', 'write']) 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('') + const defaultAssembly = await ctx.systemPrompt.assemble({ agent }) + expect(defaultAssembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write']) + expect(defaultAssembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('') + + agent.session.append('mode/set', { mode: PLAN_MODE }) + const planAssembly = await ctx.systemPrompt.assemble({ agent }) + expect(planAssembly.tools).toEqual(defaultAssembly.tools) + expect(planAssembly.sections.find(section => section.name === 'mode:policy')?.text).toBe(TEST_PLAN_SECTION) }) it('leaves an agent-less assembly untouched', async () => { @@ -328,32 +378,29 @@ describe('the soft layer', () => { expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('') }) - it('keeps the full toolset in plan mode, adds the exit tool, and renders the mode section', async () => { + it('keeps the full toolset in plan mode and renders the configured mode section', async () => { const ctx = await setup() registerNamedTools(ctx, ['read', 'write', 'todo_write']) 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', 'write']) - expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toContain('plan mode') + expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe(TEST_PLAN_SECTION) }) - it('drops exit_plan_mode outside plan mode (custom modes never see it)', async () => { - const ctx = await setup({ modes: { review: { section: 'reviewing' } } }) + it('keeps exit_plan_mode visible in custom modes while rendering their guidance', async () => { + const ctx = await setup({ modes: { ...PLAN_CONFIG.modes, review: { section: 'reviewing' } } }) registerNamedTools(ctx, ['read', 'write']) 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', 'write']) + expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write']) expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('reviewing') }) it('leaves foreign post-next() additions alone in plan mode (no general tool filtering)', async () => { - // A foreign listener that post-processes await next(): the mode filter - // wraps outside it (prepend) but hides only the exit tool outside plan — - // a foreign addition survives, because which tools a mode admits is - // deliberately not this plugin's decision (the effects question stays - // parked; module doc). + // A foreign listener that post-processes await next(): the addition + // survives, because modes do not filter the deployment's tool registry. const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -362,7 +409,7 @@ describe('the soft layer', () => { final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }] return final }) - await ctx.plugin(ModesService) + await ctx.plugin(ModesService, PLAN_CONFIG) registerNamedTools(ctx, ['read']) const agent = agentWithSession() agent.session.append('mode/set', { mode: PLAN_MODE }) @@ -382,7 +429,7 @@ describe('the soft layer', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry, { mode: 'code' }) await ctx.plugin(FakeRuntime) - await ctx.plugin(ModesService) + await ctx.plugin(ModesService, PLAN_CONFIG) registerNamedTools(ctx, ['read', 'write']) const agent = agentWithSession() agent.session.append('mode/set', { mode: PLAN_MODE }) @@ -406,13 +453,13 @@ describe('the soft layer', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry, { mode: 'both' }) await ctx.plugin(FakeRuntime) - await ctx.plugin(ModesService) + await ctx.plugin(ModesService, PLAN_CONFIG) registerNamedTools(ctx, ['read', 'write']) const agent = agentWithSession() agent.session.append('mode/set', { mode: PLAN_MODE }) const assembly = await ctx.systemPrompt.assemble({ agent }) - // ONE visibility rule covers both surfaces: in plan the exit tool is - // present on the wire AND in the SDK, alongside the untouched toolset. + // The stable registry contribution reaches both surfaces: the exit tool + // is present on the wire AND in the SDK alongside the untouched toolset. expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['exit_plan_mode', 'read', 'run_code', 'write']) const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? '' expect(sdk).toContain('read(args:') @@ -420,7 +467,7 @@ describe('the soft layer', () => { expect(sdk).toContain('exit_plan_mode(args:') }) - it('default-mode Code Mode SDK is byte-identical to a no-dsh-mode deployment (exit binding hidden)', async () => { + it('keeps the Code Mode SDK byte-identical across mode switches', async () => { class FakeRuntime extends CodeRuntime { readonly language = 'typescript' readonly isolation = 'fake' @@ -430,23 +477,27 @@ describe('the soft layer', () => { await withModes.plugin(SystemPrompt) await withModes.plugin(ToolRegistry, { mode: 'code' }) await withModes.plugin(FakeRuntime) - await withModes.plugin(ModesService) + await withModes.plugin(ModesService, PLAN_CONFIG) registerNamedTools(withModes, ['read', 'write']) const agent = agentWithSession() - const sdk = (await withModes.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? '' - expect(sdk).toContain('read(args:') - expect(sdk).toContain('write(args:') - // The always-registered exit tool is callable only in plan mode, so a - // default-mode SDK advertising it would offer a binding that can only - // error — and diverge from a deployment that never loaded dsh-mode: + const defaultSdk = (await withModes.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? '' + expect(defaultSdk).toContain('read(args:') + expect(defaultSdk).toContain('write(args:') + expect(defaultSdk).toContain('exit_plan_mode(args:') + agent.session.append('mode/set', { mode: PLAN_MODE }) + const planSdk = (await withModes.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? '' + expect(planSdk).toBe(defaultSdk) + + // Loading the mode plugin deliberately adds one stable binding compared + // with a deployment that does not compose plan mode at all. const bare = new Context() await bare.plugin(SystemPrompt) await bare.plugin(ToolRegistry, { mode: 'code' }) await bare.plugin(FakeRuntime) registerNamedTools(bare, ['read', 'write']) const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? '' - expect(sdk).toBe(bareSdk) - expect(sdk).not.toContain('exit_plan_mode(args:') + expect(bareSdk).not.toContain('exit_plan_mode(args:') + expect(defaultSdk).not.toBe(bareSdk) }) it('treats a dropped folded definition as the default mode', async () => { @@ -455,7 +506,7 @@ describe('the soft layer', () => { 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']) + expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write']) }) }) @@ -522,6 +573,7 @@ describe('exit_plan_mode', () => { const ctx = await setup() const schema = ctx.tools.schemas().find(entry => entry.name === EXIT_PLAN_MODE) const parameters = schema?.parameters as { required?: string[]; properties?: Record } + expect(schema?.description).toMatch(/^Use only in plan mode\./) expect(Object.keys(parameters.properties ?? {})).toEqual(['plan']) expect(parameters.required).toEqual(['plan']) }) @@ -533,14 +585,26 @@ describe('exit_plan_mode', () => { expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a calling agent (no session to switch)' }]) }) - it('rejects a call outside plan mode (defense in depth behind the gate)', async () => { + it('rejects a call outside plan mode while remaining advertised', async () => { const ctx = await setup() const agent = agentWithSession() + expect(ctx.tools.schemas().map(tool => tool.name)).toContain(EXIT_PLAN_MODE) const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode is only available in plan mode' }]) }) + it('rejects an empty or heading-less plan before asking the reviewer', async () => { + const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] }) + for (const plan of ['', 'do things']) { + const result = await callExit(ctx, agent, plan) + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading' }]) + } + expect(asked).toHaveLength(0) + expect(foldMode(agent.session.events)).toBe(PLAN_MODE) + }) + it('degrades to the manual exit when no user-interaction seam is composed', async () => { const ctx = await setup() const agent = agentWithSession() @@ -572,10 +636,60 @@ describe('exit_plan_mode', () => { expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE) expect(asked).toHaveLength(1) expect(asked[0]?.agent).toBe(agent) + expect(asked[0]?.questions[0]?.detail).toBe('# The plan\n\ndo things') expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning']) }) - it('an approved exit keeps the plan surface until the boundary (same-batch fold holds)', async () => { + it('carries the exact plan through a Code Mode review and logs the nested dispatch', async () => { + const plan = '# Code Mode plan\n\nUse the existing seam.' + class ExitRuntime extends CodeRuntime { + readonly language = 'typescript' + readonly isolation = 'fake' + async run(request: CodeRunRequest): Promise { + const exit = request.bindings[0]?.functions[EXIT_PLAN_MODE] + if (exit === undefined) throw new Error('missing exit_plan_mode binding') + return { logs: [], value: await exit({ plan }) } + } + } + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry, { mode: 'code' }) + await ctx.plugin(ExitRuntime) + await ctx.plugin(ModesService, PLAN_CONFIG) + await ctx.plugin(UserInteractionService) + const asked: AskUserQuestionRequest[] = [] + ctx.userInteraction.registerProvider({ + ask: (request) => { + asked.push(request) + return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] }) + }, + }) + const agent = agentWithSession('code-mode-exit') + agent.session.append('mode/set', { mode: PLAN_MODE }) + + const result = await ctx.tools.execute({ + callId: CallId(`call-exit-${++callCounter}`), + name: RUN_CODE_NAME, + arguments: { code: `return await tools.${EXIT_PLAN_MODE}({ plan: ${JSON.stringify(plan)} })` }, + agent, + }) + + expect(result.isError).toBe(false) + expect(asked).toHaveLength(1) + expect(asked[0]?.questions[0]).toMatchObject({ + header: 'Plan review', + question: 'Approve this plan and leave plan mode?', + detail: plan, + }) + expect(agent.session.events.find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({ + name: EXIT_PLAN_MODE, + arguments: { plan }, + isError: false, + }) + expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE }) + }) + + it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => { const { ctx, agent } = await setupWithReview({ selected: ['Approve'] }) const approved = await callExit(ctx, agent) expect(approved.isError).toBe(false) @@ -585,8 +699,12 @@ describe('exit_plan_mode', () => { expect(foldMode(agent.session.events)).toBe(PLAN_MODE) const assembly = await ctx.systemPrompt.assemble({ agent }) expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true) + expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe(TEST_PLAN_SECTION) await boundary(ctx, agent, 'step/end') expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE) + const afterExit = await ctx.systemPrompt.assemble({ agent }) + expect(afterExit.tools).toEqual(assembly.tools) + expect(afterExit.sections.find(section => section.name === 'mode:policy')?.text).toBe('') }) it('the exit flush narrates nothing — the tool result is the narration', async () => { @@ -598,13 +716,6 @@ describe('exit_plan_mode', () => { expect(noticeTexts(agent.session)).toEqual([]) }) - it('approve with a note carries the note into the confirmation', async () => { - const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'ship it small' }) - const result = await callExit(ctx, agent) - expect(result.isError).toBe(false) - expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step. User note: ship it small' }]) - }) - it('keep planning returns the corrective error carrying the feedback verbatim', async () => { const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'], custom: 'consider the resume path' }) const result = await callExit(ctx, agent) @@ -628,6 +739,36 @@ describe('exit_plan_mode', () => { expect(foldMode(agent.session.events)).toBe(PLAN_MODE) }) + it('requires exactly the single Approve selection', async () => { + const { ctx, agent } = await setupWithReview({ selected: ['Approve', 'Keep planning'] }) + const result = await callExit(ctx, agent) + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) + expect(foldMode(agent.session.events)).toBe(PLAN_MODE) + }) + + it('treats custom text alongside Approve as feedback, not consent', async () => { + const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'change the tests' }) + const result = await callExit(ctx, agent) + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: change the tests' }]) + expect(foldMode(agent.session.events)).toBe(PLAN_MODE) + }) + + it('treats duplicate review answer items as non-consent', async () => { + const { ctx, agent } = await setupWithReview() + ctx.userInteraction.registerProvider({ + ask: () => Promise.resolve({ answers: [ + { id: 'plan-review', selected: ['Approve'] }, + { id: 'plan-review', selected: ['Keep planning'] }, + ] }), + }) + const result = await callExit(ctx, agent) + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) + expect(foldMode(agent.session.events)).toBe(PLAN_MODE) + }) + it('a missing answer item reads as keep-planning', async () => { const { ctx, agent } = await setupWithReview() ctx.userInteraction.registerProvider({ ask: () => Promise.resolve({ answers: [] }) }) @@ -687,3 +828,20 @@ describe('exit_plan_mode', () => { }) }) }) + +describe('HMR disposal', () => { + it('unregisters the service, prompt section, and stable exit tool with the plugin fiber', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + const fiber = await ctx.plugin(ModesService, PLAN_CONFIG) + expect(ctx.get('modes')).toBeInstanceOf(ModesService) + expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined() + expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).toContain('mode:policy') + + await fiber.dispose() + expect(ctx.get('modes')).toBeUndefined() + expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined() + expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('mode:policy') + }) +}) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index f2fcadf0fa..a51886a204 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -145,11 +145,14 @@ function elicitationForQuestion( options: AskUserQuestionOption[], ): CreateElicitationRequest { const title = question.header ?? 'Question' + const message = question.detail === undefined + ? question.question + : `${question.question}\n\n${question.detail}` if (options.length === 0) { return { sessionId, mode: 'form', - message: question.question, + message, requestedSchema: { type: 'object', title, @@ -183,7 +186,7 @@ function elicitationForQuestion( return { sessionId, mode: 'form', - message: question.question, + message, requestedSchema: { type: 'object', title, @@ -504,7 +507,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // --- Stream the harness event taxonomy to ACP session/update -------------- // --- Session modes (dsh-mode, opportunistic) ------------------------------ - // The mode PICKER is dsh-mode's ACP surface (the plan-mode RFC): advertised + // The mode PICKER is dsh-mode's ACP surface (the plan-mode Agent Note): advertised // as `modes` on session/new + session/load, switched via session/set_mode — // optimistic `current_mode_update` (the pending mode IS the user's // selection; the logged `mode/set` follows at the turn boundary) — and diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index e7170c380f..093bc38ca3 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -143,15 +143,18 @@ describe('acp bridge', () => { questions: [{ id: 'language', question: 'Which language?', + detail: 'Choose the implementation language for this project.', options: [{ label: 'TypeScript' }], }], }) expect(result).toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] }) expect(harness.elicitationRequests[0]).toMatchObject({ + message: 'Which language?\n\nChoose the implementation language for this project.', requestedSchema: { properties: { choice: { + title: 'Which language?', description: 'Choose one option, or fill a custom answer below.', oneOf: [{ const: 'TypeScript', title: 'TypeScript' }], }, diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index f29a9d9b34..429deb0b96 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -227,7 +227,7 @@ export async function makeBridgeHarness(options: { await ctx.plugin(ToolTodo) } if (options.withModes) { - await ctx.plugin(ModesService) + await ctx.plugin(ModesService, { modes: { plan: { section: 'Test plan mode instructions.' } } }) } if (options.withFs) { await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir }) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index db696dde2a..39d771948b 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -751,6 +751,10 @@ class QuestionDialog implements Component, Focusable { lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`) } for (const line of wrapTextWithAnsi(this.palette.bold(displayText(this.question.question)), innerWidth)) push(line) + if (this.question.detail !== undefined) { + push('') + for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) + } push('') if (this.mode === 'custom') { for (const line of this.input.render(innerWidth)) push(line) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c0a916e4f0..f18c2df64d 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -731,12 +731,13 @@ describe('TUI user-interaction dialogs', () => { const single = result.ctx.userInteraction.ask({ questions: [{ - id: 'mode', header: 'Mode', question: 'Choose a mode', + id: 'mode', header: 'Mode', question: 'Choose a mode', detail: 'This choice controls the next turn.', options: [{ label: 'Safe', description: 'Use checks' }, { label: 'Fast' }], }], }) await tick() expect(result.terminal.output).toContain('Choose a mode') + expect(result.terminal.output).toContain('This choice controls the next turn.') expect(result.terminal.output).toContain('1/2') result.terminal.send('\x1b[B') result.terminal.send('\r') diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index c026ff0395..153e9a270d 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -11,7 +11,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod ### Key Types -- `AskUserQuestionRequest` — `{ questions: [{ id, question, header?, options?, multiSelect? }], agent?, signal? }`. +- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label. - `AskUserQuestionOption` — `{ label, description? }`. - `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`. - `UserInteractionProvider` — UI implementation with `ask(request)`. diff --git a/packages/ui/user-interaction/src/index.ts b/packages/ui/user-interaction/src/index.ts index f9c1616ade..c904f7e1ed 100644 --- a/packages/ui/user-interaction/src/index.ts +++ b/packages/ui/user-interaction/src/index.ts @@ -31,6 +31,8 @@ export interface AskUserQuestionItem { id: string /** The question to display. */ question: string + /** Optional supporting detail rendered with the question but kept out of option labels. */ + detail?: string /** Optional short heading/group label. */ header?: string /** Optional choices the UI can render as a menu. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a48703c8d..d013b11ccf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1354,7 +1354,7 @@ importers: specifier: workspace:^ version: link:../../ui/user-interaction cordis: - specifier: ^4.0.0-rc.6 + specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/sandbox/sandbox: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 62442e327d..d313def981 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -158,8 +158,8 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'mode', title: 'Session-mode policy state', mode: 'core', - consumers: ['stdio-agent', 'acp'], - 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.', + consumers: ['acp'], + note: 'Folds logged per-agent mode/set state, flushes user flips at turn boundaries, renders deployment-owned guidance, and keeps the plan-exit schema stable across modes.', }, { key: 'skills', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index e15cd31f7b..7ed3f67af2 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -174,10 +174,10 @@ const TOOL_PACKAGES: ToolPackage[] = [ requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.userInteraction (execution time, opportunistic)'], writes: ['tool/call', 'mode/set back to default on an approved review', 'tool/result'], async mount(ctx) { - await ctx.plugin(ModesService) + await ctx.plugin(ModesService, { modes: { plan: { section: 'Tool catalog schema harvest.' } } }) }, note: - 'exit_plan_mode presents the plan for the user\'s review over the user-interaction seam (approve / keep planning with feedback); approval flips the logged session mode back to default at the step boundary (same-batch calls stay plan-gated). The assemble filter shows it only while the folded mode is plan.', + 'exit_plan_mode stays in the model-facing schema in every session mode so transitions add no tool-catalog churn on top of the mode-section change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval flips the logged session mode back to default at the step boundary.', }, { pkg: '@deepseek-ai/dsh-tool-bash',