一切皆插件:DeepSeek Harness 源码精读 4 / 14

第 4 章

turn/step:ReactLoopAgent 的驱动循环

源码核对基于 deepseek-ai/deepseek-harness commit `b150a551b8`(2026-08-21),tag `course-anchor-20260823`对比语料 anthropics/claude-code commit `45bdfa96`(2026-08-23),tag `v2.1.241`本章字数:约 9800 字(不含代码与图)

场景还原

你在一个 agent 平台上挂了一个「输入审核」插件:按文档在 agent/pre-step 上注册监听,碰到敏感词就返回 { kind: 'reject' }。上线后收到一条投诉:用户发了一条消息,界面转了一圈什么都没有发生,会话历史里既没有用户消息,也没有模型回复,只有两行日志:turn/startturn/end

你开始查那条消息的下落。它明明进了输入队列,审核也如实返回了拒绝,然后呢?如果审核不过,输入是不是应该「退回队列」等下次再处理?你打开 packages/core/agent-loop/src/agent.ts,发现这个驱动循环里没有你预期的 forwhile 主干,只有一个叫 claim 的动作,和一段把语义钉死的注释:被拒绝的消息既不进模型请求,也不回队列,它就在这里结束。

顺着这个疑问往下读,你会看到「模型请求如何被决定发起、如何结束」的完整答案。本章要精读的 ReactLoopAgent 就是 dsh 里唯一的具体循环实现:它没有主循环语句,循环活在会话日志里。读完你会明白 claim 是什么、turn 与 step 为什么是两层、以及为什么「被拒绝的空 turn 也要留日志」是一条不能省的原则。

逐行精读

先看这个类自己的定位。packages/core/agent-loop/src/agent.ts 的头注释只有四行:

packages/core/agent-loop/src/agent.ts1:5
1/**2 * Default Agent driver over queued turns and step-boundary input. Every request3 * is derived from the session log.4 * @module dsh-agent-loop/agent5 */

第二行是整章的地基:每次请求都由 session log 推导而来。ReactLoopAgent 不维护自己的消息队列,模型看到的每条消息都是调用 this.session.deriveMessages() 从日志投影出来的(见本文件 341 行)。推论是:只要日志完整,重放日志就能重放整个交互过程,循环本身没有需要保存的状态。这就是「循环不在代码里、在日志里」的含义。

循环的运行时状态压缩成一个三态判别联合,同文件 38 至 52 行:

packages/core/agent-loop/src/agent.ts38:52
38type Phase =39  | { kind: 'idle'; lastTurn: number }40  | {41    kind: 'maintenance'42    abort: AbortController43    lastTurn: number44    wakeRequested: boolean45  }46  | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }4748type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }>4950type PreparedStep =51  | { kind: 'reject' }52  | { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly }

三个态:idle 没有驱动在跑,只记着上次 turn 的编号;running 是活动 turn,带着中止控制器和当前 turnstep 坐标;maintenance 是维护任务占用的窗口(例如持久化检查),也带 abort 和 wake 闩锁。PreparedStep 是 pre-step 瀑布的产物:拒绝,或进入并携带消息批次与提示词装配结果。注意外部可见的 AgentStatus 只有 idlerunning 两个值(packages/core/agent/src/runtime-types.ts 43 至 50 行),maintenance 对外显示为 idle,状态机的内部复杂度不出包。这张状态图回答「驱动循环的三态之间怎么切换」:

stateDiagram-v2 [*] --> idle idle --> running: wakeDriver 收到唤醒输入 running --> idle: kick 收敛并回写 lastTurn idle --> maintenance: runMaintenance 接管 maintenance --> idle: 维护完成或中止 running --> running: turn 之间保持 running

接着是类声明与构造器,64 至 97 行:

packages/core/agent-loop/src/agent.ts64:97
64export class ReactLoopAgent implements Agent {65  readonly inbox: Inbox66  private phase: Phase67  private activityDone: Promise<void> = Promise.resolve()6869  /** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */70  readonly scope: Scope71  readonly ctx: Context7273  /** Fused dispatcher, built once in the constructor so hot-path dispatches never allocate. */74  private readonly dispatch: AgentEventDispatch7576  /** Whether this loop instance has appended its initial/resume request anchor. */77  private requestHeaderLogged = false78  private readonly runtimeContext: RuntimeContextProjection7980  constructor(81    private loopCtx: Context,82    public readonly id: SessionId,83    public readonly options: AgentOptions,84    public readonly session: Session,85  ) {86    this.dispatch = agentEvents(loopCtx, this)87    this.inbox = new Inbox(session, {88      inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) },89      discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) },90      claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) },91    })92    const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 093    this.phase = { kind: 'idle', lastTurn }94    this.scope = createScope(loopCtx, this)95    this.ctx = this.scope.ctx.extend({ agent: this })96    this.runtimeContext = new RuntimeContextProjection(this.ctx, session)97  }

第 92 行是关键:构造时从日志里找到最后一条 turn/start,把它的编号设为 lastTurn。进程重启后新实例从日志中断处续跑,turn 编号不重置。输入箱(inbox)的构造还把三类变更映射成实时事件:inserteddiscardedclaimed。这三类事件都不是会话日志事件,是 live 通知,监听它们的人可以实时跟踪输入箱,重放时则只依赖日志本身。

现在看输入怎么进来。send 是三个投递门面(followupsteerinject)的共同底层,113 至 120 行:

