Merge remote-tracking branch 'origin/master' into worktree/pr628-merge-20260727

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/cordis-catalog/events.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/request-recovery.spec.ts
#	packages/core/agent/src/types.ts
#	packages/core/scope/tests/invariant.spec.ts
#	packages/llm/llm-retry/README.i18n.yaml
#	packages/llm/llm-retry/README.md
#	packages/llm/llm-retry/README.zh.md
#	packages/llm/llm-retry/src/index.ts
#	packages/llm/llm-retry/src/invariant.ts
#	packages/llm/llm-retry/tests/invariant.spec.ts
#	packages/llm/llm-retry/tests/retry.spec.ts
#	packages/plan/plan-mode/src/index.ts
#	packages/plan/plan-mode/tests/integration.spec.ts
#	packages/plan/plan-mode/tests/plan-mode.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-27 23:29:26 +08:00
461 changed files with 8294 additions and 10405 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +0,0 @@
/** Turn-scoped cancellation ownership for the concrete AgentLoop driver. @module dsh-agent-loop/cancellation */
import type { AgentCancelCause } from '@deepseek-ai/dsh-agent'
/** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */
export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const)
/**
* Owns the single controller shared by every asynchronous boundary of one turn.
* The first request wins because a later caller must not rewrite the cause
* observed by earlier listeners.
*/
export class TurnCancellation {
readonly #controller = new AbortController()
/** The explicit signal passed through this turn's execution boundaries. */
get signal(): AbortSignal {
return this.#controller.signal
}
/**
* Abort the turn once.
* @param reason - a typed caller cause or lifecycle disposal marker.
* @returns whether this request established the signal reason.
*/
request(reason: AgentCancelCause | typeof DISPOSED_INTERRUPT_REASON): boolean {
if (this.signal.aborted) return false
this.#controller.abort(Object.freeze({ kind: reason.kind }))
return true
}
}

View File

@@ -1,148 +0,0 @@
/**
* Per-agent message inbox: queued and steering FIFOs. Purely an in-memory
* mechanism of the loop driver — callers use `Agent`'s intent-named delivery
* methods instead.
*
* @module dsh-agent-loop/inbox
*/
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent'
/** One message waiting in an agent's inbox; `id` is the value its accepting delivery method returned. */
export interface InboxMessage {
id: AgentMessageId
content: ContentBlock[]
source: MessageSource
contexts: HookContext[]
/** Whether the item is marked to wake the driver or force a continuation. */
wakeup: boolean
/** Opaque durable JSON state retained on the durable message but hidden from the model. */
meta?: JsonValue
}
/**
* Build the `agent/inbox/*` event payload for one inbox item.
* @param message - the accepted inbox record.
* @param steering - whether the item is in the steering FIFO (`next-step`).
* @returns the live-event message for enqueue/dequeue/discard.
*/
export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage {
// Frozen: the fused emitter passes this exact object to every listener in
// turn, so one listener must not be able to mutate a field (`id`, `steering`,
// `content`, …) a later listener then observes. `message` is already a frozen
// inbox record, so its nested fields need no re-clone.
return Object.freeze({
id: message.id, content: message.content, source: message.source,
contexts: message.contexts, steering, wakeup: message.wakeup,
})
}
/**
* Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO
* (drained between steps of a running turn). Purely an in-memory mechanism of
* the loop — the public surface is `Agent`'s intent-named delivery methods.
*/
export class Inbox {
private queuedMessages: InboxMessage[] = []
private steeringMessages: InboxMessage[] = []
private wakeup: (() => void) | undefined
/** True while any queued message is pending — read by cancellation's discard snapshot and the turn-start dequeue guard. */
get hasQueued(): boolean {
return this.queuedMessages.length > 0
}
/**
* True while a queued message wants to wake the driver — the "should the loop
* run" signal read by the idle wait's fast path, the loop's idle-publish
* check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this
* false, so the driver stays parked until a waking follow-up (or a waking item
* ahead of it in FIFO order) drives the loop; the quiet item then rides along.
*/
get hasWakingQueued(): boolean {
return this.queuedMessages.some(message => message.wakeup)
}
/** True while steering messages are pending — read by cancellation and the loop's stop-override check. */
get hasSteering(): boolean {
return this.steeringMessages.length > 0
}
/**
* Add a message to the queued FIFO, waking a parked {@link waitForQueued}
* unless the item opted out. A non-waking item still runs once any woken
* item or later wakeup drives the parked loop.
* @param message - the message to queue for the next turn start.
* @param wake - whether to wake a parked idle wait (default true).
*/
enqueue(message: InboxMessage, wake = true): void {
this.queuedMessages.push(message)
if (wake) this.wakeup?.()
}
/**
* Add a message to the steering FIFO. Deliberately no wakeup: steering is
* drained between steps of a running turn, never by the idle wait —
* `Agent.steer()` on an idle agent falls back to a waking ordinary turn instead.
* @param message - the message to inject between steps of the running turn.
*/
steer(message: InboxMessage): void {
this.steeringMessages.push(message)
}
/**
* Remove the oldest queued message for one turn start.
* @returns the oldest message, or `undefined` when the queued FIFO is empty.
*/
dequeueQueued(): InboxMessage | undefined {
return this.queuedMessages.shift()
}
/**
* Drain all steering messages (between steps).
* @returns the drained messages in arrival order; the steering FIFO is left empty.
*/
drainSteering(): InboxMessage[] {
return this.steeringMessages.splice(0)
}
/**
* Snapshot the pending items (queued then steering, FIFO order) without
* removing them — the discard notification's payload source.
* @returns the pending items paired with whether each is steering.
*/
pending(): { message: InboxMessage; steering: boolean }[] {
return [
...this.queuedMessages.map(message => ({ message, steering: false })),
...this.steeringMessages.map(message => ({ message, steering: true })),
]
}
/**
* Discard all pending messages (queued + steering) without delivering them —
* used by `cancel()`, which drops un-started work rather than draining it into
* a turn. Unlike `dequeueQueued`/`drainSteering`, the messages are thrown away.
*/
clear(): void {
this.queuedMessages.length = 0
this.steeringMessages.length = 0
}
/**
* Wait until a queued message arrives or `cancel` resolves.
* @param cancel - a promise whose resolution abandons the wait without a
* message (the driver loop passes the agent's disposed promise so a parked
* loop can exit).
*/
waitForQueued(cancel: Promise<void>): Promise<void> {
if (this.hasWakingQueued) return Promise.resolve()
const { promise, resolve } = Promise.withResolvers<void>()
this.wakeup = resolve
void cancel.then(resolve)
return promise.finally(() => {
if (this.wakeup === resolve) this.wakeup = undefined
})
}
}

View File

