loop.ts 28 KB

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