packages/core/agent-loop/src/agent.ts113:120
113  send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {114    // Waking input cannot join an aborted activity, so it starts the next turn.115    // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it.116    const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted117    const resolvedTarget = wakingAfterAbort ? 'next-turn' : target118    this.inbox.splice(resolvedTarget, Infinity, 0, [message])119    if (wakeup) this.wakeDriver(wakingAfterAbort)120  }

注释说明了第一个分支的含义:正在唤醒的输入不能加入一个已经中止的活动,所以它被重新分类为 next-turn,留给下一个 turn。分类发生在插入之前,这样 splice 观察者里发生的重入 cancel 无法改判它。输入进入 inbox 只有两种队列:next-turnnext-step,对应两个语义:「开一个全新 turn 的输入」和「插进当前 turn 下一个 step 边界的输入」。steer(127 行)投 next-step 并唤醒,inject(131 行)投 next-step 但不唤醒,这就是「注入的上下文等着,直到别的消息叫醒驱动」的含义。

唤醒动作在 wakeDriver,164 至 193 行:

packages/core/agent-loop/src/agent.ts164:193
164  /**165   * Start one driver, or latch its wake behind maintenance or an aborted166   * activity. A wake sent while idle always opens its turn boundary, even167   * when its message was cleared; only a latched replay is suppressed when168   * the queue no longer holds the wake.169   * @param wakeAfterAbort - the {@link send} classification, captured before170   *   the inbox insertion so a reentrant cancel cannot reclassify it.171   */172  private wakeDriver(wakeAfterAbort = false): void {173    if (this.phase.kind !== 'idle') {174      // Maintenance and aborted drivers cannot deliver the wake: latch it for175      // replay at convergence. Live drivers claim queued work themselves;176      // disposal never latches, so teardown waits on no model turn.177      const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined178      if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) {179        this.phase.wakeRequested = true180      }181      return182    }183    const driver = Promise.withResolvers<void>()184    this.activityDone = driver.promise185    this.setPhase({186      kind: 'running',187      abort: new AbortController(),188      turn: this.phase.lastTurn,189      step: 0,190      wakeRequested: false,191    })192    this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject)193  }

idle 时:登记一个 driver promise,把 phase 切到 running,然后在 initiator 作用域里启动 kick。非 idle 时:把唤醒需求记进 wakeRequested 闩锁,等当前活动收敛后重放。第 178 行有两个排除条件:disposed 不闩(teardown 不等待模型 turn),live driver 不闩(活着的驱动自己会 claim 队列里的活,闩了会重复唤醒)。这个闩锁来自一个真实事故,取消与唤醒的竞争窗口会丢唤醒,细节记在 .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md,后面边界条件部分还会用到。

驱动本体是 kick,210 至 223 行:

packages/core/agent-loop/src/agent.ts210:223
210  private async kick(): Promise<void> {211    try {212      while (await this.turn()) {}213    } catch (_error) {214      // Reported failures and cancellation are contained at the driver boundary.215    } finally {216      /* v8 ignore next -- kick owns a running phase until this driver boundary */217      if (this.phase.kind === 'running') {218        const { turn, wakeRequested } = this.phase219        this.setPhase({ kind: 'idle', lastTurn: turn })220        if (wakeRequested && this.inbox.hasPending) this.wakeDriver()221      }222    }223  }

循环体只有一句:while (await this.turn()) {}turn() 返回 true 表示队列还有待办,继续下一轮;返回 false 表示收敛。这个循环没有计数器、没有退出条件判断,全部由 turn() 的返回值决定。finally 里回 idle,并检查闩锁:如果维护窗口期欠了一次唤醒,且队列里确实还有输入,就补一次 wakeDriver

进入 turn() 本体。先看 245 至 279 行:

packages/core/agent-loop/src/agent.ts245:279
245  /** Open one turn before claiming its first proposed step. */246  private async turn(): Promise<boolean> {247    if (this.phase.kind !== 'running') {248      this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))249    }250    const phase = this.phase251    const { signal } = phase.abort252    signal.throwIfAborted()253    const turn = phase.turn + 1254    try {255      this.session.append('turn/start', { turn })256    } catch (error: unknown) {257      this.throwError(error)258    }259    phase.turn = turn260    let turnEnds: TurnEndReason | null = null261    let target: InboxTarget = 'next-turn'262    try {263      while (true) {264        signal.throwIfAborted()265        const step = phase.step + 1266        const decision = await this.preStep(target, { turn, step })267        if (decision.kind === 'reject') {268          turnEnds = { kind: 'blocked' }269          return false270        }271        if (turnEnds && decision.messages.length === 0) break272        // A removed waking message or an enter decision rewritten to empty273        // still owns the initial turn boundary, but it spends no model call.274        if (phase.step === 0 && decision.messages.length === 0) {275          turnEnds = { kind: 'completed' }276          return false277        }278        signal.throwIfAborted()279        this.session.append('step/start', { turn, step })

turn/start 先落日志,然后才推进 phase 里的 turn 编号,日志先行。内层 while (true) 是 step 循环:每一步先向 preStep 要一个决策。三个提前退出的分支依次是:决策为 reject,turn 以 blocked 关闭,返回 false(对应场景还原里「只有两行日志」的现象,turn/startturn/end 夹着一个 blocked 理由);已经有终止理由且新决策被改写成空消息,直接 break 走正常关闭;第一个 step 的决策就是空的(唤醒消息被清除,或 enter 被插件重写为空),turn 以 completed 关闭但一个模型调用都不花。往下是 step 边界,285 至 300 行:

packages/core/agent-loop/src/agent.ts285:300
285          // max-tokens is sticky: once any step hits the ceiling, later steps286          // that complete normally must not downgrade the turn outcome.287          const stepEnd = await this.step(decision.assembly)288          // max-tokens stays sticky: a later completed step must not289          // downgrade the turn outcome.290          if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd291        } finally {292          this.session.append('step/end', { turn, step })293        }294        signal.throwIfAborted()295        if (turnEnds && this.inbox.nextStep.length === 0) {296          await this.dispatch.serial('agent/turn-stopping', { turn, signal })297          signal.throwIfAborted()298        }299        if (turnEnds && this.inbox.nextStep.length === 0) break300        target = 'next-step'

三件事值得注意。第一,step/end 写在 finally 里,任何 step 无论成败都以 step/end 闭合,与 step/start 成对。第二,max-tokens 是粘性的:一旦某个 step 触顶,后面正常完成的 step 不能把 turn 的结局从 max-tokens 降级回 completed,注释前后写了两遍,这是作者特意强调的语义。第三,turn 的停止不是一个直接 break,而是一个两段检查:当 turn 已有终止理由且 nextStep 队列为空时,先跑一次 agent/turn-stopping 串行检查点,让插件有最后发言机会;检查点过后再确认队列仍空,才 break。如果这中间有 steer 输入插进来,条件不成立,target 切到 next-step,同 turn 直接开下一个 step,steer 不会丢。

turn 的收尾在 302 至 330 行:

packages/core/agent-loop/src/agent.ts302:330
302    } catch (error: unknown) {303      if (signal.aborted) {304        turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }305        throw error306      }307      // Every failure is structured: an `LlmError` keeps its facts, anything308      // else flattens to `errorChain` text under the `UNKNOWN` code.309      turnEnds = {310        kind: 'error',311        error: error instanceof LlmError312          ? error.failure313          : { message: errorChain(error), code: 'UNKNOWN' },314      }315      this.throwError(error)316    } finally {317      try {318        // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending319        this.session.append('turn/end', { turn, reason: turnEnds! })320      } catch (error: unknown) {321        this.throwError(error)322      }323    }324    if (!this.inbox.hasPending) return false325    phase.abort = new AbortController()326    // A fresh controller makes a latch set on the old one stale: the live driver claims the queue itself.327    phase.wakeRequested = false328    phase.step = 0329    return true330  }

中止时 turnEndsaborted 并带上 signal.reason 里的取消原因;其他错误全部结构化成 LlmError 原样事实或 errorChain 展平文本。turn/end 写在 finally 里,旁边注释断言「every exit assigns a turn ending」:拒绝、空决策、完成、中止、错误,任何路径退出时 turnEnds 都有值,日志一定有终局。循环尾部:队列里还有 pending 输入就换一个全新的 AbortController(旧控制器上的闩锁随之失效,活的驱动自己 claim 队列)、把 step 归零、返回 true 开下一个 turn。

上面三个代码块合起来是一条完整的 turn 时序,这张图回答「一个 turn 从打开到闭合,事件按什么顺序落日志」:

sequenceDiagram participant U as 用户或插件 participant I as 输入箱 participant W as pre-step 瀑布 participant L as 会话日志 participant M as 模型与工具 U->>I: followup 或 steer 投递 I->>W: claim 取出批次并删除 W->>L: agent/inbox/claimed W->>W: 改写或拒绝 alt 进入 W->>L: turn/start 与 step/start W->>M: 请求与工具调用 M->>L: chunk 与 tool 事件 else 拒绝 W->>L: turn/end blocked end L->>U: turn/end 终局事件

preStep 是 claim 与决策的交汇点,225 至 243 行:

packages/core/agent-loop/src/agent.ts225:243
225  private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> {226    /* v8 ignore next -- private callers establish the running phase before proposing a step */227    if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)228    const signal = this.phase.abort.signal229    const claimed = this.inbox.claim(target, position.turn)230    const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))231    signal.throwIfAborted()232    const sections = renderContextSections(assembly)233    const context = this.runtimeContext.project(joinContextSections(sections), sections)234    const decision = await this.dispatch.waterfall(235      'agent/pre-step', { messages: claimed, ...position, signal },236      (): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({237        kind: 'enter',238        messages: context === undefined ? claimed : [...claimed, context],239      }),240    )241    signal.throwIfAborted()242    return decision.kind === 'reject' ? decision : { ...decision, assembly }243  }

顺序是:claim 取出输入批次 → 装配系统提示词与工具 schema → 投影运行时上下文(会话统计、日期这类动态段)→ 跑 agent/pre-step 瀑布 → 检查取消。瀑布的默认行为是原样 enter,把上下文段追加在 claimed 消息之后;插件可以改写消息、可以拒绝、也可以把 enter 改成空批次。瀑布之后又查了一次 signal.throwIfAborted(),因为插件等待期间 cancel 可能已经落下。注意上下文投影发生在瀑布之前,且进入 enter 后 assembly 会随决策一起返回,step 直接用装配结果。

现在补上 claim 的本体,packages/core/agent/src/inbox.ts 63 至 78 行:

packages/core/agent/src/inbox.ts63:78
63  /**64   * Remove and return the complete batch proposed for one step, publishing65   * each claimed message. The durable splices are pure deletions.66   * @param target - whether this boundary also consumes one queued turn.67   * @param turn - turn that will own the claimed batch.68   * @returns next-step input followed by the queued turn, when requested.69   * @internal - The agent loop's step-boundary operation, not a plugin extension point.70   */71  claim(target: InboxTarget, turn: number): UserMessage[] {72    const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)73    if (target === 'next-turn') {74      claimed.push(...this.mutate('next-turn', 0, 1, [], false))75    }76    for (const message of claimed) this.notifications.claimed(message, turn)77    return claimed78  }

claim 做两件事:把 next-step 整队取出,目标为 next-turn 时再取一个排队消息;然后逐个广播 claimed 通知。第 72 行 mutate 的最后一个参数是 discardRemoved: false:claim 的删除不触发 discarded 通知,JSDoc 明说「The durable splices are pure deletions」。删除动作本身是持久化的,底层 splice 先写 agent/inbox/spliced 日志事件再改内存投影,128 至 146 行:

packages/core/agent/src/inbox.ts128:146
128  /**129   * Apply standard splice semantics and durably record the normalized result.130   * The durable event commits before the live projection mutates, so synchronous131   * `session/event` observers see the pre-splice lists and can reconstruct the132   * removed messages from the normalized coordinates.133   * @param target - pending list to mutate.134   * @param start - splice position.135   * @param deleteCount - maximum number of messages to remove.136   * @param inserted - messages to insert at the resolved position.137   * @returns messages removed by the splice.138   */139  splice(140    target: InboxTarget,141    start: number,142    deleteCount: number,143    inserted: UserMessage[],144  ): UserMessage[] {145    return this.mutate(target, start, deleteCount, inserted, true)146  }

JSDoc 说的是持久化纪律:durable 事件先提交,同步观察者看到的是删除前的列表,能根据归一化坐标重建被删消息。所以「删除」在 dsh 里的含义是「从投影里消失,但删除动作本身留在日志里」,这与第 3 章的 Model-visible 与 logged 双向原则是一套机制。

接下来是 step 本体。332 至 354 行:

packages/core/agent-loop/src/agent.ts332:354
332  private async step(assembly: PromptAssembly): Promise<StepEndReason | null> {333    /* v8 ignore next -- private callers establish the running phase before executing a step */334    if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)335    const { turn, step, abort: { signal } } = this.phase336    signal.throwIfAborted()337    const system = renderPrompt(assembly)338339    while (true) {340      const { request, preparedCall } = await this.buildRequest(341        turn, step, assembly.tools, system, this.session.deriveMessages(), signal,342      )343      const assembler = new BlockAssembler()344      const chunkSeqs: number[] = []345      try {346        const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)347        signal.throwIfAborted()348        for await (const chunk of stream) {349          signal.throwIfAborted()350          chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)351          assembler.push(chunk)352        }353        signal.throwIfAborted()354      } catch (error: unknown) {

buildRequest 用日志推导出的边界消息组装一次请求(deriveMessages() 见 341 行),并绑定 adapter 的精确模型默认值。流式输出阶段:每个 chunk 先落 assistant/chunk 日志事件并记下 seq,再推进汇编器。chunk 日志保留了原始输出流,UI 和回放可以直接还原逐字效果。while (true) 在这里只服务一个目的,往下看 372 至 390 行:

packages/core/agent-loop/src/agent.ts372:390
372      const finish = assembler.finish373      if (finish.kind === 'error' || finish.kind === 'aborted') {374        const action = await this.dispatch.waterfall(375          'agent/request-error', {376            turn,377            step,378            provider: request.provider,379            failure: finish.failure,380            retryPolicy: preparedCall?.retryPolicy,381            signal,382          },383          () => Promise.resolve<RequestErrorAction>(undefined),384        )385        signal.throwIfAborted()386        if (action?.kind !== 'retry') {387          throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)388        }389        continue390      }