@@ -8,9 +8,7 @@
import { Context, FiberState, Service } from 'cordis'
import { randomUUID } from 'node:crypto'
import z from 'schemastery'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { emitAgentEvent } from '@deepseek-ai/dsh-agent'
import type {
Agent,
AgentFactory,
@@ -26,12 +24,7 @@ import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import {
bindReactLoopAgentContext,
prepareReactLoopAgent,
ReactLoopAgent,
} from './agent.ts'
import type { PreparedReactLoopAgent } from './agent.ts'
import { ReactLoopAgent } from './agent.ts'
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
/** Fiber states that cannot own or serve a new lifecycle. */
@@ -41,31 +34,43 @@ const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
FiberState.FAILED,
])
/** Factory-level ownership of every preparing or live transaction. */
/** Factory-level ownership: live agent teardowns plus config startup work. */
class FactoryOwnership {
private accepting = true
private readonly teardown = new AbortController()
private readonly inactive = Promise.withResolvers<void>()
private transactions = new Set<AgentCreationTransaction>()
private readonly liveAgents = new Set<() => Promise<void>>()
private startupTasks = new Set<Promise<void>>()
constructor(private readonly fiber: Context['fiber']) {}
/** Aborts (reason: `agent loop is not active` error) when factory teardown begins. */
get signal(): AbortSignal {
return this.teardown.signal
}
isActive(): boolean {
return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
}
track(transaction: AgentCreationTransaction): () => void {
this.transactions.add(transaction)
return () => { this.transactions.delete(transaction) }
/** Track one live agent's shared teardown until it has run. */
track(dispose: () => Promise<void>): () => void {
this.liveAgents.add(dispose)
return () => { this.liveAgents.delete(dispose) }
}
/** Join config startup work that begins before an agent transaction exists. */
/** Join config startup work that begins before an agent exists. */
trackStartup(task: Promise<void>): void {
this.startupTasks.add(task)
const forget = () => { this.startupTasks.delete(task) }
void task.then(forget, forget)
}
/** Join one public create/resume continuation; factory dispose awaits its settlement. */
trackWrapper(task: Promise<unknown>): void {
this.trackStartup(task.then(() => undefined, () => undefined))
}
/** Resolve `task`, or stop waiting when factory teardown begins. */
async waitWhileActive(task: Promise<void>): Promise<void> {
await Promise.race([task, this.inactive.promise])
@@ -73,19 +78,29 @@ class FactoryOwnership {
async dispose(): Promise<void> {
this.accepting = false
this.teardown.abort(new Error('agent loop is not active'))
this.inactive.resolve()
const reason = new Error('agent loop is not active')
await Promise.all([
...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
...[...this.liveAgents].map(dispose => dispose()),
...this.startupTasks,
])
}
}
/** Build the public cancellation error while preserving a caller-supplied cause. */
function signalAbortError(id: SessionId, signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
/** Await `operation`, or throw the signal's reason as soon as it aborts. */
async function raceAbort<T>(operation: PromiseLike<T> | T, signal: AbortSignal, id: SessionId): Promise<T> {
const toAbortError = (): Error => signal.reason instanceof Error
? signal.reason
: new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
if (signal.aborted) throw toAbortError()
const aborted = Promise.withResolvers<never>()
const listener = (): void => { aborted.reject(toAbortError()) }
signal.addEventListener('abort', listener, { once: true })
try {
return await Promise.race([Promise.resolve(operation), aborted.promise])
} finally {
signal.removeEventListener('abort', listener)
}
}
/** Resolve the deployment-wide scheduler cap at the owning config boundary. */
@@ -97,243 +112,15 @@ function resolveMaxParallelToolCalls(value: number | undefined): number {
return maxParallelToolCalls
}
/**
* Caller-owned create/resume transaction through rollback-covered publication
* and quiescent teardown. Resources remain private until the final registry
* entry arbitrates identity.
*/
class AgentCreationTransaction {
private active = true
private failure: Error | undefined
private readonly deactivation = Promise.withResolvers<void>()
private readonly publication = Promise.withResolvers<void>()
private readonly torndown = Promise.withResolvers<void>()
private readonly wrapperCompletion = Promise.withResolvers<void>()
private preparing: Promise<void> | undefined
private driver: PreparedReactLoopAgent | undefined
private scope: Scope | undefined
private session: Session | undefined
private lifecycleDispose: (() => Promise<void> | void) | undefined
private detachSession: (() => void) | undefined
private detachAgent: (() => void) | undefined
private publishing = false
private cleanupTask: Promise<void> | undefined
private ownerFollowing = true
private readonly ownerDispose: () => Promise<void> | void
private readonly untrackFactory: () => void
private readonly abortListener: (() => void) | undefined
readonly ownerAgent: Context['agent']
readonly ownerFiber: Context['fiber']
constructor(
private readonly loopCtx: Context,
private readonly ownerCtx: Context,
private readonly ownership: FactoryOwnership,
readonly id: SessionId,
signal?: AbortSignal,
) {
ownerCtx.fiber.assertActive()
this.ownerAgent = ownerCtx.agent
this.ownerFiber = ownerCtx.fiber
if (!ownership.isActive()) throw new Error('agent loop is not active')
this.ownerDispose = ownerCtx.effect(() => () => {
if (!this.ownerFollowing) return
return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
}, `agentLoop.owner(${id})`)
this.untrackFactory = ownership.track(this)
if (signal === undefined) {
this.abortListener = undefined
} else {
this.abortListener = () => {
/* v8 ignore next 3 -- transaction teardown contains callback/driver failures; rejection is a future-drift backstop. */
void this.dispose(signalAbortError(id, signal)).catch((error: unknown) => {
this.loopCtx.logger.error(error)
})
}
signal.addEventListener('abort', this.abortListener, { once: true })
if (signal.aborted) this.deactivate(signalAbortError(id, signal))
}
this.signal = signal
}
private readonly signal: AbortSignal | undefined
/** Whether caller, provider, and optional parent-agent ownership remain live. */
isActive(): boolean {
return this.active
&& this.ownership.isActive()
&& this.ownerFiber.uid !== null
&& !INACTIVE_STATES.has(this.ownerFiber.state)
&& this.ownerAgent?.status !== 'disposed'
}
/** Fail synchronously at every real lifecycle boundary after deactivation. */
assertActive(): void {
if (this.isActive()) return
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
throw this.failure ?? new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
}
/** Race an external async operation against structural/signal deactivation. */
async waitFor<T>(operation: PromiseLike<T> | T): Promise<T> {
this.assertActive()
return await Promise.race([
Promise.resolve(operation),
this.deactivation.promise.then(() => {
/* v8 ignore next -- deactivate() assigns failure before resolving deactivation. */
throw this.failure ?? new Error(`agent "${this.id}" creation deactivated`)
}),
])
}
/** Construct the driver and scope, then install their complete ordered lifecycle. */
prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent {
this.assertActive()
const gate = Promise.withResolvers<void>()
this.preparing = gate.promise
try {
this.session = session
const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session, maxParallelToolCalls)
this.driver = driver
const agent = driver.agent
const scope = createScope(this.loopCtx, agent)
this.scope = scope
bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
this.installLifecycle(scope, driver)
this.assertActive()
return agent
} catch (error: unknown) {
if (!this.isActive() && error instanceof Error && /inactive context/.test(error.message)) {
throw this.failure ?? this.disposalReason()
}
throw error
} finally {
gate.resolve()
this.preparing = undefined
}
}
/** Register the exact scope disposer inside the ordered transaction effect. */
private installLifecycle(scope: Scope, driver: PreparedReactLoopAgent): void {
this.lifecycleDispose = this.ownerCtx.effect(function* (this: AgentCreationTransaction) {
// First yielded, disposed last.
yield () => { this.finish() }
yield scope.rawDispose
yield () => {
this.detachSession?.()
this.detachSession = undefined
}
yield () => {
this.detachAgent?.()
this.detachAgent = undefined
}
// Last yielded, disposed first.
yield () => {
this.deactivate(this.disposalReason())
if (this.publishing) {
return this.publication.promise.then(() => driver.dispose())
}
return driver.dispose()
}
}.bind(this), `agentLoop.lifecycle(${this.id})`)
}
/** Publish the exact prepared objects and start the driver. */
publish(source: SessionStartSource): AgentHandle {
this.assertActive()
const driver = this.driver
/* v8 ignore next -- publish() is private and every caller invokes prepare() first. */
if (driver === undefined) throw new Error(`agent "${this.id}" is not prepared`)
const agent = driver.agent
const session = this.session
/* v8 ignore next -- prepare() assigns the session before it can produce the driver above. */
if (session === undefined) throw new Error(`agent "${this.id}" has no prepared session`)
this.publishing = true
try {
this.detachSession = agent.ctx.sessions.enter(session)
this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent)
agent.ctx.sessions.announce(session)
this.assertActive()
this.loopCtx.agents.announce(agent)
this.assertActive()
driver.markPublished()
agentEvents(this.loopCtx, agent).emit('agent/session-start', source)
this.assertActive()
driver.startDriver()
return { agent, dispose: () => this.dispose() }
} finally {
this.publishing = false
this.publication.resolve()
}
}
/** Mark the transaction inactive exactly once and wake load/setup races. */
private deactivate(reason: Error): void {
if (!this.active) return
this.active = false
this.failure = reason
this.deactivation.resolve()
}
/** Choose the structural cause when an owner/factory effect starts teardown first. */
private disposalReason(): Error {
if (this.failure !== undefined) return this.failure
if (!this.ownership.isActive()) return new Error('agent loop is not active')
if (this.ownerFiber.uid === null || INACTIVE_STATES.has(this.ownerFiber.state) || this.ownerAgent?.status === 'disposed') {
return new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
}
return new Error(`agent "${this.id}" lifecycle disposed`)
}
/** Complete ownership bookkeeping after every resource reached quiescence. */
private finish(): void {
this.untrackFactory()
this.ownerFollowing = false
void this.ownerDispose()
this.torndown.resolve()
}
/**
* Deactivate and quiesce this transaction. The promise is memoized because
* Cordis effect disposers are single-shot while handles promise shared
* quiescence to every racing owner.
*/
dispose(reason = new Error(`agent "${this.id}" lifecycle disposed`)): Promise<void> {
this.deactivate(reason)
return (this.cleanupTask ??= (async () => {
if (this.preparing !== undefined) await this.preparing
if (this.lifecycleDispose !== undefined) {
await this.lifecycleDispose()
await this.torndown.promise
return
}
try {
await this.driver?.dispose()
} finally {
try {
await this.scope?.dispose()
} finally {
this.finish()
}
}
})())
}
/** Mark the public create/resume continuation settled and detach its creation-only signal. */
finishWrapper(): void {
if (this.signal !== undefined && this.abortListener !== undefined) {
this.signal.removeEventListener('abort', this.abortListener)
}
this.wrapperCompletion.resolve()
}
/** Factory shutdown joins both resource teardown and the public wrapper's deactivation continuation. */
async disposeForFactory(reason: Error): Promise<void> {
await this.dispose(reason)
await this.wrapperCompletion.promise
}
/** Prepared-but-unpublished agent resources sharing one memoized teardown. */
interface PreparedAgent {
agent: ReactLoopAgent
/** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */
signal: AbortSignal
/** Enter registries, announce, notify session-start, and start the machine. */
publish(source: SessionStartSource): AgentHandle
/** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */
dispose(): Promise<void>
}
declare module 'cordis' {
@@ -376,6 +163,9 @@ export interface Config {
})[]
}
/** Agent-loop configuration after defaults and load-time validation. */
type ResolvedConfig = Config & { maxParallelToolCalls: number }
/** Reject self-contained identity conflicts before any configured agent starts. */
function validateConfiguredAgents(agents: Config['agents']): void {
const exactIdentities = new Map<SessionId, string>()
@@ -409,18 +199,21 @@ export class AgentLoop extends Service implements AgentFactory {
cwd: z.string(),
resumeSessionId: z.string(),
})).default([]),
}) as unknown as z<Config>
}) as z<Config>
/** Validated configuration owned by the agent-loop service. */
readonly config: ResolvedConfig
private readonly ownership: FactoryOwnership
/** Resolved concurrency cap for every driver created by this factory. */
private readonly maxParallelToolCalls: number
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
private readonly runtime: { ctx: Context }
constructor(ctx: Context, public config: Config) {
constructor(ctx: Context, config: Config) {
super(ctx, 'agentLoop')
validateConfiguredAgents(config.agents)
this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls)
this.config = {
...config,
maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
}
validateConfiguredAgents(this.config.agents)
this.ownership = new FactoryOwnership(ctx.fiber)
this.runtime = { ctx }
ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
@@ -429,7 +222,7 @@ export class AgentLoop extends Service implements AgentFactory {
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) {
for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) {
const meta = cwd === undefined ? {} : { cwd }
if (resumeSessionId === undefined || resumeSessionId === '') {
const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`)
@@ -490,19 +283,25 @@ export class AgentLoop extends Service implements AgentFactory {
): Promise<void> {
await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
if (!this.ownership.isActive()) return
const exists = (await persistence.list()).some(header => header.id === sessionId)
if (!this.ownership.isActive()) return
if (exists) {
try {
await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
return
} catch (error: unknown) {
if (!this.ownership.isActive()) return
// A load is the per-id serialization barrier for eager write-behind and
// lifecycle retirement. Only a genuinely absent artifact falls back to
// first creation; corruption and backend failures stay loud.
const exists = (await persistence.list()).some(header => header.id === sessionId)
if (exists) throw error
}
this.create(sessionId, agentOptions, meta)
}
/** Wait for an already-disposed same-id lifecycle to finish registry teardown. */
/** Wait for a draining same-id lifecycle to finish registry teardown. */
private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
const current = ownerCtx.agents.get(sessionId)
if (current?.status !== 'disposed') return
// Only an id still occupying a registry needs waiting for; a live healthy
// occupant is a collision the create/resume below will surface itself.
if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) return
const released = Promise.withResolvers<void>()
const checkReleased = (): void => {
@@ -521,6 +320,143 @@ export class AgentLoop extends Service implements AgentFactory {
}
}
/**
* Construct the driver, scope, and one memoized reverse teardown for a new
* agent. The teardown is registered with the factory and the owner fiber
* BEFORE publication, so a mid-setup unload rolls everything back; `signal`
* fuses caller cancellation with lifecycle teardown for setup awaits.
*/
private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent {
ownerCtx.fiber.assertActive()
// Every caller reaches prepare() synchronously from a service method
// whose Cordis dispatch already requires the live factory fiber, or
// re-checks ownership itself after its awaits (resume's load barrier).
/* v8 ignore next -- unreachable backstop, see above */
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
if (callerSignal?.aborted) {
throw callerSignal.reason instanceof Error
? callerSignal.reason
: new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason })
}
const loopCtx = this.runtime.ctx
// Deactivation fuses three owners, each with its own reason: the caller's
// cancellation signal, the owner fiber's unload, and factory teardown.
// It is registered BEFORE any resource exists, over mutable slots, so an
// unload arriving while the scope is still minting finds a working
// disposer instead of a leak.
const abort = new AbortController()
const onCallerAbort = (): void => {
abort.abort(callerSignal?.reason instanceof Error
? callerSignal.reason
: new Error(`agent "${id}" creation aborted`, { cause: callerSignal?.reason }))
}
const onFactoryTeardown = (): void => { abort.abort(this.ownership.signal.reason) }
callerSignal?.addEventListener('abort', onCallerAbort, { once: true })
this.ownership.signal.addEventListener('abort', onFactoryTeardown, { once: true })
let machine: ReactLoopAgent | undefined
let detachSession: (() => void) | undefined
let detachAgent: (() => void) | undefined
let disposing: Promise<void> | undefined
const machineReady = Promise.withResolvers<void>()
// Reverse teardown, memoized so every racing owner awaits one quiescence:
// stop the machine, leave the registries, unwind the scope, release
// bookkeeping.
const dispose = (ownerTriggered = false): Promise<void> => (disposing ??= (async () => {
abort.abort(new Error(`agent "${id}" lifecycle disposed`))
callerSignal?.removeEventListener('abort', onCallerAbort)
this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
try {
// Disposal IS a disposed-cause cancel followed by quiescence. New work
// sent after this point is the sender's bug — the registries are about
// to drop the agent, so nothing should still hold it.
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.scope.dispose()
}
} finally {
try {
detachAgent?.()
detachSession?.()
} finally {
untrack()
if (!ownerTriggered) await unfollowOwner()
}
}
})())
const untrack = this.ownership.track(dispose)
let unfollowOwner: () => Promise<void> | void
try {
unfollowOwner = ownerCtx.effect(() => () => {
// Owner disposal owns the same quiescence boundary. Its teardown skips
// unregistering this already-running owner effect from inside itself.
if (disposing !== undefined) return
abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
return dispose(true)
}, `agentLoop.lifecycle(${id})`)
/* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */
} catch (error: unknown) {
untrack()
callerSignal?.removeEventListener('abort', onCallerAbort)
this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
throw error
}
/* v8 ignore stop */
const assertLive = (): void => {
if (!abort.signal.aborted) return
// Every fused abort source carries an Error reason: onCallerAbort and
// raceAbort wrap non-Error caller reasons, and the factory/lifecycle
// owners abort with constructed Errors.
/* v8 ignore next -- unreachable String() arm, see above */
throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason))
}
try {
const agent = machine = new ReactLoopAgent(loopCtx, id, options, session)
machineReady.resolve()
assertLive()
return {
agent,
signal: abort.signal,
publish: (source) => {
assertLive()
detachSession = agent.ctx.sessions.enter(session)
detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent)
agent.ctx.sessions.announce(session)
assertLive()
loopCtx.agents.announce(agent)
assertLive()
// A synchronous announce/session-start listener may have started
// teardown; the machine is already live (send() works from the
// session-start seam), so only the liveness recheck is owed.
emitAgentEvent(loopCtx, agent, 'agent/session-start', source)
assertLive()
return { agent, dispose }
},
dispose,
}
} catch (error: unknown) {
machineReady.resolve()
void dispose()
throw error
}
}
/**
* Create an agent and session under one caller-supplied identity, owned by
* the accessing fiber. Constructor-driven config calls mint a fresh combined
@@ -531,51 +467,39 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the published running agent.
*/
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
const loopCtx = this.runtime.ctx
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
const session = this.runtime.ctx.sessions.prepare(id, { meta })
const prepared = this.prepare(this.ctx, id, options, session)
try {
const session = loopCtx.sessions.prepare(id, { meta })
const agent = transaction.prepare(options, session, this.maxParallelToolCalls)
transaction.publish('startup')
return agent
return prepared.publish('startup').agent
} catch (error: unknown) {
void transaction.dispose(error instanceof Error ? error : new Error(String(error)))
void prepared.dispose()
throw error
} finally {
transaction.finishWrapper()
}
}
/**
* Create an owned agent on a caller-supplied session id.
* @param ownerCtx - caller context that structurally owns the transaction.
* @param ownerCtx - caller context that structurally owns the lifecycle.
* @param options - identities, session seed/metadata, loop options, setup, and cancellation.
* @returns the published handle.
*/
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
const agentOptions = options.agentOptions ?? {}
const transaction = new AgentCreationTransaction(
this.runtime.ctx,
ownerCtx,
this.ownership,
options.sessionId,
options.signal,
)
try {
const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
...options.seed === undefined ? {} : { seed: options.seed },
...options.meta === undefined ? {} : { meta: options.meta },
})
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
await transaction.waitFor(options.setup?.(agent.ctx))
transaction.assertActive()
return transaction.publish('startup')
} catch (error: unknown) {
await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
throw error
} finally {
transaction.finishWrapper()
}
const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
...options.seed === undefined ? {} : { seed: options.seed },
...options.meta === undefined ? {} : { meta: options.meta },
})
const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal)
const published = (async () => {
try {
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId)
return prepared.publish('startup')
} catch (error: unknown) {
await prepared.dispose()
throw error
}
})()
this.ownership.trackWrapper(published)
return published
}
/**
@@ -593,36 +517,48 @@ export class AgentLoop extends Service implements AgentFactory {
}
/** Resume through an explicit persistence handle used by the deferred config path. */
private async resumeWith(
private resumeWith(
ownerCtx: Context,
persistence: SessionPersistence,
options: ResumeAgentOptions,
): Promise<AgentHandle> {
const agentOptions = options.agentOptions ?? {}
const transaction = new AgentCreationTransaction(
this.runtime.ctx,
ownerCtx,
this.ownership,
options.resumeSessionId,
options.signal,
)
try {
const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId))
transaction.assertActive()
const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
const id = options.resumeSessionId
const published = (async () => {
// The load may outlive its owner: race it against caller cancellation,
// owner-fiber unload, and factory teardown so a never-settling backend
// cannot pin the identity.
const ownerAbort = new AbortController()
const unfollowOwner = ownerCtx.effect(() => () => {
ownerAbort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
}, `agentLoop.resume-load(${id})`)
const fused = AbortSignal.any([
...options.signal === undefined ? [] : [options.signal],
ownerAbort.signal,
this.ownership.signal,
])
let loaded: Awaited<ReturnType<SessionPersistence['load']>>
try {
loaded = await raceAbort(persistence.load(id), fused, id)
} finally {
await unfollowOwner()
}
ownerCtx.fiber.assertActive()
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
const session = this.runtime.ctx.sessions.prepare(id, {
seed: loaded.events,
meta: loaded.meta,
})
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
await transaction.waitFor(options.setup?.(agent.ctx))
transaction.assertActive()
return transaction.publish('resume')
} catch (error: unknown) {
await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
throw error
} finally {
transaction.finishWrapper()
}
const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal)
try {
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
return prepared.publish('resume')
} catch (error: unknown) {
await prepared.dispose()
throw error
}
})()
this.ownership.trackWrapper(published)
return published
}
}

View File

@@ -48,7 +48,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
SessionId(`${String(session.id)}-invariant-rebuild`),
structuredClone(events.slice(0, boundary)),
)
const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()]
const expected = rebuilt.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
}

View File

@@ -1,863 +0,0 @@
/**
* Drives one agent across queued durable turns. Turn failures are contained so
* later work can run; the session log, not this driver, owns conversation state.
* See .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
* @module dsh-agent-loop/loop
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import { isDeepStrictEqual } from 'node:util'
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, llmRetryPolicyOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
import type { TransmissionLog } from './request-log.ts'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
import { agentMessage, type Inbox, type InboxMessage } from './inbox.ts'
import type { TurnCancellation } from './cancellation.ts'
/** Normalize thrown values while preserving an existing error code. */
function toError(error: unknown): RequestError {
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
}
/** Distinguishes final model-request failures from failures in later step processing. */
class TerminalModelRequestFailure extends Error {
constructor(
readonly requestError: RequestError,
readonly failure: LlmFailure,
readonly retryPolicy: ResolvedRetryPolicy | undefined,
) {
super(failure.message, { cause: requestError })
this.name = 'TerminalModelRequestFailure'
}
}
/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */
function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined {
switch (finish.kind) {
case 'error':
case 'aborted': {
const facts = finish.failure
const error = new LlmError(facts.message, facts.code, {
...facts.status === undefined ? {} : { status: facts.status },
...facts.providerRetryAfterMs === undefined
? {}
: { providerRetryAfterMs: facts.providerRetryAfterMs },
...facts.requestId === undefined ? {} : { requestId: facts.requestId },
})
return { error, failure: error.failure }
}
// stop / tool-calls / max-tokens / plugin-added kinds → not a failure.
default:
return undefined
}
}
/**
* Build the `{ message, code? }` part of an error payload, omitting the
* `code` key entirely when absent (exactOptionalPropertyTypes-correct).
* The durable message renders the full cause chain: `turn/end` is the single
* durable record of an in-turn failure, so a wrapper message alone (e.g.
* `fetch failed`) would lose the diagnosis the session log exists to keep.
*/
function errorData(err: RequestError): { message: string; code?: string } {
return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }
}
/** Preserve cause diagnostics, falling back to adapter-normalized prose for a hostile Error. */
function durableFailure(err: RequestError, failure: LlmFailure): LlmFailure {
const message = errorChain(err)
return { ...failure, message: message === '<unrenderable value>' ? failure.message : message }
}
/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
switch (finish.kind) {
case 'max-tokens':
return { kind: 'max-tokens' }
// stop / tool-calls / plugin-added kinds → no turn-end contribution
// beyond the default `completed`. FinishReason is merge-extensible, so a
// default (not assertNever) handles unknown kinds as ordinary success.
default:
return undefined
}
}
/** Internal control-flow sentinel; durable classification comes only from the turn signal. */
const TURN_INTERRUPTED = new Error('turn interrupted')
const PROMPT_PREFIX_REQUEST_DELIMITER: ContentBlock = {
type: 'text',
text: '\n\n## My request:\n',
}
interface PreparedPromptMessage {
data: PromptMessageData
separateContexts: HookContext[]
}
/** Bake declared prefix contexts into one reconstructable prompt message. */
function preparePromptMessage(
content: ContentBlock[],
source: PromptMessageData['source'],
contexts: readonly HookContext[],
): PreparedPromptMessage {
const prefixContexts = contexts.filter(context => context.placement === 'prompt-prefix')
const separateContexts = contexts.filter(context => context.placement !== 'prompt-prefix')
if (prefixContexts.length === 0) return { data: { content, source }, separateContexts }
return {
data: {
content: [
...prefixContexts.flatMap(context => context.content),
PROMPT_PREFIX_REQUEST_DELIMITER,
...content,
],
source,
envelope: {
displayContent: content,
prefixContexts: prefixContexts.map(context => ({
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
})),
},
},
separateContexts,
}
}
/** Stop at an explicit cooperative boundary without stringifying the runtime reason. */
function interruptionCheckpoint(signal: AbortSignal): void {
if (signal.aborted) throw TURN_INTERRUPTED
}
/** Classify a supported turn interruption, with lifecycle disposal taking precedence. */
function interruptionTurnEndReason(handle: LoopHandle, signal: AbortSignal): TurnEndReason | undefined {
if (handle.isDisposed()) return { kind: 'disposed' }
const reason = agentInterruptReasonOf(signal)
if (reason === undefined) return undefined
switch (reason.kind) {
case 'user':
case 'parent':
return { kind: 'aborted' }
/* v8 ignore next 2 -- the private holder requests disposed only after lifecycle state flips, which returns above. */
case 'disposed':
return { kind: 'disposed' }
/* v8 ignore next 2 -- AgentInterruptReason is closed and the public helper filters unsupported reasons. */
default:
return assertNever(reason, 'AgentInterruptReason')
}
}
/** Mutable agent controls supplied to the loop driver. */
export interface LoopHandle {
/** Native-private agent inbox handed to the driver only at internal startup. */
readonly inbox: Inbox
/** Maximum parallel-safe calls allowed in one step. */
readonly maxParallelToolCalls: number
setStatus(status: 'idle' | 'running'): void
/** Install a fresh active-turn owner before the running notification. */
installTurnCancellation(): TurnCancellation
/** Clear only the exact owner whose turn reached its terminal event boundary. */
clearTurnCancellation(cancellation: TurnCancellation): void
/** Resolves when the agent is disposed — unblocks the idle wait. */
disposed: Promise<void>
isDisposed(): boolean
/** Whether queued work was cancelled before an active turn owner existed. */
isPreRunCancelled(): boolean
/** Clear the cause-less pre-run marker without affecting replacement work. */
clearPreRunCancel(): void
/** Settle idle waiters before pre-running cancellation publishes idle. */
settleIdle(): void
/** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */
readonly withToolBatch: <T>(run: (acceptContext: (context: HookContext) => void) => Promise<T>) => Promise<T>
}
/**
* Drive queued messages as independent durable turns until disposal. Plugin
* failures end the current turn without terminating the driver. The caller
* establishes the `ctx.agents.withInitiator()` boundary before entry; package-private
* orchestration recovers that exact Agent and captures its Session locally.
* @param ctx - the plugin context the loop reaches its initiating Agent,
* events (agent/…, session/flush), and services (systemPrompt, llm, tools)
* through.
* @param handle - the bridge to status, turn cancellation ownership, disposal, and pre-run cancellation state.
* @throws when no initiating Agent is active.
*/
export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
const agent = ctx.agents.requireInitiator()
// Per-instance prefix and request-header state; conversation history remains in the session log.
const transmission = createTransmissionLog()
const { session } = agent
// Fused subject and scope carrier for every agent event below.
const events = agentEvents(ctx, agent)
while (!handle.isDisposed()) {
// An idle listener can enqueue and cancel replacement work before the next
// wait is installed. Consume that empty marker before parking the driver.
// A quiet (`wakeup:false`) item alone must not un-park the loop, so gate on
// hasWakingQueued, not hasQueued.
if (handle.isPreRunCancelled()) {
handle.clearPreRunCancel()
if (!handle.inbox.hasWakingQueued) {
handle.settleIdle()
handle.setStatus('idle')
continue
}
}
await handle.inbox.waitForQueued(handle.disposed)
if (handle.isDisposed()) break
// Cancellation between wake and `running` skips only the cancelled work;
// a replacement prompt still runs before the eventual idle transition.
if (handle.isPreRunCancelled()) {
handle.clearPreRunCancel()
if (!handle.inbox.hasWakingQueued) {
// Settle before publishing idle: the already-idle path has no status
// transition, while an idle listener can register waiters for new work.
handle.settleIdle()
handle.setStatus('idle')
continue
}
}
let cancellation = handle.installTurnCancellation()
handle.setStatus('running')
if (handle.isDisposed()) {
handle.clearTurnCancellation(cancellation)
break
}
// A synchronous `running` listener can cancel before `runTurn`; balance the
// status only when no waking replacement prompt was queued by that listener
// (a lone quiet item parks at idle rather than driving a turn).
if (cancellation.signal.aborted) {
handle.clearTurnCancellation(cancellation)
if (!handle.inbox.hasWakingQueued) {
handle.setStatus('idle')
continue
}
cancellation = handle.installTurnCancellation()
}
// Idle injection can add a turn, so derive the next number from the log.
const turn = lastTurnNumber(session) + 1
let terminalStopped = false
try {
terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation)
} catch (error: unknown) {
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${errorChain(err)}`)
try {
events.emit('agent/error', turn, 0, err)
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
} finally {
handle.clearTurnCancellation(cancellation)
}
// Late steering (arriving after runTurn returns, e.g. during the post-turn
// flush) becomes queued input — unless terminal policy stopped the turn, in
// which case it is dropped and must publish a discard so its enqueue is
// still matched (the invariant only catches a NEGATIVE count, not a leak).
const lateSteering = handle.inbox.drainSteering()
if (terminalStopped) {
if (lateSteering.length > 0) {
events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true)))
}
} else {
for (const message of lateSteering) handle.inbox.enqueue(message)
}
// Park at idle unless a waking item still wants the model to run; a lone
// quiet (`wakeup:false`) item stays queued but does not keep the loop busy.
if (!handle.inbox.hasWakingQueued) handle.setStatus('idle')
}
}
async function runTurn(
ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog,
cancellation: TurnCancellation,
): Promise<boolean> {
const agent = ctx.agents.requireInitiator()
const { session } = agent
const { signal } = cancellation
const drainSteering = (): boolean => {
const messages = handle.inbox.drainSteering()
for (const message of messages) {
events.emit('agent/inbox/dequeue', agentMessage(message, true))
const prepared = preparePromptMessage(message.content, message.source, message.contexts)
session.append('steering/message', {
turn, ...prepared.data,
...message.meta === undefined ? {} : { meta: message.meta },
}, { surfaceOp: 'append' })
for (const context of prepared.separateContexts) {
session.append('user/message', {
content: context.content,
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
}, { surfaceOp: 'append' })
}
}
return messages.length > 0
}
// Claim one queued message before opening its turn, but append it only after `turn/start`.
const message = handle.inbox.dequeueQueued()
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
events.emit('agent/inbox/dequeue', agentMessage(message, false))
const trigger: TurnTrigger = { kind: 'message', source: message.source }
let reason: TurnEndReason = { kind: 'completed' }
let step = 0
let requestFailureHistory: readonly LlmFailure[] = Object.freeze([])
let stepOpen = false
let errorReported = false
let terminalStopped = false
// Close the committed step once; pre-commit validation failure still escapes.
const closeStep = (): void => {
if (!stepOpen) return
session.append('step/end', { turn, step })
stepOpen = false
}
// Record the durable turn failure once and contain the live error notification.
const failTurn = (err: RequestError, failure?: LlmFailure): void => {
if (errorReported) return
errorReported = true
reason = failure === undefined
? { kind: 'error', step, ...errorData(err) }
: { kind: 'error', step, failure: durableFailure(err, failure) }
try {
events.emit('agent/error', turn, step, err)
} catch {
// contained: the error is already captured on `reason`; a throwing
// agent/error listener must not prevent the turn from closing.
}
}
// Retire cancellation authority before publishing the terminal event. The
// following durability flush is quiescent turn work, but no longer part of
// the cancellable turn lifetime.
const closeTurn = (): void => {
handle.clearTurnCancellation(cancellation)
session.append('turn/end', { turn, reason })
}
try {
// --- Turn boundary. Once turn/start is appended, a turn/end is owed no
// matter what throws below; the catch + closeTurn guarantee it. A pre-commit
// veto leaves no turn/start in the log and therefore owes no turn/end.
session.append('turn/start', { turn, trigger })
interruptionCheckpoint(signal)
// The claimed message runs the `agent/prompt-submit` waterfall before it
// becomes a `user/message` — a hook can rewrite the prompt or block it.
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
// throws) is caught below and the turn still closes.
const promptDecision = await events.waterfall(
'agent/prompt-submit', message.content, message.source, signal,
() => Promise.resolve<PromptDecision>({
kind: 'allow',
...message.contexts.length === 0 ? {} : { additionalContexts: message.contexts },
}),
)
interruptionCheckpoint(signal)
if (promptDecision.kind === 'block') {
session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason })
reason = { kind: 'rejected', reason: promptDecision.reason }
} else {
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
const content = promptDecision.content ?? message.content
const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? [])
session.append('user/message', {
...prepared.data,
...message.meta === undefined ? {} : { meta: message.meta },
}, { surfaceOp: 'append' })
// Separate contexts still enter THIS turn through inject(). Prefix
// contexts are already baked into the user/message with their durable
// display envelope, so appending them again would duplicate model input.
for (const context of prepared.separateContexts) {
agent.inject(context.content, {
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
})
}
}
while (true) {
// A blocked prompt closes its zero-step turn as rejected.
if (promptDecision.kind === 'block') break
step += 1
// Steering from the previous round's continuation listeners joins before
// the request.
drainSteering()
// Assemble once before pre-step so listener work and the request share one prompt value.
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal))
interruptionCheckpoint(signal)
const fullSystemPrompt = renderPrompt(assembly)
// Compose the request-only prefix once per loop instance before the first
// request boundary. It precedes all derived history and is recorded only
// in the request header, not as session history.
if (transmission.sessionPrefix === undefined) {
const emptyPrefix: Message[] = deepFreeze([])
const composed = await events.waterfall(
'agent/session-prefix', emptyPrefix, signal,
() => Promise.resolve(emptyPrefix),
)
// Never cache an interrupted composition; the next turn recomposes it.
interruptionCheckpoint(signal)
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
}
// Await surface mutations outside the step before snapshotting history.
await events.serial('agent/pre-step', turn, step, signal)
interruptionCheckpoint(signal)
// Snapshot the exact log prefix before step/start: the reconstruction
// boundary. Appends after this synchronous snapshot join the next request.
const boundaryMessages = session.deriveMessages()
session.append('step/start', { turn, step })
// Only a committed step/start creates a balancing obligation. A
// pre-commit veto throws before this assignment; post-commit observers
// are contained inside Session.append().
stepOpen = true
// A synchronous step/start observer can cancel after the step opened.
interruptionCheckpoint(signal)
let stepOutcome:
| { hadToolCalls: boolean; finish: FinishReason }
| { requestError: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
| { error: RequestError }
try {
stepOutcome = await runStep(
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal)
} catch (error: unknown) {
if (error instanceof TerminalModelRequestFailure) {
stepOutcome = {
requestError: error.requestError,
failure: error.failure,
retryPolicy: error.retryPolicy,
}
} else {
stepOutcome = { error: toError(error) }
}
}
if ('requestError' in stepOutcome) {
// Recovery observes a balanced failed step and the original provider
// error while the failed step's signal remains the active owner.
closeStep()
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted !== undefined) {
reason = interrupted
break
}
const defaultDecision: RequestErrorDecision = { action: 'fail' }
let recoveryDecision: RequestErrorDecision = defaultDecision
try {
recoveryDecision = await events.waterfall(
'agent/request-error', turn, step, stepOutcome.requestError,
stepOutcome.failure, requestFailureHistory, stepOutcome.retryPolicy, signal,
() => Promise.resolve(defaultDecision),
)
} catch (recoveryError: unknown) {
ctx.logger.warn(
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
)
}
// Cancellation and disposal always win over either a recovery decision
// or a recovery-listener failure.
const recoveryInterrupted = interruptionTurnEndReason(handle, signal)
if (recoveryInterrupted !== undefined) {
reason = recoveryInterrupted
break
}
switch (recoveryDecision.action) {
case 'retry':
requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure])
continue
case 'fail':
failTurn(stepOutcome.requestError, stepOutcome.failure)
break
/* v8 ignore next -- closed-union exhaustiveness guard */
default:
assertNever(recoveryDecision, 'agent request-error decision')
}
break
}
if ('error' in stepOutcome) {
// Steering that arrived during the failed step stays in the inbox —
// runLoop re-enqueues it as a queued message, so an abort-then-steer
// starts a fresh turn instead of being silently consumed.
closeStep()
const { error } = stepOutcome
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(error)
else reason = interrupted
break
}
requestFailureHistory = Object.freeze([])
// Preserve max-token completion unless a later disposal, abort, or error wins.
const stepReason = stepFinishReason(stepOutcome.finish)
if (stepReason) reason = stepReason
// Steering that arrived during streaming/tool execution.
const steered = drainSteering()
try {
await events.serial('agent/post-step', turn, step, signal)
} catch (error: unknown) {
stepOutcome = { error: toError(error) }
}
if ('error' in stepOutcome) {
closeStep()
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(stepOutcome.error)
else reason = interrupted
break
}
const postStepInterrupted = interruptionTurnEndReason(handle, signal)
if (postStepInterrupted !== undefined) {
reason = postStepInterrupted
closeStep()
break
}
closeStep()
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
let decision: ContinuationDecision
try {
decision = await events.waterfall(
'agent/turn-continuation', turn, defaultDecision, signal,
() => Promise.resolve(defaultDecision),
)
interruptionCheckpoint(signal)
} catch (error: unknown) {
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(toError(error))
else reason = interrupted
break
}
// A continuation reason becomes next-step steering. Publish the same
// enqueue event a public steer would, so the inbox ledger stays balanced
// (every FIFO entry has a matching enqueue before its dequeue/discard).
if (decision.action === 'continue' && decision.reason) {
// Detach and freeze the listener-owned reason like a public steer, so an
// enqueue listener or the producer cannot mutate the durable/model-visible
// steering message before it drains.
const item: InboxMessage = deepFreeze({
id: AgentMessageId(randomUUID()),
content: structuredClone(decision.reason.content),
source: structuredClone(decision.reason.source),
contexts: [], wakeup: true,
})
handle.inbox.steer(item)
events.emit('agent/inbox/enqueue', agentMessage(item, true))
}
let shouldContinue = decision.action === 'continue'
// Pending steering overrides an ordinary stop.
if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
// Terminal policy is monotonic and runs after ordinary continuation folding.
let terminalStop = false
try {
const stop = await events.serial('agent/turn-stop', turn, signal)
interruptionCheckpoint(signal)
terminalStop = stop !== undefined
} catch (error: unknown) {
// A broken terminal policy is an ordinary continuation failure: fail
// this turn closed while leaving the driver alive for later turns.
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(toError(error))
else reason = interrupted
break
}
if (terminalStop) {
terminalStopped = true
// Terminal stop discards steering but preserves ordinary queued prompts.
// Publish a discard for every dropped steering item so the enqueue ⇒
// dequeue-or-discard ledger stays balanced (the outstanding-count
// invariant and correlation consumers must not be left with dangling ids).
const dropped = handle.inbox.drainSteering()
if (dropped.length > 0) {
events.emit('agent/inbox/discard', dropped.map(item => agentMessage(item, true)))
}
shouldContinue = false
}
if (!shouldContinue) break
}
// Normal / inline-error loop exit: close the turn.
closeTurn()
} catch (error: unknown) {
// Close only a turn whose start committed to the log.
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
if (!turnStartLogged) throw error
closeStep()
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(toError(error))
else reason = interrupted
closeTurn()
}
// Flush through the store-owned durability checkpoint without killing the driver on failure.
try {
await ctx.sessions.flush(session)
} catch (error: unknown) {
// The turn is closed, so report the failed flush live rather than append outside a turn.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${errorChain(err)}`)
try {
events.emit('agent/error', turn, step, err)
} catch {
// contained: a throwing agent/error listener must not escape the loop.
}
}
return terminalStopped
}
/**
* Run one committed step: transform call config, log the request header, build
* the request from the cached prefix plus the step-boundary snapshot, stream and
* record the response, then execute tools. The caller has already assembled the
* prompt, run `agent/pre-step`, snapshotted history, and opened the step.
*/
async function runStep(
ctx: Context,
events: AgentEventDispatch,
handle: LoopHandle,
turn: number,
step: number,
assembly: PromptAssembly,
system: string,
boundaryMessages: Message[],
transmission: TransmissionLog,
signal: AbortSignal,
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
const agent = ctx.agents.requireInitiator()
const { session, options } = agent
// Seed the first request from agent options and later requests from the logged header;
// detach and freeze so listeners must return an attributable replacement.
const loggedConfig = session.requestHeader()?.config
const initialProvider = options.provider ?? ''
const initialModel = options.model ?? ''
const initialConfig: LlmCallConfig = {
provider: initialProvider,
model: initialModel,
...loggedConfig?.provider === initialProvider
&& loggedConfig.model === initialModel
&& loggedConfig.reasoningEffort !== undefined
? { reasoningEffort: loggedConfig.reasoningEffort }
: {},
}
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(
transmission.loggedHeader
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
? session.requestHeader()!.config
: initialConfig,
))
// Listener replacements are recorded in the request header before dispatch.
const proposedConfig = await events.waterfall(
'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig),
)
interruptionCheckpoint(signal)
if (!proposedConfig.provider || !proposedConfig.model) {
throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
}
let config: LlmCallConfig
let preparedCall: PreparedLlmCall | undefined
try {
preparedCall = await ctx.llm.prepareCall(proposedConfig, signal)
config = preparedCall.config
} catch (error: unknown) {
// A waterfall listener may own and short-circuit a route with no adapter.
// Terminal dispatch still raises NO_ADAPTER when no listener handles it.
if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
config = proposedConfig
}
interruptionCheckpoint(signal)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
const sessionPrefix = transmission.sessionPrefix!
// Record the canonical header, including the otherwise-unlogged prefix, before dispatch.
const header = canonicalHeader({
config,
...system ? { system } : {},
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
...sessionPrefix.length > 0 ? { messagePrefix: sessionPrefix } : {},
})
recordRequestHeader(session, transmission, header)
// Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
const request: GenerateOptions = markAgentLoopRequest(deepFreeze({
provider: header.config.provider,
model: header.config.model,
...header.config.reasoningEffort !== undefined
? { reasoningEffort: header.config.reasoningEffort }
: {},
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
...header.system !== undefined ? { system: header.system } : {},
...header.tools !== undefined ? { tools: header.tools } : {},
...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {},
...header.config.maxTokens !== undefined ? { maxTokens: header.config.maxTokens } : {},
...header.config.stop !== undefined ? { stop: header.config.stop } : {},
sessionId: session.id,
signal,
}))
// --- Model call (streaming-first; raw chunks are the replay record) ---
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = preparedCall?.stream(request) ?? ctx.llm.stream(request)
try {
for await (const chunk of stream) {
interruptionCheckpoint(signal)
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
chunkSeqs.push(chunkEvent.seq)
assembler.push(chunk)
}
} catch (error: unknown) {
const failure = llmFailureOf(stream, error)
if (failure !== undefined && error instanceof Error) {
throw new TerminalModelRequestFailure(error, failure, llmRetryPolicyOf(stream))
}
throw error
}
interruptionCheckpoint(signal)
// Normalize failure finish chunks into the same path as thrown stream errors.
const stepError = finishError(assembler.finish)
if (stepError) {
throw new TerminalModelRequestFailure(stepError.error, stepError.failure, llmRetryPolicyOf(stream))
}
const recordAssistantMessage = (
assembledContent: ContentBlock[],
message: Message,
preserveReplayState = true,
): void => {
session.append(
'assistant/message',
{
turn,
step,
content: message.content,
provenance: assistantProvenance(
header.config,
assembler.replayState,
preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
),
...assembler.usage === undefined ? {} : { usage: assembler.usage },
},
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
}
// A rejected result still records the successful provider call without retaining rejected output.
const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise<Message> => {
try {
const processed = await events.waterfall(
'agent/step-result', turn, step, message, signal, () => Promise.resolve(message),
)
interruptionCheckpoint(signal)
return processed
} catch (error: unknown) {
recordAssistantMessage(assembledContent, { ...message, content: [] }, false)
throw error
}
}
if (assembler.finish.kind === 'max-tokens') {
const assembled = assembler.message()
const assembledContent = structuredClone(assembled.content)
let message: Message = withoutToolCalls(assembled)
message = withoutToolCalls(await processStepResult(assembledContent, message))
// Preserve usage even when max-token truncation produced no content.
recordAssistantMessage(assembledContent, message)
return { hadToolCalls: false, finish: assembler.finish }
}
// Record the post-waterfall message that tool dispatch uses.
const assembled = assembler.message()
const assembledContent = structuredClone(assembled.content)
let message: Message = assembled
message = await processStepResult(assembledContent, message)
// Every successful call records its completion anchor, including explicit
// empty chunk provenance for a contentless, usage-less provider response.
recordAssistantMessage(assembledContent, message)
// Dispatch may overlap; policy, durable results, and result context stay model-ordered.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish }
return handle.withToolBatch(async (acceptContext) => {
await executeToolCalls(
ctx, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
)
return { hadToolCalls: true, finish: assembler.finish }
})
}
/** Build durable assistant provenance, dropping replay state after any content rewrite. */
function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable<Message['provenance']> {
return {
provider: config.provider,
model: config.model,
...contentUnchanged && replayState !== undefined ? { replayState } : {},
}
}
function withoutToolCalls(message: Message): Message {
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
}
/**
* The last turn number in a (possibly seeded) session log, or 0.
* @param session - the session whose log is scanned for the latest `turn/start`.
* @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one).
*/
export function lastTurnNumber(session: Session): number {
const lastStart = session.events.findLast(event => event.type === 'turn/start')
return lastStart?.data.turn ?? 0
}
/**
* Whether the session log has an unmatched `turn/start`. Agent status is not
* sufficient during pre-start and post-end windows.
* @param session - the session whose log is inspected.
* @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
*/
export function isTurnOpen(session: Session): boolean {
const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')
return last?.type === 'turn/start'
}

