|
|
@@ -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'
|
|
|
}
|
|
|
|
|
|
- /** 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
|
|
|
+ /** 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)
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- 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 })
|
|
|
+ /** Accept and route one unified send item. */
|
|
|
+ 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()
|
|
|
}
|
|
|
- // 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
|
|
|
}
|
|
|
|
|
|
- /** 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')
|
|
|
- }
|
|
|
- // 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()
|
|
|
+ /** 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)
|
|
|
+ }
|
|
|
|
|
|
- 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)}`)
|
|
|
- }
|
|
|
+ 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)
|
|
|
}
|
|
|
-
|
|
|
- // 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
|
|
|
+ } finally {
|
|
|
+ if (this.phase.kind === 'running') {
|
|
|
+ this.setPhase({ kind: 'idle', lastTurn: this.phase.turn })
|
|
|
}
|
|
|
- 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')
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Claim and admit the next queued prompt, then start its turn. */
|
|
|
+ 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' }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 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)
|
|
|
- }
|
|
|
- // 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)
|
|
|
+ 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
|
|
|
}
|
|
|
+ } 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! })
|
|
|
}
|
|
|
-
|
|
|
- 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()
|
|
|
- }
|
|
|
+ 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')
|
|
|
- }
|
|
|
- }
|
|
|
}
|