流式结束但块不完整(finish.kinderroraborted)时,跑 agent/request-error 瀑布,默认不重试直接抛 LlmError;插件返回 { kind: 'retry' }continue 回 while 顶部重新 buildRequest。所以 step 内的 while (true) 是同一步内的重试循环,它与工具续请求无关,工具是否续请求由 410 至 418 行决定:

packages/core/agent-loop/src/agent.ts410:418
410      if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }411412      const toolCalls = message.content.filter(block => block.type === 'tool-call')413      if (toolCalls.length === 0) return { kind: 'completed' }414      const { concluded } = await executeToolCalls(415        this.loopCtx, turn, step, toolCalls, signal,416        context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),417      )418      return concluded ? { kind: 'completed' } : null

触顶返回 max-tokens;没有工具调用直接 completed;有工具调用就执行,concluded 为 true 时 step 完成,为 false 时返回 null,由 turn 层决定同 turn 下一个 step。executeToolCalls 的 acceptor 参数把工具产生的上下文投回 next-step 队列,这正是「步骤之间的输入衔接」:工具结论是下一轮请求的输入。

executeToolCalls 的完整实现在 packages/core/agent-loop/src/tool-calls.ts 59 至 101 行:

packages/core/agent-loop/src/tool-calls.ts59:101
59export async function executeToolCalls(60  ctx: Context,61  turn: number,62  step: number,63  toolCalls: ToolCallBlock[],64  signal: AbortSignal,65  acceptContext: (context: UserMessage) => void,66): Promise<{ concluded: boolean }> {67  const agent = ctx.agents.requireInitiator()68  const { session } = agent6970  // Inputs are distinct because tools/execute wrappers may replace `exec.signal`.71  const planned: PlannedCall[] = toolCalls.map(block => ({72    block,73    exec: {74      callId: block.id,75      name: block.name,76      arguments: parseArguments(block.arguments),77      agent,78      signal,79    },80  }))8182  let next = 083  let concluded = false84  while (next < planned.length) {85    // Commit before classifying again so registry changes affect unstarted calls.86    // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition87    const first = planned[next]!88    const mode = ctx.tools.executionMode(first.exec).kind89    const group = mode === 'parallel' ? planned.slice(next) : [first]90    const outcome = await runGroup(91      ctx, turn, step, group, mode, signal, acceptContext,92    )93    next += outcome.consumed94    concluded ||= outcome.concluded95    if (outcome.aborted) {96      for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block)97      return { concluded }98    }99  }100  return { concluded }101}

调度按并发模式分组:排他调用构成屏障一次跑一个,并行调用切进滚动池。模型顺序由 runGroup 内部的 committed 游标保证:只有连续完成的槽位才按序提交结果。第 96 行的 abort 分支把尚未启动的调用逐个补上合成结果,这个细节在 237 至 259 行:

packages/core/agent-loop/src/tool-calls.ts237:259
237  if (aborted) {238    // Started calls and accepted context settle first; every remaining model239    // call then receives an ordered synthetic result before the turn aborts.240    for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)241    return { consumed: group.length, aborted: true, concluded }242  }243  /* v8 ignore next -- unreachable: a non-aborted group commits every started call */244  if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')245  return { consumed: started, aborted: false, concluded }246}247248/** Append the durable call/result pair for a model call skipped after cancellation. */249function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void {250  const callSeq = appendToolCall(session, turn, step, block)251  appendToolResult(session, turn, step, block, {252    content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],253    isError: true,254    error: {255      message: 'tool call aborted before dispatch',256      info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },257    },258  }, callSeq)259}

模块头注释(tool-calls.ts 8 至 9 行)说明了动机:中止时给跳过的调用补合成错误结果,回放才保持有效。模型已经发出了 tool-call,日志里必须出现配对的 tool/result,否则从日志推导出的历史里会出现「请求了工具却没有结果」的破洞。

最后补一处请求锚点。buildRequest 尾部 484 至 489 行:

packages/core/agent-loop/src/agent.ts484:489
484    if (!this.requestHeaderLogged) {485      this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })486      this.requestHeaderLogged = true487    } else if (baseline === undefined || !headerEquals(baseline, header)) {488      this.session.append('request/header', { header, reason: 'change' })489    }

