agent.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  1. /**
  2. * Concrete Agent loop over two pending-input lists: queued prompts each open a
  3. * turn that logs its admitted input after `turn/start` commits, while steering
  4. * and injected context enter through the outbox at step boundaries. Every
  5. * request is derived from the session log.
  6. *
  7. * @module dsh-agent-loop/agent
  8. */
  9. import type { Context } from 'cordis'
  10. import { randomUUID } from 'node:crypto'
  11. import { agentCarrier, assembleContextFor, emitAgentEvent, InboxItemId } from '@deepseek-ai/dsh-agent'
  12. import { createScope } from '@deepseek-ai/dsh-scope'
  13. import type { Scope } from '@deepseek-ai/dsh-scope'
  14. import type {
  15. Agent,
  16. CancelOptions,
  17. AgentInterruptReason,
  18. InboxAction,
  19. InboxActionResult,
  20. InboxItem,
  21. InboxItemId as InboxItemIdType,
  22. InboxPlacement,
  23. AgentOptions,
  24. AgentStatus,
  25. SettleReason,
  26. PromptDecision,
  27. RequestError,
  28. RequestErrorAction,
  29. SendOptions,
  30. } from '@deepseek-ai/dsh-agent'
  31. import {
  32. BlockAssembler,
  33. LlmError,
  34. assertNever,
  35. createAssistantMessage,
  36. deepFreeze,
  37. errorChain,
  38. freezeMessage,
  39. isHarnessError,
  40. llmFailureOf,
  41. llmRetryPolicyOf,
  42. markAgentLoopRequest,
  43. } from '@deepseek-ai/dsh-llm'
  44. import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
  45. import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
  46. import type { AssistantMessage, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
  47. import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  48. import type {} from '@deepseek-ai/dsh-tools'
  49. import { executeToolCalls } from './tool-calls.ts'
  50. /** One completed step or a final-adapter failure eligible for recovery. */
  51. type StepOutcome =
  52. | { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean }
  53. | { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
  54. /**
  55. * The concrete {@link Agent}: each `run()` owns one turn and repeats model
  56. * steps while tools or steering require another request.
  57. */
  58. export class ReactLoopAgent implements Agent {
  59. /** Prompts awaiting individual turns. */
  60. private queued: { item: InboxItem; wakeup: boolean }[] = []
  61. /** Input taken into the session log at step boundaries. */
  62. private outbox: { message: UserMessage; steering: boolean; item?: InboxItem }[] = []
  63. /** Whether observers see a running interval; consecutive turns share it. */
  64. private busy = false
  65. /** Whether an idle waking send has deferred driver admission. */
  66. private wakeScheduled = false
  67. /** Whether next-step input belongs to the current admission or open turn. */
  68. acceptsNextStep = false
  69. /** Abort owner for the current admission or turn. */
  70. private abort: AbortController | undefined
  71. /** Resolves when the current admission and turn exit. */
  72. done: Promise<void> = Promise.resolve()
  73. /** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */
  74. readonly scope: Scope
  75. /** The agent's scoped composition context ({@link Agent.ctx}). */
  76. readonly ctx: Context
  77. /** Last turn number opened by this loop or present in its seeded log. */
  78. private lastTurn: number
  79. /** Whether the session log is owed a matching turn end event. */
  80. private turnOpen = false
  81. private stepOpen = false
  82. /** Whether this loop instance has appended its initial/resume request anchor. */
  83. private requestHeaderLogged = false
  84. constructor(
  85. private loopCtx: Context,
  86. public readonly id: SessionId,
  87. public readonly options: AgentOptions,
  88. public readonly session: Session,
  89. ) {
  90. this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
  91. this.scope = createScope(loopCtx, this)
  92. this.ctx = this.scope.ctx.extend({ agent: this })
  93. }
  94. /** Last activity state published to observers. */
  95. get status(): AgentStatus {
  96. return this.busy ? 'running' : 'idle'
  97. }
  98. /** Accept and route one unified send item. */
  99. send(
  100. message: UserMessage,
  101. options: SendOptions,
  102. ): void {
  103. const { target, wakeup } = options
  104. if (target === 'next-step' && !wakeup) {
  105. if (this.acceptsNextStep) {
  106. this.outbox.push({ message, steering: false })
  107. return
  108. }
  109. this.session.append('user/message', message, { surfaceOp: 'append' })
  110. return
  111. }
  112. const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued'
  113. const item: InboxItem = Object.freeze({
  114. id: InboxItemId(randomUUID()),
  115. message,
  116. placement,
  117. })
  118. if (placement === 'steering') {
  119. this.outbox.push({ message, steering: true, item })
  120. } else {
  121. this.queued.push({ item, wakeup })
  122. }
  123. // Preserve the routing decision for every send in this synchronous caller
  124. // stack, while installing quiescence ownership before enqueue observers
  125. // can cancel or dispose.
  126. if (placement === 'queued' && wakeup) this.scheduleKick()
  127. emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item)
  128. }
  129. /** Apply one synchronous mutation to a still-pending queued occurrence. */
  130. updateInbox(id: InboxItemIdType, action: InboxAction): InboxActionResult {
  131. const queuedIndex = this.queued.findIndex(candidate => candidate.item.id === id)
  132. if (queuedIndex === -1) return 'not-found'
  133. const pending = this.queued[queuedIndex]
  134. /* v8 ignore next -- the index was resolved from this array without an async boundary. */
  135. if (pending === undefined) throw new Error(`agent "${this.id}" queued item disappeared during update`)
  136. /* v8 ignore next -- InboxAction is a closed discriminated union; all variants are covered below. */
  137. switch (action.kind) {
  138. case 'edit': {
  139. const item: InboxItem = Object.freeze({
  140. ...pending.item,
  141. message: freezeMessage({ ...pending.item.message, content: action.content }),
  142. })
  143. this.queued[queuedIndex] = { ...pending, item }
  144. emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', item)
  145. return 'applied'
  146. }
  147. case 'remove': {
  148. this.queued.splice(queuedIndex, 1)
  149. emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item])
  150. return 'applied'
  151. }
  152. default:
  153. /* v8 ignore next -- InboxAction is a closed discriminated union. */
  154. return assertNever(action)
  155. }
  156. }
  157. /** Queue one ordinary prompt turn and wake the driver. */
  158. followup(input: UserMessage): void {
  159. this.send(input, {
  160. target: 'next-turn',
  161. wakeup: true,
  162. })
  163. }
  164. /** Steer the open turn, falling back to a waking prompt while idle. */
  165. steer(input: UserMessage): void {
  166. this.send(input, {
  167. target: 'next-step',
  168. wakeup: true,
  169. })
  170. }
  171. /** Append model-facing context without waking the driver. */
  172. inject(input: UserMessage): void {
  173. this.send(input, {
  174. target: 'next-step',
  175. wakeup: false,
  176. })
  177. }
  178. /**
  179. * Clear all pending work and abort the active turn; the first cause wins.
  180. * The cause is signal payload for observers and the durable turn/end
  181. * classification — it selects no machine behavior. Teardown is just
  182. * `cancel({kind:'disposed'})` + await {@link done} + {@link scope} dispose,
  183. * all owned by the factory.
  184. */
  185. cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void {
  186. // Effective only when it aborts the active turn or actually discards
  187. // pending work: a keepInbox call with no active turn is a documented
  188. // no-op, so it must not emit cancel-requested for consumers to misread.
  189. const discards = !options.keepInbox && (this.queued.length > 0 || this.outbox.length > 0)
  190. if (this.abort !== undefined || discards) {
  191. // Observe-only: coordination consumers update their state before the
  192. // inboxes clear; listener failures are contained by the dispatcher.
  193. if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)
  194. }
  195. if (!options.keepInbox) {
  196. const discarded = this.queued.map(item => item.item)
  197. for (const item of this.outbox) {
  198. if (item.steering && item.item !== undefined) discarded.push(item.item)
  199. }
  200. // Clear before abort observers run: replacement work belongs to the next turn.
  201. this.queued.length = 0
  202. this.outbox.length = 0
  203. if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
  204. }
  205. const reason = Object.freeze({ kind: cause.kind })
  206. this.abort?.abort(reason)
  207. }
  208. /** Resolve at idle quiescence: no run driving and no waking prompt waiting. */
  209. async whenIdle(): Promise<void> {
  210. // `done` is replaced per activity, so re-reading it follows chained turns.
  211. // Every driver failure today is contained before it can reject `done`,
  212. // but the waiter must not gamble quiescence on that: a future escape
  213. // still counts as settled activity.
  214. /* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */
  215. while (this.busy || this.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) {
  216. await this.done.catch(() => undefined)
  217. }
  218. }
  219. /** Defer idle admission while keeping {@link done} as its quiescence owner. */
  220. private scheduleKick(): void {
  221. if (this.abort !== undefined || this.wakeScheduled) return
  222. this.wakeScheduled = true
  223. const pending = Promise.withResolvers<void>()
  224. const scheduled = pending.promise
  225. queueMicrotask(() => {
  226. this.wakeScheduled = false
  227. this.kick()
  228. const activity = this.done
  229. if (activity === scheduled) {
  230. pending.resolve()
  231. } else {
  232. void activity.then(
  233. () => { pending.resolve() },
  234. () => { pending.resolve() },
  235. )
  236. }
  237. })
  238. this.done = scheduled
  239. }
  240. /** Claim and admit the next queued prompt, then start its turn. */
  241. private kick(): void {
  242. if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return
  243. // The some() guard above proves the queue is non-empty; the non-null
  244. // assertion expresses that invariant.
  245. // oxlint-disable-next-line typescript/no-non-null-assertion
  246. const { item } = this.queued.shift()!
  247. const { message } = item
  248. const inheritedOutboxLength = this.outbox.length
  249. const admission = new AbortController()
  250. this.abort = admission
  251. this.acceptsNextStep = true
  252. // Claimed admission is part of the running interval: it is cancellable
  253. // activity, so observers (and their cancel routing) must see it.
  254. if (!this.busy) {
  255. this.busy = true
  256. emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
  257. }
  258. // The admission body runs synchronously up to the prompt-submit
  259. // waterfall's first await, so the waterfall snapshots its listeners
  260. // before a disposal initiated by the running-status emit above can
  261. // unregister a vetoing plugin.
  262. this.done = this.loopCtx.agents.withInitiator(this, async () => {
  263. const signal = admission.signal
  264. const trigger: TurnTrigger = { kind: 'message', source: message.source }
  265. // Admitted input stays on the stack until its turn/start commits: the
  266. // turn owns it only once the turn exists in the log.
  267. let admitted: UserMessage[] | undefined
  268. try {
  269. signal.throwIfAborted()
  270. const decision = await this.loopCtx.waterfall(
  271. agentCarrier(this), 'agent/prompt-submit', this, message, signal,
  272. () => Promise.resolve<PromptDecision>({ kind: 'allow' }),
  273. )
  274. signal.throwIfAborted()
  275. if (decision.kind === 'allow') {
  276. admitted = [decision.content === undefined
  277. ? message
  278. : freezeMessage({ ...message, content: decision.content })]
  279. for (const context of decision.additionalContexts ?? []) {
  280. admitted.push(freezeMessage(context))
  281. }
  282. }
  283. } catch (error: unknown) {
  284. if (!signal.aborted) {
  285. this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(error)}`)
  286. }
  287. }
  288. // cancel() aborts but never clears the slot, and kick()/run()
  289. // all refuse to install a new owner while one exists, so the admission
  290. // still owns the slot here and releasing it unconditionally is exact.
  291. this.abort = undefined
  292. if (admitted === undefined) {
  293. this.acceptsNextStep = false
  294. try {
  295. this.flushRejectedAdmissionContexts()
  296. } catch (error: unknown) {
  297. // No turn exists for agent/error coordinates. Preserve the
  298. // uncommitted suffix for a later boundary and report locally.
  299. this.loopCtx.logger.warn(
  300. `agent "${this.id}": committing rejected-admission context failed: ${errorChain(error)}`,
  301. )
  302. }
  303. // A synchronously aborted admission would otherwise publish idle
  304. // inside send()'s own synchronous extent, before any post-send
  305. // subscriber could observe the transition.
  306. await Promise.resolve()
  307. this.continueOrIdle()
  308. return
  309. }
  310. await this.run(trigger, admitted, inheritedOutboxLength)
  311. })
  312. // Published only after the abort owner and pending done are installed: a
  313. // dequeue listener that cancels or disposes must find live cancellation
  314. // and quiescence ownership, not the previous activity's settled state.
  315. emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item)
  316. }
  317. /**
  318. * Run one turn and any request-error retry. `admitted` input enters the log
  319. * only after `turn/start` commits; until then it has no owner state to unwind.
  320. */
  321. private async run(
  322. trigger: TurnTrigger,
  323. admitted: UserMessage[] = [],
  324. inheritedOutboxLength = 0,
  325. priorFailures: readonly LlmFailure[] = Object.freeze([]),
  326. ): Promise<void> {
  327. // Both entries hold the invariant: kick() clears the admission slot before
  328. // awaiting run(), and a retry is entered only after the prior run clears it.
  329. /* v8 ignore next -- unreachable guard: every caller clears or checks the abort slot first */
  330. if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
  331. const controller = new AbortController()
  332. this.abort = controller
  333. this.acceptsNextStep = true
  334. const signal = controller.signal
  335. const turn = this.lastTurn + 1
  336. let step = 0
  337. let opened = false
  338. let reason: TurnEndReason = { kind: 'completed' }
  339. let settleReason: SettleReason = { kind: 'completed' }
  340. let requestFailureHistory = priorFailures
  341. let retryFailures: readonly LlmFailure[] | undefined
  342. const cancelRetry = (): void => { retryFailures = undefined }
  343. signal.addEventListener('abort', cancelRetry, { once: true })
  344. try {
  345. signal.throwIfAborted()
  346. this.session.append('turn/start', { turn, trigger })
  347. // Committed: publish the turn to the machine's own bookkeeping and let
  348. // the admitted input enter the log it now belongs to.
  349. this.turnOpen = true
  350. opened = true
  351. this.lastTurn = turn
  352. // Context or steering retained by an earlier rejected admission happened
  353. // before this prompt and must occupy the same order in durable history.
  354. this.drainOutbox(turn, inheritedOutboxLength)
  355. for (const input of admitted) {
  356. this.session.append('user/message', input, { surfaceOp: 'append' })
  357. }
  358. signal.throwIfAborted()
  359. this.drainOutbox(turn)
  360. steps: while (true) {
  361. step += 1
  362. const outcome = await this.step(turn, step, signal)
  363. switch (outcome.kind) {
  364. case 'completed':
  365. requestFailureHistory = Object.freeze([])
  366. if (outcome.maxTokens) reason = { kind: 'max-tokens' }
  367. // A concluding tool result is terminal: steering already in the
  368. // log waits for the next turn's request instead of reopening this
  369. // one, and the agent/turn-stopping drain below is skipped for the same
  370. // reason.
  371. if (outcome.concluded) break steps
  372. if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue
  373. break
  374. case 'request-failed': {
  375. // step() reports request failures only after step/start commits
  376. // and before its own step/end, so the step is always open here.
  377. this.stepOpen = false
  378. this.session.append('step/end', { turn, step })
  379. if (!signal.aborted) {
  380. try {
  381. const action = await this.loopCtx.waterfall(
  382. agentCarrier(this), 'agent/request-error', this, turn, step, outcome.error,
  383. outcome.failure, requestFailureHistory, outcome.retryPolicy, signal,
  384. () => Promise.resolve<RequestErrorAction>(undefined),
  385. )
  386. // oxlint-disable-next-line typescript/no-unnecessary-condition -- signal can abort while recovery is awaited.
  387. if (action?.kind === 'retry' && !signal.aborted) {
  388. retryFailures = Object.freeze([...requestFailureHistory, outcome.failure])
  389. }
  390. } catch (recoveryError: unknown) {
  391. this.loopCtx.logger.warn(
  392. `agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
  393. )
  394. }
  395. }
  396. const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure)
  397. reason = settlement.reason
  398. settleReason = settlement.settleReason
  399. break steps
  400. }
  401. /* v8 ignore next 2 -- closed-union exhaustiveness guard */
  402. default:
  403. assertNever(outcome)
  404. }
  405. await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal)
  406. signal.throwIfAborted()
  407. if (!this.drainOutbox(turn)) break
  408. }
  409. } catch (caught: unknown) {
  410. try {
  411. if (this.stepOpen) {
  412. this.stepOpen = false
  413. this.session.append('step/end', { turn, step })
  414. }
  415. } catch (closeError: unknown) {
  416. // Contained like the finally's turn close: a persistently rejecting
  417. // step boundary must not escape run(), or the post-finally tail would
  418. // never publish the terminal status and observers would see a
  419. // permanently running agent whose whenIdle() already resolved.
  420. this.loopCtx.logger.warn(`agent "${this.id}": closing step ${turn}/${step} failed: ${errorChain(closeError)}`)
  421. emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, closeError)
  422. }
  423. ({ reason, settleReason } = this.settle(turn, step, caught, signal))
  424. } finally {
  425. // Every step-close happens before this point on both success and
  426. // failure paths (step(), the request-failed branch, the catch), so the
  427. // finally owes only the turn boundary.
  428. this.acceptsNextStep = false
  429. try {
  430. if (this.turnOpen) {
  431. // Re-entrant turn/end listeners must route new input to a later turn.
  432. this.turnOpen = false
  433. this.session.append('turn/end', { turn, reason })
  434. }
  435. } catch (error: unknown) {
  436. retryFailures = undefined
  437. this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`)
  438. emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
  439. }
  440. // cancel() aborts but never clears the slot, and no second run can
  441. // install a controller while this one is still unwinding, so the slot
  442. // is still this run's controller here.
  443. this.abort = undefined
  444. signal.removeEventListener('abort', cancelRetry)
  445. }
  446. if (opened) {
  447. try {
  448. await this.loopCtx.sessions.flush(this.session)
  449. } catch (error: unknown) {
  450. this.loopCtx.logger.warn(`agent "${this.id}": session/flush failed at turn ${turn}: ${errorChain(error)}`)
  451. emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
  452. }
  453. }
  454. if (retryFailures !== undefined) {
  455. await this.run({ kind: 'retry' }, [], 0, retryFailures)
  456. } else {
  457. // agent/settled names only committed turns: a run aborted or rejected
  458. // before turn/start has no durable turn/end for consumers to settle
  459. // against, so it exits without the notification.
  460. if (opened) emitAgentEvent(this.loopCtx, this, 'agent/settled', turn, settleReason)
  461. this.continueOrIdle()
  462. }
  463. }
  464. /**
  465. * Run the `agent/step` extension point, commit pending input, derive one
  466. * request, and execute its tool calls inside one durable step boundary.
  467. */
  468. private async step(
  469. turn: number,
  470. step: number,
  471. signal: AbortSignal,
  472. ): Promise<StepOutcome> {
  473. const { session } = this
  474. // The single between-steps extension point: listeners inject, steer, or
  475. // edit the log here; the request derives from the log after this settles.
  476. await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal)
  477. signal.throwIfAborted()
  478. // Take the outbox whole — same-boundary steering and context leave in
  479. // this request together.
  480. this.drainOutbox(turn)
  481. // Assemble the system prompt fresh each step (it may depend on log state).
  482. const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
  483. signal.throwIfAborted()
  484. const system = renderPrompt(assembly)
  485. // Snapshot the exact log prefix: the reconstruction boundary. Appends
  486. // after this synchronous snapshot join the next request.
  487. const boundaryMessages = session.deriveMessages()
  488. session.append('step/start', { turn, step })
  489. this.stepOpen = true
  490. signal.throwIfAborted()
  491. const { request, preparedCall } = await this.buildRequest(
  492. turn, step, assembly.tools, system, boundaryMessages, signal,
  493. )
  494. const assembler = new BlockAssembler()
  495. const chunkSeqs: number[] = []
  496. const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
  497. try {
  498. for await (const chunk of stream) {
  499. signal.throwIfAborted()
  500. const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
  501. chunkSeqs.push(chunkEvent.seq)
  502. assembler.push(chunk)
  503. }
  504. } catch (error: unknown) {
  505. const facts = llmFailureOf(stream, error)
  506. if (facts !== undefined && error instanceof Error) {
  507. return { kind: 'request-failed', error, failure: facts, retryPolicy: llmRetryPolicyOf(stream) }
  508. }
  509. throw error
  510. }
  511. signal.throwIfAborted()
  512. // Failure finish chunks take the same path as thrown stream errors.
  513. const finish = assembler.finish
  514. if (finish.kind === 'error' || finish.kind === 'aborted') {
  515. const error = new LlmError(finish.failure.message, finish.failure.code, finish.failure)
  516. return { kind: 'request-failed', error, failure: finish.failure, retryPolicy: llmRetryPolicyOf(stream) }
  517. }
  518. // Truncated (max-tokens) output cannot owe tool calls.
  519. const assembled = assembler.blocks()
  520. const content = finish.kind === 'max-tokens'
  521. ? assembled.filter(block => block.type !== 'tool-call')
  522. : assembled
  523. const message: AssistantMessage = createAssistantMessage({
  524. content,
  525. source: {
  526. provider: request.provider,
  527. model: request.model,
  528. ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
  529. },
  530. })
  531. session.append(
  532. 'assistant/message',
  533. {
  534. turn,
  535. step,
  536. message,
  537. ...assembler.usage === undefined ? {} : { usage: assembler.usage },
  538. },
  539. { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
  540. )
  541. const toolCalls = content.filter(block => block.type === 'tool-call')
  542. let concluded = false
  543. if (toolCalls.length > 0) {
  544. ({ concluded } = await executeToolCalls(
  545. this.loopCtx, turn, step, toolCalls, signal,
  546. context => this.outbox.push({ message: freezeMessage(context), steering: false }),
  547. ))
  548. }
  549. // Tool results stay adjacent to their calls; input accepted during the
  550. // request enters the log only after the complete result batch.
  551. const steered = this.drainOutbox(turn)
  552. session.append('step/end', { turn, step })
  553. this.stepOpen = false
  554. return {
  555. kind: 'completed',
  556. continueTurn: (toolCalls.length > 0 && !concluded) || steered,
  557. concluded,
  558. maxTokens: finish.kind === 'max-tokens',
  559. }
  560. }
  561. /**
  562. * Compose one frozen request and bind it to the adapter registration that
  563. * resolved its exact-model defaults.
  564. */
  565. private async buildRequest(
  566. turn: number,
  567. step: number,
  568. tools: GenerateOptions['tools'] & object,
  569. system: string,
  570. boundaryMessages: Message[],
  571. signal: AbortSignal,
  572. ): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
  573. const { session } = this
  574. // A loop instance starts from its declared route, restoring only an opaque
  575. // effort owned by that exact model. Later steps fold the config it logged.
  576. const persistedConfig = session.requestHeader()?.config
  577. const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }
  578. const reasoningEffort = persistedConfig?.provider === route.provider
  579. && persistedConfig.model === route.model
  580. ? persistedConfig.reasoningEffort
  581. : undefined
  582. const maxTokens = this.options.maxTokens
  583. const seedConfig = deepFreeze(structuredClone(
  584. this.requestHeaderLogged
  585. // oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds
  586. ? persistedConfig!
  587. : {
  588. ...route,
  589. ...reasoningEffort === undefined ? {} : { reasoningEffort },
  590. ...maxTokens === undefined ? {} : { maxTokens },
  591. },
  592. ))
  593. const proposedConfig = await this.loopCtx.waterfall(
  594. agentCarrier(this), 'agent/request', this, turn, step, signal,
  595. () => Promise.resolve(seedConfig),
  596. )
  597. signal.throwIfAborted()
  598. if (!proposedConfig.provider || !proposedConfig.model) {
  599. throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
  600. }
  601. let config: LlmCallConfig
  602. let preparedCall: PreparedLlmCall | undefined
  603. try {
  604. preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal)
  605. config = preparedCall.config
  606. } catch (error: unknown) {
  607. // A llm/stream listener may own and short-circuit a route with no
  608. // adapter. Terminal dispatch still raises NO_ADAPTER when none does.
  609. if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
  610. config = proposedConfig
  611. }
  612. signal.throwIfAborted()
  613. const header = canonicalHeader({
  614. config,
  615. ...system ? { system } : {},
  616. ...tools.length > 0 ? { tools } : {},
  617. })
  618. const baseline = session.requestHeader()
  619. if (!this.requestHeaderLogged) {
  620. session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
  621. this.requestHeaderLogged = true
  622. } else if (baseline === undefined || !headerEquals(baseline, header)) {
  623. session.append('request/header', { header, reason: 'change' })
  624. }
  625. const request = markAgentLoopRequest(deepFreeze({
  626. ...header.config,
  627. messages: boundaryMessages,
  628. ...header.system !== undefined ? { system: header.system } : {},
  629. ...header.tools !== undefined ? { tools: header.tools } : {},
  630. sessionId: session.id,
  631. signal,
  632. }))
  633. return { request, ...preparedCall === undefined ? {} : { preparedCall } }
  634. }
  635. /** Commit the outbox and report whether it contained steering. */
  636. private drainOutbox(turn: number, limit = this.outbox.length): boolean {
  637. let steered = false
  638. for (const item of this.outbox.splice(0, limit)) {
  639. if (item.steering) {
  640. steered = true
  641. /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */
  642. if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`)
  643. emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item)
  644. this.session.append(
  645. 'steering/message',
  646. { turn, message: item.message },
  647. { surfaceOp: 'append' },
  648. )
  649. } else {
  650. this.session.append('user/message', item.message, { surfaceOp: 'append' })
  651. }
  652. }
  653. return steered
  654. }
  655. /**
  656. * Give context-only input its ordinary idle placement when admission
  657. * produces no turn. Steering keeps the whole boundary staged so context
  658. * accepted beside it cannot split from the request it accompanies.
  659. */
  660. private flushRejectedAdmissionContexts(): void {
  661. if (this.outbox.some(item => item.steering)) return
  662. const contexts = this.outbox.splice(0)
  663. for (let index = 0; index < contexts.length; index += 1) {
  664. const item = contexts[index]
  665. /* v8 ignore next 2 -- the steering precheck proves this batch is context-only */
  666. if (item === undefined || item.steering) throw new Error('rejected-admission context batch changed')
  667. try {
  668. this.session.append('user/message', item.message, { surfaceOp: 'append' })
  669. } catch (error: unknown) {
  670. this.outbox.unshift(...contexts.slice(index))
  671. throw error
  672. }
  673. }
  674. }
  675. /**
  676. * The single settlement funnel: classify one turn failure (interruption
  677. * beats error) into the durable turn/end reason and live settlement report.
  678. */
  679. private settle(
  680. turn: number,
  681. step: number,
  682. error: unknown,
  683. signal: AbortSignal,
  684. failure?: LlmFailure,
  685. ): { reason: TurnEndReason; settleReason: SettleReason } {
  686. if (signal.aborted) {
  687. // Slot invariant, stated rather than re-validated: the turn controller
  688. // is machine-private and cancel() is its only aborter, always with one
  689. // frozen canonical cause as the reason.
  690. const interrupt = signal.reason as AgentInterruptReason
  691. return {
  692. reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' },
  693. settleReason: { kind: 'aborted' },
  694. }
  695. }
  696. if (failure !== undefined) {
  697. emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
  698. // The durable record renders the full cause chain: turn/end is the one
  699. // durable trace of the failure, so a wrapper message alone would lose
  700. // the transport detail the log exists to keep.
  701. const rendered = errorChain(error)
  702. return {
  703. reason: { kind: 'error', step, failure: { ...failure, ...rendered === '<unrenderable value>' ? {} : { message: rendered } } },
  704. settleReason: { kind: 'error', error, failure },
  705. }
  706. }
  707. emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
  708. return {
  709. reason: { kind: 'error', step, message: errorChain(error), ...isHarnessError(error) ? { code: error.code } : {} },
  710. settleReason: { kind: 'error', error },
  711. }
  712. }
  713. /** Continue with a waking prompt, or publish the idle status. */
  714. private continueOrIdle(): void {
  715. if (this.queued.some(item => item.wakeup)) {
  716. this.kick()
  717. } else {
  718. // Every caller sits inside an admission or run whose install marked the
  719. // interval busy, so the flag is still set here.
  720. this.busy = false
  721. emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle')
  722. }
  723. }
  724. }