refactor(agent-loop): simplify message machine

This commit is contained in:
_Kerman
2026-07-30 13:49:57 +08:00
parent d554ae3019
commit f2e20c1ef0
212 changed files with 1326 additions and 2382 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: 6bb8b12af69f54c2a75cd672e4d3802887808c76
README.zh.md: f9eb8aa3cdead427a88492e35c00eab80ba12f91
README.md: 33e349f8945b45bf322171d4c02b9a940a68f2c2
README.zh.md: 65a81ea82a02ea81bc3e0a8892fd23b281477df2

View File

@@ -55,7 +55,7 @@ Configured agents start automatically. A model call requires both `provider` and
The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue` with the same placement, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
The concrete driver routes `followup()`/`steer()`/`inject()` through one private `send()` primitive. A follow-up joins the queued FIFO and wakes the driver; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue` with the same placement, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
### Loop lifecycle (`agent.ts`)
@@ -65,7 +65,7 @@ Every provider call that reaches a successful finish appends exactly one `assist
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause.

View File

@@ -55,7 +55,7 @@ interface Config {
实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()``inject()` 会暂存到同一个 outbox接纳获准后会开启轮次记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering中途引导及与其一同暂存的上下文则继续待处理以供重试或之后获准的提示词使用。窗口之外steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue`,并携带相同的路由归类;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`
具体驱动器通过一个私有 `send()` 原语路由 `followup()`/`steer()`/`inject()`。后续消息加入排队 FIFO 并唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()``inject()` 会暂存到同一个 outbox接纳获准后会开启轮次记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering中途引导及与其一同暂存的上下文则继续待处理以供重试或之后获准的提示词使用。窗口之外steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue`,并携带相同的路由归类;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`
### 循环生命周期(`agent.ts`
@@ -65,7 +65,7 @@ interface Config {
`agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`在活跃轮次信号的控制下校验由适配器持有的推理reasoning强度并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR热模块替换不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志因此监听器可以在步骤之间更改推理强度而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID并单独解析新模型。
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`中间件、结果处理、工具及其他扩展失败直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end``user``parent` 记录 `aborted`dispose资源释放则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end``user``parent` 记录 `aborted`dispose资源释放则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
在步骤内独占调用形成屏障并行安全调用使用有界滚动池并在启动前重新分类。只有分发主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。

View File

