Merge origin/master into feat/plan-mode
This commit is contained in:
@@ -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 [stdio front door](../ui/stdio) exposes `/mode`, the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker. RFC: [plan mode](../../docs/rfc/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 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).
|
||||
|
||||
@@ -10,7 +10,7 @@ The `default` mode is the absence of policy: no section, no extra tool. An agent
|
||||
|
||||
## 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 `request/header` event on the next step (entering plan adds the exit tool at its canonical alphabetical position — a non-tail insertion the append-only tools delta cannot express → the full fallback snapshot; the approved exit removes exactly that tool and the section, a pure removal → one `request/header-delta`).
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -40,28 +40,44 @@ Definitions are validated at load (`resolveConfig`): the built-in `plan` (the sh
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
### System prompt and mode tool
|
||||
|
||||
**What the model sees**: In the default mode, nothing — assemblies are byte-identical to a deployment without this plugin (the always-registered `exit_plan_mode` tool is filtered from the wire and the Code Mode SDK). In a non-default mode, the mode's `section` renders as the `mode:policy` section (order 50) and, in plan mode, the `exit_plan_mode` tool joins the toolset — nothing else changes. A mode flip mid-session appends one coalesced `context/message` notice when the last header disagrees.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Zero in the default mode. In plan mode, the section text plus one tool schema per request; every mode transition changes the logged header and therefore resets the provider prefix cache.
|
||||
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.
|
||||
|
||||
#### Plan-mode policy section
|
||||
##### Plan-mode policy section
|
||||
|
||||
```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.
|
||||
```
|
||||
|
||||
#### 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.
|
||||
|
||||
#### 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 `exit_plan_mode` call carries the full plan markdown as its argument (retained in context as ordinary tool args); its result is one short confirmation or the reviewer's feedback verbatim.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: The plan text is paid once as tool args and stays in the conversation; a keep-planning round adds one feedback-sized result per revision.
|
||||
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.
|
||||
|
||||
## 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 RFC](../../../docs/rfc/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 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`.
|
||||
|
||||
RFC: [plan mode](../../../docs/rfc/implemented/feature/2026-07-07-plan-mode.md).
|
||||
Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md).
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
* told the model appends one coalesced `context/message` notice in the same
|
||||
* frame.
|
||||
*
|
||||
* RFC: docs/rfc/implemented/feature/2026-07-07-plan-mode.md
|
||||
* Agent Note: .agents/notes/implemented/feature/2026-07-07-plan-mode.md
|
||||
*
|
||||
* @module @deepseek-ai/dsh-mode
|
||||
*/
|
||||
@@ -200,7 +200,7 @@ function modeAtLastHeader(events: readonly SessionEvent[]): string | undefined {
|
||||
let lastHeader = -1
|
||||
let index = 0
|
||||
for (const event of events) {
|
||||
if (event.type === 'request/header' || event.type === 'request/header-delta') lastHeader = index
|
||||
if (event.type === 'request/header') lastHeader = index
|
||||
index++
|
||||
}
|
||||
if (lastHeader < 0) return undefined
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
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'
|
||||
|
||||
@@ -14,7 +14,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
|
||||
* Full-loop integration: a scripted mock model drives the REAL mode plugin
|
||||
* through the agent loop — the pending-intent flush at the turn boundary, the
|
||||
* assembly the soft layer shapes (the exit tool + mode section), and the
|
||||
* `request/header`/`request/header-delta` trail every transition leaves.
|
||||
* `request/header` snapshots every transition leaves.
|
||||
* Only the model is mocked; the loop, the session log, and the plugin are
|
||||
* real.
|
||||
*/
|
||||
@@ -39,7 +39,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -69,7 +69,7 @@ describe('plan mode through the agent loop', () => {
|
||||
textResponse('Noted in the plan.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('it-plan-seed'), { model: 'mock', mode: PLAN_MODE })
|
||||
const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock', mode: PLAN_MODE })
|
||||
|
||||
agent.send([{ type: 'text', text: 'explore the repo' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -91,13 +91,13 @@ 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 plan-shaped fallback header', async () => {
|
||||
it('a user flip between turns lands at the boundary: one notice and the changed plan header', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('First turn, default mode.'),
|
||||
textResponse('Second turn, plan mode.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('it-plan-flip'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'hello' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -114,13 +114,10 @@ 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.' },
|
||||
])
|
||||
// The header change is logged as a FULL fallback snapshot, not a delta:
|
||||
// the tools delta can only APPEND additions, while the canonical
|
||||
// (alphabetical) order places exit_plan_mode before read/write — a
|
||||
// non-tail insertion the writer's round-trip guard rejects, so it
|
||||
// falls back.
|
||||
// Entering plan changes both the section and tool catalog, so the next
|
||||
// request logs a complete changed header.
|
||||
const second = findEvent(log, 'request/header', 'last')
|
||||
expect(second.data.reason).toBe('fallback')
|
||||
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.system).toContain('plan mode')
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
|
||||
@@ -21,7 +21,7 @@ import type { ModeConfig } from '../src/index.ts'
|
||||
|
||||
function agentWithSession(id = 'agent-1', options: { mode?: string } = {}): Agent & { session: Session } {
|
||||
const session = new Session(SessionId(id))
|
||||
return { id: AgentId(id), session, options } as unknown as Agent & { session: Session }
|
||||
return { id: SessionId(id), session, options } as unknown as Agent & { session: Session }
|
||||
}
|
||||
|
||||
async function setup(config?: ModeConfig): Promise<Context> {
|
||||
@@ -51,7 +51,7 @@ async function boundary(ctx: Context, agent: Agent & { session: Session }, type:
|
||||
|
||||
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
|
||||
function header(session: Session): void {
|
||||
session.append('request/header', { header: { config: { model: 'test-model' } }, reason: 'initial' })
|
||||
session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'initial' })
|
||||
}
|
||||
|
||||
function noticeTexts(session: Session): string[] {
|
||||
|
||||
Reference in New Issue
Block a user