View File

@@ -1,55 +0,0 @@
/**
* Per-loop-instance request-header bookkeeping for reconstructability. The
* comparison baseline is folded from the session log; a fresh instance anchors
* it with an initial/resume snapshot and later logs full changed snapshots.
*
* @module dsh-agent-loop/request-log
*/
import { headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
import type { Message } from '@deepseek-ai/dsh-llm'
/** Per-loop-instance bookkeeping: whether THIS instance has logged a header yet. */
export interface TransmissionLog {
/** True once this loop instance appended its anchoring `request/header` snapshot. */
loggedHeader: boolean
/**
* The instance's composed session prefix (the `agent/session-prefix`
* waterfall's deep-frozen product), cached on the instance's first
* request-building step and reused verbatim for every request it sends —
* the structural guarantee that the prefix never changes mid-session.
* `undefined` until composed.
*/
sessionPrefix?: Message[]
}
/**
* Fresh bookkeeping for a newly-started loop instance.
* @returns state with `loggedHeader` false, so the instance's first request appends an anchoring snapshot.
*/
export function createTransmissionLog(): TransmissionLog {
return { loggedHeader: false }
}
/**
* Append the full header snapshot owed by this request: initial/resume for the
* instance's first request, nothing when unchanged, or change otherwise.
*
* @param session - the session whose log explains the request.
* @param state - this loop instance's bookkeeping (mutated on first log).
* @param header - the canonical header the request will ACTUALLY use
* (post-`agent/request`).
*/
export function recordRequestHeader(session: Session, state: TransmissionLog, header: EpochHeader): void {
if (!state.loggedHeader) {
session.append('request/header', { header, reason: session.requestHeader() === undefined ? 'initial' : 'resume' })
state.loggedHeader = true
return
}
// This instance logged a snapshot, so the fold is necessarily defined.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const baseline = session.requestHeader()!
if (headerEquals(baseline, header)) return
session.append('request/header', { header, reason: 'change' })
}

View File

@@ -11,8 +11,7 @@
import type { Context } from 'cordis'
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
import type { HookContext } from '@deepseek-ai/dsh-agent'
import type { Session } from '@deepseek-ai/dsh-session'
import type { Session, UserMessageData } from '@deepseek-ai/dsh-session'
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
/** One tool call after argument parsing, ready to schedule. */
@@ -32,13 +31,16 @@ interface Slot {
interface GroupOutcome {
consumed: number
aborted: boolean
/** Whether any committed result carried {@link ToolExecutionResult.concludesTurn}. */
concluded: boolean
}
/**
* Schedule one assistant step's tool calls by their live concurrency mode.
* Started calls receive ordered results. Abort drains them, records synthetic
* results for unstarted calls, and returns with the signal still aborted after
* accepting started-call context into the batch FIFO owned by the caller.
* accepting started-call context through the caller-supplied acceptor (the
* machine stages it on its outbox for the next step boundary).
* The committed step's AgentLoop driver boundary supplies the initiating Agent
* that becomes each explicit {@link ToolExecutionInput.agent}.
*
@@ -47,8 +49,7 @@ interface GroupOutcome {
* @param step - current step number.
* @param toolCalls - assistant calls in model order.
* @param signal - abort signal shared by the step.
* @param maxParallel - validated in-flight cap.
* @param acceptContext - accepts committed result context into the active batch.
* @param acceptContext - accepts committed result context for the next step boundary.
*/
export async function executeToolCalls(
ctx: Context,
@@ -56,9 +57,8 @@ export async function executeToolCalls(
step: number,
toolCalls: ToolCallBlock[],
signal: AbortSignal,
maxParallel: number,
acceptContext: (context: HookContext) => void,
): Promise<void> {
acceptContext: (context: UserMessageData) => void,
): Promise<{ concluded: boolean }> {
const agent = ctx.agents.requireInitiator()
const { session } = agent
@@ -75,6 +75,7 @@ export async function executeToolCalls(
}))
let next = 0
let concluded = false
while (next < planned.length) {
// Commit before classifying again so registry changes affect unstarted calls.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
@@ -82,14 +83,16 @@ export async function executeToolCalls(
const mode = ctx.tools.executionMode(first.exec).kind
const group = mode === 'parallel' ? planned.slice(next) : [first]
const outcome = await runGroup(
ctx, turn, step, group, mode, signal, maxParallel, acceptContext,
ctx, turn, step, group, mode, signal, acceptContext,
)
next += outcome.consumed
concluded ||= outcome.concluded
if (outcome.aborted) {
for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block)
return
return { concluded }
}
}
return { concluded }
}
/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
@@ -116,10 +119,10 @@ async function runGroup(
group: PlannedCall[],
mode: ToolExecutionMode['kind'],
signal: AbortSignal,
maxParallel: number,
acceptContext: (context: HookContext) => void,
acceptContext: (context: UserMessageData) => void,
): Promise<GroupOutcome> {
const { session } = ctx.agents.requireInitiator()
const { maxParallelToolCalls } = ctx.agentLoop.config
const slots: (Slot | undefined)[] = group.map(() => undefined)
// Started slots retain their tool/call seq for result provenance.
const callSeqs: number[] = group.map(() => -1)
@@ -127,6 +130,7 @@ async function runGroup(
let committed = 0
let started = 0
let aborted: boolean = signal.aborted
let concluded = false
// `committed` advances only across contiguous model-order slots.
const commitReady = async (): Promise<void> => {
@@ -140,6 +144,7 @@ async function runGroup(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
for (const context of result.additionalContexts ?? []) acceptContext(context)
concluded ||= result.concludesTurn === true
committed++
}
}
@@ -174,7 +179,7 @@ async function runGroup(
}
const fillPool = async (): Promise<void> => {
while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) {
while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) {
// Re-read later modes after ordered commits so registry changes can create a barrier.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
const nextCall = group[nextToStart]!
@@ -206,11 +211,11 @@ async function runGroup(
// Started calls and accepted context settle first; every remaining model
// call then receives an ordered synthetic result before the turn aborts.
for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
return { consumed: group.length, aborted: true }
return { consumed: group.length, aborted: true, concluded }
}
/* v8 ignore next -- unreachable: a non-aborted group commits every started call */
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
return { consumed: started, aborted: false }
return { consumed: started, aborted: false, concluded }
}
/** Append the durable call/result pair for a model call skipped after cancellation. */