@@ -7,47 +7,41 @@
* @module dsh-agent-loop/agent
*/
import type { Context } from 'cordis'
import { agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import type {
Agent,
CancelOptions,
AgentInterruptReason,
InboxPlacement,
AgentCancelCause,
AgentOptions,
AgentStatus,
SettleReason,
PromptDecision,
RequestError,
CancelOptions,
RequestErrorAction,
SendOptions,
} from '@deepseek-ai/dsh-agent'
import { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import {
BlockAssembler,
LlmError,
assertNever,
createAssistantMessage,
deepFreeze,
errorChain,
freezeMessage,
isHarnessError,
llmFailureOf,
llmRetryPolicyOf,
markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { AssistantMessage, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { AssistantMessage, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { Context } from 'cordis'
import { executeToolCalls } from './tool-calls.ts'
/** One completed step or a final-adapter failure eligible for recovery. */
type StepOutcome =
| { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean }
| { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
type Phase =
| { kind: 'idle'; lastTurn: number }
| { kind: 'collecting'; abort: AbortController; lastTurn: number }
| { kind: 'running'; abort: AbortController; turn: number; step: number }
type Admission =
| { kind: 'empty' }
| { kind: 'admitted'; claimed: UserMessage[]; messages: UserMessage[] }
| { kind: 'blocked' }
/**
* The concrete {@link Agent}: each `run()` owns one turn and repeats model
@@ -55,31 +49,18 @@ type StepOutcome =
*/
export class ReactLoopAgent implements Agent {
/** Prompts awaiting individual turns. */
private queued: { message: UserMessage; wakeup: boolean }[] = []
private queued: UserMessage[] = []
/** Input taken into the session log at step boundaries. */
private outbox: { message: UserMessage; steering: boolean }[] = []
private outbox: UserMessage[] = []
/** Whether observers see a running interval; consecutive turns share it. */
private busy = false
/** Whether an idle waking send has deferred driver admission. */
private wakeScheduled = false
/** Whether next-step input belongs to the current admission or open turn. */
acceptsNextStep = false
/** Abort owner for the current admission or turn. */
private abort: AbortController | undefined
/** Resolves when the current admission and turn exit. */
done: Promise<void> = Promise.resolve()
private phase: Phase
private driverDone: Promise<void> = Promise.resolve()
/** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */
/** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */
readonly scope: Scope
/** The agent's scoped composition context ({@link Agent.ctx}). */
readonly ctx: Context
/** Last turn number opened by this loop or present in its seeded log. */
private lastTurn: number
/** Whether the session log is owed a matching turn end event. */
private turnOpen = false
private stepOpen = false
/** Whether this loop instance has appended its initial/resume request anchor. */
private requestHeaderLogged = false
@@ -89,474 +70,282 @@ export class ReactLoopAgent implements Agent {
public readonly options: AgentOptions,
public readonly session: Session,
) {
this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
this.phase = { kind: 'idle', lastTurn }
this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx.extend({ agent: this })
}
/** Last activity state published to observers. */
get status(): AgentStatus {
return this.busy ? 'running' : 'idle'
return this.phase.kind === 'idle' ? 'idle' : 'running'
}
/** Commit a phase and publish its externally visible status transition. */
private setPhase(next: Phase): void {
const previousStatus = this.status
this.phase = next
const status = this.status
if (status !== previousStatus) {
emitAgentEvent(this.loopCtx, this, 'agent/status', status)
}
}
/** Accept and route one unified send item. */
send(
message: UserMessage,
options: SendOptions,
): void {
const { target, wakeup } = options
if (target === 'next-step' && !wakeup) {
if (this.acceptsNextStep) {
this.outbox.push({ message, steering: false })
return
}
this.session.append('user/message', message, { surfaceOp: 'append' })
return
private send(message: UserMessage, target: 'next-turn' | 'next-step', wakeup: boolean): void {
this.session.append('agent/inbox/added', message)
// Waking input cannot join an aborted admission or turn, so it starts the next turn.
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
const inbox = target === 'next-turn' || wakingAfterAbort ? this.queued : this.outbox
inbox.push(message)
if (wakeup) {
this.scheduleKick()
}
const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued'
if (placement === 'steering') {
this.outbox.push({ message, steering: true })
} else {
this.queued.push({ message, wakeup })
}
// Preserve the routing decision for every send in this synchronous caller
// stack, while installing quiescence ownership before enqueue observers
// can cancel or dispose.
if (placement === 'queued' && wakeup) this.scheduleKick()
emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message, placement)
}
/** Queue one ordinary prompt turn and wake the driver. */
followup(input: UserMessage): void {
this.send(input, {
target: 'next-turn',
wakeup: true,
})
this.send(input, 'next-turn', true)
}
/** Steer the open turn, falling back to a waking prompt while idle. */
steer(input: UserMessage): void {
this.send(input, {
target: 'next-step',
wakeup: true,
})
this.send(input, 'next-step', true)
}
/** Append model-facing context without waking the driver. */
inject(input: UserMessage): void {
this.send(input, {
target: 'next-step',
wakeup: false,
})
this.send(input, 'next-step', false)
}
/**
* Clear all pending work and abort the active turn; the first cause wins.
* The cause is signal payload for observers and the durable turn/end
* classification — it selects no machine behavior. Teardown is just
* `cancel({kind:'disposed'})` + await {@link done} + {@link scope} dispose,
* all owned by the factory.
* `cancel({kind:'disposed'})` + driver join + {@link scope} dispose, all
* owned by the factory.
*/
cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void {
// Effective only when it aborts the active turn or actually discards
// pending work: a keepInbox call with no active turn is a documented
// no-op, so it must not emit cancel-requested for consumers to misread.
const discards = !options.keepInbox && (this.queued.length > 0 || this.outbox.length > 0)
if (this.abort !== undefined || discards) {
// Observe-only: coordination consumers update their state before the
// inboxes clear; listener failures are contained by the dispatcher.
if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)
}
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
if (!options.keepInbox) {
const discarded = this.queued.map(item => item.message)
for (const item of this.outbox) {
if (item.steering) discarded.push(item.message)
for (const message of [...this.outbox.splice(0), ...this.queued.splice(0)]) {
emitAgentEvent(this.loopCtx, this, 'agent/inbox/canceled', message)
}
// Clear before abort observers run: replacement work belongs to the next turn.
this.queued.length = 0
this.outbox.length = 0
if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
}
const reason = Object.freeze({ kind: cause.kind })
this.abort?.abort(reason)
}
/** Resolve at idle quiescence: no run driving and no waking prompt waiting. */
async whenIdle(): Promise<void> {
// `done` is replaced per activity, so re-reading it follows chained turns.
// Every driver failure today is contained before it can reject `done`,
// but the waiter must not gamble quiescence on that: a future escape
// still counts as settled activity.
/* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */
while (this.busy || this.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) {
await this.done.catch(() => undefined)
if (this.phase.kind !== 'idle') {
this.phase.abort.abort(cause)
}
}
/** Defer idle admission while keeping {@link done} as its quiescence owner. */
/** Reserve a driver before deferring idle admission. */
private scheduleKick(): void {
if (this.abort !== undefined || this.wakeScheduled) return
this.wakeScheduled = true
const pending = Promise.withResolvers<void>()
const scheduled = pending.promise
if (this.phase.kind !== 'idle') return
const driver = Promise.withResolvers<void>()
this.driverDone = driver.promise
this.setPhase({ kind: 'collecting', abort: new AbortController(), lastTurn: this.phase.lastTurn })
queueMicrotask(() => {
this.wakeScheduled = false
this.kick()
const activity = this.done
if (activity === scheduled) {
pending.resolve()
} else {
void activity.then(
() => { pending.resolve() },
() => { pending.resolve() },
)
}
this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject)
})
this.done = scheduled
}
/** Resolve after the current driver and synchronous replacement chain exits. */
async whenIdle(): Promise<void> {
let driver: Promise<void>
do {
await (driver = this.driverDone)
} while (driver !== this.driverDone)
}
private async kick(): Promise<void> {
try {
while (await this.turn()) {}
} catch (error: unknown) {
if (this.phase.kind !== 'idle') {
const turn = this.phase.kind === 'collecting' ? this.phase.lastTurn : this.phase.turn
this.setPhase({ kind: 'idle', lastTurn: turn })
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, 0, error)
}
} finally {
if (this.phase.kind === 'running') {
this.setPhase({ kind: 'idle', lastTurn: this.phase.turn })
}
}
}
/** Claim and admit the next queued prompt, then start its turn. */
private kick(): void {
if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return
// The some() guard above proves the queue is non-empty; the non-null
// assertion expresses that invariant.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { message } = this.queued.shift()!
const inheritedOutboxLength = this.outbox.length
const admission = new AbortController()
this.abort = admission
this.acceptsNextStep = true
// Claimed admission is part of the running interval: it is cancellable
// activity, so observers (and their cancel routing) must see it.
if (!this.busy) {
this.busy = true
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
private async admit(onTurnBoundary: boolean): Promise<Admission> {
if (this.phase.kind !== 'running') throw new Error()
const signal = this.phase.abort.signal
const claimed = this.outbox.slice()
const outboxLength = this.outbox.length
const queued = onTurnBoundary ? this.queued[0] : undefined
if (queued !== undefined) claimed.push(queued)
if (claimed.length === 0) return { kind: 'empty' }
const decision = await agentEvents(this.loopCtx, this).waterfall(
'agent/prompt-submit', claimed, signal,
() => Promise.resolve({ kind: 'allow', messages: claimed }),
)
signal.throwIfAborted()
if (decision.kind === 'allow') {
this.outbox.splice(0, outboxLength)
if (queued !== undefined) this.queued.shift()
return { kind: 'admitted', claimed, messages: decision.messages }
} else {
this.cancel({ kind: 'hook', reason: decision.reason }, { keepInbox: decision.keepInbox })
return { kind: 'blocked' }
}
// The admission body runs synchronously up to the prompt-submit
// waterfall's first await, so the waterfall snapshots its listeners
// before a disposal initiated by the running-status emit above can
// unregister a vetoing plugin.
this.done = this.loopCtx.agents.withInitiator(this, async () => {
const signal = admission.signal
const trigger: TurnTrigger = { kind: 'message', source: message.source }
// Admitted input stays on the stack until its turn/start commits: the
// turn owns it only once the turn exists in the log.
let admitted: UserMessage[] | undefined
try {
signal.throwIfAborted()
const decision = await this.loopCtx.waterfall(
agentCarrier(this), 'agent/prompt-submit', this, message, signal,
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
)
signal.throwIfAborted()
if (decision.kind === 'allow') {
admitted = [decision.content === undefined
? message
: freezeMessage({ ...message, content: decision.content })]
for (const context of decision.additionalContexts ?? []) {
admitted.push(freezeMessage(context))
}
}
} catch (error: unknown) {
if (!signal.aborted) {
this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(error)}`)
}
}
// cancel() aborts but never clears the slot, and kick()/run()
// all refuse to install a new owner while one exists, so the admission
// still owns the slot here and releasing it unconditionally is exact.
this.abort = undefined
if (admitted === undefined) {
this.acceptsNextStep = false
try {
this.flushRejectedAdmissionContexts()
} catch (error: unknown) {
// No turn exists for agent/error coordinates. Preserve the
// uncommitted suffix for a later boundary and report locally.
this.loopCtx.logger.warn(
`agent "${this.id}": committing rejected-admission context failed: ${errorChain(error)}`,
)
}
// A synchronously aborted admission would otherwise publish idle
// inside send()'s own synchronous extent, before any post-send
// subscriber could observe the transition.
await Promise.resolve()
this.continueOrIdle()
return
}
await this.run(trigger, admitted, inheritedOutboxLength)
})
// Published only after the abort owner and pending done are installed: a
// dequeue listener that cancels or disposes must find live cancellation
// and quiescence ownership, not the previous activity's settled state.
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message, 'queued')
}
/**
* Run one turn and any request-error retry. `admitted` input enters the log
* only after `turn/start` commits; until then it has no owner state to unwind.
*/
private async run(
trigger: TurnTrigger,
admitted: UserMessage[] = [],
inheritedOutboxLength = 0,
priorFailures: readonly LlmFailure[] = Object.freeze([]),
): Promise<void> {
// Both entries hold the invariant: kick() clears the admission slot before
// awaiting run(), and a retry is entered only after the prior run clears it.
/* v8 ignore next -- unreachable guard: every caller clears or checks the abort slot first */
if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
const controller = new AbortController()
this.abort = controller
this.acceptsNextStep = true
const signal = controller.signal
const turn = this.lastTurn + 1
let step = 0
let opened = false
let reason: TurnEndReason = { kind: 'completed' }
let settleReason: SettleReason = { kind: 'completed' }
let requestFailureHistory = priorFailures
let retryFailures: readonly LlmFailure[] | undefined
const cancelRetry = (): void => { retryFailures = undefined }
signal.addEventListener('abort', cancelRetry, { once: true })
private async turn(): Promise<boolean> {
if (this.phase.kind === 'idle') throw new Error()
const abort = this.phase.kind === 'collecting' ? this.phase.abort : new AbortController()
const lastTurn = this.phase.kind === 'collecting' ? this.phase.lastTurn : this.phase.turn
const phase = { kind: 'running' as const, abort, turn: lastTurn, step: 0 }
this.setPhase(phase)
if (abort.signal.aborted) return this.outbox.length > 0 || this.queued.length > 0
let admission: Admission
try {
signal.throwIfAborted()
this.session.append('turn/start', { turn, trigger })
// Committed: publish the turn to the machine's own bookkeeping and let
// the admitted input enter the log it now belongs to.
this.turnOpen = true
opened = true
this.lastTurn = turn
// Context or steering retained by an earlier rejected admission happened
// before this prompt and must occupy the same order in durable history.
this.drainOutbox(turn, inheritedOutboxLength)
for (const input of admitted) {
this.session.append('user/message', input, { surfaceOp: 'append' })
}
signal.throwIfAborted()
this.drainOutbox(turn)
steps: while (true) {
step += 1
const outcome = await this.step(turn, step, signal)
switch (outcome.kind) {
case 'completed':
requestFailureHistory = Object.freeze([])
if (outcome.maxTokens) reason = { kind: 'max-tokens' }
// A concluding tool result is terminal: steering already in the
// log waits for the next turn's request instead of reopening this
// one, and the agent/turn-stopping drain below is skipped for the same
// reason.
if (outcome.concluded) break steps
if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue
break
case 'request-failed': {
// step() reports request failures only after step/start commits
// and before its own step/end, so the step is always open here.
this.stepOpen = false
this.session.append('step/end', { turn, step })
if (!signal.aborted) {
try {
const action = await this.loopCtx.waterfall(
agentCarrier(this), 'agent/request-error', this, turn, step, outcome.error,
outcome.failure, requestFailureHistory, outcome.retryPolicy, signal,
() => Promise.resolve<RequestErrorAction>(undefined),
)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
if (action?.kind === 'retry' && !signal.aborted) {
retryFailures = Object.freeze([...requestFailureHistory, outcome.failure])
}
} catch (recoveryError: unknown) {
this.loopCtx.logger.warn(
`agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
)
}
}
const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure)
reason = settlement.reason
settleReason = settlement.settleReason
break steps
admission = await this.admit(true)
if (admission.kind !== 'admitted') return false
abort.signal.throwIfAborted()
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cancel may abort while admission awaits
if (abort.signal.aborted) return this.outbox.length > 0 || this.queued.length > 0
throw error
}
const turn = ++phase.turn
this.session.append('turn/start', { turn })
let turnEnds: TurnEndReason | null = null
try {
while (true) {
if (admission.kind === 'admitted') {
for (const message of admission.claimed) {
emitAgentEvent(this.loopCtx, this, 'agent/inbox/admitted', message)
}
for (const message of admission.messages) {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
/* v8 ignore next 2 -- closed-union exhaustiveness guard */
default:
assertNever(outcome)
}
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal)
signal.throwIfAborted()
if (!this.drainOutbox(turn)) break
}
} catch (caught: unknown) {
try {
if (this.stepOpen) {
this.stepOpen = false
abort.signal.throwIfAborted()
const step = ++phase.step
this.session.append('step/start', { turn, step })
try {
turnEnds = await this.step()
} finally {
this.session.append('step/end', { turn, step })
}
} catch (closeError: unknown) {
// Contained like the finally's turn close: a persistently rejecting
// step boundary must not escape run(), or the post-finally tail would
// never publish the terminal status and observers would see a
// permanently running agent whose whenIdle() already resolved.
this.loopCtx.logger.warn(`agent "${this.id}": closing step ${turn}/${step} failed: ${errorChain(closeError)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, closeError)
}
({ reason, settleReason } = this.settle(turn, step, caught, signal))
} finally {
// Every step-close happens before this point on both success and
// failure paths (step(), the request-failed branch, the catch), so the
// finally owes only the turn boundary.
this.acceptsNextStep = false
try {
if (this.turnOpen) {
// Re-entrant turn/end listeners must route new input to a later turn.
this.turnOpen = false
this.session.append('turn/end', { turn, reason })
abort.signal.throwIfAborted()
if (turnEnds && this.outbox.length === 0) {
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, abort.signal)
abort.signal.throwIfAborted()
}
} catch (error: unknown) {
retryFailures = undefined
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
admission = await this.admit(false)
if (admission.kind === 'blocked') {
turnEnds = { kind: 'aborted', reason: abort.signal.reason as AgentCancelCause }
return false
}
abort.signal.throwIfAborted()
if (admission.kind === 'empty' && turnEnds) break
}
// cancel() aborts but never clears the slot, and no second run can
// install a controller while this one is still unwinding, so the slot
// is still this run's controller here.
this.abort = undefined
signal.removeEventListener('abort', cancelRetry)
}
if (opened) {
try {
await this.loopCtx.sessions.flush(this.session)
} catch (error: unknown) {
this.loopCtx.logger.warn(`agent "${this.id}": session/flush failed at turn ${turn}: ${errorChain(error)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
}
}
if (retryFailures !== undefined) {
await this.run({ kind: 'retry' }, [], 0, retryFailures)
} else {
// agent/settled names only committed turns: a run aborted or rejected
// before turn/start has no durable turn/end for consumers to settle
// against, so it exits without the notification.
if (opened) emitAgentEvent(this.loopCtx, this, 'agent/settled', turn, settleReason)
this.continueOrIdle()
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cancel may abort during any awaited turn operation
if (abort.signal.aborted) turnEnds = { kind: 'aborted', reason: abort.signal.reason as AgentCancelCause }
else turnEnds = { kind: 'error', error: errorChain(error) }
} finally {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the turn is always ended in this block
this.session.append('turn/end', { turn, reason: turnEnds! })
}
return this.outbox.length > 0 || this.queued.length > 0
}
/**
* Run the `agent/step` extension point, commit pending input, derive one
* request, and execute its tool calls inside one durable step boundary.
*/
private async step(
turn: number,
step: number,
signal: AbortSignal,
): Promise<StepOutcome> {
const { session } = this
// The single between-steps extension point: listeners inject, steer, or
// edit the log here; the request derives from the log after this settles.
private async step(): Promise<TurnEndReason | null> {
if (this.phase.kind !== 'running') throw new Error()
const { turn, step, abort: { signal } } = this.phase
signal.throwIfAborted()
await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal)
signal.throwIfAborted()
// Take the outbox whole — same-boundary steering and context leave in
// this request together.
this.drainOutbox(turn)
// Assemble the system prompt fresh each step (it may depend on log state).
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const system = renderPrompt(assembly)
// Snapshot the exact log prefix: the reconstruction boundary. Appends
// after this synchronous snapshot join the next request.
const boundaryMessages = session.deriveMessages()
session.append('step/start', { turn, step })
this.stepOpen = true
signal.throwIfAborted()
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, boundaryMessages, signal,
)
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
try {
let message: AssistantMessage
while (true) {
const boundaryMessages = this.session.deriveMessages()
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, boundaryMessages, signal,
)
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
signal.throwIfAborted()
for await (const chunk of stream) {
signal.throwIfAborted()
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
const chunkEvent = this.session.append('assistant/chunk', { turn, step, chunk })
chunkSeqs.push(chunkEvent.seq)
assembler.push(chunk)
}
} catch (error: unknown) {
const facts = llmFailureOf(stream, error)
if (facts !== undefined && error instanceof Error) {
return { kind: 'request-failed', error, failure: facts, retryPolicy: llmRetryPolicyOf(stream) }
signal.throwIfAborted()
const finish = assembler.finish
if (finish.kind === 'error' || finish.kind === 'aborted') {
const action = await this.loopCtx.waterfall(
agentCarrier(this), 'agent/request-error', this, {
turn,
step,
provider: request.provider,
failure: finish.failure,
retryPolicy: preparedCall?.retryPolicy,
}, signal,
() => Promise.resolve<RequestErrorAction>(undefined),
)
signal.throwIfAborted()
if (action?.kind !== 'retry') {
return { kind: 'error', error: finish.failure }
}
} else {
message = createAssistantMessage({
content: assembler.blocks(),
source: {
provider: request.provider,
model: request.model,
...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
},
})
this.session.append(
'assistant/message',
{
turn,
step,
message,
...assembler.usage === undefined ? {} : { usage: assembler.usage },
},
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
if (finish.kind === 'max-tokens') {
return { kind: 'max-tokens' }
}
break
}
throw error
}
signal.throwIfAborted()
// Failure finish chunks take the same path as thrown stream errors.
const finish = assembler.finish
if (finish.kind === 'error' || finish.kind === 'aborted') {
const error = new LlmError(finish.failure.message, finish.failure.code, finish.failure)
return { kind: 'request-failed', error, failure: finish.failure, retryPolicy: llmRetryPolicyOf(stream) }
}
// Truncated (max-tokens) output cannot owe tool calls.
const assembled = assembler.blocks()
const content = finish.kind === 'max-tokens'
? assembled.filter(block => block.type !== 'tool-call')
: assembled
const message: AssistantMessage = createAssistantMessage({
content,
source: {
provider: request.provider,
model: request.model,
...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
},
})
session.append(
'assistant/message',
{
turn,
step,
message,
...assembler.usage === undefined ? {} : { usage: assembler.usage },
},
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
const toolCalls = content.filter(block => block.type === 'tool-call')
let concluded = false
const toolCalls = message.content.filter(block => block.type === 'tool-call')
let result: TurnEndReason | null
if (toolCalls.length > 0) {
({ concluded } = await executeToolCalls(
const { concluded } = await executeToolCalls(
this.loopCtx, turn, step, toolCalls, signal,
context => this.outbox.push({ message: freezeMessage(context), steering: false }),
))
}
// Tool results stay adjacent to their calls; input accepted during the
// request enters the log only after the complete result batch.
const steered = this.drainOutbox(turn)
session.append('step/end', { turn, step })
this.stepOpen = false
return {
kind: 'completed',
continueTurn: (toolCalls.length > 0 && !concluded) || steered,
concluded,
maxTokens: finish.kind === 'max-tokens',
context => this.outbox.push(context),
)
result = concluded ? { kind: 'completed' } : null
} else {
result = { kind: 'completed' }
}
return result
}
/**
@@ -571,11 +360,9 @@ export class ReactLoopAgent implements Agent {
boundaryMessages: Message[],
signal: AbortSignal,
): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
const { session } = this
// A loop instance starts from its declared route, restoring only an opaque
// effort owned by that exact model. Later steps fold the config it logged.
const persistedConfig = session.requestHeader()?.config
const persistedConfig = this.session.requestHeader()?.config
const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }
const reasoningEffort = persistedConfig?.provider === route.provider
&& persistedConfig.model === route.model
@@ -618,113 +405,23 @@ export class ReactLoopAgent implements Agent {
...system ? { system } : {},
...tools.length > 0 ? { tools } : {},
})
const baseline = session.requestHeader()
const baseline = this.session.requestHeader()
if (!this.requestHeaderLogged) {
session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
this.requestHeaderLogged = true
} else if (baseline === undefined || !headerEquals(baseline, header)) {
session.append('request/header', { header, reason: 'change' })
this.session.append('request/header', { header, reason: 'change' })
}
signal.throwIfAborted()
const request = markAgentLoopRequest(deepFreeze({
...header.config,
messages: boundaryMessages,
...header.system !== undefined ? { system: header.system } : {},
...header.tools !== undefined ? { tools: header.tools } : {},
sessionId: session.id,
sessionId: this.session.id,
signal,
}))
return { request, ...preparedCall === undefined ? {} : { preparedCall } }
}
/** Commit the outbox and report whether it contained steering. */
private drainOutbox(turn: number, limit = this.outbox.length): boolean {
let steered = false
for (const item of this.outbox.splice(0, limit)) {
if (item.steering) {
steered = true
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.message, 'steering')
this.session.append(
'steering/message',
{ turn, message: item.message },
{ surfaceOp: 'append' },
)
} else {
this.session.append('user/message', item.message, { surfaceOp: 'append' })
}
}
return steered
}
/**
* Give context-only input its ordinary idle placement when admission
* produces no turn. Steering keeps the whole boundary staged so context
* accepted beside it cannot split from the request it accompanies.
*/
private flushRejectedAdmissionContexts(): void {
if (this.outbox.some(item => item.steering)) return
const contexts = this.outbox.splice(0)
for (let index = 0; index < contexts.length; index += 1) {
const item = contexts[index]
/* v8 ignore next 2 -- the steering precheck proves this batch is context-only */
if (item === undefined || item.steering) throw new Error('rejected-admission context batch changed')
try {
this.session.append('user/message', item.message, { surfaceOp: 'append' })
} catch (error: unknown) {
this.outbox.unshift(...contexts.slice(index))
throw error
}
}
}
/**
* The single settlement funnel: classify one turn failure (interruption
* beats error) into the durable turn/end reason and live settlement report.
*/
private settle(
turn: number,
step: number,
error: unknown,
signal: AbortSignal,
failure?: LlmFailure,
): { reason: TurnEndReason; settleReason: SettleReason } {
if (signal.aborted) {
// Slot invariant, stated rather than re-validated: the turn controller
// is machine-private and cancel() is its only aborter, always with one
// frozen canonical cause as the reason.
const interrupt = signal.reason as AgentInterruptReason
return {
reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' },
settleReason: { kind: 'aborted' },
}
}
if (failure !== undefined) {
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
// The durable record renders the full cause chain: turn/end is the one
// durable trace of the failure, so a wrapper message alone would lose
// the transport detail the log exists to keep.
const rendered = errorChain(error)
return {
reason: { kind: 'error', step, failure: { ...failure, ...rendered === '<unrenderable value>' ? {} : { message: rendered } } },
settleReason: { kind: 'error', error, failure },
}
}
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
return {
reason: { kind: 'error', step, message: errorChain(error), ...isHarnessError(error) ? { code: error.code } : {} },
settleReason: { kind: 'error', error },
}
}
/** Continue with a waking prompt, or publish the idle status. */
private continueOrIdle(): void {
if (this.queued.some(item => item.wakeup)) {
this.kick()
} else {
// Every caller sits inside an admission or run whose install marked the
// interval busy, so the flag is still set here.
this.busy = false
emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle')
}
}
}

View File

@@ -384,18 +384,7 @@ export class AgentLoop extends Service implements AgentFactory {
if (machine === undefined) await machineReady.promise
if (machine !== undefined) {
machine.cancel({ kind: 'disposed' })
// Drain to TRUE quiescence: cancel's own synchronous event chain
// (running→idle) can legitimately re-enter through an automation
// listener (goal-session's idle drive) and replace `done` with a
// fresh admission before this await captures it. The replacement
// work is cancelled and drained in turn until the slot stabilizes.
let done = machine.done
while (true) {
await Promise.allSettled([done])
if (machine.done === done) break
done = machine.done
machine.cancel({ kind: 'disposed' })
}
await machine.whenIdle()
await machine.scope.dispose()
}
} finally {
@@ -452,7 +441,7 @@ export class AgentLoop extends Service implements AgentFactory {
loopCtx.agents.announce(agent)
assertLive()
// A synchronous announce/session-start listener may have started
// teardown; the machine is already live (send() works from the
// teardown; the machine is already live (delivery works from the
// session-start seam), so only the liveness recheck is owed.
emitAgentEvent(loopCtx, agent, 'agent/session-start', source)
assertLive()

View File

@@ -7,7 +7,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
* @module dsh-agent-loop/tests/cancel
*/
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -55,33 +55,6 @@ function userTexts(agent: Agent): string[] {
}
describe('Agent.cancel()', () => {
it('notifies every observer before clearing work and contains listener failures', async () => {
const adapter = new MockAdapter([textResponse('must remain unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' })
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: string[] = []
ctx.on('agent/cancel-requested', (subject, cause) => {
if (subject !== agent) return
seen.push(`first:${cause.kind}`)
subject.followup(createUserMessage({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } }))
throw new Error('observer failed')
})
ctx.on('agent/cancel-requested', (subject, cause) => {
if (subject === agent) seen.push(`second:${cause.kind}`)
})
send(agent, 'drop me')
agent.cancel({ kind: 'user' })
await new Promise(resolve => setTimeout(resolve, 30))
agent.cancel({ kind: 'parent' })
expect(seen).toEqual(['first:user', 'second:user'])
expect(userTexts(agent)).toEqual([])
expect(adapter.requests).toHaveLength(0)
expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested'))
})
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
@@ -103,62 +76,29 @@ describe('Agent.cancel()', () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const discards: unknown[] = []
ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
const cancelRequests: unknown[] = []
ctx.on('agent/cancel-requested', (subject, cause) => { if (subject === agent) cancelRequests.push(cause) })
const canceled: unknown[] = []
ctx.on('agent/inbox/canceled', (subject, message) => { if (subject === agent) canceled.push(message) })
// Queue a turn WITHOUT waking the driver, so it sits in the inbox.
agent.send(createUserMessage({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
// keepInbox cancel: no active turn, work preserved, no discard event. With
// nothing to abort and nothing discarded, the call is a documented no-op,
// so it emits no cancel-requested either.
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'preserved' }],
source: { kind: 'user' },
}))
// Abort the collecting activity while preserving its queued item.
agent.cancel({ kind: 'user' }, { keepInbox: true })
expect(discards).toEqual([])
expect(cancelRequests).toEqual([])
expect(canceled).toEqual([])
// The preserved item still runs once the driver is woken by a later send.
// The preserved item still runs once a later follow-up wakes the driver.
send(agent, 'wake it')
await waitForIdle(ctx, agent)
expect(userTexts(agent)).toEqual(['preserved', 'wake it'])
})
it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// A quiet item alone must NOT wake the driver: no turn runs and whenIdle
// resolves (the agent is quiescent), leaving the item queued.
agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
await agent.whenIdle()
expect(agent.status).toBe('idle')
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
// A later waking send drives the loop, and the quiet item rides along first.
send(agent, 'wake')
await waitForIdle(ctx, agent)
expect(userTexts(agent)).toEqual(['quiet', 'wake'])
})
it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' })
await idle
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
})
it('pre-step cancel drops the about-to-start turn (no turn is opened)', 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' })
// send() queues synchronously (status still idle, loop microtask not yet
// followup() queues synchronously (status still idle, loop microtask not yet
// resumed). Cancel in that pre-step window: the queued turn must not run.
send(agent, 'drop me first')
send(agent, 'drop me second')

View File

@@ -506,24 +506,6 @@ describe('driver bookkeeping edges', () => {
expect(agent.session.events).toEqual([])
})
it('a whenIdle waiter survives a rejected driver promise', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('waiter-chain'), { provider: 'mock', model: 'mock' })
// A throwing terminal-notification listener rejects the driver promise
// (the run's containment covers only session appends); the waiter's
// catch arm must treat that rejection as quiescence instead of
// propagating it.
ctx.on('agent/settled', (subject) => {
if (subject === agent) throw new Error('settled listener exploded')
})
send(agent, 'one')
// Entered while the run owns the abort slot, the waiter awaits the
// driver promise; its rejection must count as quiescence and resolve.
await expect(agent.whenIdle()).resolves.toBeUndefined()
})
it('a request failure that concludes recovery after step/end closed keeps the boundary balanced', async () => {
const { LlmError } = await import('@deepseek-ai/dsh-llm')
// The failure finish-chunk path returns request-failed AFTER step() has

View File

@@ -25,7 +25,7 @@ function loopRequest<T extends object>(options: T): Readonly<T> {
async function requestSetup() {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('req-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -74,7 +74,7 @@ describe('request-reconstruction invariant', () => {
it('rejects loop requests with no boundary or header', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('req-bare'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
session.append('step/start', { turn: 1, step: 1 })
@@ -122,7 +122,7 @@ describe('request-reconstruction invariant', () => {
await ctx.plugin(InvariantService)
await ctx.plugin(AgentLoopInvariant)
const session = ctx.sessions.create(SessionId('prepend-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })

View File

@@ -433,8 +433,6 @@ describe('agent loop', () => {
// split the assistant tool call from the provider's tool-result message.
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
expect(turnStarts).toHaveLength(1)
const ts0 = turnStarts[0]!
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
const result = agent.session.events.find(e => e.type === 'tool/result')!
const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
expect(contexts).toHaveLength(2)
@@ -1031,16 +1029,11 @@ describe('agent loop', () => {
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } }))
await idle
const triggers = agent.session.events
.filter(event => event.type === 'turn/start')
.map(event => event.data.trigger)
const turns = agent.session.events.filter(event => event.type === 'turn/start')
const sources = agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.source)
expect(triggers).toEqual([
{ kind: 'message', source: { kind: 'user' } },
{ kind: 'message', source: { kind: 'plugin', plugin: 'test' } },
])
expect(turns).toHaveLength(2)
expect(sources).toEqual([
{ kind: 'user' },
{ kind: 'plugin', plugin: 'test' },

View File

@@ -63,13 +63,9 @@ describe('agent/request-error', () => {
retryPolicy: ResolvedRetryPolicy | undefined
}[] = []
const statuses: string[] = []
const settledTurns: number[] = []
ctx.on('agent/status', (subject, status) => {
if (subject === agent) statuses.push(status)
})
ctx.on('agent/settled', (subject, turn) => {
if (subject === agent) settledTurns.push(turn)
})
ctx.on('agent/request-error', async (
subject, turn, step, _error, failure, priorFailures, retryPolicy,
) => {
@@ -101,12 +97,7 @@ describe('agent/request-error', () => {
code: 'SERVICE_UNAVAILABLE',
},
])
expect(agent.session.events.filter(event => event.type === 'turn/start').map(event => event.data.trigger))
.toEqual([
{ kind: 'message', source: { kind: 'user' } },
{ kind: 'retry' },
{ kind: 'retry' },
])
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(seen.map(item => item.priorFailures.map(failure => failure.code)))
.toEqual([[], ['RATE_LIMIT']])
expect(seen.map(item => item.retryPolicy)).toEqual([
@@ -114,7 +105,6 @@ describe('agent/request-error', () => {
expect.objectContaining({ mode: 'normal' }),
])
expect(statuses).toEqual(['running', 'idle'])
expect(settledTurns).toEqual([3])
})
it('lets cancellation win over a retry action', async () => {

View File

@@ -43,7 +43,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
// balanced completed turn is the smallest resumable log and avoids running
// the model merely to construct this lifecycle fixture.
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
]
const session = ctx.sessions.create(sessionId, { seed })
@@ -86,7 +86,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
createdAt: 1,
})
await first.ctx.sessionPersistence.append(sessionId, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{
type: 'user/message',
seq: 1,
@@ -175,7 +175,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const { ctx } = await persistentHarness(new MockAdapter([textResponse('unused')]))
const sessionId = SessionId('live-resume-race')
const first = (await ctx.agents.create({ sessionId })).agent
first.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
first.session.append('turn/start', { turn: 1 })
await ctx.sessions.flush(first.session)
await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
@@ -494,7 +494,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
// in its header) by creating it with a complete-turn seed — the write path
// materializes the fork (header + seed) on disk.
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
]
const adapter1 = new MockAdapter([textResponse('a')])

View File

@@ -147,7 +147,7 @@ describe('agent scope lifecycle', () => {
expect(agent.ctx.agent).toBe(agent)
// The root accessor default: a plain context answers undefined, not a throw.
expect(ctx.agent).toBeUndefined()
await ctx.agents.get(SessionId('a1'))?.whenIdle()
await agent.whenIdle()
})
it('records agents created through an agent context as non-root runtime children', async () => {