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/core/agent-loop/README.md
README.md: 8dee1ed49fea6c079e9c0aa1cf6e4dd1d897793a
README.zh.md: 5d69c5c28f67dac66bee0fb08bbb6be28153dd34
README.md: 2029b9424975618beccf35c42e42d0bacb6743c0
README.zh.md: e83739635539ce5ba6f070113c164d914ca4167f

View File

@@ -53,11 +53,11 @@ Configured agents start automatically. A model call requires both `provider` and
### Internal concrete driver
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 concrete `ReactLoopAgent`, its inbox, 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 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.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then returns either rejection or the complete messages entering the proposed step. Rejection leaves the claimed batch removed; input inserted after the claim remains pending, and idle injection waits until follow-up or steering wakes the driver.
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`.
Every inbox mutation publishes one normalized `agent/inbox/spliced` event before changing the live projection. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome, after which the loop emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists, and synchronous durable-event observers can reconstruct removed values from the pre-splice projection.
### Loop lifecycle (`agent.ts`)
@@ -75,7 +75,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → definition-owned `finalizeContent` → `tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
- Compaction: pressure on `agent/step`; canonical overflow repair on `agent/request-error`
- Compaction: pressure on `agent/pre-step`; canonical overflow repair on `agent/request-error`
- Model-request recovery: `dsh-llm-retry` records and waits exact-provider normal or unbounded backoff on `agent/request-error`, emits non-surface `llm/retry` status, then returns a retry action
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection.

View File

