Merge origin/master (agent-scope runtime #224, session-query, run-gates CI harness)

Conflict resolution beyond line merges: the ACP bridge's mode/set
re-notify moved inside master's new containment finally (a throwing
presenter must not desync the picker), and session/new keeps the modes
advertisement beside master's plain object return. dsh-mode's boundary
flush moved off the session/event feed onto the loop's interception
seams (agent/prompt-submit / agent/turn-continuation): master's scoped
dispatch made post-commit session observers observe-only — an append
from one re-enters the publishing append — while the two waterfalls
fire outside any log emit and bracket the same boundaries; mode.spec's
boundary helper now dispatches those seams via agentEvents, and the
containment tests stub only the mode/set append. Catalogs, doc graphs,
and the module graph regenerated.
This commit is contained in:
kingwl
2026-07-13 15:12:11 +08:00
244 changed files with 14613 additions and 4835 deletions

View File

@@ -18,7 +18,7 @@ The `default` mode is the absence of policy: no section, no cap. An agent that n
## `ctx.modes`
`list()` returns the selectable vocabulary (`default` first, then the configured definitions); `get(agent)` returns the folded mode (a folded name the config no longer defines reads as `default`) plus any pending intent; `set(agent, mode)` validates against `list()` (loud on unknown; `default` is always a valid target) and records a pending intent — every session event is turn-enclosed and an idle agent has no open turn, so the service flushes the intent at the next `turn/start`/`step/end` and, when the flushed mode differs from what the last logged request header told the model, appends one coalesced `context/message` notice in the same frame. A net-zero flip sequence appends nothing.
`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.
`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.

View File