请求配置的演变本身也写日志:第一次是 initial,重载后首次是 resume,配置变化时是 change。回放时能还原「这个请求用什么模型、什么配置发出」的完整历史。到这里,「一次模型请求如何被决定发起、如何结束」的链路已经完整:发起需要 inbox 有输入、claim 取出、瀑布放行、日志推导消息、请求落 assistant/chunkassistant/message;结束需要 step/end 配对、工具结果配对、turn/end 在 finally 兜底。

设计决策分析

四个决策,每个都问「为什么这样做,不这样做会出什么事」。

决策一:turn 与 step 双层边界。 架构文档 docs/architecture.md 63 至 65 行给定义:

docs/architecture.md63:65
63## Turn flow6465A **step** is one model request plus the tools it calls. A **turn** is zero or more steps: it opens before its first input is claimed and closes once nothing is owed.

step 是模型侧的最小可观察单位,一次请求加它调的工具;turn 是用户侧的语义单位,从第一条输入被 claim 到一切欠账还清。分层的原因在代码里能直接看到:max-tokens 的粘性语义要跨多个 step 才有意义,一个 turn 里先触顶、后正常完成的 step 不能降级结局;拒绝也形成一个带 blocked 理由的 turn 终局,让上层(UI、workflow、回放)能区分「尝试过但被拦下」与「什么都没发生」。如果只有单层边界,被拒的输入就没有归属单元,日志里只有孤立的 turn/start,上层无法向用户解释「为什么我的消息没有被回复」。两层边界各留各的事件,UI 可以只订阅 turn 级事件做状态展示,回放可以拿 step 级事件做逐字还原。