@@ -53,11 +53,11 @@ interface Config {
### 包内部实体驱动器
实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
实体 `ReactLoopAgent`、其 inbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./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`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。block 必须通过 `discardClaimed` 选择是否丢弃本次提交的批次;之后到达的 next-step 输入和排队提示词会继续保持待处理,等待后续获准的提示词。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`followup()` 追加到 `next-turn` FIFO 并唤醒驱动器,`steer()` 追加到 `next-step` inbox 并唤醒驱动器,`inject()` 则追加到同一个 `next-step` inbox,但不唤醒驱动器。在轮次边界,驱动器会原子领取待处理的 next-step 输入和一条排队提示词;在步骤之间则只领取 next-step 输入。领取通过纯删除 splice 移除批次,并针对每条消息发出 `agent/inbox/claimed { message, turn }`。随后 `agent/pre-step` 返回 reject,或返回拟进入步骤的完整消息。reject 后已领取批次保持已删除;领取后插入的输入仍等待后续处理,而空闲注入会一直等待,直到 follow-up 或 steering 唤醒驱动器。
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
每次 inbox 变更都会先发布一条规范化的 `agent/inbox/spliced` 事件,再修改实时投影。因此,插入、编辑、移除、领取与取消都通过同一组标准 splice 坐标回放。普通删除携带 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`;领取使用不带 outcome 的纯删除,随后由循环发出 `agent/inbox/claimed`。每次插入都会发出 `agent/inbox/inserted { message }`。`MessageId` 在两个待处理列表之间保持唯一,同步持久事件观察方可以从 splice 前投影重建被移除的值。
### 循环生命周期(`agent.ts`)
@@ -75,7 +75,7 @@ interface Config {
超出「调用模型、运行工具、重复」的所有内容,都属于监听事件分类体系的插件:
- 钩子与策略:相关的 `agent/*` 检查点,加上受守卫保护的 `tools/pre-execute` → `tools/execute` → `tools/post-execute` → 定义拥有的 `finalizeContent` → `tools/result` 流水线;确切事件签名与 mode 位于生成的[事件目录](../../../docs/cordis-catalog/events.md)
- 压缩(compaction):在 `agent/step` 上观测压力;在 `agent/request-error` 上进行规范的溢出修复
- 压缩(compaction):在 `agent/pre-step` 上观测压力;在 `agent/request-error` 上进行规范的溢出修复
- 模型请求恢复:`dsh-llm-retry` 在 `agent/request-error` 上记录并等待按确切提供方配置的 normal 或无界退避,发出不进入表层的 `llm/retry` 状态,然后返回重试动作
- 沙箱、权限、计划模式:使用 `tools/pre-execute` 提供可扩展的拒绝/询问,使用 `tools.guard()` 提供单调拥有方策略,使用 `tools/post-execute` 处理结果决定,并使用 `tools/result` 进行最终观测
- subagent:在循环外部实现为 `ctx.subagents` 提供方;进程内提供方使用 `ctx.agents.create()` 和拥有的 `AgentHandle` 进行 teardown,而通用的 [`ctx.tasks`](../../tasks/tasks/) 与 [`dsh-tool-subagent`](../../subagent/tool-subagent/) 负责后台收集。

View File

@@ -11,6 +11,7 @@ import type {
AgentStatus,
CancelOptions,
InboxTarget,
PreStepDecision,
RequestErrorAction,
} from '@deepseek-ai/dsh-agent'
import { Inbox, agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
@@ -36,11 +37,6 @@ type Phase =
| { kind: 'collecting'; abort: AbortController; lastTurn: number }
| { kind: 'running'; abort: AbortController; turn: number; step: number }
type Admission =
| { kind: 'empty' }
| { kind: 'admitted'; messages: UserMessage[] }
| { kind: 'blocked' }
type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }>
/** Remove adapter-derived values before plugins propose the next request config. */
@@ -71,7 +67,10 @@ export class ReactLoopAgent implements Agent {
public readonly options: AgentOptions,
public readonly session: Session,
) {
this.inbox = new Inbox(session)
this.inbox = new Inbox(session, {
inserted: (message) =>{ emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) },
discarded: (message) =>{ emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) },
})
const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
this.phase = { kind: 'idle', lastTurn }
this.scope = createScope(loopCtx, this)
@@ -93,7 +92,7 @@ export class ReactLoopAgent implements Agent {
}
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
// Waking input cannot join an aborted admission or turn, so it starts the next turn.
// Waking input cannot join an aborted pre-step or turn, so it starts the next turn.
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
this.inbox.splice(resolvedTarget, Infinity, 0, [message])
@@ -114,13 +113,13 @@ export class ReactLoopAgent implements Agent {
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
if (!options.keepInbox) {
this.inbox.splice('next-step', 0, this.inbox.nextStep.length, [], 'canceled')
this.inbox.splice('next-turn', 0, this.inbox.nextTurn.length, [], 'canceled')
this.inbox.splice('next-step', 0, this.inbox.nextStep.length, [])
this.inbox.splice('next-turn', 0, this.inbox.nextTurn.length, [])
}
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
}
/** Reserve a driver before deferring idle admission. */
/** Reserve a driver before deferring idle pre-step processing. */
private scheduleKick(): void {
if (this.phase.kind !== 'idle') return
const driver = Promise.withResolvers<void>()
@@ -158,33 +157,22 @@ export class ReactLoopAgent implements Agent {
}
}
private async admit(onTurnBoundary: boolean): Promise<Admission> {
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": admit outside running phase`)
private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreStepDecision> {
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
const signal = this.phase.abort.signal
const claimed = [...this.inbox.nextStep]
const outboxLength = claimed.length
const queued = onTurnBoundary ? this.inbox.nextTurn[0] : undefined
if (queued !== undefined) claimed.push(queued)
if (claimed.length === 0) return { kind: 'empty' }
const claimed = this.inbox.claim(target)
for (const message of claimed) {
emitAgentEvent(this.loopCtx, this, 'agent/inbox/claimed', { message, turn: position.turn })
}
const decision = await agentEvents(this.loopCtx, this).waterfall(
'agent/prompt-submit', claimed, signal,
() => Promise.resolve({ kind: 'allow', messages: claimed }),
'agent/pre-step', claimed, { ...position, signal },
() => Promise.resolve({ kind: 'enter', messages: claimed }),
)
signal.throwIfAborted()
if (decision.kind === 'allow') {
this.inbox.splice('next-step', 0, outboxLength, [], 'admitted')
if (queued !== undefined) this.inbox.splice('next-turn', 0, 1, [], 'admitted')
return { kind: 'admitted', messages: decision.messages }
}
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' }
return decision
}
/** Admitted input stays unowned until `turn/start` commits. */
/** Claimed input stays unowned until `turn/start` commits. */
private async turn(): Promise<boolean> {
if (this.phase.kind === 'idle') {
this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
@@ -195,10 +183,10 @@ export class ReactLoopAgent implements Agent {
const phase = { kind: 'running' as const, abort, turn: lastTurn, step: 0 }
this.setPhase(phase)
signal.throwIfAborted()
let admission: Admission
let decision: PreStepDecision
try {
admission = await this.admit(true)
if (admission.kind !== 'admitted') return false
decision = await this.preStep('next-turn', { turn: phase.turn + 1, step: 1 })
if (decision.kind === 'reject') return false
signal.throwIfAborted()
} catch (error: unknown) {
if (signal.aborted) throw error
@@ -213,15 +201,13 @@ export class ReactLoopAgent implements Agent {
let turnEnds: TurnEndReason | null = null
try {
while (true) {
if (admission.kind === 'admitted') {
for (const message of admission.messages) {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
}
signal.throwIfAborted()
const step = ++phase.step
this.session.append('step/start', { turn, step })
try {
for (const message of decision.messages) {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
turnEnds = await this.step()
} finally {
this.session.append('step/end', { turn, step })
@@ -231,13 +217,13 @@ export class ReactLoopAgent implements Agent {
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal)
signal.throwIfAborted()
}
admission = await this.admit(false)
if (admission.kind === 'blocked') {
decision = await this.preStep('next-step', { turn, step: phase.step + 1 })
if (decision.kind === 'reject') {
turnEnds = { kind: 'blocked' }
return false
}
signal.throwIfAborted()
if (admission.kind === 'empty' && turnEnds) break
if (decision.messages.length === 0 && turnEnds) break
}
} catch (error: unknown) {
if (signal.aborted) {
@@ -264,8 +250,6 @@ export class ReactLoopAgent implements Agent {
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)
const { turn, step, abort: { signal } } = this.phase
signal.throwIfAborted()
await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal)
signal.throwIfAborted()
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const system = renderPrompt(assembly)

View File

@@ -42,7 +42,7 @@ interface GroupOutcome {
* Ordinary completion and abort commit started-call results in order. Abort
* drains them, records synthetic results for unstarted calls, and returns with
* the signal still aborted after accepting started-call context through the
* caller-supplied acceptor (the machine stages it on its outbox for the next
* caller-supplied acceptor (the machine stages it in its next-step inbox for the
* step boundary). An internal scheduler failure stops new dispatches, drains
* already-started dispatches, and rejects with the first failure without
* fabricating tool results.

View File

@@ -153,7 +153,7 @@ describe('AgentLoop initiator scope', () => {
const { ctx } = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' })
let signals: AbortSignal[] = []
let admissionSignals: AbortSignal[] = []
let preStepSignals: AbortSignal[] = []
const capture = (signal: AbortSignal | undefined): void => {
if (signal === undefined) throw new Error('turn seam omitted its explicit signal')
expect(ctx.agents.requireInitiator()).toBe(agent)
@@ -164,16 +164,13 @@ describe('AgentLoop initiator scope', () => {
if (context.agent === agent) capture(context.signal)
return next()
})
ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => {
ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => {
if (subject === agent) {
expect(ctx.agents.requireInitiator()).toBe(agent)
admissionSignals.push(signal)
preStepSignals.push(signal)
}
return next()
})
ctx.on('agent/step', (subject, _turn, _step, signal) => {
if (subject === agent) capture(signal)
})
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => {
if (subject === agent) capture(signal)
return next()
@@ -197,19 +194,19 @@ describe('AgentLoop initiator scope', () => {
const firstSignal = signals[0]
expect(firstSignal).toBeDefined()
expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal]))
expect(admissionSignals).toHaveLength(1)
expect(admissionSignals[0]).toBe(firstSignal)
expect(preStepSignals).toHaveLength(2)
expect(new Set(preStepSignals)).toEqual(new Set([firstSignal]))
signals = []
admissionSignals = []
preStepSignals = []
const secondIdle = waitForIdle(ctx, agent)
send(agent, 'second')
await secondIdle
const secondSignal = signals[0]
expect(secondSignal).toBeDefined()
expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal]))
expect(admissionSignals).toHaveLength(1)
expect(admissionSignals[0]).toBe(secondSignal)
expect(preStepSignals).toHaveLength(1)
expect(preStepSignals[0]).toBe(secondSignal)
expect(secondSignal).not.toBe(firstSignal)
expect(ctx.agents.currentInitiator()).toBeUndefined()
await ctx.fiber.dispose()

View File

@@ -50,7 +50,37 @@ describe('Agent', () => {
.toEqual({ kind: 'plugin', plugin: '' })
})
it('idle inject() rejects invalid input before append', async () => {
it('emits exact inserted, claimed, and discarded inbox messages', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('inbox-events'), { provider: 'mock', model: 'mock' })
const inserted: unknown[] = []
const claimed: unknown[] = []
const discarded: unknown[] = []
ctx.on('agent/inbox/inserted', (subject, event) => {
if (subject === agent) inserted.push(event)
})
ctx.on('agent/inbox/claimed', (subject, event) => {
if (subject === agent) claimed.push(event)
})
ctx.on('agent/inbox/discarded', (subject, event) => {
if (subject === agent) discarded.push(event)
})
const context = createUserMessage({
content: [{ type: 'text', text: 'discard me' }],
source: { kind: 'plugin', plugin: 'test' },
})
agent.inject(context)
agent.inbox.remove('next-step', context.id)
const prompt = createUserMessage({ content: [{ type: 'text', text: 'run' }], source: { kind: 'user' } })
agent.followup(prompt)
await agent.whenIdle()
expect(inserted).toEqual([{ message: context }, { message: prompt }])
expect(discarded).toEqual([{ message: context }])
expect(claimed).toEqual([{ message: prompt, turn: 1 }])
})
it('idle inject() rejects invalid input before enqueue', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })

View File

@@ -668,9 +668,8 @@ describe('Agent.cancel()', () => {
})
it.each([
'prompt-submit',
'pre-step',
'system-prompt',
'step',
'request',
'stopping',
'tool',
@@ -690,8 +689,8 @@ describe('Agent.cancel()', () => {
}
switch (stage) {
case 'prompt-submit':
ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => {
case 'pre-step':
ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
@@ -705,11 +704,6 @@ describe('Agent.cancel()', () => {
return next()
})
break
case 'step':
ctx.on('agent/step', async (subject, _turn, _step, signal) => {
if (subject === agent) await blockUntilAbort(signal)
})
break
case 'request':
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
@@ -741,7 +735,7 @@ describe('Agent.cancel()', () => {
agent.cancel({ kind: 'user' })
await idle
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
if (stage === 'prompt-submit') {
if (stage === 'pre-step') {
expect(turnEnd).toBeUndefined()
} else {
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })

View File

@@ -67,30 +67,30 @@ describe('addressable inbox operations', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' })
const admission = Promise.withResolvers<undefined>()
const preStep = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('agent/prompt-submit', async (_subject, messages, _signal, next) => {
ctx.on('agent/pre-step', async (_subject, messages, _signal, next) => {
if (messages[0]?.content[0]?.type === 'text' && messages[0].content[0].text === 'first') {
admission.resolve(undefined)
preStep.resolve(undefined)
await release.promise
}
return next()
})
send(agent, 'first')
await admission.promise
await preStep.promise
send(agent, 'remove me')
send(agent, 'edit me')
const pending = agent.inbox.nextTurn
expect(pending.map(inboxText)).toEqual(['first', 'remove me', 'edit me'])
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
const remove = pending[1]!
const edit = pending[2]!
expect(agent.inbox.splice('next-turn', 2, 1, [freezeMessage({
const remove = pending[0]!
const edit = pending[1]!
expect(agent.inbox.splice('next-turn', 1, 1, [freezeMessage({
...edit,
content: [{ type: 'text', text: 'edited' }],
})])).toEqual([edit])
expect(agent.inbox.splice('next-turn', 1, 1, [])).toEqual([remove])
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([remove])
const idle = waitForIdle(ctx, agent)
release.resolve(undefined)
@@ -316,11 +316,19 @@ describe('abort during tool execution ends the turn', () => {
send(agent, 'leave an unmatched historical call')
await waitForIdle(ctx, agent)
const disposeInjection = ctx.on('agent/step', (subject, turn) => {
if (subject === agent && turn === 2) {
const disposeInjection = ctx.on('agent/pre-step', async (subject, _messages, { turn }, next) => {
const decision = await next()
if (subject === agent && turn === 2 && decision.kind === 'enter') {
disposeInjection()
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } }))
return {
kind: 'enter' as const,
messages: [...decision.messages, createUserMessage({
content: [{ type: 'text', text: 'new turn context' }],
source: { kind: 'plugin', plugin: 'test' },
})],
}
}
return decision
})
send(agent, 'start a text-only turn')
await waitForIdle(ctx, agent)
@@ -903,8 +911,7 @@ describe('turn and step boundary recovery', () => {
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
})
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
// Disposal remains authoritative when the listener also throws.
it('contains a pre-step throw after disposal without opening a turn', async () => {
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
let agent!: Agent
@@ -913,8 +920,8 @@ describe('turn and step boundary recovery', () => {
}, { inject: ['agentLoop'] }))
let threw = false
ctx.on('agent/step', () => {
if (threw) return
ctx.on('agent/pre-step', (_subject, _messages, _context, next) => {
if (threw) return next()
threw = true
void fiber.dispose()
throw new Error('boom pre-step during disposal')
@@ -928,12 +935,9 @@ describe('turn and step boundary recovery', () => {
await agent.whenIdle()
const e = [...agent.session.events]
// Balanced: one turn/start, one turn/end carrying disposed (NOT error).
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
expect(e.some(x => x.type === 'step/start')).toBe(true)
expect(e.some(x => x.type === 'turn/start')).toBe(false)
expect(e.some(x => x.type === 'turn/end')).toBe(false)
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(errorEmits).toHaveLength(0)
})
@@ -1224,9 +1228,8 @@ describe('disposal and cancellation during pre-step assembly', () => {
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
})
it('disposal during agent/step listeners ends the turn disposed', { timeout: 15000 }, async () => {
// Start disposal, then release pre-step; awaiting disposal first would
// deadlock on the blocked driver.
it('disposal during pre-step prevents the turn from opening', { timeout: 15000 }, async () => {
// Start disposal, then release pre-step; awaiting disposal first would deadlock on the blocked driver.
const adapter = new MockAdapter(['hang'])
let releasePreStep!: () => void
const blocker = new Promise<void>(r => void (releasePreStep = r))
@@ -1241,8 +1244,9 @@ describe('disposal and cancellation during pre-step assembly', () => {
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/step', async () => {
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
await blocker
return next()
})
let agent!: Agent
@@ -1261,23 +1265,17 @@ describe('disposal and cancellation during pre-step assembly', () => {
await disposalDone
await driverDone(agent)
// After the agent/step listeners finish, the post-listener cancel/dispose
// check catches disposal before any LLM call.
// The post-listener cancellation check catches disposal before any turn or LLM call.
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
// Disposal wins the post-listener check — reason is `disposed`.
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
expect(e.some(x => x.type === 'turn/start')).toBe(false)
expect(e.some(x => x.type === 'turn/end')).toBe(false)
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
// The durable turn/end record is the authoritative turn-boundary signal
// (turn boundaries have no agent/* mirror).
expect(reasons).toEqual([])
})
it('cancel during agent/step listeners ends the turn aborted', { timeout: 15000 }, async () => {
// Release agent/step after cancellation to exercise the post-listener check.
it('cancel during pre-step prevents the turn from opening', { timeout: 15000 }, async () => {
// Release pre-step after cancellation to exercise the post-listener check.
const adapter = new MockAdapter(['hang'])
let releasePreStep!: () => void
const blocker = new Promise<void>(r => void (releasePreStep = r))
@@ -1292,8 +1290,9 @@ describe('disposal and cancellation during pre-step assembly', () => {
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/step', async () => {
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
await blocker
return next()
})
let agent!: Agent
@@ -1314,14 +1313,11 @@ describe('disposal and cancellation during pre-step assembly', () => {
await driverDone(agent)
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
expect(e.some(x => x.type === 'turn/start')).toBe(false)
expect(e.some(x => x.type === 'turn/end')).toBe(false)
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
expect(reasons).toEqual([])
})
it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {

View File

@@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, {
type Agent,
type PromptDecision,
type PreStepDecision,
type SessionStartSource,
} from '@deepseek-ai/dsh-agent'
@@ -19,7 +19,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
/**
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
* The interception seams introduced by the hooks taxonomy: `agent/pre-step`,
* `agent/session-start`, `agent/turn-stopping`, and the
* `tools/pre-execute` / `tools/post-execute`
* split with `additionalContexts` buffering. These verify the canonical event
@@ -58,14 +58,14 @@ function events(agent: Agent): SessionEvent[] {
return [...agent.session.events]
}
describe('agent/prompt-submit', () => {
it('allow (default via next) records the user/message unchanged', async () => {
describe('agent/pre-step', () => {
it('enter (default via next) records the user/message unchanged', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const seen: string[] = []
ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next) => {
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join(''))
return next()
})
@@ -78,15 +78,43 @@ describe('agent/prompt-submit', () => {
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }])
})
it('reports the request coordinates for initial and tool-continuation prompts', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'hi' }),
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo',
parameters: { text: { type: 'string', required: true } },
execute: async ({ text }) => [{ type: 'text', text }],
}))
const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' })
const seen: Array<{ turn: number; step: number; messages: number }> = []
ctx.on('agent/pre-step', async (_agent, messages, context, next) => {
seen.push({ turn: context.turn, step: context.step, messages: messages.length })
return next()
})
send(agent, 'hello')
await waitForIdle(ctx, agent)
expect(seen).toEqual([
{ turn: 1, step: 1, messages: 1 },
{ turn: 1, step: 2, messages: 0 },
])
})
it('publishes frozen input without replacing its identity', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
const decision = Promise.withResolvers<PreStepDecision>()
const observed: UserMessage[] = []
ctx.on('agent/prompt-submit', async (subject, messages) => {
if (subject !== agent) return { kind: 'allow', messages }
ctx.on('agent/pre-step', async (subject, messages) => {
if (subject !== agent) return { kind: 'enter', messages }
const message = messages[0]!
expect(Object.isFrozen(message)).toBe(true)
expect(Object.isFrozen(message.content)).toBe(true)
@@ -115,7 +143,7 @@ describe('agent/prompt-submit', () => {
expect(() => {
if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation'
}).toThrow(TypeError)
decision.resolve({ kind: 'allow', messages: [input] })
decision.resolve({ kind: 'enter', messages: [input] })
await idle
expect(observed).toHaveLength(1)
@@ -128,14 +156,14 @@ describe('agent/prompt-submit', () => {
expect(userMsg?.type === 'user/message' && userMsg.data).toEqual(input)
})
it('allow with content REWRITES the prompt before it is recorded', async () => {
it('enter with content rewrites the prompt before it is recorded', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, messages): Promise<PromptDecision> =>
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
({
kind: 'allow',
kind: 'enter',
messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }],
}))
@@ -149,14 +177,14 @@ describe('agent/prompt-submit', () => {
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
})
it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => {
it('enter with additional messages records separately sourced context in the turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, messages): Promise<PromptDecision> =>
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
({
kind: 'allow',
kind: 'enter',
messages: [...messages, createUserMessage({
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
source: { kind: 'plugin', plugin: 'test' },
@@ -176,43 +204,12 @@ describe('agent/prompt-submit', () => {
expect(sent).toContain('extra ctx')
})
it('runs pre-step after prompt rewrites and injected context become durable', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, messages): Promise<PromptDecision> =>
({
kind: 'allow',
messages: [{
...messages[0]!,
content: [{ type: 'text', text: 'REWRITTEN prompt' }],
}, createUserMessage({
content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' },
})],
}))
let preStepDerived: string | undefined
ctx.on('agent/step', (subject, _turn, step) => {
if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
})
send(agent, 'ORIGINAL prompt')
await waitForIdle(ctx, agent)
expect(preStepDerived).toBeDefined()
expect(preStepDerived).toContain('REWRITTEN prompt')
expect(preStepDerived).toContain('injected ctx')
expect(preStepDerived).not.toContain('ORIGINAL prompt')
})
it('block drops the claimed prompt before any turn or model call', async () => {
it('reject drops the claimed prompt before any turn or model call', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({ kind: 'block', reason: 'blocked by policy', discardClaimed: true }))
ctx.on('agent/pre-step', async (): Promise<PreStepDecision> => ({ kind: 'reject' }))
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -230,44 +227,24 @@ 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<PromptDecision> => ({
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 () => {
it('stages inject and steer during pre-step for the entered turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('admission-outbox'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('pre-step-outbox'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
const decision = Promise.withResolvers<PreStepDecision>()
let claimed: UserMessage[] = []
let firstAdmission = true
ctx.on('agent/prompt-submit', async (_agent, messages) => {
if (!firstAdmission) return { kind: 'allow', messages }
firstAdmission = false
let firstProposal = true
ctx.on('agent/pre-step', async (_agent, messages) => {
if (!firstProposal) return { kind: 'enter', messages }
firstProposal = false
claimed = messages
entered.resolve(undefined)
return decision.promise
})
const idle = waitForIdle(ctx, agent)
send(agent, 'admitted prompt')
send(agent, 'entered prompt')
await entered.promise
expect(agent.status).toBe('running')
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
@@ -276,15 +253,15 @@ describe('agent/prompt-submit', () => {
content: [{ type: 'text', text: 'attached context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'admission steering' }], source: { kind: 'user' } }))
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'pre-step steering' }], source: { kind: 'user' } }))
expect(events(agent).some(event => event.type === 'user/message')).toBe(false)
expect(agent.inbox.nextStep.map(message => message.content[0]))
.toEqual([
{ type: 'text', text: 'attached context' },
{ type: 'text', text: 'admission steering' },
{ type: 'text', text: 'pre-step steering' },
])
decision.resolve({ kind: 'allow', messages: claimed })
decision.resolve({ kind: 'enter', messages: claimed })
await idle
expect(agent.inbox.hasPending).toBe(false)
@@ -297,27 +274,27 @@ describe('agent/prompt-submit', () => {
'user/message',
])
expect(staged[1]?.type === 'user/message' && staged[1].data.content)
.toEqual([{ type: 'text', text: 'admitted prompt' }])
.toEqual([{ type: 'text', text: 'entered prompt' }])
expect(staged[2]?.type === 'user/message' && staged[2].data.content)
.toEqual([{ type: 'text', text: 'attached context' }])
expect(staged[3]?.type === 'user/message' && staged[3].data.content)
.toEqual([{ type: 'text', text: 'admission steering' }])
.toEqual([{ type: 'text', text: 'pre-step steering' }])
const firstRequest = JSON.stringify(adapter.requests[0]?.messages)
expect(firstRequest).toContain('admitted prompt')
expect(firstRequest).toContain('entered prompt')
expect(firstRequest).not.toContain('attached context')
expect(firstRequest).not.toContain('admission steering')
expect(firstRequest).not.toContain('pre-step steering')
const nextRequest = JSON.stringify(adapter.requests[1]?.messages)
expect(nextRequest).toContain('attached context')
expect(nextRequest).toContain('admission steering')
expect(nextRequest).toContain('pre-step steering')
})
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' })
const agent = ctx.agentLoop.create(SessionId('blocked-pre-step-outbox'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
const decision = Promise.withResolvers<PreStepDecision>()
const disposeBlock = ctx.on('agent/pre-step', async () => {
entered.resolve(undefined)
return decision.promise
})
@@ -330,7 +307,7 @@ 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', discardClaimed: true })
decision.resolve({ kind: 'reject' })
await blockedIdle
expect(agent.inbox.nextStep.map(message => message.content[0]))
@@ -357,24 +334,24 @@ describe('agent/prompt-submit', () => {
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering')
})
it('preserves later queued work when an admission is blocked', async () => {
it('preserves later queued work when a step is rejected', async () => {
const adapter = new MockAdapter([
textResponse('continued'),
textResponse('wake reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('rejected-admission-order'), {
const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-order'), {
provider: 'mock',
model: 'mock',
})
ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next) => {
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
const decision = await next()
return messages.some(message =>
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))
? { kind: 'block', reason: 'policy', discardClaimed: true }
? { kind: 'reject' as const }
: decision
})
ctx.on('agent/prompt-submit', async (subject, messages, _signal, next) => {
ctx.on('agent/pre-step', async (subject, messages, _signal, next) => {
if (messages.some(message =>
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) {
subject.inject(createUserMessage({
@@ -414,13 +391,13 @@ describe('agent/prompt-submit', () => {
expect(request).not.toContain('blocked prompt')
})
it('preserves context-only injection staged after admission began', async () => {
it('preserves context-only injection staged after pre-step 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 agent = ctx.agentLoop.create(SessionId('rejected-pre-step-context'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
const decision = Promise.withResolvers<PreStepDecision>()
const disposeBlock = ctx.on('agent/pre-step', async () => {
entered.resolve(undefined)
return decision.promise
})
@@ -432,7 +409,7 @@ describe('agent/prompt-submit', () => {
content: [{ type: 'text', text: 'independent context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
decision.resolve({ kind: 'block', reason: 'policy', discardClaimed: true })
decision.resolve({ kind: 'reject' })
await idle
const log = events(agent)
@@ -452,7 +429,7 @@ describe('agent/prompt-submit', () => {
it('leaves inbox state unchanged when its durable append fails', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('blocked-admission-append-failure'), {
const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-append-failure'), {
provider: 'mock',
model: 'mock',
})
@@ -476,11 +453,11 @@ describe('agent/prompt-submit', () => {
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<PromptDecision> => {
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => {
const text = messages.flatMap(message => message.content)
.map(b => (b.type === 'text' ? b.text : '')).join('')
return text === 'secret'
? { kind: 'block', reason: 'policy: no secrets', discardClaimed: true }
? { kind: 'reject' }
: next()
})
@@ -506,15 +483,15 @@ describe('agent/prompt-submit', () => {
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 () => {
it('a throwing pre-step listener reports the driver error and retains adjacent work', async () => {
const adapter = new MockAdapter([textResponse('after')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threw = false
ctx.on('agent/prompt-submit', async (_agent, messages) => {
ctx.on('agent/pre-step', async (_agent, messages) => {
if (!threw) { threw = true; throw new Error('prompt hook broke') }
return { kind: 'allow' as const, messages }
return { kind: 'enter' as const, messages }
})
const errors: Error[] = []
const reasons: TurnEndReason[] = []
@@ -538,7 +515,8 @@ describe('agent/prompt-submit', () => {
expect(reasons).toEqual([])
expect(statuses).toEqual(['running', 'idle'])
expect(adapter.requests).toHaveLength(0)
expect(agent.inbox.nextTurn).toHaveLength(2)
expect(agent.inbox.nextTurn.map(message => message.content[0]))
.toEqual([{ type: 'text', text: 'second' }])
})
})
@@ -716,16 +694,12 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
ctx.on('agent/session-start', (agent, source) => {
agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } }))
})
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next): Promise<PromptDecision> => {
// 2. PreStep: reject a forbidden prompt, annotate the rest.
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => {
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',
discardClaimed: true,
}
return { kind: 'reject' }
}
return next()
})
@@ -800,7 +774,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' })
send(agent, 'run rm -rf /')
await waitForIdle(ctx, agent)
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed
// the prompt ran (not rejected) — proving the pre-step listener was disposed
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(e => e.type === 'user/message')).toBe(true)
})

View File

@@ -94,7 +94,7 @@ describe('agent loop', () => {
expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
const types = agent.session.events.map(e => e.type)
// Durable inbox receipt and admission bracket the turn-owned transcript.
// Durable inbox receipt precedes the turn-owned transcript.
expect(types[0]).toBe('agent/inbox/spliced')
expect(types).toContain('turn/start')
expect(types).toContain('user/message')
@@ -313,7 +313,7 @@ describe('agent loop', () => {
const steering = agent.session.events.find(e =>
e.type === 'user/message' && JSON.stringify(e.data.content).includes('change of plans'))
expect(steering).toBeDefined()
// Steering is admitted before the second step's request derives history.
// Steering enters history before the second step's request derives it.
const steeringSeq = steering!.seq
const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1]
expect(secondStepStart).toBeDefined()
@@ -348,30 +348,30 @@ describe('agent loop', () => {
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('second idle steer')
})
it('stops after a throwing step observer and retains steering until a later wakeup', async () => {
it('stops after a throwing pre-step listener and retains later steering until a wakeup', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
let fail = true
ctx.on('agent/step', (subject) => {
if (subject !== agent || !fail) return
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
if (subject !== agent || !fail) return next()
fail = false
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } }))
throw new Error('step failed')
throw new Error('pre-step failed')
})
send(agent, 'prompt')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0)
expect(agent.inbox.nextStep).toHaveLength(1)
send(agent, 'resume')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering')
})
@@ -603,7 +603,7 @@ describe('agent loop', () => {
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model')
})
it('agent/step fires once per step before the step is opened', async () => {
it('agent/pre-step fires once per proposed step before the step is opened', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', {}, 'calling echo'),
textResponse('done'),
@@ -616,8 +616,9 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const fires: { turn: number; step: number; signal: AbortSignal }[] = []
ctx.on('agent/step', (subject, turn, step, signal) => {
ctx.on('agent/pre-step', (subject, _messages, { turn, step, signal }, next) => {
if (subject === agent) fires.push({ turn, step, signal })
return next()
})
send(agent, 'go')
@@ -630,33 +631,33 @@ describe('agent loop', () => {
expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true)
})
it('agent/step fires after its step boundary opens and before the request', async () => {
it('agent/pre-step fires before its step boundary opens and before the request', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let boundaryOpen = false
ctx.on('agent/step', (subject) => {
let boundaryOpen = true
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start'
return next()
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(boundaryOpen).toBe(true)
expect(boundaryOpen).toBe(false)
expect(adapter.requests).toHaveLength(1)
})
it('a throwing agent/step listener ends the turn (error), not the loop', async () => {
// Before step/start, a pre-step throw reaches the turn catch: no step needs
// closing, the turn records error, and the loop remains available.
it('a throwing agent/pre-step listener fails the proposal, not the loop', async () => {
const adapter = new MockAdapter([textResponse('second turn ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let throwOnce = true
ctx.on('agent/step', () => {
ctx.on('agent/pre-step', (_agent, _messages, _context, next) => {
if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') }
return next()
})
const errors: Error[] = []
@@ -666,14 +667,11 @@ describe('agent loop', () => {
send(agent, 'first')
await waitForIdle(ctx, agent)
// The first turn failed at step 1 before a model call.
// The first proposal failed before opening a turn or calling the model.
expect(errors.map(error => error.message)).toEqual(['boom in pre-step'])
expect(adapter.requests.length).toBe(0)
const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error' })
// The step opened-and-closed count stays balanced even though it never ran.
const types = agent.session.events.map(e => e.type)
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(false)
// The loop survived: a second prompt runs a normal completed turn.
send(agent, 'second')

View File

@@ -532,7 +532,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx2.fiber.dispose()
})
it('an idle inject() survives persist + resume without a synthetic turn', async () => {
it('a pending idle inject() survives persist + resume without a synthetic turn', async () => {
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent

View File

@@ -1066,7 +1066,7 @@ describe('agent scope lifecycle', () => {
await waitForIdle(ctx, agent)
expect(reentered).toBe(true)
// Idle again: the reentrant admission was already claimed and settled (its
// Idle again: the reentrant batch was already claimed and settled (its
// prompt was blocked by nothing, so it ran) — arm a SECOND reentry that
// fires from the disposal cancel's idle transition itself.
reentered = false