From c2ff9ddec89c7f89f52532001f3f97da5843728c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 31 Jul 2026 14:35:58 +0800 Subject: [PATCH] fix(agent-loop): scope blocked admission cleanup --- docs/core-data-structures/core.md | 9 +- docs/core-data-structures/core.zh.md | 9 +- packages/acp/acp/tests/turns.spec.ts | 8 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 6 +- .../agent-loop/tests/interception.spec.ts | 113 ++++++++++++++---- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/types.ts | 7 +- packages/examples/cli-demo/tests/cli.spec.ts | 8 +- packages/goal/goal-session/src/index.ts | 4 +- .../goal-session/tests/goal-session.spec.ts | 13 +- packages/hooks/hooks-claude/src/index.ts | 6 +- .../hooks-claude/tests/coverage-cases.ts | 6 +- packages/hooks/hooks-codex/src/index.ts | 8 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 2 +- 18 files changed, 162 insertions(+), 51 deletions(-) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1a0f6d0cc7..85bf93c42b 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -595,17 +595,18 @@ Prompt decisions use the same identified `UserMessage` shape as durable user-rol Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/prompt-submit` returns a `PromptDecision` before a turn opens. Allow supplies the complete admitted batch; block rejects admission without creating turn events and may leave the claimed messages pending: +`agent/prompt-submit` returns a `PromptDecision` before a turn opens. Allow supplies the complete admitted batch; block rejects admission without creating turn events and must choose whether to discard the claimed messages. Messages not claimed by that admission remain pending: ```ts type-equiv /** * Prompt interception result. An allowed batch replaces the submitted - * messages. A listener wrapping `next()` preserves the returned batch unless - * it intentionally replaces it. + * messages; a listener wrapping `next()` preserves that batch unless it + * intentionally replaces it. A blocked batch explicitly chooses whether to + * discard the claimed messages; unclaimed work remains pending. */ type PromptDecision = | { kind: 'allow'; messages: UserMessage[] } - | { kind: 'block'; reason: string; keepInbox?: boolean } + | { kind: 'block'; reason: string; discardClaimed: boolean } ``` `agent/request-error` runs after a failed model step closes and before its turn closes. Listeners can repair durable state or await policy work while the failed turn's signal is still live. A handling listener returns `{ kind: 'retry' }` without calling `next()`; the default `undefined` leaves the failure terminal. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 28d1b57f48..0453d62a0b 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -603,17 +603,18 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/prompt-submit` 在轮次打开前返回 `PromptDecision`。allow 提供完整的准入批次;block 拒绝准入且不产生任何轮次事件,并可以让已领取的消息保持待处理: +`agent/prompt-submit` 在轮次打开前返回 `PromptDecision`。allow 提供完整的准入批次;block 拒绝准入且不产生任何轮次事件,并且必须选择是否丢弃已领取的消息。未被此次接纳领取的消息会继续保持待处理: ```ts type-equiv /** * Prompt interception result. An allowed batch replaces the submitted - * messages. A listener wrapping `next()` preserves the returned batch unless - * it intentionally replaces it. + * messages; a listener wrapping `next()` preserves that batch unless it + * intentionally replaces it. A blocked batch explicitly chooses whether to + * discard the claimed messages; unclaimed work remains pending. */ type PromptDecision = | { kind: 'allow'; messages: UserMessage[] } - | { kind: 'block'; reason: string; keepInbox?: boolean } + | { kind: 'block'; reason: string; discardClaimed: boolean } ``` `agent/request-error` 在失败的模型步骤关闭之后、其轮次关闭之前运行。listener 可以在失败轮次的 signal 仍然存活时修复持久状态或 await 策略工作。处理该错误的 listener 返回 `{ kind: 'retry' }` 且不调用 `next()`;默认的 `undefined` 会让失败保持终态。 diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 65305ba672..9aa8acb76e 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -214,7 +214,11 @@ describe('ACP prompt lifecycle', () => { it('an admission-blocked prompt settles instead of hanging', async () => { harness = await makeBridgeHarness({ script: [] }) - harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy said no' })) + harness.ctx.on('agent/prompt-submit', async () => ({ + kind: 'block' as const, + reason: 'policy said no', + discardClaimed: true, + })) const sessionId = await newSession(harness) await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) .resolves.toEqual({ stopReason: 'end_turn' }) @@ -227,7 +231,7 @@ describe('ACP prompt lifecycle', () => { harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'defer forever', - keepInbox: true, + discardClaimed: false, })) const sessionId = await newSession(harness) const agent = harness.ctx.agents.get(SessionId(sessionId))! diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 8dee1ed49f..39622bc584 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -55,7 +55,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. +The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A block's mandatory `discardClaimed` controls only its submitted batch; later next-step input and queued prompts remain pending for a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 5d69c5c28f..232b15d91f 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -55,7 +55,7 @@ interface Config { 实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 -统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 +统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。block 必须通过 `discardClaimed` 选择是否丢弃本次提交的批次;之后到达的 next-step 输入和排队提示词会继续保持待处理,等待后续获准的提示词。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index aab8e2610b..ee096cd75c 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -176,7 +176,11 @@ export class ReactLoopAgent implements Agent { if (queued !== undefined) this.inbox.splice('next-turn', 0, 1, [], 'admitted') return { kind: 'admitted', messages: decision.messages } } - this.cancel({ kind: 'hook', reason: decision.reason }, { keepInbox: decision.keepInbox }) + if (decision.discardClaimed) { + this.inbox.splice('next-step', 0, outboxLength, [], 'canceled') + if (queued !== undefined) this.inbox.splice('next-turn', 0, 1, [], 'canceled') + } + this.cancel({ kind: 'hook', reason: decision.reason }, { keepInbox: true }) return { kind: 'blocked' } } diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 6998ccb7f4..c37779e8fb 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -212,7 +212,7 @@ describe('agent/prompt-submit', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => - ({ kind: 'block', reason: 'blocked by policy' })) + ({ kind: 'block', reason: 'blocked by policy', discardClaimed: true })) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -230,6 +230,26 @@ describe('agent/prompt-submit', () => { expect(reasons).toEqual([]) }) + it('block can retain the claimed prompt without opening a turn', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('retained-claim'), { provider: 'mock', model: 'mock' }) + + ctx.on('agent/prompt-submit', async (): Promise => ({ + kind: 'block', + reason: 'try later', + discardClaimed: false, + })) + + send(agent, 'retained') + await agent.whenIdle() + + expect(agent.inbox.nextTurn.map(message => message.content[0])) + .toEqual([{ type: 'text', text: 'retained' }]) + expect(events(agent).some(event => event.type === 'turn/start')).toBe(false) + expect(adapter.requests).toEqual([]) + }) + it('stages inject and steer during admission for the admitted turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -291,7 +311,7 @@ describe('agent/prompt-submit', () => { expect(nextRequest).toContain('admission steering') }) - it('cancels admission-time input when admission is blocked', async () => { + it('preserves input staged after the blocked batch was claimed', async () => { const adapter = new MockAdapter([textResponse('retried')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' }) @@ -310,10 +330,14 @@ describe('agent/prompt-submit', () => { source: { kind: 'plugin', plugin: 'test' }, })) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } })) - decision.resolve({ kind: 'block', reason: 'policy' }) + decision.resolve({ kind: 'block', reason: 'policy', discardClaimed: true }) await blockedIdle - expect(agent.inbox.nextStep).toHaveLength(0) + expect(agent.inbox.nextStep.map(message => message.content[0])) + .toEqual([ + { type: 'text', text: 'staged context' }, + { type: 'text', text: 'staged steering' }, + ]) expect(events(agent).some(event => event.type === 'turn/start')).toBe(false) expect(adapter.requests).toEqual([]) @@ -323,14 +347,21 @@ describe('agent/prompt-submit', () => { const staged = events(agent).filter(event => event.type === 'user/message' || event.type === 'steering/message') - expect(staged.map(event => event.type)).toEqual(['user/message']) + expect(staged.map(event => event.type)).toEqual([ + 'user/message', + 'user/message', + 'user/message', + ]) expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt') - expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('staged context') - expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('staged steering') + expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged context') + expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering') }) - it('cancels later queued work when an admission is blocked', async () => { - const adapter = new MockAdapter([textResponse('continued')]) + it('preserves later queued work when an admission is blocked', async () => { + const adapter = new MockAdapter([ + textResponse('continued'), + textResponse('wake reply'), + ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('rejected-admission-order'), { provider: 'mock', @@ -340,7 +371,7 @@ describe('agent/prompt-submit', () => { const decision = await next() return messages.some(message => message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) - ? { kind: 'block', reason: 'policy' } + ? { kind: 'block', reason: 'policy', discardClaimed: true } : decision }) ctx.on('agent/prompt-submit', async (subject, messages, _signal, next) => { @@ -364,17 +395,32 @@ describe('agent/prompt-submit', () => { await idle expect(events(agent).some(event => event.type === 'turn/start')).toBe(false) - expect(agent.inbox.hasPending).toBe(false) + expect(agent.inbox.nextStep.map(message => message.content[0])) + .toEqual([ + { type: 'text', text: 'earlier state change' }, + { type: 'text', text: 'earlier steering' }, + ]) + expect(agent.inbox.nextTurn.map(message => message.content[0])) + .toEqual([{ type: 'text', text: 'later prompt' }]) expect(adapter.requests).toEqual([]) + + const resumed = waitForIdle(ctx, agent) + send(agent, 'wake') + await resumed + const request = JSON.stringify(adapter.requests[0]?.messages) + expect(request).toContain('earlier state change') + expect(request).toContain('earlier steering') + expect(request).toContain('later prompt') + expect(request).not.toContain('blocked prompt') }) - it('cancels context-only injection when admission closes without a turn', async () => { - const adapter = new MockAdapter([]) + it('preserves context-only injection staged after admission began', async () => { + const adapter = new MockAdapter([textResponse('continued')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('blocked-admission-context'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const decision = Promise.withResolvers() - ctx.on('agent/prompt-submit', async () => { + const disposeBlock = ctx.on('agent/prompt-submit', async () => { entered.resolve(undefined) return decision.promise }) @@ -386,13 +432,21 @@ describe('agent/prompt-submit', () => { content: [{ type: 'text', text: 'independent context' }], source: { kind: 'plugin', plugin: 'test' }, })) - decision.resolve({ kind: 'block', reason: 'policy' }) + decision.resolve({ kind: 'block', reason: 'policy', discardClaimed: true }) await idle const log = events(agent) expect(log.some(event => event.type === 'user/message')).toBe(false) - expect(agent.inbox.hasPending).toBe(false) + expect(agent.inbox.nextStep.map(message => message.content[0])) + .toEqual([{ type: 'text', text: 'independent context' }]) expect(adapter.requests).toEqual([]) + + disposeBlock() + const resumed = waitForIdle(ctx, agent) + send(agent, 'wake') + await resumed + expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('independent context') + expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt') }) it('leaves inbox state unchanged when its durable append fails', async () => { @@ -414,15 +468,20 @@ describe('agent/prompt-submit', () => { expect(agent.status).toBe('idle') }) - it('a blocked prompt cancels adjacent queued prompts', async () => { - const adapter = new MockAdapter([textResponse('ran once')]) + it('a blocked prompt preserves adjacent queued prompts', async () => { + const adapter = new MockAdapter([ + textResponse('safe reply'), + textResponse('wake reply'), + ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') - return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next() + return text === 'secret' + ? { kind: 'block', reason: 'policy: no secrets', discardClaimed: true } + : next() }) const reasons: TurnEndReason[] = [] @@ -437,6 +496,14 @@ describe('agent/prompt-submit', () => { expect(adapter.requests).toHaveLength(0) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(0) expect(reasons).toEqual([]) + expect(agent.inbox.nextTurn.map(message => message.content[0])) + .toEqual([{ type: 'text', text: 'safe' }]) + + const resumed = waitForIdle(ctx, agent) + send(agent, 'wake') + await resumed + expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('safe') + expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('secret') }) it('a throwing prompt-submit listener reports the driver error and retains adjacent work', async () => { @@ -653,7 +720,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') - if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' } + if (text.includes('rm -rf')) { + return { + kind: 'block', + reason: 'destructive prompt blocked', + discardClaimed: true, + } + } return next() }) // 3. PreToolUse: deny a dangerous tool by name. diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index b21ec0dc49..69b1724fdf 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -52,7 +52,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. -`PromptDecision.allow.messages` is the complete identified, frozen batch admitted by prompt interception. A listener that wraps a downstream allow preserves that batch unless it intentionally replaces it. +`PromptDecision.allow.messages` is the complete identified, frozen batch admitted by prompt interception. A listener that wraps a downstream allow preserves that batch unless it intentionally replaces it. A block must choose `discardClaimed`; this affects only the submitted batch, while messages not claimed by that admission remain pending. Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 2cf6960310..8517fcfa41 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -52,7 +52,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 大多数拦截点都是协作式 waterfall(瀑布式事件)。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 -`PromptDecision.allow.messages` 是提示词拦截所准入的完整、带标识且冻结的批次。包装下游 allow 的监听器会保留该批次,除非有意替换它。 +`PromptDecision.allow.messages` 是提示词拦截所准入的完整、带标识且冻结的批次。包装下游 allow 的监听器会保留该批次,除非有意替换它。block 必须指定 `discardClaimed`;该字段仅影响本次提交的批次,未被此次接纳认领的消息会继续保持待处理。 轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。 diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 456a7f95fe..33f6c9ca26 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -50,12 +50,13 @@ export type AgentStatus = 'idle' | 'running' /** * Prompt interception result. An allowed batch replaces the submitted - * messages. A listener wrapping `next()` preserves the returned batch unless - * it intentionally replaces it. + * messages; a listener wrapping `next()` preserves that batch unless it + * intentionally replaces it. A blocked batch explicitly chooses whether to + * discard the claimed messages; unclaimed work remains pending. */ export type PromptDecision = | { kind: 'allow'; messages: UserMessage[] } - | { kind: 'block'; reason: string; keepInbox?: boolean } + | { kind: 'block'; reason: string; discardClaimed: boolean } /** One failed model-request attempt presented to recovery listeners. */ export interface RequestFailureContext { diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index ff1916fe42..5c162e0fdd 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -448,14 +448,18 @@ describe('runOneShot and executeCli', () => { it('settles blocked tasks at whole-agent idle without attributing a result', async () => { const blocked = await harness([]) - blocked.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'denied' })) + blocked.ctx.on('agent/prompt-submit', async () => ({ + kind: 'block' as const, + reason: 'denied', + discardClaimed: true, + })) await expect(runOneShot(blocked.ctx, { task: 'task' })).resolves.toMatchObject({ output: '' }) const retained = await harness([]) retained.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'deferred', - keepInbox: true, + discardClaimed: false, })) await expect(runOneShot(retained.ctx, { task: 'task' })).resolves.toMatchObject({ output: '' }) expect(retained.agent.status).toBe('idle') diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index c65f317657..5a32195213 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -350,7 +350,7 @@ export function apply(ctx: Context): void { cancelReservation(agent, attempt) } requestDrive(state) - return { kind: 'block', reason: STALE_ROUND_REASON, keepInbox: true } + return { kind: 'block', reason: STALE_ROUND_REASON, discardClaimed: false } } let decision: PromptDecision try { @@ -398,7 +398,7 @@ export function apply(ctx: Context): void { cancelReservation(agent, attempt) } requestDrive(state) - return { kind: 'block', reason: STALE_ROUND_REASON, keepInbox: true } + return { kind: 'block', reason: STALE_ROUND_REASON, discardClaimed: false } } return decision }) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index a0bc6defaf..6226372d13 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -238,7 +238,7 @@ describe('same-session goal driving', () => { it('maps a downstream prompt veto to blocked without admitting the round', async () => { const test = await harness([]) test.ctx.on('agent/prompt-submit', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' - ? Promise.resolve({ kind: 'block', reason: 'deployment policy' }) + ? Promise.resolve({ kind: 'block', reason: 'deployment policy', discardClaimed: true }) : next()) test.ctx.goals.create(test.agent, { objective: 'respect policy' }) @@ -253,7 +253,7 @@ describe('same-session goal driving', () => { it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => { const test = await harness([textResponse('human follow-up')]) test.ctx.on('agent/prompt-submit', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' - ? Promise.resolve({ kind: 'block', reason: 'stop this round' }) + ? Promise.resolve({ kind: 'block', reason: 'stop this round', discardClaimed: true }) : next()) test.ctx.on('goal/changed', (agent, change) => { if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } })) @@ -264,7 +264,8 @@ describe('same-session goal driving', () => { await test.agent.whenIdle() expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.inbox.nextTurn).toHaveLength(0) + expect(test.agent.inbox.nextTurn.map(message => message.content[0])) + .toEqual([{ type: 'text', text: 'inspect the blocker' }]) }) it('pauses and drops a reserved round when cancellation lands before admission', async () => { @@ -884,7 +885,11 @@ describe('same-session goal driving', () => { if (messages[0]?.source.kind === 'goal' && !vetoed) { vetoed = true agent.cancel({ kind: 'user' }) - return Promise.resolve({ kind: 'block', reason: 'cancelled by policy' }) + return Promise.resolve({ + kind: 'block', + reason: 'cancelled by policy', + discardClaimed: true, + }) } return next() }) diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index bd3d979a14..e16c27dac5 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -222,7 +222,11 @@ export function apply(ctx: Context, config: Config): void { const content = messages.flatMap(message => message.content) const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, signal }) if (merged.decision === 'deny') { - return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } + return { + kind: 'block', + reason: merged.reason ?? 'blocked by UserPromptSubmit hook', + discardClaimed: true, + } } // Delegate so later listeners may still rewrite or block, then prepend our // context only to a downstream allow decision. diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 33e098b4da..b0a0a2a363 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -495,7 +495,11 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(path, adapter) // A later listener that blocks every prompt (registered AFTER the bridge). - ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) + ctx.on('agent/prompt-submit', async () => ({ + kind: 'block' as const, + reason: 'policy veto', + discardClaimed: true, + })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 19d1c43f7d..1aee38fff2 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -209,7 +209,13 @@ export function apply(ctx: Context, config: Config): void { } const merged = await runPoint('UserPromptSubmit', '', payload, { agent, plainStdoutAsContext: true, signal }) /* jscpd:ignore-start */ - if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } + if (merged.decision === 'deny') { + return { + kind: 'block', + reason: merged.reason ?? 'blocked by UserPromptSubmit hook', + discardClaimed: true, + } + } // Context alone is not a veto: DELEGATE so a later prompt-submit listener can // still block/rewrite, then fold our context onto its decision. const downstream = await next() diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 6cd2cd4617..419792ca08 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -109,7 +109,11 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) + ctx.on('agent/prompt-submit', async () => ({ + kind: 'block' as const, + reason: 'policy veto', + discardClaimed: true, + })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 4a09434cda..85616e8243 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2743,7 +2743,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // 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' } : next()) + blockPrompts ? { kind: 'block' as const, reason: 'policy', discardClaimed: true } : next()) result.terminal.send('@blocked-source') await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · blocked-source') })