决策二:输入决策开放成 waterfall。 .agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md 13 至 18 行是事件分派模式的完整清单:

.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md13:18
13Pure Cordis event taxonomy. The loop's extension points are typed events with deliberate dispatch modes:1415- **waterfall** (around-middleware) where plugins transform, short-circuit, recover, or wrap: `agent/pre-step`, `agent/request`, `agent/request-error`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`.16- **serial** (awaited in listener order) for ordered checkpoints such as `agent/turn-stopping`.17- **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint.18- **emit** (synchronous fire-and-forget) for notifications: inbox transitions, lifecycle, errors, and the contained immutable `tools/result` observation. Durable session events own turn and step boundaries.

同一个 note 的第 20 行写明,agent-loop 是唯一的具体循环实现,本身可替换,循环之外没有任何代码依赖它。输入决策(审核、改写、补上下文、拒绝)全部挂在 agent/pre-step 这个 waterfall 上,default 是原样进入。docs/subsystems/core.md 217 至 219 行把 payload 与语义说透:

docs/subsystems/core.md217:219
217`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), the proposed step's coordinates (`turn`, `step`), and the current turn's cancellation `signal`. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps:218219It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complete message batch appended after `step/start`; claimed messages omitted by the final decision remain removed, while input inserted after the claim stays pending:

不这样做会怎样:任何新输入规则都要改 agent-loop 本体。仓库红线条款 AGENTS.md 108 至 109 行把这件事写成铁律:

AGENTS.md108:109
108- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.109- **Plugins, not loop changes**: new behavior goes on documented extension points; changing `agent-loop` requires updating docs/architecture.md.

第二条直译:新行为上文档化的扩展点,改 agent-loop 必须同步改架构文档。每一条红线背后都是一次真实事故,这条线保护的是「循环是唯一、稳定、可替换的」。waterfall 的代价是语义要学:监听者必须调用 next() 放行,直接 return 会短路整条链,这条语义同样被写进了 AGENTS.md 107 行。

决策三:claim 是破坏性的取出。 三个证据已经读过:inbox.ts 的 JSDoc「pure deletions」、core.md 219 行「claimed messages omitted by the final decision remain removed」、以及 agent/inbox/claimed 事件的 JSDoc,packages/core/agent/src/runtime-types.ts 187 至 197 行:

packages/core/agent/src/runtime-types.ts187:197
187    /**188     * One message left the inbox inside its open turn. If the proposed step189     * is rejected, the claimed message ends here: it is neither discarded nor190     * re-emitted as a user/message, and the turn closes without a step.191     * @param payload.agent - the agent whose inbox changed.192     * @param payload.message - the claimed message.193     * @param payload.turn - the owning turn.194     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.195     * @mode emit196     */197    'agent/inbox/claimed'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; turn: number }): void

「neither discarded nor re-emitted」:被拒的输入不触发丢弃通知、也不重新作为 user/message 出现。为什么做成不可逆:日志是唯一事实源,一次输入只能被消费一次。如果拒绝后自动放回,重放日志时同一输入会被投影两次,模型会看到重复消息,插件与驱动对「这条消息还在不在队列里」的判断也会分裂。代价是插件没有反悔权:一旦 reject,输入就从所有队列消失。但这个代价有补偿,删除动作本身在 agent/inbox/spliced 日志里有归一化坐标,要找回被删的消息可以从日志重建,丢失的是「它还在队列里」这个状态,不丢失事实。

决策四:终局日志写在 finally。 turn/end 的写入在 try/catch 之外单独一个 finally,turnEnds! 的非空断言旁边就是注释「every exit assigns a turn ending」。reject、空决策、正常完成、中止、错误,五条路径全部收敛到同一行日志写入,reason 一定有值。不这样做会怎样:某条路径忘了写终局,事件流里 turn/start 出现而 turn/end 缺失,UI 永远显示「正在思考」,回放无法闭合,上层等待收敛的逻辑(whenIdle)永久悬挂。把结束事件放进 finally,是让「每对 start/end 必然成对」从约定变成结构。

边界条件剖析

追问一:pre-step 里插件拒绝后,claimed 的输入去哪了?

分两层回答。被 claim 的那批输入已经由 mutate(..., false) 从投影里删除(inbox.ts:71-78),拒绝不会把它放回,也不会重发 user/message;turn 以 blocked 结束(agent.ts:267-269),然后 turn/end 在 finally 落日志。claim 之后新插入的输入不受影响,留在 next-step 队列,等下一个 turn 的 claim。三层证据都齐:测试 packages/core/agent-loop/tests/interception.spec.ts 234 至 255 行完整断言了这条路径:

packages/core/agent-loop/tests/interception.spec.ts234:255
234  it('reject closes the claimed prompt turn without a step or model call', async () => {235    const adapter = new MockAdapter([textResponse('should not run')])236    const ctx = await harness(adapter)237    const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })238239    ctx.on('agent/pre-step', async (): Promise<PreStepDecision> => ({ kind: 'reject' }))240241    const reasons: TurnEndReason[] = []242    ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })243244    agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } }))245    await agent.whenIdle()246247    // the model was never called248    expect(adapter.requests).toHaveLength(0)249    const log = events(agent)250    expect(log.filter(e => e.type === 'turn/start' || e.type === 'turn/end').map(e => e.type))251      .toEqual(['turn/start', 'turn/end'])252    expect(log.some(e => e.type === 'user/message')).toBe(false)253    expect(log.some(e => e.type === 'step/start')).toBe(false)254    expect(reasons).toEqual([{ kind: 'blocked' }])255  })

模型零调用、日志只有 turn/startturn/end、没有 user/messagestep/start、reason 精确等于 blocked。同文件 318 至 363 行的另一个测试补上了「claim 之后 staged 的输入」那半边:

packages/core/agent-loop/tests/interception.spec.ts318:363
318  it('preserves input staged after the blocked batch was claimed', async () => {319    const adapter = new MockAdapter([textResponse('retried')])320    const ctx = await harness(adapter)321    const agent = ctx.agentLoop.create(SessionId('blocked-pre-step-outbox'), { provider: 'mock', model: 'mock' })322    const entered = Promise.withResolvers<undefined>()323    const decision = Promise.withResolvers<PreStepDecision>()324    const disposeBlock = ctx.on('agent/pre-step', async () => {325      entered.resolve(undefined)326      return decision.promise327    })328329    const blockedIdle = waitForIdle(ctx, agent)330    send(agent, 'blocked prompt')331    await entered.promise332    agent.inject(createUserMessage({333      content: [{ type: 'text', text: 'staged context' }],334      source: { kind: 'plugin', plugin: 'test' },335    }))336    agent.steer(createUserMessage({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } }))337    decision.resolve({ kind: 'reject' })338    await blockedIdle339340    expect(agent.inbox.nextStep.map(message => message.content[0]))341      .toEqual([342        { type: 'text', text: 'staged context' },343        { type: 'text', text: 'staged steering' },344      ])345    expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'turn/end')346      .map(event => event.type)).toEqual(['turn/start', 'turn/end'])347    expect(adapter.requests).toEqual([])348349    disposeBlock()350    send(agent, 'resume')351    await waitForIdle(ctx, agent)352353    const staged = events(agent).filter(event =>354      event.type === 'user/message')355    expect(staged.map(event => event.type)).toEqual([356      'user/message',357      'user/message',358      'user/message',359    ])360    expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt')361    expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged context')362    expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering')363  })

reject 之后两个 staged 输入还躺在 nextStep(340-344 行);解除拦截、补发一条唤醒消息后,新 turn 的请求里没有 blocked prompt,只有 staged 的两条(360-362 行)。被拒的输入永久缺席,claim 之后到达的输入完整保留,这就是 claim 语义的边界。

追问二:step 刚结束、turn-stopping 检查点之前,steer 输入到达怎么办?

回到 agent.ts:295-300。检查点有两个前置条件:turnEnds 非 null 且 nextStep 为空。steer 会把 nextStep 填上,条件不成立,agent/turn-stopping 不执行、break 不执行,target 切到 next-step,同 turn 直接开下一个 step。所以 step 结束到 turn 收尾之间的窗口不是空洞:steer 落在窗口里会被下一个 step 的 claim 接走。这能成立的前提是 step 结束与检查点之间的检查发生在同一次同步逻辑里,中间没有 await 让输入插入后被漏看。检查点本身也是一个二次确认:agent/turn-stopping 是 serial 事件,插件在 turn 真正闭合前还有一次插入机会,所以检查点之后再查一次 nextStep 才 break。若插件在检查点里投了输入,turn 继续执行,不会关闭。

追问三:cancel 落在工具执行中途,会发生什么?

两条路径并行收尾。已启动的工具调用排空并按模型顺序提交结果;尚未启动的调用逐个补上 tool/calltool/result 的合成错误对(tool-calls.ts:237-246、248-259),错误码是 TOOL_ABORTED_BEFORE_DISPATCH。然后异常向 turn 层抛,catch 里检测到 signal.abortedturnEnds{ kind: 'aborted', reason: signal.reason }(agent.ts:302-306),finally 写 turn/end。合成结果是硬性要求:模型已经发过 tool-call,日志里必须有配对 result,否则从日志投影出的模型历史出现「有请求无结果」的断口,下一轮请求的消息序列就不成立。这就是「取消不破坏日志完备性」的落地方式:中止也要留下完整的、可回放的账。

横向对比

对比的问题是:循环状态如何管理。两侧给出的是两种相反的答案。

侧 A,dsh 的 ReactLoopAgent:隐式数据驱动。控制流不写在代码里,写进数据和事件里:输入进 inbox,事件唤醒驱动,claim 取出输入,瀑布决定下一步,日志是唯一事实源。循环状态 = 内存里的三态 Phase 加日志里的完整事件流。这一侧我们已经精读完。

侧 B,对比语料 claude-code(commit 45bdfa96):未找到对应实现,检索关键词为 main loop、agent loop、ReactLoop、turn、step、loop、transcript、session log、query。仓库根目录只有 CHANGELOG.md、examples/、plugins/、scripts/、README.md、SECURITY.md,全仓库的 .ts/.js 文件仅 5 个,且全部位于 scripts/ 下,是 GitHub issue 检索类工具脚本,不含主产品源码。plugins/README.md 第 3 行是能找到的最接近产品定位的描述:

plugins/README.md3:3
3This directory contains some official Claude Code plugins that extend functionality through custom commands, agents, and workflows. These are examples of what's possible with the Claude Code plugin system—many more plugins are available through community marketplaces.

产品是闭源的终端 CLI,主循环不在公开仓库里。它内部有循环是确定的,形态无从确认(这是推断:按交互式终端的产品形态,它必然维护某种会话上下文与工具循环,但实现细节不可见)。所以这一组对比改用 dsh 内部的两套驱动补足:同一仓库里,agent-looppackages/workflow/workflow-worker-thread 回答了同一个问题的两种答案。

侧 B',workflow 的 worker-thread 引擎:显式脚本驱动。程序员写一段顺序脚本,agent() 是脚本里唯一的交互钩子,每次调用派发一个子 agent。引擎不管「下一步做什么」,只管纪律。runtime.ts 249 至 262 行是钩子头:

packages/workflow/workflow-worker-thread/src/runtime.ts249:262
249  /** The `agent(prompt, opts)` hook. */250  private async agent(rawPrompt: unknown, rawOpts: unknown): Promise<unknown> {251    this.throwIfCancelled()252    if (typeof rawPrompt !== 'string' || rawPrompt.length === 0) {253      throw new WorkflowError('agent() requires a non-empty prompt string', 'INVALID_ARGUMENT')254    }255    const opts = this.readAgentOptions(rawOpts)256    if (this.started >= this.limits.maxTotalAgents) {257      throw new WorkflowError(258        `this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`,259        'AGENT_CAP',260      )261    }262    this.started += 1

每次 agent() 先查取消,再查总上限 maxTotalAgents,注释自述是「runaway-loop backstop」,防失控循环的兜底。每次调用都是跨 worker 的一次 ChildStart RPC(session.ts 74 至 90 行),带独立的 pending 簿记:

packages/workflow/workflow-worker-thread/src/session.ts74:90
74  async startAgent(request: ChildStartRequest): Promise<ChildHandle> {75    this.nextCallId += 176    const callId = this.nextCallId77    const entry: PendingChild = {78      started: Promise.withResolvers<string>(),79      settled: Promise.withResolvers<ChildResult>(),80      disposed: Promise.withResolvers<void>(),81    }82    // Containment: when asynchronous provider start fails (or83    // the run is torn down), the settled promise may never gain a consumer —84    // it must not surface as an unhandled rejection and kill the worker.85    entry.settled.promise.catch(() => { /* consumed: unconsumed child settlement after failed start */ })86    this.pending.set(callId, entry)87    this.post(WorkerToHostType.ChildStart, { callId, request })88    const childId = await entry.started.promise89    return new RpcChildHandle(this.post, callId, entry, childId)90  }

同一个问题「循环状态如何管理」,两种答案:隐式驱动把状态钉在日志事件加内存 Phase,控制流由数据和瀑布推导,插件能插进任意边界,代价是状态机复杂(三态、abort 传播、wake 闩锁),要推理「这个请求为什么发出」必须回放日志;显式脚本驱动把状态钉在脚本局部变量加 worker 槽位,控制流写在脚本里一眼可读,代价是每次交互都是隔离的子 agent,走 IPC、看不到对方实时流,插件化粒度是脚本级,够不到 step 边界。agent-loop 用复杂度换来了插件密度,workflow 用隔离换来了确定性。这张流程图回答「同一仓库里两套驱动各自的状态与交汇点」:

flowchart LR subgraph A["隐式数据驱动 agent-loop"] A1["日志推导请求输入"] --> A2["三态 Phase 状态机"] A2 --> A3["claim 后瀑布决策"] end subgraph B["显式脚本驱动 workflow"] B1["脚本顺序调用"] --> B2["agent 钩子"] B2 --> B3["并发槽与总上限"] end A3 --> C["事件成对留痕"] B3 --> C

互动演示设计

形态:模拟器。一句话结论:模型每发起一次请求,都从「claim 下一轮输入」这个动作开始;每个 turn 无论结果如何,都以一条 turn/end 收尾。

舞台比喻是一条邮局流水线。元素:传送带(inbox,两条道:绿色 next-turn、蓝色 next-step,消息是信件)、安检门(agent/pre-step 瀑布,两个插件灯泡依次亮)、车间工位(step 流水线:请求、chunk 打字机、工具工位)、出口台账(session log 滚动条,每行一条事件)、总闸(cancel 按钮)。

分六步动画,每步配字幕:

  1. 投递:「followup 投递的信进绿道,唤醒驱动;inject 投的信只进蓝道,静默等着。」
  2. claim 抓取:「claim 把蓝道整队加一个绿道信取走,逐封广播 agent/inbox/claimed,传送带变空。」
  3. 安检门:「两个插件灯泡依次亮,可改写信、可拒收;默认放行并附上运行时上下文。」
  4. 流水线:「turn/start、step/start 落台账,信变成 user/message,请求发出,chunk 打字机逐字回填。」
  5. 工具工位:「工具调用分槽执行,结果按模型顺序落账;concludesTurn 为假就再开一个 step。」
  6. 收尾:「台账写下 turn/end,reason 亮绿字 completed;如果队列还有信,直接开下一个 turn。」

两个变体分支。拒绝变体:第 3 步把安检门开关拨到「拒绝」,流水线被跳过,第 6 步的 reason 亮红字 blocked。中止变体:第 5 步按总闸,未启动的工具信被盖上「tool call aborted before dispatch」红章,reason 亮黄字 aborted。

读者可操作:三个投递按钮(followup / steer / inject)、安检门拒绝开关、总闸、调速滑块。台账每一行可点击,高亮对应代码行。

逻辑轨迹面板,随动画步进逐行高亮,右侧标真实行号:

text
phase = { kind: 'idle', lastTurn }            // agent.ts:93
send 分类投递,落 inbox                      // agent.ts:113-120
wakeDriver 开 running 并 kick                // agent.ts:172-193
while (await turn()) {}                      // agent.ts:212
  turn/start 先落日志                        // agent.ts:255
  claimed = inbox.claim(target, turn)        // agent.ts:229  inbox.ts:71
  agent/pre-step 瀑布                        // agent.ts:234-240
  reject -> turn/end blocked                 // agent.ts:267-269, 316-323
  step/start -> user/message -> step()       // agent.ts:279-287
  step/end 写在 finally                      // agent.ts:291-293
  next-step 空且 turnEnds -> 停止检查点      // agent.ts:295-300
  turn/end 写在 finally,reason 必有值       // agent.ts:316-323

演示页底部一行大字:每一次请求都从 claim 开始,每一个 turn 都以 turn/end 结束。

可迁移结论

第一,循环的每次迭代以配对事件开闭,结束事件放 finally。这是本章最值得抄的一条,且不依赖任何语言特性。最小形态:任意语言里 emit(turn_start); try { 干活 } finally { emit(turn_end, reason) }。自己写 agent 循环时,先定 start/end 事件对再写业务逻辑,reason 永远有值,上层永远能判断「这次交互结束了、为什么结束」。这个习惯的回报在排障时最大:没有终局记录的循环,出问题时只能猜。

第二,输入消费做成不可逆取出。如果系统的输入事实源是日志或回放流,消费即删除,中间层要材料就趁取出时拿。claim 的价值在于一次输入只有一次归属,拒绝之后不会幽灵一样回到队列。最小形态:一个 take() 方法返回批次并从队列摘除,决策层拿批次时就知道它不再属于队列。代价写清楚:决策层不能反悔,这个语义要像 core.md 219 行那样用一句话钉死在文档里。

第三,双层边界:最小可观察单位与用户语义单位分开,各自留日志。step 供回放与 UI 细粒度渲染,turn 供用户与上层工作流判断「这次交互完没完」。最小形态:每层一个 start/end 事件对,两层各自独立,不要为了省事件把两层压成一层。

第四,本章有些设计是这个体量下才需要的过度设计。三态 Phase、wake 闩锁、abort 重放、turn-stopping 二次确认,全部是为「取消与唤醒并发、插件可在任意边界插入、进程可重启续跑」这三个前提服务的。如果自己写一个单线程、无取消、无插件的 agent,砍到最小形态:while (queue.hasNext()) { step(queue.take()) } 就够了,闩锁和状态机都是负担。识别「哪些复杂度是威胁模型买的单」,比照抄整套状态机更重要。

思考题

  1. 动手题:打开 packages/core/agent-loop/tests/interception.spec.ts 第 234 至 255 行的 reject 测试,把 ctx.on('agent/pre-step', ...) 回调改成记录 claimed 数组(let claimed: UserMessage[] = [],回调里先 claimed = messages 再返回 reject),然后加两条断言:expect(claimed).toHaveLength(1)expect(agent.inbox.hasPending).toBe(false)。在仓库根执行 pnpm exec vitest run packages/core/agent-loop/tests/interception.spec.ts,两条都应通过,这是「claim 破坏性取出」的直接证据。再把第二条断言改成 toBe(true) 重跑,观察它失败,对照 318 至 363 行测试理解「为什么失败反而证明设计成立」。

  2. 概念题:把 agent.ts:178 的闩锁条件改为无条件设置(去掉 reason?.kind !== 'disposed'this.phase.kind === 'maintenance' 的判断),跑 packages/core/agent-loop/tests/cancel.spec.ts,哪些用例变红?结合 .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md 说明:live driver 已经会自己 claim 队列,为什么多余的闩锁会造成重复唤醒。

  3. 概念题:如果产品需求变成「pre-step 拒绝后输入自动退回队列」,需要改动哪些位置?至少列出:inbox.ts 的 claim 与 discardRemoved 语义、core.md:219 的句子、runtime-types.ts:188-190 的 claimed JSDoc、agent.ts 的 blocked 分支。改动后会引入什么问题?提示:考虑插件反复拒绝时输入在队列与瀑布之间循环,以及重放日志时同一输入被投影两次。

  4. 语言无关题:用你熟悉的语言写一个 15 行内的 agent 循环骨架:从队列不可逆取输入、处理、以 start/end 事件对闭合迭代(结束事件必须在异常路径也执行)。对照本章第 316 至 323 行的 finally 写法,检查你的骨架里有没有「某个 return 路径漏了结束事件」。