loop.ts 28 KB

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