loop.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
  1. /**
  2. * Drives one agent across queued durable turns. Turn failures are contained so
  3. * later work can run; the session log, not this driver, owns conversation state.
  4. * See .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
  5. * @module dsh-agent-loop/loop
  6. */
  7. import { randomUUID } from 'node:crypto'
  8. import type { Context } from 'cordis'
  9. import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
  10. import { isDeepStrictEqual } from 'node:util'
  11. import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
  12. import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent'
  13. import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
  14. import { canonicalHeader } from '@deepseek-ai/dsh-session'
  15. import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
  16. import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
  17. import type { TransmissionLog } from './request-log.ts'
  18. import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  19. import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
  20. import type {} from '@deepseek-ai/dsh-tools'
  21. import { executeToolCalls } from './tool-calls.ts'
  22. import { agentMessage, type Inbox, type InboxMessage } from './inbox.ts'
  23. import type { TurnCancellation } from './cancellation.ts'
  24. /** Normalize thrown values while preserving an existing error code. */
  25. function toError(error: unknown): RequestError {
  26. return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
  27. }
  28. /** Distinguishes final model-request failures from failures in later step processing. */
  29. class TerminalModelRequestFailure extends Error {
  30. constructor(
  31. readonly requestError: RequestError,
  32. readonly failure: LlmFailure,
  33. ) {
  34. super(failure.message, { cause: requestError })
  35. this.name = 'TerminalModelRequestFailure'
  36. }
  37. }
  38. /** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */
  39. function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined {
  40. switch (finish.kind) {
  41. case 'error':
  42. case 'aborted': {
  43. const facts = finish.failure
  44. const error = new LlmError(facts.message, facts.code, {
  45. ...facts.status === undefined ? {} : { status: facts.status },
  46. ...facts.providerRetryAfterMs === undefined
  47. ? {}
  48. : { providerRetryAfterMs: facts.providerRetryAfterMs },
  49. ...facts.requestId === undefined ? {} : { requestId: facts.requestId },
  50. })
  51. return { error, failure: error.failure }
  52. }
  53. // stop / tool-calls / max-tokens / plugin-added kinds → not a failure.
  54. default:
  55. return undefined
  56. }
  57. }
  58. /**
  59. * Build the `{ message, code? }` part of an error payload, omitting the
  60. * `code` key entirely when absent (exactOptionalPropertyTypes-correct).
  61. * The durable message renders the full cause chain: `turn/end` is the single
  62. * durable record of an in-turn failure, so a wrapper message alone (e.g.
  63. * `fetch failed`) would lose the diagnosis the session log exists to keep.
  64. */
  65. function errorData(err: RequestError): { message: string; code?: string } {
  66. return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }
  67. }
  68. /** Preserve cause diagnostics, falling back to adapter-normalized prose for a hostile Error. */
  69. function durableFailure(err: RequestError, failure: LlmFailure): LlmFailure {
  70. const message = errorChain(err)
  71. return { ...failure, message: message === '<unrenderable value>' ? failure.message : message }
  72. }
  73. /** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
  74. function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
  75. switch (finish.kind) {
  76. case 'max-tokens':
  77. return { kind: 'max-tokens' }
  78. // stop / tool-calls / plugin-added kinds → no turn-end contribution
  79. // beyond the default `completed`. FinishReason is merge-extensible, so a
  80. // default (not assertNever) handles unknown kinds as ordinary success.
  81. default:
  82. return undefined
  83. }
  84. }
  85. /** Internal control-flow sentinel; durable classification comes only from the turn signal. */
  86. const TURN_INTERRUPTED = new Error('turn interrupted')
  87. const PROMPT_PREFIX_REQUEST_DELIMITER: ContentBlock = {
  88. type: 'text',
  89. text: '\n\n## My request:\n',
  90. }
  91. interface PreparedPromptMessage {
  92. data: PromptMessageData
  93. separateContexts: HookContext[]
  94. }
  95. /** Bake declared prefix contexts into one reconstructable prompt message. */
  96. function preparePromptMessage(
  97. content: ContentBlock[],
  98. source: PromptMessageData['source'],
  99. contexts: readonly HookContext[],
  100. ): PreparedPromptMessage {
  101. const prefixContexts = contexts.filter(context => context.placement === 'prompt-prefix')
  102. const separateContexts = contexts.filter(context => context.placement !== 'prompt-prefix')
  103. if (prefixContexts.length === 0) return { data: { content, source }, separateContexts }
  104. return {
  105. data: {
  106. content: [
  107. ...prefixContexts.flatMap(context => context.content),
  108. PROMPT_PREFIX_REQUEST_DELIMITER,
  109. ...content,
  110. ],
  111. source,
  112. envelope: {
  113. displayContent: content,
  114. prefixContexts: prefixContexts.map(context => ({
  115. source: context.source,
  116. ...context.meta === undefined ? {} : { meta: context.meta },
  117. })),
  118. },
  119. },
  120. separateContexts,
  121. }
  122. }
  123. /** Stop at an explicit cooperative boundary without stringifying the runtime reason. */
  124. function interruptionCheckpoint(signal: AbortSignal): void {
  125. if (signal.aborted) throw TURN_INTERRUPTED
  126. }
  127. /** Classify a supported turn interruption, with lifecycle disposal taking precedence. */
  128. function interruptionTurnEndReason(handle: LoopHandle, signal: AbortSignal): TurnEndReason | undefined {
  129. if (handle.isDisposed()) return { kind: 'disposed' }
  130. const reason = agentInterruptReasonOf(signal)
  131. if (reason === undefined) return undefined
  132. switch (reason.kind) {
  133. case 'user':
  134. case 'parent':
  135. return { kind: 'aborted' }
  136. /* v8 ignore next 2 -- the private holder requests disposed only after lifecycle state flips, which returns above. */
  137. case 'disposed':
  138. return { kind: 'disposed' }
  139. /* v8 ignore next 2 -- AgentInterruptReason is closed and the public helper filters unsupported reasons. */
  140. default:
  141. return assertNever(reason, 'AgentInterruptReason')
  142. }
  143. }
  144. /** Mutable agent controls supplied to the loop driver. */
  145. export interface LoopHandle {
  146. /** Native-private agent inbox handed to the driver only at internal startup. */
  147. readonly inbox: Inbox
  148. /** Maximum parallel-safe calls allowed in one step. */
  149. readonly maxParallelToolCalls: number
  150. setStatus(status: 'idle' | 'running'): void
  151. /** Install a fresh active-turn owner before the running notification. */
  152. installTurnCancellation(): TurnCancellation
  153. /** Clear only the exact owner whose turn reached its terminal event boundary. */
  154. clearTurnCancellation(cancellation: TurnCancellation): void
  155. /** Resolves when the agent is disposed — unblocks the idle wait. */
  156. disposed: Promise<void>
  157. isDisposed(): boolean
  158. /** Whether queued work was cancelled before an active turn owner existed. */
  159. isPreRunCancelled(): boolean
  160. /** Clear the cause-less pre-run marker without affecting replacement work. */
  161. clearPreRunCancel(): void
  162. /** Settle idle waiters before pre-running cancellation publishes idle. */
  163. settleIdle(): void
  164. /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */
  165. readonly withToolBatch: <T>(run: (acceptContext: (context: HookContext) => void) => Promise<T>) => Promise<T>
  166. }
  167. /**
  168. * Drive queued messages as independent durable turns until disposal. Plugin
  169. * failures end the current turn without terminating the driver. The caller
  170. * establishes the `ctx.agents.withInitiator()` boundary before entry; package-private
  171. * orchestration recovers that exact Agent and captures its Session locally.
  172. * @param ctx - the plugin context the loop reaches its initiating Agent,
  173. * events (agent/…, session/flush), and services (systemPrompt, llm, tools)
  174. * through.
  175. * @param handle - the bridge to status, turn cancellation ownership, disposal, and pre-run cancellation state.
  176. * @throws when no initiating Agent is active.
  177. */
  178. export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
  179. const agent = ctx.agents.requireInitiator()
  180. // Per-instance prefix and request-header state; conversation history remains in the session log.
  181. const transmission = createTransmissionLog()
  182. const { session } = agent
  183. // Fused subject and scope carrier for every agent event below.
  184. const events = agentEvents(ctx, agent)
  185. while (!handle.isDisposed()) {
  186. // An idle listener can enqueue and cancel replacement work before the next
  187. // wait is installed. Consume that empty marker before parking the driver.
  188. // A quiet (`wakeup:false`) item alone must not un-park the loop, so gate on
  189. // hasWakingQueued, not hasQueued.
  190. if (handle.isPreRunCancelled()) {
  191. handle.clearPreRunCancel()
  192. if (!handle.inbox.hasWakingQueued) {
  193. handle.settleIdle()
  194. handle.setStatus('idle')
  195. continue
  196. }
  197. }
  198. await handle.inbox.waitForQueued(handle.disposed)
  199. if (handle.isDisposed()) break
  200. // Cancellation between wake and `running` skips only the cancelled work;
  201. // a replacement prompt still runs before the eventual idle transition.
  202. if (handle.isPreRunCancelled()) {
  203. handle.clearPreRunCancel()
  204. if (!handle.inbox.hasWakingQueued) {
  205. // Settle before publishing idle: the already-idle path has no status
  206. // transition, while an idle listener can register waiters for new work.
  207. handle.settleIdle()
  208. handle.setStatus('idle')
  209. continue
  210. }
  211. }
  212. let cancellation = handle.installTurnCancellation()
  213. handle.setStatus('running')
  214. if (handle.isDisposed()) {
  215. handle.clearTurnCancellation(cancellation)
  216. break
  217. }
  218. // A synchronous `running` listener can cancel before `runTurn`; balance the
  219. // status only when no waking replacement prompt was queued by that listener
  220. // (a lone quiet item parks at idle rather than driving a turn).
  221. if (cancellation.signal.aborted) {
  222. handle.clearTurnCancellation(cancellation)
  223. if (!handle.inbox.hasWakingQueued) {
  224. handle.setStatus('idle')
  225. continue
  226. }
  227. cancellation = handle.installTurnCancellation()
  228. }
  229. // Idle injection can add a turn, so derive the next number from the log.
  230. const turn = lastTurnNumber(session) + 1
  231. let terminalStopped = false
  232. try {
  233. terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation)
  234. } catch (error: unknown) {
  235. // Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
  236. const err = toError(error)
  237. ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${errorChain(err)}`)
  238. try {
  239. events.emit('agent/error', turn, 0, err)
  240. } catch { /* contained: a throwing agent/error listener must not kill the driver */ }
  241. } finally {
  242. handle.clearTurnCancellation(cancellation)
  243. }
  244. // Late steering (arriving after runTurn returns, e.g. during the post-turn
  245. // flush) becomes queued input — unless terminal policy stopped the turn, in
  246. // which case it is dropped and must publish a discard so its enqueue is
  247. // still matched (the invariant only catches a NEGATIVE count, not a leak).
  248. const lateSteering = handle.inbox.drainSteering()
  249. if (terminalStopped) {
  250. if (lateSteering.length > 0) {
  251. events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true)))
  252. }
  253. } else {
  254. for (const message of lateSteering) handle.inbox.enqueue(message)
  255. }
  256. // Park at idle unless a waking item still wants the model to run; a lone
  257. // quiet (`wakeup:false`) item stays queued but does not keep the loop busy.
  258. if (!handle.inbox.hasWakingQueued) handle.setStatus('idle')
  259. }
  260. }
  261. async function runTurn(
  262. ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog,
  263. cancellation: TurnCancellation,
  264. ): Promise<boolean> {
  265. const agent = ctx.agents.requireInitiator()
  266. const { session } = agent
  267. const { signal } = cancellation
  268. const drainSteering = (): boolean => {
  269. const messages = handle.inbox.drainSteering()
  270. for (const message of messages) {
  271. events.emit('agent/inbox/dequeue', agentMessage(message, true))
  272. const prepared = preparePromptMessage(message.content, message.source, message.contexts)
  273. session.append('steering/message', {
  274. turn, ...prepared.data,
  275. ...message.meta === undefined ? {} : { meta: message.meta },
  276. }, { surfaceOp: 'append' })
  277. for (const context of prepared.separateContexts) {
  278. session.append('user/message', {
  279. content: context.content,
  280. source: context.source,
  281. ...context.meta === undefined ? {} : { meta: context.meta },
  282. }, { surfaceOp: 'append' })
  283. }
  284. }
  285. return messages.length > 0
  286. }
  287. // Claim one queued message before opening its turn, but append it only after `turn/start`.
  288. const message = handle.inbox.dequeueQueued()
  289. /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
  290. if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
  291. events.emit('agent/inbox/dequeue', agentMessage(message, false))
  292. const trigger: TurnTrigger = { kind: 'message', source: message.source }
  293. let reason: TurnEndReason = { kind: 'completed' }
  294. let step = 0
  295. let requestFailureHistory: readonly LlmFailure[] = Object.freeze([])
  296. let stepOpen = false
  297. let errorReported = false
  298. let terminalStopped = false
  299. // Close the committed step once; pre-commit validation failure still escapes.
  300. const closeStep = (): void => {
  301. if (!stepOpen) return
  302. session.append('step/end', { turn, step })
  303. stepOpen = false
  304. }
  305. // Record the durable turn failure once and contain the live error notification.
  306. const failTurn = (err: RequestError, failure?: LlmFailure): void => {
  307. if (errorReported) return
  308. errorReported = true
  309. reason = failure === undefined
  310. ? { kind: 'error', step, ...errorData(err) }
  311. : { kind: 'error', step, failure: durableFailure(err, failure) }
  312. try {
  313. events.emit('agent/error', turn, step, err)
  314. } catch {
  315. // contained: the error is already captured on `reason`; a throwing
  316. // agent/error listener must not prevent the turn from closing.
  317. }
  318. }
  319. // Retire cancellation authority before publishing the terminal event. The
  320. // following durability flush is quiescent turn work, but no longer part of
  321. // the cancellable turn lifetime.
  322. const closeTurn = (): void => {
  323. handle.clearTurnCancellation(cancellation)
  324. session.append('turn/end', { turn, reason })
  325. }
  326. try {
  327. // --- Turn boundary. Once turn/start is appended, a turn/end is owed no
  328. // matter what throws below; the catch + closeTurn guarantee it. A pre-commit
  329. // veto leaves no turn/start in the log and therefore owes no turn/end.
  330. session.append('turn/start', { turn, trigger })
  331. interruptionCheckpoint(signal)
  332. // The claimed message runs the `agent/prompt-submit` waterfall before it
  333. // becomes a `user/message` — a hook can rewrite the prompt or block it.
  334. // Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
  335. // turn/end is now owed, so a throwing prompt-submit listener (the waterfall
  336. // throws) is caught below and the turn still closes.
  337. const promptDecision = await events.waterfall(
  338. 'agent/prompt-submit', message.content, message.source, signal,
  339. () => Promise.resolve<PromptDecision>({
  340. kind: 'allow',
  341. ...message.contexts.length === 0 ? {} : { additionalContexts: message.contexts },
  342. }),
  343. )
  344. interruptionCheckpoint(signal)
  345. if (promptDecision.kind === 'block') {
  346. session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason })
  347. reason = { kind: 'rejected', reason: promptDecision.reason }
  348. } else {
  349. // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
  350. const content = promptDecision.content ?? message.content
  351. const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? [])
  352. session.append('user/message', {
  353. ...prepared.data,
  354. ...message.meta === undefined ? {} : { meta: message.meta },
  355. }, { surfaceOp: 'append' })
  356. // Separate contexts still enter THIS turn through inject(). Prefix
  357. // contexts are already baked into the user/message with their durable
  358. // display envelope, so appending them again would duplicate model input.
  359. for (const context of prepared.separateContexts) {
  360. agent.inject(context.content, {
  361. source: context.source,
  362. ...context.meta !== undefined ? { meta: context.meta } : {},
  363. })
  364. }
  365. }
  366. while (true) {
  367. // A blocked prompt closes its zero-step turn as rejected.
  368. if (promptDecision.kind === 'block') break
  369. step += 1
  370. // Steering from the previous round's continuation listeners joins before
  371. // the request.
  372. drainSteering()
  373. // Assemble once before pre-step so listener work and the request share one prompt value.
  374. const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal))
  375. interruptionCheckpoint(signal)
  376. const fullSystemPrompt = renderPrompt(assembly)
  377. // Compose the request-only prefix once per loop instance before the first
  378. // request boundary. It precedes all derived history and is recorded only
  379. // in the request header, not as session history.
  380. if (transmission.sessionPrefix === undefined) {
  381. const emptyPrefix: Message[] = deepFreeze([])
  382. const composed = await events.waterfall(
  383. 'agent/session-prefix', emptyPrefix, signal,
  384. () => Promise.resolve(emptyPrefix),
  385. )
  386. // Never cache an interrupted composition; the next turn recomposes it.
  387. interruptionCheckpoint(signal)
  388. transmission.sessionPrefix = deepFreeze(structuredClone(composed))
  389. }
  390. // Await surface mutations outside the step before snapshotting history.
  391. await events.serial('agent/pre-step', turn, step, signal)
  392. interruptionCheckpoint(signal)
  393. // Snapshot the exact log prefix before step/start: the reconstruction
  394. // boundary. Appends after this synchronous snapshot join the next request.
  395. const boundaryMessages = session.deriveMessages()
  396. session.append('step/start', { turn, step })
  397. // Only a committed step/start creates a balancing obligation. A
  398. // pre-commit veto throws before this assignment; post-commit observers
  399. // are contained inside Session.append().
  400. stepOpen = true
  401. // A synchronous step/start observer can cancel after the step opened.
  402. interruptionCheckpoint(signal)
  403. let stepOutcome:
  404. | { hadToolCalls: boolean; finish: FinishReason }
  405. | { requestError: RequestError; failure: LlmFailure }
  406. | { error: RequestError }
  407. try {
  408. stepOutcome = await runStep(
  409. ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal)
  410. } catch (error: unknown) {
  411. if (error instanceof TerminalModelRequestFailure) {
  412. stepOutcome = { requestError: error.requestError, failure: error.failure }
  413. } else {
  414. stepOutcome = { error: toError(error) }
  415. }
  416. }
  417. if ('requestError' in stepOutcome) {
  418. // Recovery observes a balanced failed step and the original provider
  419. // error while the failed step's signal remains the active owner.
  420. closeStep()
  421. const interrupted = interruptionTurnEndReason(handle, signal)
  422. if (interrupted !== undefined) {
  423. reason = interrupted
  424. break
  425. }
  426. const defaultDecision: RequestErrorDecision = { action: 'fail' }
  427. let recoveryDecision: RequestErrorDecision = defaultDecision
  428. try {
  429. recoveryDecision = await events.waterfall(
  430. 'agent/request-error', turn, step, stepOutcome.requestError,
  431. stepOutcome.failure, requestFailureHistory, signal,
  432. () => Promise.resolve(defaultDecision),
  433. )
  434. } catch (recoveryError: unknown) {
  435. ctx.logger.warn(
  436. `agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
  437. )
  438. }
  439. // Cancellation and disposal always win over either a recovery decision
  440. // or a recovery-listener failure.
  441. const recoveryInterrupted = interruptionTurnEndReason(handle, signal)
  442. if (recoveryInterrupted !== undefined) {
  443. reason = recoveryInterrupted
  444. break
  445. }
  446. switch (recoveryDecision.action) {
  447. case 'retry':
  448. requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure])
  449. continue
  450. case 'fail':
  451. failTurn(stepOutcome.requestError, stepOutcome.failure)
  452. break
  453. /* v8 ignore next -- closed-union exhaustiveness guard */
  454. default:
  455. assertNever(recoveryDecision, 'agent request-error decision')
  456. }
  457. break
  458. }
  459. if ('error' in stepOutcome) {
  460. // Steering that arrived during the failed step stays in the inbox —
  461. // runLoop re-enqueues it as a queued message, so an abort-then-steer
  462. // starts a fresh turn instead of being silently consumed.
  463. closeStep()
  464. const { error } = stepOutcome
  465. const interrupted = interruptionTurnEndReason(handle, signal)
  466. if (interrupted === undefined) failTurn(error)
  467. else reason = interrupted
  468. break
  469. }
  470. requestFailureHistory = Object.freeze([])
  471. // Preserve max-token completion unless a later disposal, abort, or error wins.
  472. const stepReason = stepFinishReason(stepOutcome.finish)
  473. if (stepReason) reason = stepReason
  474. // Steering that arrived during streaming/tool execution.
  475. const steered = drainSteering()
  476. try {
  477. await events.serial('agent/post-step', turn, step, signal)
  478. } catch (error: unknown) {
  479. stepOutcome = { error: toError(error) }
  480. }
  481. if ('error' in stepOutcome) {
  482. closeStep()
  483. const interrupted = interruptionTurnEndReason(handle, signal)
  484. if (interrupted === undefined) failTurn(stepOutcome.error)
  485. else reason = interrupted
  486. break
  487. }
  488. const postStepInterrupted = interruptionTurnEndReason(handle, signal)
  489. if (postStepInterrupted !== undefined) {
  490. reason = postStepInterrupted
  491. closeStep()
  492. break
  493. }
  494. closeStep()
  495. const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
  496. let decision: ContinuationDecision
  497. try {
  498. decision = await events.waterfall(
  499. 'agent/turn-continuation', turn, defaultDecision, signal,
  500. () => Promise.resolve(defaultDecision),
  501. )
  502. interruptionCheckpoint(signal)
  503. } catch (error: unknown) {
  504. const interrupted = interruptionTurnEndReason(handle, signal)
  505. if (interrupted === undefined) failTurn(toError(error))
  506. else reason = interrupted
  507. break
  508. }
  509. // A continuation reason becomes next-step steering. Publish the same
  510. // enqueue event a public steer would, so the inbox ledger stays balanced
  511. // (every FIFO entry has a matching enqueue before its dequeue/discard).
  512. if (decision.action === 'continue' && decision.reason) {
  513. // Detach and freeze the listener-owned reason like a public steer, so an
  514. // enqueue listener or the producer cannot mutate the durable/model-visible
  515. // steering message before it drains.
  516. const item: InboxMessage = deepFreeze({
  517. id: AgentMessageId(randomUUID()),
  518. content: structuredClone(decision.reason.content),
  519. source: structuredClone(decision.reason.source),
  520. contexts: [], wakeup: true,
  521. })
  522. handle.inbox.steer(item)
  523. events.emit('agent/inbox/enqueue', agentMessage(item, true))
  524. }
  525. let shouldContinue = decision.action === 'continue'
  526. // Pending steering overrides an ordinary stop.
  527. if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
  528. // Terminal policy is monotonic and runs after ordinary continuation folding.
  529. let terminalStop = false
  530. try {
  531. const stop = await events.serial('agent/turn-stop', turn, signal)
  532. interruptionCheckpoint(signal)
  533. terminalStop = stop !== undefined
  534. } catch (error: unknown) {
  535. // A broken terminal policy is an ordinary continuation failure: fail
  536. // this turn closed while leaving the driver alive for later turns.
  537. const interrupted = interruptionTurnEndReason(handle, signal)
  538. if (interrupted === undefined) failTurn(toError(error))
  539. else reason = interrupted
  540. break
  541. }
  542. if (terminalStop) {
  543. terminalStopped = true
  544. // Terminal stop discards steering but preserves ordinary queued prompts.
  545. // Publish a discard for every dropped steering item so the enqueue ⇒
  546. // dequeue-or-discard ledger stays balanced (the outstanding-count
  547. // invariant and correlation consumers must not be left with dangling ids).
  548. const dropped = handle.inbox.drainSteering()
  549. if (dropped.length > 0) {
  550. events.emit('agent/inbox/discard', dropped.map(item => agentMessage(item, true)))
  551. }
  552. shouldContinue = false
  553. }
  554. if (!shouldContinue) break
  555. }
  556. // Normal / inline-error loop exit: close the turn.
  557. closeTurn()
  558. } catch (error: unknown) {
  559. // Close only a turn whose start committed to the log.
  560. const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
  561. if (!turnStartLogged) throw error
  562. closeStep()
  563. const interrupted = interruptionTurnEndReason(handle, signal)
  564. if (interrupted === undefined) failTurn(toError(error))
  565. else reason = interrupted
  566. closeTurn()
  567. }
  568. // Flush through the store-owned durability checkpoint without killing the driver on failure.
  569. try {
  570. await ctx.sessions.flush(session)
  571. } catch (error: unknown) {
  572. // The turn is closed, so report the failed flush live rather than append outside a turn.
  573. const err = toError(error)
  574. ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${errorChain(err)}`)
  575. try {
  576. events.emit('agent/error', turn, step, err)
  577. } catch {
  578. // contained: a throwing agent/error listener must not escape the loop.
  579. }
  580. }
  581. return terminalStopped
  582. }
  583. /**
  584. * Run one committed step: transform call config, log the request header, build
  585. * the request from the cached prefix plus the step-boundary snapshot, stream and
  586. * record the response, then execute tools. The caller has already assembled the
  587. * prompt, run `agent/pre-step`, snapshotted history, and opened the step.
  588. */
  589. async function runStep(
  590. ctx: Context,
  591. events: AgentEventDispatch,
  592. handle: LoopHandle,
  593. turn: number,
  594. step: number,
  595. assembly: PromptAssembly,
  596. system: string,
  597. boundaryMessages: Message[],
  598. transmission: TransmissionLog,
  599. signal: AbortSignal,
  600. ): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
  601. const agent = ctx.agents.requireInitiator()
  602. const { session, options } = agent
  603. // Seed the first request from agent options and later requests from the logged header;
  604. // detach and freeze so listeners must return an attributable replacement.
  605. const loggedConfig = session.requestHeader()?.config
  606. const initialProvider = options.provider ?? ''
  607. const initialModel = options.model ?? ''
  608. const initialConfig: LlmCallConfig = {
  609. provider: initialProvider,
  610. model: initialModel,
  611. ...loggedConfig?.provider === initialProvider
  612. && loggedConfig.model === initialModel
  613. && loggedConfig.reasoningEffort !== undefined
  614. ? { reasoningEffort: loggedConfig.reasoningEffort }
  615. : {},
  616. }
  617. const seedConfig: LlmCallConfig = deepFreeze(structuredClone(
  618. transmission.loggedHeader
  619. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
  620. ? session.requestHeader()!.config
  621. : initialConfig,
  622. ))
  623. // Listener replacements are recorded in the request header before dispatch.
  624. const proposedConfig = await events.waterfall(
  625. 'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig),
  626. )
  627. interruptionCheckpoint(signal)
  628. if (!proposedConfig.provider || !proposedConfig.model) {
  629. throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
  630. }
  631. let config: LlmCallConfig
  632. let preparedCall: PreparedLlmCall | undefined
  633. try {
  634. preparedCall = await ctx.llm.prepareCall(proposedConfig, signal)
  635. config = preparedCall.config
  636. } catch (error: unknown) {
  637. // A waterfall listener may own and short-circuit a route with no adapter.
  638. // Terminal dispatch still raises NO_ADAPTER when no listener handles it.
  639. if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
  640. config = proposedConfig
  641. }
  642. interruptionCheckpoint(signal)
  643. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
  644. const sessionPrefix = transmission.sessionPrefix!
  645. // Record the canonical header, including the otherwise-unlogged prefix, before dispatch.
  646. const header = canonicalHeader({
  647. config,
  648. ...system ? { system } : {},
  649. ...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
  650. ...sessionPrefix.length > 0 ? { messagePrefix: sessionPrefix } : {},
  651. })
  652. recordRequestHeader(session, transmission, header)
  653. // Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
  654. const request: GenerateOptions = markAgentLoopRequest(deepFreeze({
  655. provider: header.config.provider,
  656. model: header.config.model,
  657. ...header.config.reasoningEffort !== undefined
  658. ? { reasoningEffort: header.config.reasoningEffort }
  659. : {},
  660. messages: [...header.messagePrefix ?? [], ...boundaryMessages],
  661. ...header.system !== undefined ? { system: header.system } : {},
  662. ...header.tools !== undefined ? { tools: header.tools } : {},
  663. ...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {},
  664. ...header.config.maxTokens !== undefined ? { maxTokens: header.config.maxTokens } : {},
  665. ...header.config.stop !== undefined ? { stop: header.config.stop } : {},
  666. sessionId: session.id,
  667. signal,
  668. }))
  669. // --- Model call (streaming-first; raw chunks are the replay record) ---
  670. const assembler = new BlockAssembler()
  671. const chunkSeqs: number[] = []
  672. const stream = preparedCall?.stream(request) ?? ctx.llm.stream(request)
  673. try {
  674. for await (const chunk of stream) {
  675. interruptionCheckpoint(signal)
  676. const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
  677. chunkSeqs.push(chunkEvent.seq)
  678. assembler.push(chunk)
  679. }
  680. } catch (error: unknown) {
  681. const failure = llmFailureOf(stream, error)
  682. if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure)
  683. throw error
  684. }
  685. interruptionCheckpoint(signal)
  686. // Normalize failure finish chunks into the same path as thrown stream errors.
  687. const stepError = finishError(assembler.finish)
  688. if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure)
  689. const recordAssistantMessage = (
  690. assembledContent: ContentBlock[],
  691. message: Message,
  692. preserveReplayState = true,
  693. ): void => {
  694. session.append(
  695. 'assistant/message',
  696. {
  697. turn,
  698. step,
  699. content: message.content,
  700. provenance: assistantProvenance(
  701. header.config,
  702. assembler.replayState,
  703. preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
  704. ),
  705. ...assembler.usage === undefined ? {} : { usage: assembler.usage },
  706. },
  707. { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
  708. )
  709. }
  710. // A rejected result still records the successful provider call without retaining rejected output.
  711. const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise<Message> => {
  712. try {
  713. const processed = await events.waterfall(
  714. 'agent/step-result', turn, step, message, signal, () => Promise.resolve(message),
  715. )
  716. interruptionCheckpoint(signal)
  717. return processed
  718. } catch (error: unknown) {
  719. recordAssistantMessage(assembledContent, { ...message, content: [] }, false)
  720. throw error
  721. }
  722. }
  723. if (assembler.finish.kind === 'max-tokens') {
  724. const assembled = assembler.message()
  725. const assembledContent = structuredClone(assembled.content)
  726. let message: Message = withoutToolCalls(assembled)
  727. message = withoutToolCalls(await processStepResult(assembledContent, message))
  728. // Preserve usage even when max-token truncation produced no content.
  729. recordAssistantMessage(assembledContent, message)
  730. return { hadToolCalls: false, finish: assembler.finish }
  731. }
  732. // Record the post-waterfall message that tool dispatch uses.
  733. const assembled = assembler.message()
  734. const assembledContent = structuredClone(assembled.content)
  735. let message: Message = assembled
  736. message = await processStepResult(assembledContent, message)
  737. // Every successful call records its completion anchor, including explicit
  738. // empty chunk provenance for a contentless, usage-less provider response.
  739. recordAssistantMessage(assembledContent, message)
  740. // Dispatch may overlap; policy, durable results, and result context stay model-ordered.
  741. const toolCalls = message.content.filter(block => block.type === 'tool-call')
  742. if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish }
  743. return handle.withToolBatch(async (acceptContext) => {
  744. await executeToolCalls(
  745. ctx, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
  746. )
  747. return { hadToolCalls: true, finish: assembler.finish }
  748. })
  749. }
  750. /** Build durable assistant provenance, dropping replay state after any content rewrite. */
  751. function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable<Message['provenance']> {
  752. return {
  753. provider: config.provider,
  754. model: config.model,
  755. ...contentUnchanged && replayState !== undefined ? { replayState } : {},
  756. }
  757. }
  758. function withoutToolCalls(message: Message): Message {
  759. return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
  760. }
  761. /**
  762. * The last turn number in a (possibly seeded) session log, or 0.
  763. * @param session - the session whose log is scanned for the latest `turn/start`.
  764. * @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one).
  765. */
  766. export function lastTurnNumber(session: Session): number {
  767. const lastStart = session.events.findLast(event => event.type === 'turn/start')
  768. return lastStart?.data.turn ?? 0
  769. }
  770. /**
  771. * Whether the session log has an unmatched `turn/start`. Agent status is not
  772. * sufficient during pre-start and post-end windows.
  773. * @param session - the session whose log is inspected.
  774. * @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
  775. */
  776. export function isTurnOpen(session: Session): boolean {
  777. const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')
  778. return last?.type === 'turn/start'
  779. }