loop.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
  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 type { Context } from 'cordis'
  8. import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
  9. import { isDeepStrictEqual } from 'node:util'
  10. import { BlockAssembler, HarnessError, assertNever, deepFreeze, errorChain, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm'
  11. import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
  12. import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
  13. import { canonicalHeader } from '@deepseek-ai/dsh-session'
  14. import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
  15. import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
  16. import type { TransmissionLog } from './request-log.ts'
  17. import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  18. import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
  19. import type {} from '@deepseek-ai/dsh-tools'
  20. import { executeToolCalls } from './tool-calls.ts'
  21. import type { Inbox } from './inbox.ts'
  22. /** Normalize thrown values while preserving an existing error code. */
  23. function toError(error: unknown): RequestError {
  24. return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
  25. }
  26. /** Distinguishes final model-request failures from failures in later step processing. */
  27. class TerminalModelRequestFailure extends Error {
  28. constructor(readonly requestError: RequestError) {
  29. super(requestError.message, { cause: requestError })
  30. this.name = 'TerminalModelRequestFailure'
  31. }
  32. }
  33. /** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */
  34. function finishError(finish: FinishReason): RequestError | undefined {
  35. switch (finish.kind) {
  36. case 'error': {
  37. const error: RequestError = new Error(finish.message)
  38. if (finish.code !== undefined) error.code = finish.code
  39. return error
  40. }
  41. case 'aborted': {
  42. const error: RequestError = new Error('model stream aborted')
  43. error.code = 'ABORTED'
  44. return error
  45. }
  46. // stop / tool-calls / max-tokens / plugin-added kinds → not a failure.
  47. default:
  48. return undefined
  49. }
  50. }
  51. /**
  52. * Build the `{ message, code? }` part of an error payload, omitting the
  53. * `code` key entirely when absent (exactOptionalPropertyTypes-correct).
  54. * The durable message renders the full cause chain: `turn/end` is the single
  55. * durable record of an in-turn failure, so a wrapper message alone (e.g.
  56. * `fetch failed`) would lose the diagnosis the session log exists to keep.
  57. */
  58. function errorData(err: RequestError): { message: string; code?: string } {
  59. return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }
  60. }
  61. /** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
  62. function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
  63. switch (finish.kind) {
  64. case 'max-tokens':
  65. return { kind: 'max-tokens' }
  66. // stop / tool-calls / plugin-added kinds → no turn-end contribution
  67. // beyond the default `completed`. FinishReason is merge-extensible, so a
  68. // default (not assertNever) handles unknown kinds as ordinary success.
  69. default:
  70. return undefined
  71. }
  72. }
  73. /** Mutable agent controls supplied to the loop driver. */
  74. export interface LoopHandle {
  75. /** Native-private agent inbox handed to the driver only at internal startup. */
  76. readonly inbox: Inbox
  77. /** Maximum parallel-safe calls allowed in one step. */
  78. readonly maxParallelToolCalls: number
  79. setStatus(status: 'idle' | 'running'): void
  80. setAbort(controller: AbortController | undefined): void
  81. /** Resolves when the agent is disposed — unblocks the idle wait. */
  82. disposed: Promise<void>
  83. isDisposed(): boolean
  84. /** Whether cancellation is pending for the current loop iteration. */
  85. isCancelled(): boolean
  86. /** Resolved pending-cancellation reason; meaningful only while {@link isCancelled} is true. */
  87. cancelReason(): string
  88. /** Clear the cancel marker (called once per iteration after the turn returns). */
  89. clearCancel(): void
  90. /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */
  91. settleIdle(): void
  92. /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */
  93. readonly withToolBatch: <T>(run: (acceptContext: (context: HookContext) => void) => Promise<T>) => Promise<T>
  94. }
  95. /**
  96. * Drive queued batches as durable turns until disposal. Plugin failures end the
  97. * current turn without terminating the driver. The caller establishes the
  98. * `ctx.agents.withInitiator()` boundary before entry; package-private
  99. * orchestration recovers that exact Agent and captures its Session locally.
  100. * @param ctx - the plugin context the loop reaches its initiating Agent,
  101. * events (agent/…, session/flush), and services (systemPrompt, llm, tools)
  102. * through.
  103. * @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
  104. * @throws when no initiating Agent is active.
  105. */
  106. export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
  107. const agent = ctx.agents.requireInitiator()
  108. // Per-instance prefix and request-header state; conversation history remains in the session log.
  109. const transmission = createTransmissionLog()
  110. const { session } = agent
  111. // Fused subject and scope carrier for every agent event below.
  112. const events = agentEvents(ctx, agent)
  113. while (!handle.isDisposed()) {
  114. await handle.inbox.waitForQueued(handle.disposed)
  115. if (handle.isDisposed()) break
  116. // Cancellation between wake and `running` skips only the cancelled work;
  117. // a replacement prompt still runs and owns the eventual idle transition.
  118. if (handle.isCancelled()) {
  119. handle.clearCancel()
  120. if (!handle.inbox.hasQueued) {
  121. handle.settleIdle()
  122. continue
  123. }
  124. }
  125. handle.setStatus('running')
  126. // A synchronous `running` listener can cancel before `runTurn`; balance the
  127. // status only when no replacement prompt was queued by that listener.
  128. if (handle.isCancelled()) {
  129. handle.clearCancel()
  130. if (!handle.inbox.hasQueued) {
  131. handle.setStatus('idle')
  132. continue
  133. }
  134. }
  135. // Idle injection can add a turn, so derive the next number from the log.
  136. const turn = lastTurnNumber(session) + 1
  137. let terminalStopped = false
  138. try {
  139. terminalStopped = await runTurn(ctx, events, handle, turn, transmission)
  140. } catch (error: unknown) {
  141. // Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
  142. const err = toError(error)
  143. ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${errorChain(err)}`)
  144. try {
  145. events.emit('agent/error', turn, 0, err)
  146. } catch { /* contained: a throwing agent/error listener must not kill the driver */ }
  147. }
  148. // Reset per iteration, including when a prompt arrives during the flush window.
  149. handle.clearCancel()
  150. // Late steering becomes queued input unless terminal policy stopped the turn.
  151. for (const message of handle.inbox.drainSteering()) {
  152. if (!terminalStopped) handle.inbox.enqueue(message)
  153. }
  154. if (!handle.inbox.hasQueued) handle.setStatus('idle')
  155. }
  156. }
  157. async function runTurn(
  158. ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog,
  159. ): Promise<boolean> {
  160. const agent = ctx.agents.requireInitiator()
  161. const { session } = agent
  162. const drainSteering = (): boolean => {
  163. const messages = handle.inbox.drainSteering()
  164. for (const message of messages) {
  165. session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
  166. }
  167. return messages.length > 0
  168. }
  169. // Drain before opening the turn, but append only after `turn/start`.
  170. const queued = handle.inbox.drainQueued()
  171. const first = queued[0]
  172. /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
  173. if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
  174. const trigger: TurnTrigger = { kind: 'message', source: first.source }
  175. let reason: TurnEndReason = { kind: 'completed' }
  176. let step = 0
  177. let requestRetryAttempt = 0
  178. let stepOpen = false
  179. let errorReported = false
  180. let terminalStopped = false
  181. // Close the committed step once; pre-commit validation failure still escapes.
  182. const closeStep = (): void => {
  183. if (!stepOpen) return
  184. session.append('step/end', { turn, step })
  185. stepOpen = false
  186. }
  187. // Record the durable turn failure once and contain the live error notification.
  188. const failTurn = (err: RequestError): void => {
  189. if (errorReported) return
  190. errorReported = true
  191. reason = { kind: 'error', step, ...errorData(err) }
  192. try {
  193. events.emit('agent/error', turn, step, err)
  194. } catch {
  195. // contained: the error is already captured on `reason`; a throwing
  196. // agent/error listener must not prevent the turn from closing.
  197. }
  198. }
  199. // Pre-commit validation failure escapes rather than masquerading as a committed boundary.
  200. const closeTurn = (): void => {
  201. session.append('turn/end', { turn, reason })
  202. }
  203. try {
  204. // --- Turn boundary. Once turn/start is appended, a turn/end is owed no
  205. // matter what throws below; the catch + closeTurn guarantee it. A pre-commit
  206. // veto leaves no turn/start in the log and therefore owes no turn/end.
  207. session.append('turn/start', { turn, trigger })
  208. // Each drained queued message runs the `agent/prompt-submit` waterfall before
  209. // it becomes a `user/message` — a hook can rewrite the prompt or block it.
  210. // Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
  211. // turn/end is now owed, so a throwing prompt-submit listener (the waterfall
  212. // throws) is caught below and the turn still closes.
  213. let anyAllowed = false
  214. // Seeded with a floor (only observable if the batch were empty, which
  215. // runTurn never allows — it is called with ≥1 queued message); each `block`
  216. // decision carries a required `reason` and overwrites it, so a fully-blocked
  217. // batch always reports the last vetoing reason.
  218. let lastBlockReason = 'prompt blocked by hook'
  219. for (const message of queued) {
  220. const decision = await events.waterfall(
  221. 'agent/prompt-submit', message.content, message.source,
  222. () => Promise.resolve<PromptDecision>({ kind: 'allow' }),
  223. )
  224. if (decision.kind === 'block') {
  225. lastBlockReason = decision.reason
  226. // Record the veto durably: `PromptDecision.reason` is the durable record
  227. // of why a prompt was blocked, but a fully-blocked batch's `rejected`
  228. // turn/end only preserves the LAST reason, and a MIXED batch (this prompt
  229. // blocked, another allowed) does not end `rejected` at all — so without
  230. // this append a blocked prompt would vanish from the log whenever any
  231. // sibling prompt is allowed. `prompt/blocked` sits in the open turn in
  232. // place of the `user/message` this prompt would have become.
  233. session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
  234. continue
  235. }
  236. anyAllowed = true
  237. // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
  238. const content = decision.content ?? message.content
  239. session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
  240. // Every `allow.additionalContexts` entry is a separate context/message the
  241. // next request also sees. The turn is open, so inject() appends each one
  242. // into THIS turn without flattening provenance or metadata.
  243. for (const context of decision.additionalContexts ?? []) {
  244. agent.inject(context.content, {
  245. source: context.source,
  246. ...context.meta !== undefined ? { meta: context.meta } : {},
  247. })
  248. }
  249. }
  250. while (true) {
  251. // A fully blocked batch closes its zero-step turn as rejected.
  252. if (!anyAllowed) {
  253. reason = { kind: 'rejected', reason: lastBlockReason }
  254. break
  255. }
  256. step += 1
  257. // Steering from the previous round's continuation listeners joins before
  258. // the request.
  259. drainSteering()
  260. // The step's AbortController exists BEFORE any async pre-step work so a
  261. // dispose() or cancel() — in a synchronous turn-start listener or an
  262. // async listener whose effect fires before we block — always has an armed
  263. // abort to cancel against. isDisposed below covers disposal, which does
  264. // NOT set the cancel marker. Cleared on every exit path below.
  265. const abort = new AbortController()
  266. handle.setAbort(abort)
  267. // Assemble once before pre-step so listener work and the request share one prompt value.
  268. const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
  269. const fullSystemPrompt = renderPrompt(assembly)
  270. // Cancellation or disposal during assembly ends the turn before any step opens.
  271. if (handle.isCancelled() || handle.isDisposed()) {
  272. handle.setAbort(undefined)
  273. reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
  274. break
  275. }
  276. // Compose the request-only prefix once per loop instance before the first
  277. // request boundary. It precedes all derived history and is recorded only
  278. // in the request header, not as session history.
  279. if (transmission.sessionPrefix === undefined) {
  280. const emptyPrefix: Message[] = deepFreeze([])
  281. const composed = await events.waterfall(
  282. 'agent/session-prefix', emptyPrefix, abort.signal,
  283. () => Promise.resolve(emptyPrefix),
  284. )
  285. // Never cache an interrupted composition; the next turn recomposes it.
  286. if (handle.isCancelled() || handle.isDisposed()) {
  287. handle.setAbort(undefined)
  288. reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
  289. break
  290. }
  291. transmission.sessionPrefix = deepFreeze(structuredClone(composed))
  292. }
  293. // Await surface mutations outside the step before snapshotting history.
  294. await events.serial('agent/pre-step', turn, step, abort.signal)
  295. // Interruption landing during the pre-step seam: do not open an empty step.
  296. if (handle.isCancelled() || handle.isDisposed()) {
  297. handle.setAbort(undefined)
  298. reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
  299. break
  300. }
  301. // Snapshot the exact log prefix before step/start: the reconstruction
  302. // boundary. Appends after this synchronous snapshot join the next request.
  303. const boundaryMessages = session.deriveMessages()
  304. session.append('step/start', { turn, step })
  305. // Only a committed step/start creates a balancing obligation. A
  306. // pre-commit veto throws before this assignment; post-commit observers
  307. // are contained inside Session.append().
  308. stepOpen = true
  309. // Cancel landing in the step-start window: a synchronous `session/event`
  310. // step/start listener can cancel after the step is already open. Check
  311. // AFTER the step/start append and before `runStep`: drop the step, end the
  312. // turn accordingly. closeStep balances the already-appended step/start.
  313. if (handle.isCancelled() || handle.isDisposed()) {
  314. handle.setAbort(undefined)
  315. reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
  316. closeStep()
  317. break
  318. }
  319. let stepOutcome:
  320. | { hadToolCalls: boolean; finish: FinishReason }
  321. | { requestError: RequestError }
  322. | { error: RequestError }
  323. try {
  324. stepOutcome = await runStep(
  325. ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
  326. } catch (error: unknown) {
  327. if (error instanceof TerminalModelRequestFailure) {
  328. stepOutcome = { requestError: error.requestError }
  329. } else {
  330. stepOutcome = { error: toError(error) }
  331. }
  332. }
  333. if ('requestError' in stepOutcome) {
  334. // Recovery observes a balanced failed step and the original provider
  335. // error while the failed step's signal remains the active owner.
  336. closeStep()
  337. if (handle.isDisposed() || abort.signal.aborted) {
  338. handle.setAbort(undefined)
  339. reason = handle.isDisposed()
  340. ? { kind: 'disposed' }
  341. : { kind: 'aborted', reason: String(abort.signal.reason) }
  342. break
  343. }
  344. const defaultDecision: RequestErrorDecision = { action: 'fail' }
  345. let recoveryDecision: RequestErrorDecision = defaultDecision
  346. try {
  347. recoveryDecision = await events.waterfall(
  348. 'agent/request-error', turn, step, stepOutcome.requestError,
  349. requestRetryAttempt, abort.signal,
  350. () => Promise.resolve(defaultDecision),
  351. )
  352. } catch (recoveryError: unknown) {
  353. ctx.logger.warn(
  354. `agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
  355. )
  356. }
  357. handle.setAbort(undefined)
  358. // Cancellation and disposal always win over either a recovery decision
  359. // or a recovery-listener failure.
  360. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  361. if (handle.isDisposed() || abort.signal.aborted) {
  362. reason = handle.isDisposed()
  363. ? { kind: 'disposed' }
  364. : { kind: 'aborted', reason: String(abort.signal.reason) }
  365. break
  366. }
  367. switch (recoveryDecision.action) {
  368. case 'retry':
  369. requestRetryAttempt += 1
  370. continue
  371. case 'fail':
  372. failTurn(stepOutcome.requestError)
  373. break
  374. /* v8 ignore next -- closed-union exhaustiveness guard */
  375. default:
  376. assertNever(recoveryDecision, 'agent request-error decision')
  377. }
  378. break
  379. }
  380. if ('error' in stepOutcome) {
  381. // Steering that arrived during the failed step stays in the inbox —
  382. // runLoop re-enqueues it as a queued message, so an abort-then-steer
  383. // starts a fresh turn instead of being silently consumed.
  384. closeStep()
  385. handle.setAbort(undefined)
  386. const { error } = stepOutcome
  387. /* v8 ignore next -- narrow race: disposal while non-request step work throws. */
  388. if (handle.isDisposed()) {
  389. reason = { kind: 'disposed' }
  390. } else if (abort.signal.aborted) {
  391. /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
  392. reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
  393. } else {
  394. failTurn(error)
  395. }
  396. break
  397. }
  398. requestRetryAttempt = 0
  399. // Preserve max-token completion unless a later disposal, abort, or error wins.
  400. const stepReason = stepFinishReason(stepOutcome.finish)
  401. if (stepReason) reason = stepReason
  402. // Steering that arrived during streaming/tool execution.
  403. const steered = drainSteering()
  404. try {
  405. await events.serial('agent/post-step', turn, step, abort.signal)
  406. } catch (error: unknown) {
  407. stepOutcome = { error: toError(error) }
  408. }
  409. if ('error' in stepOutcome) {
  410. closeStep()
  411. handle.setAbort(undefined)
  412. /* v8 ignore next -- narrow race: disposal while a post-step listener throws. */
  413. if (handle.isDisposed()) {
  414. reason = { kind: 'disposed' }
  415. } else if (abort.signal.aborted) {
  416. /* v8 ignore next -- signal.reason always set by cancellation or disposal. */
  417. reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
  418. } else {
  419. failTurn(stepOutcome.error)
  420. }
  421. break
  422. }
  423. if (handle.isDisposed() || abort.signal.aborted) {
  424. reason = handle.isDisposed()
  425. ? { kind: 'disposed' }
  426. : { kind: 'aborted', reason: String(abort.signal.reason) }
  427. closeStep()
  428. handle.setAbort(undefined)
  429. break
  430. }
  431. closeStep()
  432. handle.setAbort(undefined)
  433. const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
  434. let decision: ContinuationDecision
  435. try {
  436. decision = await events.waterfall(
  437. 'agent/turn-continuation', turn, defaultDecision,
  438. () => Promise.resolve(defaultDecision),
  439. )
  440. } catch (error: unknown) {
  441. // A broken continuation plugin ends the turn, not the loop.
  442. failTurn(toError(error))
  443. break
  444. }
  445. // A continuation reason becomes next-step steering.
  446. if (decision.action === 'continue' && decision.reason) {
  447. handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
  448. }
  449. let shouldContinue = decision.action === 'continue'
  450. // Pending steering overrides an ordinary stop.
  451. if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
  452. // Terminal policy is monotonic and runs after ordinary continuation folding.
  453. let terminalStop = false
  454. try {
  455. const stop = await events.serial('agent/turn-stop', turn)
  456. terminalStop = stop !== undefined
  457. } catch (error: unknown) {
  458. // A broken terminal policy is an ordinary continuation failure: fail
  459. // this turn closed while leaving the driver alive for later turns.
  460. failTurn(toError(error))
  461. break
  462. }
  463. if (terminalStop) {
  464. terminalStopped = true
  465. // Terminal stop discards steering but preserves ordinary queued prompts.
  466. handle.inbox.drainSteering()
  467. shouldContinue = false
  468. }
  469. // The marker catches cancellation after the step controller was cleared.
  470. if (handle.isCancelled()) {
  471. reason = { kind: 'aborted', reason: handle.cancelReason() }
  472. break
  473. }
  474. if (!shouldContinue || handle.isDisposed()) {
  475. /* v8 ignore next -- disposal during continuation-decision window is a narrow race; error-path disposal is covered elsewhere */
  476. if (handle.isDisposed()) reason = { kind: 'disposed' }
  477. break
  478. }
  479. }
  480. // Normal / inline-error loop exit: close the turn.
  481. closeTurn()
  482. } catch (error: unknown) {
  483. // Close only a turn whose start committed to the log.
  484. const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
  485. if (!turnStartLogged) throw error
  486. closeStep()
  487. // Preserve an established disposal reason; otherwise report the failure.
  488. if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
  489. reason = { kind: 'disposed' }
  490. } else {
  491. failTurn(toError(error))
  492. }
  493. closeTurn()
  494. }
  495. // Flush through the store-owned durability checkpoint without killing the driver on failure.
  496. try {
  497. await ctx.sessions.flush(session)
  498. } catch (error: unknown) {
  499. // The turn is closed, so report the failed flush live rather than append outside a turn.
  500. const err = toError(error)
  501. ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${errorChain(err)}`)
  502. try {
  503. events.emit('agent/error', turn, step, err)
  504. } catch {
  505. // contained: a throwing agent/error listener must not escape the loop.
  506. }
  507. }
  508. return terminalStopped
  509. }
  510. /**
  511. * Run one committed step: transform call config, log the request header, build
  512. * the request from the cached prefix plus the step-boundary snapshot, stream and
  513. * record the response, then execute tools. The caller has already assembled the
  514. * prompt, run `agent/pre-step`, snapshotted history, and opened the step.
  515. */
  516. async function runStep(
  517. ctx: Context,
  518. events: AgentEventDispatch,
  519. handle: LoopHandle,
  520. turn: number,
  521. step: number,
  522. assembly: PromptAssembly,
  523. system: string,
  524. boundaryMessages: Message[],
  525. transmission: TransmissionLog,
  526. signal: AbortSignal,
  527. ): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
  528. const agent = ctx.agents.requireInitiator()
  529. const { session, options } = agent
  530. // Seed the first request from agent options and later requests from the logged header;
  531. // detach and freeze so listeners must return an attributable replacement.
  532. const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader
  533. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
  534. ? session.requestHeader()!.config
  535. : { provider: options.provider ?? '', model: options.model ?? '' }))
  536. // Listener replacements are recorded in the request header before dispatch.
  537. const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig))
  538. if (!config.provider || !config.model) {
  539. throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
  540. }
  541. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
  542. const sessionPrefix = transmission.sessionPrefix!
  543. // Record the canonical header, including the otherwise-unlogged prefix, before dispatch.
  544. const header = canonicalHeader({
  545. config,
  546. ...system ? { system } : {},
  547. ...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
  548. ...sessionPrefix.length > 0 ? { messagePrefix: sessionPrefix } : {},
  549. })
  550. recordRequestHeader(session, transmission, header)
  551. // Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
  552. const request: GenerateOptions = deepFreeze({
  553. provider: header.config.provider,
  554. model: header.config.model,
  555. messages: [...header.messagePrefix ?? [], ...boundaryMessages],
  556. ...header.system !== undefined ? { system: header.system } : {},
  557. ...header.tools !== undefined ? { tools: header.tools } : {},
  558. ...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {},
  559. ...header.config.maxTokens !== undefined ? { maxTokens: header.config.maxTokens } : {},
  560. ...header.config.stop !== undefined ? { stop: header.config.stop } : {},
  561. sessionId: session.id,
  562. signal,
  563. })
  564. // --- Model call (streaming-first; raw chunks are the replay record) ---
  565. const assembler = new BlockAssembler()
  566. const chunkSeqs: number[] = []
  567. const stream = ctx.llm.stream(request)
  568. try {
  569. for await (const chunk of stream) {
  570. /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
  571. if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
  572. const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
  573. chunkSeqs.push(chunkEvent.seq)
  574. assembler.push(chunk)
  575. }
  576. } catch (error: unknown) {
  577. if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error)
  578. throw error
  579. }
  580. // Normalize failure finish chunks into the same path as thrown stream errors.
  581. const stepError = finishError(assembler.finish)
  582. if (stepError) throw new TerminalModelRequestFailure(stepError)
  583. const recordAssistantMessage = (
  584. assembledContent: ContentBlock[],
  585. message: Message,
  586. preserveReplayState = true,
  587. ): void => {
  588. session.append(
  589. 'assistant/message',
  590. {
  591. turn,
  592. step,
  593. content: message.content,
  594. provenance: assistantProvenance(
  595. header.config,
  596. assembler.replayState,
  597. preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
  598. ),
  599. ...assembler.usage === undefined ? {} : { usage: assembler.usage },
  600. },
  601. { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
  602. )
  603. }
  604. // A rejected result still records the successful provider call without retaining rejected output.
  605. const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise<Message> => {
  606. try {
  607. return await events.waterfall(
  608. 'agent/step-result', turn, step, message, () => Promise.resolve(message),
  609. )
  610. } catch (error: unknown) {
  611. recordAssistantMessage(assembledContent, { ...message, content: [] }, false)
  612. throw error
  613. }
  614. }
  615. if (assembler.finish.kind === 'max-tokens') {
  616. const assembled = assembler.message()
  617. const assembledContent = structuredClone(assembled.content)
  618. let message: Message = withoutToolCalls(assembled)
  619. message = withoutToolCalls(await processStepResult(assembledContent, message))
  620. // Preserve usage even when max-token truncation produced no content.
  621. recordAssistantMessage(assembledContent, message)
  622. return { hadToolCalls: false, finish: assembler.finish }
  623. }
  624. // Record the post-waterfall message that tool dispatch uses.
  625. const assembled = assembler.message()
  626. const assembledContent = structuredClone(assembled.content)
  627. let message: Message = assembled
  628. message = await processStepResult(assembledContent, message)
  629. // Every successful call records its completion anchor, including explicit
  630. // empty chunk provenance for a contentless, usage-less provider response.
  631. recordAssistantMessage(assembledContent, message)
  632. // Dispatch may overlap; policy, durable results, and result context stay model-ordered.
  633. const toolCalls = message.content.filter(block => block.type === 'tool-call')
  634. if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish }
  635. return handle.withToolBatch(async (acceptContext) => {
  636. await executeToolCalls(
  637. ctx, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
  638. )
  639. return { hadToolCalls: true, finish: assembler.finish }
  640. })
  641. }
  642. /** Build durable assistant provenance, dropping replay state after any content rewrite. */
  643. function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable<Message['provenance']> {
  644. return {
  645. provider: config.provider,
  646. model: config.model,
  647. ...contentUnchanged && replayState !== undefined ? { replayState } : {},
  648. }
  649. }
  650. function withoutToolCalls(message: Message): Message {
  651. return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
  652. }
  653. /**
  654. * The last turn number in a (possibly seeded) session log, or 0.
  655. * @param session - the session whose log is scanned for the latest `turn/start`.
  656. * @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one).
  657. */
  658. export function lastTurnNumber(session: Session): number {
  659. const lastStart = session.events.findLast(event => event.type === 'turn/start')
  660. return lastStart?.data.turn ?? 0
  661. }
  662. /**
  663. * Whether the session log has an unmatched `turn/start`. Agent status is not
  664. * sufficient during pre-start and post-end windows.
  665. * @param session - the session whose log is inspected.
  666. * @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
  667. */
  668. export function isTurnOpen(session: Session): boolean {
  669. const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')
  670. return last?.type === 'turn/start'
  671. }