@@ -20,10 +20,14 @@
*
* User flips go through {@link ModesService.set}: every session event is
* turn-enclosed and an idle agent has no open turn, so `set()` records a
* pending intent and the service flushes it at the next boundary
* (`turn/start` / `step/end` — both outside the step's tool-execution window).
* A flush that changes what the last logged request header told the model
* appends one coalesced `context/message` notice in the same frame.
* pending intent and the service flushes it on the loop's interception seams
* — `agent/prompt-submit` (inside the just-opened turn, before its first
* assembly) and `agent/turn-continuation` (after each step closed, before the
* next assembly) — both outside the step's tool-execution window and outside
* any log emit (post-commit `session/event` observers are observe-only and
* cannot append). A flush that changes what the last logged request header
* told the model appends one coalesced `context/message` notice in the same
* frame.
*
* RFC: docs/rfc/implemented/feature/2026-07-07-plan-mode.md
*
@@ -270,15 +274,32 @@ export class ModesService extends Service {
super(ctx, 'modes')
this.resolved = resolveConfig(config)
ctx.on('session/event', (session, event) => {
if (event.type !== 'turn/start' && event.type !== 'step/end') return
// Boundary flushes ride the loop's interception seams, NOT the
// `session/event` feed: post-commit session observers are observe-only
// (an append from one would re-enter the publishing append and be
// contained away), while these two waterfalls fire OUTSIDE any log emit
// and bracket exactly the boundaries the flush wants — prompt-submit
// inside the just-opened turn before its first assembly, and
// turn-continuation after each step closed before the next assembly (or
// the turn's end), so a flushed mode always lands before the prompt that
// should reflect it. Contained: a policy plugin must never block a
// prompt or a turn; the only throw path in onBoundary is session.append
// rejecting mid-teardown.
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
try {
this.onBoundary(session, event.type === 'turn/start')
this.onBoundary(agent.session, true)
} catch (error) {
// Contained (a policy plugin must never kill the session feed): the only
// throw path in onBoundary is session.append rejecting mid-teardown.
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
return next()
})
ctx.on('agent/turn-continuation', (agent, _turn, _decision, next) => {
try {
this.onBoundary(agent.session, false)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
return next()
})
ctx.on('agent/created', (agent) => {
@@ -508,11 +529,14 @@ export class ModesService extends Service {
}
/**
* One turn-boundary pass: narrate a folded mode the config dropped (once per
* name, turn starts only), then flush the pending intent — append the
* One boundary pass (`turnStart` = a prompt-submit flush, else a
* turn-continuation flush): narrate a folded mode the config dropped (once
* per name, turn starts only), then flush the pending intent — append the
* `mode/set` (skipped when the fold already matches: a net-zero flip
* sequence) and the one coalesced notice when the flushed mode differs from
* what the last logged request header told the model.
* what the last logged request header told the model. Idempotent per
* boundary, so the per-message prompt-submit dispatches of one batch flush
* once.
*/
private onBoundary(session: Session, turnStart: boolean): void {
if (turnStart) this.noticeDroppedDefinition(session)

View File

@@ -4,8 +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 type { SessionEvent } from '@deepseek-ai/dsh-session'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import { AgentId, 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 { BashExecutor, setSandboxMode } from '@deepseek-ai/dsh-bash'
@@ -17,8 +16,9 @@ import type { ModeConfig } from '../src/index.ts'
* Drives the REAL plugin: mounts `dsh-mode` beside real `SystemPrompt` and
* `ToolRegistry` services, with fake Agents carrying real `Session`s (the
* tool-todo test shape). Turn boundaries are simulated by appending the real
* boundary events and emitting `session/event` by hand — exactly the feed the
* store wires in production.
* boundary events and dispatching the interception seams the loop fires there
* (`agent/prompt-submit` / `agent/turn-continuation`) — exactly the seams the
* flush rides in production.
*/
function agentWithSession(id = 'agent-1', options: { mode?: string } = {}): Agent & { session: Session } {
@@ -34,12 +34,21 @@ async function setup(config?: ModeConfig): Promise<Context> {
return ctx
}
/** Append a boundary event and hand-emit the `session/event` feed the store would. */
function boundary(ctx: Context, session: Session, type: 'turn/start' | 'step/end'): void {
const event = type === 'turn/start'
? session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
: session.append('step/end', { turn: 1, step: 1 })
ctx.emit('session/event', session, event as SessionEvent)
/**
* Append a boundary event and dispatch the interception seam the loop fires
* there — `agent/prompt-submit` inside the just-opened turn,
* `agent/turn-continuation` after the step closed — exactly the seams the
* flush rides (post-commit `session/event` observers are observe-only).
*/
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> {
const events = agentEvents(ctx, agent)
if (type === 'turn/start') {
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await events.waterfall('agent/prompt-submit', [{ type: 'text', text: 'boundary probe' }], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' }))
return
}
agent.session.append('step/end', { turn: 1, step: 1 })
await events.waterfall('agent/turn-continuation', 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' }))
}
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
@@ -189,7 +198,7 @@ describe('the boundary flush', () => {
const ctx = await setup()
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
boundary(ctx, agent.session, 'turn/start')
await boundary(ctx, agent, 'turn/start')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
})
@@ -198,7 +207,7 @@ describe('the boundary flush', () => {
const ctx = await setup()
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
boundary(ctx, agent.session, 'step/end')
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
@@ -207,7 +216,7 @@ describe('the boundary flush', () => {
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
ctx.modes.set(agent, DEFAULT_MODE)
boundary(ctx, agent.session, 'turn/start')
await boundary(ctx, agent, 'turn/start')
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -216,7 +225,7 @@ describe('the boundary flush', () => {
const ctx = await setup()
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
boundary(ctx, agent.session, 'turn/start')
await boundary(ctx, agent, 'turn/start')
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -225,9 +234,9 @@ describe('the boundary flush', () => {
const agent = agentWithSession()
header(agent.session)
ctx.modes.set(agent, PLAN_MODE)
boundary(ctx, agent.session, 'turn/start')
await boundary(ctx, agent, 'turn/start')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
boundary(ctx, agent.session, 'step/end')
await boundary(ctx, agent, 'step/end')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
})
@@ -237,7 +246,7 @@ describe('the boundary flush', () => {
agent.session.append('mode/set', { mode: PLAN_MODE })
header(agent.session)
ctx.modes.set(agent, DEFAULT_MODE)
boundary(ctx, agent.session, 'step/end')
await boundary(ctx, agent, 'step/end')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
})
@@ -248,7 +257,7 @@ describe('the boundary flush', () => {
header(agent.session)
agent.session.append('mode/set', { mode: DEFAULT_MODE })
ctx.modes.set(agent, PLAN_MODE)
boundary(ctx, agent.session, 'step/end')
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -257,47 +266,55 @@ describe('the boundary flush', () => {
const ctx = await setup()
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
boundary(ctx, agent.session, 'turn/start')
boundary(ctx, agent.session, 'turn/start')
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.',
])
boundary(ctx, agent.session, 'step/end')
await boundary(ctx, agent, 'step/end')
expect(noticeTexts(agent.session)).toHaveLength(1)
})
it('ignores non-boundary session events on the feed', async () => {
const ctx = await setup()
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
const event = agent.session.append('mode/set', { mode: DEFAULT_MODE })
ctx.emit('session/event', agent.session, event as SessionEvent)
expect(ctx.modes.get(agent).pending).toBe(PLAN_MODE)
})
it('contains an append failure instead of killing the session feed', async () => {
it('contains an append failure instead of blocking the prompt or the turn', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
const original = agent.session.append.bind(agent.session)
agent.session.append = (() => { throw new Error('backend gone') })
expect(() => { boundary({ emit: ctx.emit.bind(ctx) } as never as Context, agent.session, 'step/end') }).toThrow('backend gone')
agent.session.append = original
const event = agent.session.append('step/end', { turn: 1, step: 2 })
agent.session.append = (() => { throw new Error('backend gone') })
ctx.emit('session/event', agent.session, event as SessionEvent)
// Only the flush's own mode/set append fails; the boundary event itself
// lands (the loop appended it before the seam fires).
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'mode/set') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'step/end')
expect(warn).toHaveBeenCalledOnce()
// The failed flush re-parks the intent (cleared only after a landed
// append), so the next healthy boundary converges the log with the
// picker's optimistic state instead of dropping the switch forever.
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
agent.session.append = original
boundary(ctx, agent.session, 'step/end')
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent).pending).toBeUndefined()
})
it('contains an append failure on the prompt-submit seam the same way', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'mode/set') 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(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
})
})
describe('the soft layer', () => {
@@ -571,7 +588,7 @@ describe('exit_plan_mode', () => {
// step's end, so the plan policy covers any remaining call of the SAME batch.
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE })
boundary(ctx, agent.session, 'step/end')
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
expect(asked).toHaveLength(1)
expect(asked[0]?.agent).toBe(agent)
@@ -587,7 +604,7 @@ describe('exit_plan_mode', () => {
// requested under the plan-shaped header — the read-only clamp must
// still hold for it; the boundary flush is what widens the next step.
expect(await ctx.bash.resolveMode(agent.session)).toBe('read-only')
boundary(ctx, agent.session, 'step/end')
await boundary(ctx, agent, 'step/end')
expect(await ctx.bash.resolveMode(agent.session)).toBe('workspace-write')
})
@@ -595,7 +612,7 @@ describe('exit_plan_mode', () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
header(agent.session)
await callExit(ctx, agent)
boundary(ctx, agent.session, 'step/end')
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
expect(noticeTexts(agent.session)).toEqual([])
})