loop.ts 32 KB

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