refactor agent pre-step inbox lifecycle

This commit is contained in:
_Kerman
2026-07-31 19:21:16 +08:00
parent c2ff9ddec8
commit fcc2b5e282
267 changed files with 2052 additions and 1546 deletions

View File

@@ -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 packages/ui/tui/README.md
README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e
README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d
README.md: 3b9153c42885dfb29821ecf452811c1bb15446f2
README.zh.md: 1d6f7bcedcd4fa8b9e3e87df137ba59f8f535666

View File

@@ -20,7 +20,7 @@ Before model output, session events, tool presenters, questions, configuration,
Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed.
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation; both enter the inbox and pass through `agent/pre-step` after the loop claims their batch.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.

View File

@@ -20,7 +20,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应
在 token 边界输入 `@` 会搜索会话工作目录下的文件和目录。没有路径的模糊查询使用可复用的有界工作区索引;包含 `/` 的查询直接列出该目录,选择文件夹后会保持补全开启以继续深入。含空白的路径会插入为 `@"path with spaces"`。选择文件只会插入其路径和一个尾随空格:TUI 不会读取文件、附加隐藏上下文,也不会把路径替换为引用对象。注册模型侧 `read` 工具后,TUI 会添加一条固定系统提示词指令,要求模型在需要显式路径内容时读取该路径。
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`;二者都会进入 inbox,并在循环领取相应批次后经过 `agent/pre-step`。
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。

View File

@@ -1234,8 +1234,7 @@ export function createTuiChat(
return
}
if (agent.status === 'running') {
// Steering is never subject to prompt admission; an attached snapshot
// drains beside it at the same step boundary through the outbox.
// The attached snapshot is claimed beside steering at the same step boundary.
if (attachedContext !== undefined) {
agent.inject(attachedContext)
}
@@ -1249,48 +1248,41 @@ export function createTuiChat(
agent.followup(createUserMessage({ content, source: { kind: 'user' } }))
return
}
// Idle: the snapshot rides the prompt's admission transaction so a
// blocking hook discards both together.
// Idle: the snapshot rides the prompt's pre-step decision so rejection
// discards both together.
let cleanedUp = false
const message: UserMessage = createUserMessage({ content, source: { kind: 'user' } })
const acceptedId = message.id
const discarded = new Set<MessageId>()
const cleanup = (): void => {
// Every completion path detaches both listeners. Keep this
// idempotent so later cleanup paths cannot double-release them.
/* v8 ignore next -- unreachable idempotence guard, see above */
if (cleanedUp) return
cleanedUp = true
detachSubmit()
detachSplice()
detachPreStep()
detachDiscarded()
}
// Prepended so this wrapper is outermost: it observes the exact accepted
// message identity whether a downstream hook allows or blocks, then detaches.
const detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _signal, next) => {
// Prepended so this wrapper is outermost: it observes the exact claimed
// message identity whether downstream enters or rejects, then detaches.
const detachPreStep = ctx.on('agent/pre-step', async (subject, submitted, _context, next) => {
if (subject !== agent || !submitted.some(item => item.id === message.id)) return next()
cleanup()
const decision = await next()
if (decision.kind !== 'allow') return decision
return { ...decision, messages: [...decision.messages, attachedContext] }
if (decision.kind !== 'enter') return decision
return {
...decision,
messages: [...decision.messages, attachedContext],
}
}, { prepend: true })
// Installed before followup(): an inbox observer can synchronously cancel
// Installed before followup(): an inbox observer can synchronously discard
// the inserted message before followup() returns.
const detachSplice = ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'agent/inbox/spliced'
|| event.data.target !== 'next-turn' || event.data.outcome !== 'canceled') return
const removed = agent.inbox.nextTurn.slice(
event.data.start,
event.data.start + (event.data.removedCount ?? 0),
)
for (const item of removed) discarded.add(item.id)
if (discarded.has(acceptedId)) cleanup()
const detachDiscarded = ctx.on('agent/inbox/discarded', (subject, { message: discarded }) => {
if (subject === agent && discarded.id === message.id) cleanup()
})
// followup() accepts any typed input and contains listener failures;
// this guards a future synchronous throw so the wrapper cannot leak.
/* v8 ignore start -- future-proofing guard, see above */
try {
agent.followup(message)
if (discarded.has(acceptedId)) cleanup()
} catch (error: unknown) {
cleanup()
throw error
@@ -1449,7 +1441,7 @@ export function createTuiChat(
if (disposed) return
editor.addToHistory(text)
if (editor.getText() === value) editor.setText('')
// The snapshot travels with the prompt so a blocking admission hook
// The snapshot travels with the prompt so a rejecting pre-step hook
// discards them together — see dispatchMessage's attached-context path.
dispatchMessage(prepared.content, prepared.additionalContext)
}, (error: unknown) => {
@@ -1503,15 +1495,6 @@ export function createTuiChat(
const disposeSessionEvents = ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-step') {
const removed = agent.inbox.nextStep.slice(
event.data.start,
event.data.start + (event.data.removedCount ?? 0),
)
let changed = false
for (const message of removed) changed = pendingSteering.delete(message.id) || changed
if (changed) refreshStatus()
}
if (event.type === 'tool/result') fileSearch.invalidate()
recordEventUsage(tokens, event)
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
@@ -1526,6 +1509,15 @@ export function createTuiChat(
renderEvent(event, { addHistory: false, renderChunks: true })
requestRender()
})
const removePendingSteering = (subject: Agent, message: UserMessage): void => {
if (subject === agent && pendingSteering.delete(message.id)) refreshStatus()
}
const disposeInboxClaimed = ctx.on('agent/inbox/claimed', (subject, { message }) => {
removePendingSteering(subject, message)
})
const disposeInboxDiscarded = ctx.on('agent/inbox/discarded', (subject, { message }) => {
removePendingSteering(subject, message)
})
const disposeStatus = ctx.on('agent/status', (subject, status) => {
if (subject !== agent) return
// Leaving 'running' ends the turn's status line; clear any badge so the
@@ -1566,6 +1558,8 @@ export function createTuiChat(
for (const value of promptValues) value.dispose()
stopBannerReveal()
disposeSessionEvents()
disposeInboxClaimed()
disposeInboxDiscarded()
disposeStatus()
disposeError()
disposeAgent()

View File

@@ -2,6 +2,7 @@ import { createUserMessage, MessageId , createMessage } from '@deepseek-ai/dsh-l
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, {
agentEvents,
Inbox,
type Agent,
type AgentCancelCause,
@@ -187,11 +188,15 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const injected: ContentBlock[][] = []
const injectedOptions: UserMessage[] = []
const cancelled: AgentCancelCause[] = []
const inbox = new Inbox(session, {
inserted: (message) =>{ agentEvents(ctx, agent).emit('agent/inbox/inserted', { message }) },
discarded: (message) =>{ agentEvents(ctx, agent).emit('agent/inbox/discarded', { message }) },
})
const agent: FakeAgent = {
id: sessionId,
options: options.agentOptions ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
session,
inbox: new Inbox(session),
inbox,
status: options.status ?? 'idle',
ctx,
sent,

View File

@@ -24,7 +24,7 @@ class SnapshotAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
// The snapshot rides the prompt's admission: the loop appends the
// The snapshot rides the prompt's pre-step result: the loop appends the
// prompt first, then its additional contexts (the branch-wide ordering
// for plugin-sourced context).
const [prompt, context] = options.messages.slice(-2)

View File

@@ -49,6 +49,10 @@ import {
} from './harness.ts'
import { TestSessionQueryService } from './session-query.ts'
function preStepContext(signal = new AbortController().signal) {
return { turn: 1, step: 1, signal }
}
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
schema: { type: 'null' },
render: () => [],
@@ -1700,7 +1704,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const id = result.agent.steeredIds.shift()
if (id !== undefined) {
const index = result.agent.inbox.nextStep.findIndex(message => message.id === id)
if (index >= 0) result.agent.inbox.splice('next-step', index, 1, [], 'admitted')
if (index >= 0) result.agent.inbox.splice('next-step', index, 1, [])
}
result.session.append('steering/message', {
turn: 1,
@@ -2600,28 +2604,28 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
expect(result.agent.sent).toEqual([[{ type: 'text', text: '@Source chat' }]])
// Idle: the snapshot rides the prompt's admission (additionalContexts on
// the allow decision), not a separate pre-admission inject.
// Idle: the snapshot rides the prompt's pre-step message decision, not a
// separate inject.
expect(result.agent.injected).toHaveLength(0)
const submitted = result.agent.sentMessages[0]!
const decision = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [submitted],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [submitted] }),
'agent/pre-step', [submitted],
preStepContext(),
() => Promise.resolve({ kind: 'enter' as const, messages: [submitted] }),
)
expect(decision.kind).toBe('allow')
expect(decision.kind === 'allow'
expect(decision.kind).toBe('enter')
expect(decision.kind === 'enter'
&& decision.messages.find(message => message.source.kind === 'session-reference')?.source)
.toMatchObject({ kind: 'session-reference', references: [{ sessionId: 'source-session' }] })
// The one-shot wrapper detached itself at admission: replaying the
// The one-shot wrapper detached itself at pre-step: replaying the
// waterfall attaches nothing a second time.
const replay = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [submitted],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [submitted] }),
'agent/pre-step', [submitted],
preStepContext(),
() => Promise.resolve({ kind: 'enter' as const, messages: [submitted] }),
)
expect(replay.kind === 'allow' && replay.messages).toEqual([submitted])
expect(replay.kind === 'enter' && replay.messages).toEqual([submitted])
const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
result.agent.status = 'running'
@@ -2629,12 +2633,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.steered).toHaveLength(1) })
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer @Source chat' }]])
// Steering bypasses admission, so its snapshot still arrives via inject.
// Running steering queues its snapshot through inject at the same boundary.
expect(result.agent.injected).toHaveLength(1)
await dispose(result)
})
it('releases the reference-admission wrapper on the ordinary allowed path', async () => {
it('releases the reference pre-step wrapper on the ordinary enter path', async () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
@@ -2656,16 +2660,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
await send()
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
// Each wrapper releases on its own identified message's allowed admission.
// Running each prompt's admission waterfall detaches its wrapper.
// Each wrapper releases on its own identified message's pre-step decision.
for (const sent of result.agent.sentMessages) {
await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [sent],
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const, messages: [sent] }),
'agent/pre-step', [sent],
preStepContext(), () => Promise.resolve({ kind: 'enter' as const, messages: [sent] }),
)
}
// Both wrappers now gone: a discard naming either prompt's content finds
// no armed listener, and an unrelated admission is untouched. The leak
// no armed listener, and an unrelated pre-step is untouched. The leak
// regression: a listener installed after its cleanup already ran would
// survive every future cleanup.
const unrelatedMessage = createUserMessage({
@@ -2673,19 +2676,19 @@ describe('pi-tui chat lifecycle and transcript', () => {
source: { kind: 'user' },
})
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [unrelatedMessage],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [unrelatedMessage] }),
'agent/pre-step', [unrelatedMessage],
preStepContext(),
() => Promise.resolve({ kind: 'enter' as const, messages: [unrelatedMessage] }),
)
expect(unrelated.kind === 'allow' && unrelated.messages).toEqual([unrelatedMessage])
expect(unrelated.kind === 'enter' && unrelated.messages).toEqual([unrelatedMessage])
// Replaying either sent prompt attaches nothing: the one-shot wrappers
// are gone, not merely spent.
for (const sent of result.agent.sentMessages) {
const replay = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [sent],
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const, messages: [sent] }),
'agent/pre-step', [sent],
preStepContext(), () => Promise.resolve({ kind: 'enter' as const, messages: [sent] }),
)
expect(replay.kind === 'allow' && replay.messages).toEqual([sent])
expect(replay.kind === 'enter' && replay.messages).toEqual([sent])
}
await dispose(result)
})
@@ -2707,7 +2710,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.agent.sent.push(input.content)
result.agent.sentMessages.push(input)
result.agent.inbox.splice('next-turn', 0, 0, [input])
result.agent.inbox.splice('next-turn', 0, 1, [], 'canceled')
result.agent.inbox.splice('next-turn', 0, 1, [])
}
result.terminal.send('@sync-source')
@@ -2718,18 +2721,18 @@ describe('pi-tui chat lifecycle and transcript', () => {
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
// The synchronous discard released the listeners before followup()
// returned the existing id: replaying the prompt's admission attaches no
// returned the existing id: replaying the prompt's pre-step attaches no
// stranded snapshot, and nothing leaks for the TUI lifetime.
const replay = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [result.agent.sentMessages[0]!],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [result.agent.sentMessages[0]!] }),
'agent/pre-step', [result.agent.sentMessages[0]!],
preStepContext(),
() => Promise.resolve({ kind: 'enter' as const, messages: [result.agent.sentMessages[0]!] }),
)
expect(replay.kind === 'allow' && replay.messages).toEqual([result.agent.sentMessages[0]!])
expect(replay.kind === 'enter' && replay.messages).toEqual([result.agent.sentMessages[0]!])
await dispose(result)
})
it('discards the reference snapshot with its blocked or cancelled prompt', async () => {
it('discards the reference snapshot with its rejected or cancelled prompt', async () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
@@ -2739,11 +2742,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
appendUser(source, 'source background')
},
})
// A downstream admission hook blocks the prompt: the attached snapshot
// A downstream pre-step hook rejects the prompt: the attached snapshot
// must be discarded with it, not stranded for the next prompt.
let blockPrompts = true
result.ctx.on('agent/prompt-submit', async (_agent, _message, _signal, next) =>
blockPrompts ? { kind: 'block' as const, reason: 'policy', discardClaimed: true } : next())
result.ctx.on('agent/pre-step', async (_agent, _message, _signal, next) =>
blockPrompts ? { kind: 'reject' as const } : next())
result.terminal.send('@blocked-source')
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · blocked-source') })
@@ -2753,13 +2756,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
const blocked = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [result.agent.sentMessages[0]!],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [result.agent.sentMessages[0]!] }),
'agent/pre-step', [result.agent.sentMessages[0]!],
preStepContext(),
() => Promise.resolve({ kind: 'enter' as const, messages: [result.agent.sentMessages[0]!] }),
)
expect(blocked.kind).toBe('block')
expect(blocked.kind).toBe('reject')
// Nothing entered history and nothing waits for a later prompt: a fresh
// unrelated admission sees no leftover contexts.
// unrelated pre-step sees no leftover contexts.
expect(result.agent.injected).toHaveLength(0)
blockPrompts = false
const unrelatedMessage = createUserMessage({
@@ -2767,14 +2770,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
source: { kind: 'user' },
})
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [unrelatedMessage],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [unrelatedMessage] }),
'agent/pre-step', [unrelatedMessage],
preStepContext(),
() => Promise.resolve({ kind: 'enter' as const, messages: [unrelatedMessage] }),
)
expect(unrelated.kind === 'allow' && unrelated.messages).toEqual([unrelatedMessage])
expect(unrelated.kind === 'enter' && unrelated.messages).toEqual([unrelatedMessage])
// Second referenced prompt, this time dropped by a broad cancel before
// any admission runs: the discard listener releases the wrapper.
// any pre-step runs: the discard listener releases the wrapper.
result.terminal.send('@blocked-source')
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · blocked-source') })
result.terminal.send('\t')
@@ -2787,28 +2790,28 @@ describe('pi-tui chat lifecycle and transcript', () => {
source: { kind: 'user' },
})
const passthrough = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [differentMessage],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [differentMessage] }),
'agent/pre-step', [differentMessage],
preStepContext(),
() => Promise.resolve({ kind: 'enter' as const, messages: [differentMessage] }),
)
expect(passthrough.kind === 'allow' && passthrough.messages).toEqual([differentMessage])
expect(passthrough.kind === 'enter' && passthrough.messages).toEqual([differentMessage])
// Canceling the exact pending message releases its wrapper.
const canceled = result.agent.sentMessages.at(-1)!
result.agent.inbox.splice('next-turn', 0, 0, [canceled])
result.agent.inbox.splice('next-turn', 0, 1, [], 'canceled')
result.agent.inbox.splice('next-turn', 0, 1, [])
const unrelatedDiscard = createUserMessage({
content: [{ type: 'text', text: 'unrelated discard' }],
source: { kind: 'user' },
})
result.agent.inbox.splice('next-turn', 0, 0, [unrelatedDiscard])
result.agent.inbox.splice('next-turn', 0, 1, [], 'canceled')
result.agent.inbox.splice('next-turn', 0, 1, [])
await tick()
const afterDiscard = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [canceled],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [canceled] }),
'agent/pre-step', [canceled],
preStepContext(),
() => Promise.resolve({ kind: 'enter' as const, messages: [canceled] }),
)
expect(afterDiscard.kind === 'allow' && afterDiscard.messages).toEqual([canceled])
expect(afterDiscard.kind === 'enter' && afterDiscard.messages).toEqual([canceled])
await dispose(result)
})
@@ -2961,11 +2964,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
]])
const submitted = result.agent.sentMessages[0]!
const decision = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [submitted],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [submitted] }),
'agent/pre-step', [submitted],
preStepContext(),
() => Promise.resolve({ kind: 'enter' as const, messages: [submitted] }),
)
expect(decision.kind === 'allow'
expect(decision.kind === 'enter'
&& decision.messages.find(message => message.source.kind === 'session-reference')?.source)
.toMatchObject({ references: [{ sessionId: unsafeId }] })
await dispose(result)
@@ -5113,7 +5116,7 @@ describe('terminal mounting', () => {
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
@@ -5138,7 +5141,7 @@ describe('terminal mounting', () => {
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
@@ -5173,14 +5176,14 @@ describe('terminal mounting', () => {
const otherSession = ctx.sessions.create(SessionId('other-session'))
ctx.agents.register({
id: otherSession.id, options: {}, session: otherSession, inbox: new Inbox(otherSession), status: 'idle', ctx,
id: otherSession.id, options: {}, session: otherSession, inbox: new Inbox(otherSession, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
expect(terminal.started).toBe(0)
const session = ctx.sessions.create(SessionId('late-session'))
const agent = {
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
} as Agent
ctx.agents.register(agent)
@@ -5211,7 +5214,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main-session'))
ctx.agents.register({
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
await tick()
@@ -5255,7 +5258,7 @@ describe('terminal mounting', () => {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'running', ctx,
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'running', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()

View File

@@ -7,7 +7,7 @@
import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
@@ -212,7 +212,7 @@ export interface Config {
/**
* Approval service that applies session policy before answerers and logs every
* ask/outcome pair to the requesting session. It exposes deterministic policy
* changes to the model through prompt and pre-step notices.
* changes to the model through prompt-submission notices.
*/
export class ApprovalService extends Service {
static Config: z<Config> = z.object({
@@ -239,18 +239,21 @@ export class ApprovalService extends Service {
})
})
// Visibility layer 2: the boundary narrator. agent/step runs before the
// request history is derived, so the notice is
// seen by THIS step's request: idle-time flip-flops coalesce at the
// turn's first step (net-zero → nothing), and a mid-turn switch is
// narrated no later than the next step. What each session was last told
// is in-memory with a log-derived fallback (the folded header's system
// text), so restarts lose nothing. Attribution is positional: an
// override event after the log's last `request/header` was a runtime
// switch by the user; otherwise the configured default moved under the
// session (operator/config).
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
ctx.on('agent/step', (agent) => {
// Visibility layer 2: pre-step processing narrates a policy delta in the
// exact request whose prompt is being finalized. The last request header
// is authoritative for what the model was told, so a later listener that
// rejects or throws cannot advance narration state. Attribution is
// positional: an override event after the log's last `request/header` was
// a runtime switch by the user; otherwise the configured default moved
// under the session.
ctx.on('agent/pre-step', async (
agent,
_messages,
_signal,
next,
): Promise<PreStepDecision> => {
const decision = await next()
if (decision.kind === 'reject') return decision
const session = agent.session
const events = session.events
let overrideIndex = -1
@@ -269,18 +272,23 @@ export class ApprovalService extends Service {
// for POSITIONAL attribution; the default lives once, in the method.
const current = this.effectivePolicy(session)
const header = session.requestHeader()
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
narrated.set(session, current)
const told = toldApprovalPolicy(header?.system)
// Cold start (nothing ever told) narrates nothing — the section about
// to go out states the truth, and there is no delta to explain.
if (told === undefined || told === current) return
if (told === undefined || told === current) return decision
const cause = overrideSource === 'delegation'
? 'inherited from the delegating session'
: overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
source: { kind: 'plugin', plugin: 'user-approval' },
}), { surfaceOp: 'append' })
return {
...decision,
messages: [
...decision.messages,
createUserMessage({
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
source: { kind: 'plugin', plugin: 'user-approval' },
}),
],
}
})
}

View File

@@ -369,8 +369,26 @@ describe('approval policy (the approval/policy fold)', () => {
return { agent, session }
}
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
const submitPrompt = async (ctx: Context, agent: Agent): Promise<void> => {
const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[],
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
if (decision.kind === 'enter') {
for (const message of decision.messages) {
agent.session.append('user/message', message, { surfaceOp: 'append' })
}
const configured = ctx.get('approval')?.config.policy ?? 'ask'
const current = effectiveApprovalPolicy(agent.session.events) ?? configured
appendHeader(
agent.session,
current === 'never' ? `${NEVER_SENTENCE}\n${NEVER_MARKER}` : ASK_MARKER,
)
}
}
const narrations = (session: Session): string[] => session.events.flatMap(event =>
event.type === 'user/message'
@@ -487,18 +505,18 @@ describe('approval policy (the approval/policy fold)', () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-narr-1')
await preStep(ctx, agent)
await submitPrompt(ctx, agent)
expect(narrations(session)).toEqual([])
setApprovalPolicy(session, 'never')
setApprovalPolicy(session, 'ask')
setApprovalPolicy(session, 'never')
await preStep(ctx, agent)
await submitPrompt(ctx, agent)
expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
await preStep(ctx, agent)
await submitPrompt(ctx, agent)
expect(narrations(session)).toHaveLength(1)
setApprovalPolicy(session, 'ask')
setApprovalPolicy(session, 'never')
await preStep(ctx, agent)
await submitPrompt(ctx, agent)
expect(narrations(session)).toHaveLength(1)
})
@@ -509,10 +527,32 @@ describe('approval policy (the approval/policy fold)', () => {
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-narr-2')
appendHeader(session, `persona\n\n${NEVER_SENTENCE}\n${NEVER_MARKER}`)
await preStep(ctx, agent)
await submitPrompt(ctx, agent)
expect(narrations(session)).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).'])
})
it('retries narration when an outer pre-step listener throws before entry', async () => {
const ctx = new Context()
let fail = true
ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => {
const decision = await next()
if (fail) {
fail = false
throw new Error('outer failure')
}
return decision
})
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-narr-retry')
appendHeader(session, ASK_MARKER)
setApprovalPolicy(session, 'never')
await expect(submitPrompt(ctx, agent)).rejects.toThrow('outer failure')
await submitPrompt(ctx, agent)
expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
})
it('attributes a constructor-seeded policy event to delegation', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
@@ -520,7 +560,7 @@ describe('approval policy (the approval/policy fold)', () => {
appendHeader(session, ASK_MARKER)
session.append('approval/policy', { policy: 'never', source: 'delegation' })
await preStep(ctx, agent)
await submitPrompt(ctx, agent)
expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).'])
})
@@ -530,7 +570,7 @@ describe('approval policy (the approval/policy fold)', () => {
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session } = sessionAgent('sess-narr-3')
appendHeader(session, `persona only\n${ASK_MARKER}`)
await preStep(ctx, agent)
await submitPrompt(ctx, agent)
expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).'])
})
@@ -541,7 +581,7 @@ describe('approval policy (the approval/policy fold)', () => {
appendHeader(session, `persona only\n${ASK_MARKER}`)
setApprovalPolicy(session, 'ask')
appendHeader(session, `persona only\n${ASK_MARKER}`)
await preStep(ctx, agent)
await submitPrompt(ctx, agent)
expect(narrations(session)).toEqual([])
})
@@ -550,7 +590,7 @@ describe('approval policy (the approval/policy fold)', () => {
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-narr-spoof-prose')
appendHeader(session, `persona quotes this warning: ${NEVER_SENTENCE}\n${ASK_MARKER}`)
await preStep(ctx, agent)
await submitPrompt(ctx, agent)
expect(narrations(session)).toEqual([])
})
@@ -559,7 +599,7 @@ describe('approval policy (the approval/policy fold)', () => {
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session } = sessionAgent('sess-narr-unmarked-header')
appendHeader(session, 'legacy persona-only header')
await preStep(ctx, agent)
await submitPrompt(ctx, agent)
expect(narrations(session)).toEqual([])
})
@@ -568,7 +608,7 @@ describe('approval policy (the approval/policy fold)', () => {
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-narr-spoof-marker')
appendHeader(session, `persona quotes ${NEVER_MARKER}\n${ASK_MARKER}`)
await preStep(ctx, agent)
await submitPrompt(ctx, agent)
expect(narrations(session)).toEqual([])
})
@@ -584,7 +624,7 @@ describe('approval policy (the approval/policy fold)', () => {
appendHeader(live.session, `persona\n${ASK_MARKER}`)
setApprovalPolicy(live.session, 'never')
await preStep(ctx, live.agent)
await submitPrompt(ctx, live.agent)
expect(narrations(live.session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
appendHeader(afterDispose.session, `persona\n${ASK_MARKER}`)
@@ -592,7 +632,7 @@ describe('approval policy (the approval/policy fold)', () => {
await fiber.dispose()
expect(await sectionFor()).toBeUndefined()
await preStep(ctx, afterDispose.agent)
await submitPrompt(ctx, afterDispose.agent)
expect(narrations(afterDispose.session)).toEqual([])
})
})