agent.ts 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013
  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. An idle turn-admission reservation
  6. * can withhold the driver from the queue without touching its contents.
  7. *
  8. * @module dsh-agent-loop/agent
  9. */
  10. import type { Context } from 'cordis'
  11. import { randomUUID } from 'node:crypto'
  12. import { agentCarrier, assembleContextFor, emitAgentEvent, InboxItemId } from '@deepseek-ai/dsh-agent'
  13. import { createScope } from '@deepseek-ai/dsh-scope'
  14. import type { Scope } from '@deepseek-ai/dsh-scope'
  15. import type {
  16. Agent,
  17. CancelOptions,
  18. AgentInterruptReason,
  19. InboxAction,
  20. InboxActionResult,
  21. InboxItem,
  22. InboxItemId as InboxItemIdType,
  23. InboxPlacement,
  24. AgentOptions,
  25. AgentStatus,
  26. SettleReason,
  27. PromptDecision,
  28. RequestError,
  29. RequestErrorAction,
  30. SendOptions,
  31. SteeringOutcome,
  32. SteeringReceipt,
  33. } from '@deepseek-ai/dsh-agent'
  34. import {
  35. BlockAssembler,
  36. LlmError,
  37. assertNever,
  38. createAssistantMessage,
  39. createUserMessage,
  40. deepFreeze,
  41. errorChain,
  42. freezeMessage,
  43. isHarnessError,
  44. llmFailureOf,
  45. llmRetryPolicyOf,
  46. markAgentLoopRequest,
  47. } from '@deepseek-ai/dsh-llm'
  48. import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
  49. import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
  50. import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
  51. import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  52. import type {} from '@deepseek-ai/dsh-tools'
  53. import { executeToolCalls } from './tool-calls.ts'
  54. /** One completed step or a final-adapter failure eligible for recovery. */
  55. type StepOutcome =
  56. | { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean }
  57. | { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
  58. /** Internal one-shot controller paired with a public steering receipt. */
  59. interface SteeringDelivery {
  60. readonly receipt: SteeringReceipt
  61. settle(outcome: SteeringOutcome): void
  62. }
  63. /** Create one idempotent steering-admission controller. */
  64. function createSteeringDelivery(): SteeringDelivery {
  65. const { promise, resolve } = Promise.withResolvers<SteeringOutcome>()
  66. let settled = false
  67. return {
  68. receipt: { outcome: promise },
  69. settle(outcome): void {
  70. if (settled) return
  71. settled = true
  72. resolve(outcome)
  73. },
  74. }
  75. }
  76. const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
  77. /** Clearing marker kept distinct from every prefixed {@link renderContextSnapshot} result. */
  78. const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
  79. /** Whether one user message is owned by runtime-context materialization. */
  80. function isRuntimeContextMessage(message: UserMessage): boolean {
  81. return message.source.kind === 'plugin' && message.source.plugin === RUNTIME_CONTEXT_SOURCE
  82. }
  83. /** Latest retained runtime-context snapshot; `found` distinguishes malformed content from absence. */
  84. function retainedRuntimeContext(session: Session): { found: boolean; text: string | undefined } {
  85. const events = session.events
  86. const nodes = session.surface.nodes
  87. for (let index = nodes.length - 1; index >= 0; index -= 1) {
  88. const event = events[nodes[index] as number]
  89. if (event?.type !== 'user/message' || !isRuntimeContextMessage(event.data)) continue
  90. const [block] = event.data.content
  91. return {
  92. found: true,
  93. text: event.data.content.length === 1 && block?.type === 'text' ? block.text : undefined,
  94. }
  95. }
  96. return { found: false, text: undefined }
  97. }
  98. /** Append a full current snapshot only when it changed or compaction removed it. */
  99. function materializeRuntimeContext(session: Session, current: string): void {
  100. const previous = retainedRuntimeContext(session)
  101. if (!previous.found && current.length === 0) {
  102. const compactedPriorSnapshot = session.surface.replaceGeneration > 0
  103. && session.events.some(event => event.type === 'user/message' && isRuntimeContextMessage(event.data))
  104. if (!compactedPriorSnapshot) return
  105. }
  106. const snapshot = current.length === 0 ? CLEARED_RUNTIME_CONTEXT : current
  107. if (previous.text === snapshot) return
  108. session.append('user/message', createUserMessage({
  109. content: [{ type: 'text', text: snapshot }],
  110. source: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE },
  111. }), { surfaceOp: 'append' })
  112. }
  113. /** Remove adapter-derived values before plugins propose the next request config. */
  114. function requestProposal(header: EpochHeader): LlmCallConfig {
  115. if (header.adapterDefaults === undefined) return header.config
  116. const proposal = { ...header.config }
  117. if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort
  118. if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens
  119. return proposal
  120. }
  121. /**
  122. * The concrete {@link Agent}: each `run()` owns one turn and repeats model
  123. * steps while tools or steering require another request.
  124. */
  125. export class ReactLoopAgent implements Agent {
  126. /** Prompts awaiting individual turns. */
  127. private queued: { item: InboxItem; wakeup: boolean; delivery?: SteeringDelivery }[] = []
  128. /** Input taken into the session log at step boundaries. */
  129. private outbox: { message: UserMessage; steering: boolean; item?: InboxItem; delivery?: SteeringDelivery }[] = []
  130. /** Steering already committed to the log but not yet captured by a request. */
  131. private pendingAdmissions: SteeringDelivery[] = []
  132. /** Whether the active cancellation preserves already committed pending delivery. */
  133. private preservePendingAdmissionsOnAbort = false
  134. /** Whether observers see a running interval; consecutive turns share it. */
  135. private busy = false
  136. /** Whether an idle waking send has deferred driver admission. */
  137. private wakeScheduled = false
  138. /**
  139. * The live idle turn-admission reservation, holding the driver out of the
  140. * queue until its owner releases. It settles idle waiters instead of
  141. * {@link done} so lifecycle teardown never awaits the reserving operation.
  142. */
  143. private admission: { readonly settled: Promise<void>; readonly settle: () => void } | undefined
  144. /** Whether next-step input belongs to the current admission or open turn. */
  145. acceptsNextStep = false
  146. /** Abort owner for the current admission or turn. */
  147. private abort: AbortController | undefined
  148. /** Resolves when the current admission and turn exit. */
  149. done: Promise<void> = Promise.resolve()
  150. /** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */
  151. readonly scope: Scope
  152. /** The agent's scoped composition context ({@link Agent.ctx}). */
  153. readonly ctx: Context
  154. /** Last turn number opened by this loop or present in its seeded log. */
  155. private lastTurn: number
  156. /** Whether the session log is owed a matching turn end event. */
  157. private turnOpen = false
  158. private stepOpen = false
  159. /** Whether this loop instance has appended its initial/resume request anchor. */
  160. private requestHeaderLogged = false
  161. constructor(
  162. private loopCtx: Context,
  163. public readonly id: SessionId,
  164. public readonly options: AgentOptions,
  165. public readonly session: Session,
  166. ) {
  167. this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
  168. this.scope = createScope(loopCtx, this)
  169. this.ctx = this.scope.ctx.extend({ agent: this })
  170. }
  171. /** Last activity state published to observers. */
  172. get status(): AgentStatus {
  173. return this.busy ? 'running' : 'idle'
  174. }
  175. /** Accept and route one unified send item. */
  176. send(
  177. message: UserMessage,
  178. options: SendOptions,
  179. ): void {
  180. this.route(message, options)
  181. }
  182. /** Route one accepted message, optionally tracking steering admission. */
  183. private route(
  184. message: UserMessage,
  185. options: SendOptions,
  186. delivery?: SteeringDelivery,
  187. ): void {
  188. const { target, wakeup } = options
  189. if (target === 'next-step' && !wakeup) {
  190. if (this.acceptsNextStep) {
  191. this.outbox.push({ message, steering: false })
  192. return
  193. }
  194. this.session.append('user/message', message, { surfaceOp: 'append' })
  195. return
  196. }
  197. const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued'
  198. const item: InboxItem = Object.freeze({
  199. id: InboxItemId(randomUUID()),
  200. message,
  201. placement,
  202. })
  203. if (placement === 'steering') {
  204. this.outbox.push({ message, steering: true, item, ...delivery === undefined ? {} : { delivery } })
  205. } else {
  206. this.queued.push({ item, wakeup, ...delivery === undefined ? {} : { delivery } })
  207. }
  208. // Preserve the routing decision for every send in this synchronous caller
  209. // stack, while installing quiescence ownership before enqueue observers
  210. // can cancel or dispose.
  211. if (placement === 'queued' && wakeup) this.scheduleKick()
  212. emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item)
  213. }
  214. /** Apply one synchronous mutation to a still-pending queued occurrence. */
  215. updateInbox(id: InboxItemIdType, action: InboxAction): InboxActionResult {
  216. const queuedIndex = this.queued.findIndex(candidate => candidate.item.id === id)
  217. if (queuedIndex === -1) return 'not-found'
  218. const pending = this.queued[queuedIndex]
  219. /* v8 ignore next -- the index was resolved from this array without an async boundary. */
  220. if (pending === undefined) throw new Error(`agent "${this.id}" queued item disappeared during update`)
  221. /* v8 ignore next -- InboxAction is a closed discriminated union; all variants are covered below. */
  222. switch (action.kind) {
  223. case 'edit': {
  224. const item: InboxItem = Object.freeze({
  225. ...pending.item,
  226. message: freezeMessage({ ...pending.item.message, content: action.content }),
  227. })
  228. this.queued[queuedIndex] = { ...pending, item }
  229. emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', item)
  230. return 'applied'
  231. }
  232. case 'remove': {
  233. this.queued.splice(queuedIndex, 1)
  234. pending.delivery?.settle({ status: 'rejected' })
  235. emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item])
  236. return 'applied'
  237. }
  238. default:
  239. /* v8 ignore next -- InboxAction is a closed discriminated union. */
  240. return assertNever(action)
  241. }
  242. }
  243. /** Queue one ordinary prompt turn and wake the driver. */
  244. followup(input: UserMessage): void {
  245. this.send(input, {
  246. target: 'next-turn',
  247. wakeup: true,
  248. })
  249. }
  250. /** Steer the open turn, falling back to a tracked waking prompt while idle. */
  251. steer(input: UserMessage): SteeringReceipt {
  252. const delivery = createSteeringDelivery()
  253. this.route(input, {
  254. target: 'next-step',
  255. wakeup: true,
  256. }, delivery)
  257. return delivery.receipt
  258. }
  259. /** Append model-facing context without waking the driver. */
  260. inject(input: UserMessage): void {
  261. this.send(input, {
  262. target: 'next-step',
  263. wakeup: false,
  264. })
  265. }
  266. /**
  267. * Hold the idle admission boundary so no queued prompt can open a turn until
  268. * the returned release runs. Later sends keep their ordinary placement and
  269. * `wakeup` facts; only the driver's claim waits.
  270. * @returns the idempotent release, or `undefined` when the driver is active or already committed to waking work.
  271. */
  272. reserveTurnAdmission(): (() => void) | undefined {
  273. // `busy` covers every abort owner: kick() and run() mark the interval
  274. // running before they install one. `wakeScheduled` is the same-tick state
  275. // of an accepted waking prompt whose claim is still a pending microtask.
  276. if (this.busy || this.wakeScheduled || this.admission !== undefined
  277. || this.queued.some(item => item.wakeup)) return undefined
  278. const pending = Promise.withResolvers<void>()
  279. const reservation = { settled: pending.promise, settle: pending.resolve }
  280. this.admission = reservation
  281. return () => {
  282. // Idempotent, and inert once a later reservation owns the boundary.
  283. if (this.admission !== reservation) return
  284. this.admission = undefined
  285. // Re-arm the ordinary path first, so an idle waiter released below
  286. // re-reads live admission activity instead of settled state.
  287. if (this.queued.some(item => item.wakeup)) this.scheduleKick()
  288. reservation.settle()
  289. }
  290. }
  291. /**
  292. * Clear all pending work and abort the active turn; the first cause wins.
  293. * The cause is signal payload for observers and the durable turn/end
  294. * classification — it selects no machine behavior. Teardown is just
  295. * `cancel({kind:'disposed'})` + await {@link done} + {@link scope} dispose,
  296. * all owned by the factory.
  297. */
  298. cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void {
  299. // Effective only when it aborts the active turn or actually discards
  300. // pending work: a keepInbox call with no active turn is a documented
  301. // no-op, so it must not emit cancel-requested for consumers to misread.
  302. const discards = !options.keepInbox && (this.queued.length > 0 || this.outbox.length > 0)
  303. if (this.abort !== undefined || discards) {
  304. // Observe-only: coordination consumers update their state before the
  305. // inboxes clear; listener failures are contained by the dispatcher.
  306. if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)
  307. }
  308. if (options.keepInbox && this.abort !== undefined) this.preservePendingAdmissionsOnAbort = true
  309. if (!options.keepInbox) {
  310. const discarded = this.queued.map(item => item.item)
  311. for (const item of this.queued) item.delivery?.settle({ status: 'rejected' })
  312. for (const item of this.outbox) {
  313. if (item.steering && item.item !== undefined) {
  314. item.delivery?.settle({ status: 'rejected' })
  315. discarded.push(item.item)
  316. }
  317. }
  318. this.rejectPendingAdmissions()
  319. // Clear before abort observers run: replacement work belongs to the next turn.
  320. this.queued.length = 0
  321. this.outbox.length = 0
  322. if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
  323. }
  324. const reason = Object.freeze({ kind: cause.kind })
  325. this.abort?.abort(reason)
  326. }
  327. /** Resolve at idle quiescence: no run driving and no waking prompt waiting. */
  328. async whenIdle(): Promise<void> {
  329. while (true) {
  330. // `done` is replaced per activity, so re-reading it follows chained turns.
  331. // Every driver failure today is contained before it can reject `done`,
  332. // but the waiter must not gamble quiescence on that: a future escape
  333. // still counts as settled activity.
  334. /* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */
  335. while (this.busy || this.wakeScheduled || this.abort !== undefined || this.runnableWakingQueued) {
  336. await this.done.catch(() => undefined)
  337. }
  338. // A reservation is unfinished activity even with an empty queue, and a
  339. // prompt it withholds is not quiescent — but `done` never owns it, so
  340. // waiting on the queue alone would spin on an already-settled promise.
  341. const reservation = this.admission
  342. if (reservation === undefined) return
  343. await reservation.settled
  344. }
  345. }
  346. /** Whether a queued waking prompt may claim the driver now. */
  347. private get runnableWakingQueued(): boolean {
  348. return this.admission === undefined && this.queued.some(item => item.wakeup)
  349. }
  350. /** Defer idle admission while keeping {@link done} as its quiescence owner. */
  351. private scheduleKick(): void {
  352. // A held reservation keeps the item queued with no scheduled claim; its
  353. // release re-arms this path for whatever is queued by then.
  354. if (this.abort !== undefined || this.wakeScheduled || this.admission !== undefined) return
  355. this.wakeScheduled = true
  356. const pending = Promise.withResolvers<void>()
  357. const scheduled = pending.promise
  358. queueMicrotask(() => {
  359. this.wakeScheduled = false
  360. this.kick()
  361. const activity = this.done
  362. if (activity === scheduled) {
  363. pending.resolve()
  364. } else {
  365. void activity.then(
  366. () => { pending.resolve() },
  367. () => { pending.resolve() },
  368. )
  369. }
  370. })
  371. this.done = scheduled
  372. }
  373. /** Claim and admit the next queued prompt, then start its turn. */
  374. private kick(): void {
  375. if (this.abort !== undefined || !this.runnableWakingQueued) return
  376. // The some() guard above proves the queue is non-empty; the non-null
  377. // assertion expresses that invariant.
  378. // oxlint-disable-next-line typescript/no-non-null-assertion
  379. const pending = this.queued.shift()!
  380. const { item, delivery } = pending
  381. const { message } = item
  382. const inheritedOutboxLength = this.outbox.length
  383. const admission = new AbortController()
  384. this.abort = admission
  385. this.acceptsNextStep = true
  386. // Claimed admission is part of the running interval: it is cancellable
  387. // activity, so observers (and their cancel routing) must see it.
  388. if (!this.busy) {
  389. this.busy = true
  390. emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
  391. }
  392. // The admission body runs synchronously up to the prompt-submit
  393. // waterfall's first await, so the waterfall snapshots its listeners
  394. // before a disposal initiated by the running-status emit above can
  395. // unregister a vetoing plugin.
  396. this.done = this.loopCtx.agents.withInitiator(this, async () => {
  397. const signal = admission.signal
  398. const trigger: TurnTrigger = { kind: 'message', source: message.source }
  399. // Admitted input stays on the stack until its turn/start commits: the
  400. // turn owns it only once the turn exists in the log.
  401. let admitted: UserMessage[] | undefined
  402. try {
  403. signal.throwIfAborted()
  404. const decision = await this.loopCtx.waterfall(
  405. agentCarrier(this), 'agent/prompt-submit', this, message, signal,
  406. () => Promise.resolve<PromptDecision>({ kind: 'allow' }),
  407. )
  408. signal.throwIfAborted()
  409. if (decision.kind === 'allow') {
  410. admitted = [decision.content === undefined
  411. ? message
  412. : freezeMessage({ ...message, content: decision.content })]
  413. for (const context of decision.additionalContexts ?? []) {
  414. admitted.push(freezeMessage(context))
  415. }
  416. }
  417. } catch (error: unknown) {
  418. if (!signal.aborted) {
  419. this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(error)}`)
  420. }
  421. }
  422. // cancel() aborts but never clears the slot, and kick()/run()
  423. // all refuse to install a new owner while one exists, so the admission
  424. // still owns the slot here and releasing it unconditionally is exact.
  425. this.abort = undefined
  426. if (admitted === undefined) {
  427. delivery?.settle({ status: 'rejected' })
  428. this.acceptsNextStep = false
  429. try {
  430. this.flushRejectedAdmissionContexts()
  431. } catch (error: unknown) {
  432. // No turn exists for agent/error coordinates. Preserve the
  433. // uncommitted suffix for a later boundary and report locally.
  434. this.loopCtx.logger.warn(
  435. `agent "${this.id}": committing rejected-admission context failed: ${errorChain(error)}`,
  436. )
  437. }
  438. // A synchronously aborted admission would otherwise publish idle
  439. // inside send()'s own synchronous extent, before any post-send
  440. // subscriber could observe the transition.
  441. await Promise.resolve()
  442. this.continueOrIdle()
  443. return
  444. }
  445. await this.run(trigger, admitted, inheritedOutboxLength, Object.freeze([]), delivery)
  446. })
  447. // Published only after the abort owner and pending done are installed: a
  448. // dequeue listener that cancels or disposes must find live cancellation
  449. // and quiescence ownership, not the previous activity's settled state.
  450. emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item)
  451. }
  452. /**
  453. * Run one turn and any request-error retry. `admitted` input enters the log
  454. * only after `turn/start` commits; until then it has no owner state to unwind.
  455. */
  456. private async run(
  457. trigger: TurnTrigger,
  458. admitted: UserMessage[] = [],
  459. inheritedOutboxLength = 0,
  460. priorFailures: readonly LlmFailure[] = Object.freeze([]),
  461. promptDelivery?: SteeringDelivery,
  462. ): Promise<void> {
  463. // Both entries hold the invariant: kick() clears the admission slot before
  464. // awaiting run(), and a retry is entered only after the prior run clears it.
  465. /* v8 ignore next -- unreachable guard: every caller clears or checks the abort slot first */
  466. if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
  467. const controller = new AbortController()
  468. this.abort = controller
  469. this.preservePendingAdmissionsOnAbort = false
  470. this.acceptsNextStep = true
  471. const signal = controller.signal
  472. const turn = this.lastTurn + 1
  473. let step = 0
  474. let opened = false
  475. let reason: TurnEndReason = { kind: 'completed' }
  476. let settleReason: SettleReason = { kind: 'completed' }
  477. let requestFailureHistory = priorFailures
  478. let retryFailures: readonly LlmFailure[] | undefined
  479. const cancelRetry = (): void => { retryFailures = undefined }
  480. signal.addEventListener('abort', cancelRetry, { once: true })
  481. try {
  482. signal.throwIfAborted()
  483. this.session.append('turn/start', { turn, trigger })
  484. // Committed: publish the turn to the machine's own bookkeeping and let
  485. // the admitted input enter the log it now belongs to.
  486. this.turnOpen = true
  487. opened = true
  488. this.lastTurn = turn
  489. // Context or steering retained by an earlier rejected admission happened
  490. // before this prompt and must occupy the same order in durable history.
  491. this.drainOutbox(turn, inheritedOutboxLength)
  492. if (promptDelivery !== undefined) this.pendingAdmissions.push(promptDelivery)
  493. for (const input of admitted) {
  494. this.session.append('user/message', input, { surfaceOp: 'append' })
  495. }
  496. signal.throwIfAborted()
  497. steps: while (true) {
  498. step += 1
  499. const outcome = await this.step(turn, step, signal)
  500. switch (outcome.kind) {
  501. case 'completed':
  502. requestFailureHistory = Object.freeze([])
  503. if (outcome.maxTokens) reason = { kind: 'max-tokens' }
  504. // A concluding tool result is terminal: reject steering that did
  505. // not enter a request, while retaining same-boundary context in
  506. // durable history before the turn closes.
  507. if (outcome.concluded) {
  508. this.discardOutboxSteering()
  509. this.drainOutbox(turn)
  510. break steps
  511. }
  512. if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue
  513. break
  514. case 'request-failed': {
  515. // step() reports request failures only after step/start commits
  516. // and before its own step/end, so the step is always open here.
  517. this.stepOpen = false
  518. this.session.append('step/end', { turn, step })
  519. if (!signal.aborted) {
  520. try {
  521. const action = await this.loopCtx.waterfall(
  522. agentCarrier(this), 'agent/request-error', this, turn, step, outcome.error,
  523. outcome.failure, requestFailureHistory, outcome.retryPolicy, signal,
  524. () => Promise.resolve<RequestErrorAction>(undefined),
  525. )
  526. // oxlint-disable-next-line typescript/no-unnecessary-condition -- signal can abort while recovery is awaited.
  527. if (action?.kind === 'retry' && !signal.aborted) {
  528. retryFailures = Object.freeze([...requestFailureHistory, outcome.failure])
  529. }
  530. } catch (recoveryError: unknown) {
  531. this.loopCtx.logger.warn(
  532. `agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
  533. )
  534. }
  535. }
  536. const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure)
  537. reason = settlement.reason
  538. settleReason = settlement.settleReason
  539. break steps
  540. }
  541. /* v8 ignore next 2 -- closed-union exhaustiveness guard */
  542. default:
  543. assertNever(outcome)
  544. }
  545. await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal)
  546. signal.throwIfAborted()
  547. this.drainOutboxContexts()
  548. if (!this.outbox.some(item => item.steering)) {
  549. break
  550. }
  551. }
  552. } catch (caught: unknown) {
  553. try {
  554. if (this.stepOpen) {
  555. this.stepOpen = false
  556. this.session.append('step/end', { turn, step })
  557. }
  558. } catch (closeError: unknown) {
  559. // Contained like the finally's turn close: a persistently rejecting
  560. // step boundary must not escape run(), or the post-finally tail would
  561. // never publish the terminal status and observers would see a
  562. // permanently running agent whose whenIdle() already resolved.
  563. this.loopCtx.logger.warn(`agent "${this.id}": closing step ${turn}/${step} failed: ${errorChain(closeError)}`)
  564. emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, closeError)
  565. }
  566. ({ reason, settleReason } = this.settle(turn, step, caught, signal))
  567. } finally {
  568. // Every step-close happens before this point on both success and
  569. // failure paths (step(), the request-failed branch, the catch), so the
  570. // finally owes only the turn boundary.
  571. this.acceptsNextStep = false
  572. try {
  573. if (this.turnOpen) {
  574. // Re-entrant turn/end listeners must route new input to a later turn.
  575. this.turnOpen = false
  576. this.session.append('turn/end', { turn, reason })
  577. }
  578. } catch (error: unknown) {
  579. retryFailures = undefined
  580. this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`)
  581. emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
  582. }
  583. // cancel() aborts but never clears the slot, and no second run can
  584. // install a controller while this one is still unwinding, so the slot
  585. // is still this run's controller here.
  586. this.abort = undefined
  587. signal.removeEventListener('abort', cancelRetry)
  588. const preservePending = signal.aborted && this.preservePendingAdmissionsOnAbort
  589. this.preservePendingAdmissionsOnAbort = false
  590. // oxlint-disable-next-line typescript/no-unnecessary-condition -- keepInbox cancellation can set this while turn work is awaited.
  591. if (!preservePending) this.rejectPendingAdmissions()
  592. }
  593. if (opened) {
  594. try {
  595. await this.loopCtx.sessions.flush(this.session)
  596. } catch (error: unknown) {
  597. this.loopCtx.logger.warn(`agent "${this.id}": session/flush failed at turn ${turn}: ${errorChain(error)}`)
  598. emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
  599. }
  600. }
  601. if (retryFailures !== undefined) {
  602. await this.run({ kind: 'retry' }, [], 0, retryFailures)
  603. } else {
  604. // agent/settled names only committed turns: a run aborted or rejected
  605. // before turn/start has no durable turn/end for consumers to settle
  606. // against, so it exits without the notification.
  607. if (opened) emitAgentEvent(this.loopCtx, this, 'agent/settled', turn, settleReason)
  608. this.continueOrIdle()
  609. }
  610. }
  611. /**
  612. * Run the `agent/step` extension point, commit pending input, derive one
  613. * request, and execute its tool calls inside one durable step boundary.
  614. */
  615. private async step(
  616. turn: number,
  617. step: number,
  618. signal: AbortSignal,
  619. ): Promise<StepOutcome> {
  620. const { session } = this
  621. // The single between-steps extension point: listeners inject, steer, or
  622. // edit the log here; the request derives from the log after this settles.
  623. await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal)
  624. signal.throwIfAborted()
  625. // Assemble request-owned prompt inputs fresh each step. Dynamic context is
  626. // committed at the tail before deriving history once, preserving the stable
  627. // system/history cache prefix while keeping every model-visible byte logged.
  628. const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
  629. signal.throwIfAborted()
  630. const system = renderPrompt(assembly)
  631. materializeRuntimeContext(session, renderContextSnapshot(assembly))
  632. // Commit the exact pending batch only after every asynchronous
  633. // pre-request contribution succeeded. Input accepted after this splice
  634. // remains pending for a later request.
  635. this.drainOutbox(turn)
  636. // Snapshot the exact log prefix: the reconstruction boundary. Appends
  637. // after this synchronous snapshot join the next request.
  638. const boundaryMessages = session.deriveMessages()
  639. session.append('step/start', { turn, step })
  640. this.stepOpen = true
  641. this.admitPendingAdmissions(turn, step)
  642. signal.throwIfAborted()
  643. const { request, preparedCall } = await this.buildRequest(
  644. turn, step, assembly.tools, system, boundaryMessages, signal,
  645. )
  646. const assembler = new BlockAssembler()
  647. const chunkSeqs: number[] = []
  648. const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
  649. try {
  650. for await (const chunk of stream) {
  651. signal.throwIfAborted()
  652. const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
  653. chunkSeqs.push(chunkEvent.seq)
  654. assembler.push(chunk)
  655. }
  656. } catch (error: unknown) {
  657. const facts = llmFailureOf(stream, error)
  658. if (facts !== undefined && error instanceof Error) {
  659. return { kind: 'request-failed', error, failure: facts, retryPolicy: llmRetryPolicyOf(stream) }
  660. }
  661. throw error
  662. }
  663. signal.throwIfAborted()
  664. // Failure finish chunks take the same path as thrown stream errors.
  665. const finish = assembler.finish
  666. if (finish.kind === 'error' || finish.kind === 'aborted') {
  667. const error = new LlmError(finish.failure.message, finish.failure.code, finish.failure)
  668. return { kind: 'request-failed', error, failure: finish.failure, retryPolicy: llmRetryPolicyOf(stream) }
  669. }
  670. // Truncated (max-tokens) output cannot owe tool calls.
  671. const assembled = assembler.blocks()
  672. const content = finish.kind === 'max-tokens'
  673. ? assembled.filter(block => block.type !== 'tool-call')
  674. : assembled
  675. const message: AssistantMessage = createAssistantMessage({
  676. content,
  677. source: {
  678. provider: request.provider,
  679. model: request.model,
  680. ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
  681. },
  682. })
  683. session.append(
  684. 'assistant/message',
  685. {
  686. turn,
  687. step,
  688. message,
  689. ...assembler.usage === undefined ? {} : { usage: assembler.usage },
  690. },
  691. { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
  692. )
  693. const toolCalls = content.filter(block => block.type === 'tool-call')
  694. let concluded = false
  695. if (toolCalls.length > 0) {
  696. ({ concluded } = await executeToolCalls(
  697. this.loopCtx, turn, step, toolCalls, signal,
  698. context => this.outbox.push({ message: freezeMessage(context), steering: false }),
  699. ))
  700. }
  701. // Ordinary context keeps the base loop's result-adjacent commit point.
  702. // Steering remains provisional until the next request snapshot admits it.
  703. this.drainOutboxContexts()
  704. session.append('step/end', { turn, step })
  705. this.stepOpen = false
  706. return {
  707. kind: 'completed',
  708. continueTurn: (toolCalls.length > 0 && !concluded) || this.outbox.some(item => item.steering),
  709. concluded,
  710. maxTokens: finish.kind === 'max-tokens',
  711. }
  712. }
  713. /**
  714. * Compose one frozen request and bind it to the adapter registration that
  715. * resolved its exact-model defaults.
  716. */
  717. private async buildRequest(
  718. turn: number,
  719. step: number,
  720. tools: GenerateOptions['tools'] & object,
  721. system: string,
  722. boundaryMessages: Message[],
  723. signal: AbortSignal,
  724. ): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
  725. const { session } = this
  726. // A loop instance starts from its declared route, restoring only an explicit
  727. // effort owned by that exact model. Later steps re-resolve marked defaults.
  728. const persistedHeader = session.requestHeader()
  729. const persistedConfig = persistedHeader?.config
  730. const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }
  731. const reasoningEffort = persistedConfig?.provider === route.provider
  732. && persistedConfig.model === route.model
  733. && persistedHeader?.adapterDefaults?.reasoningEffort !== true
  734. ? persistedConfig.reasoningEffort
  735. : undefined
  736. const maxTokens = this.options.maxTokens
  737. const seedConfig = deepFreeze(structuredClone(
  738. this.requestHeaderLogged
  739. // oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds
  740. ? requestProposal(persistedHeader!)
  741. : {
  742. ...route,
  743. ...reasoningEffort === undefined ? {} : { reasoningEffort },
  744. ...maxTokens === undefined ? {} : { maxTokens },
  745. },
  746. ))
  747. const proposedConfig = await this.loopCtx.waterfall(
  748. agentCarrier(this), 'agent/request', this, turn, step, signal,
  749. () => Promise.resolve(seedConfig),
  750. )
  751. signal.throwIfAborted()
  752. if (!proposedConfig.provider || !proposedConfig.model) {
  753. throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
  754. }
  755. let config: LlmCallConfig
  756. let preparedCall: PreparedLlmCall | undefined
  757. try {
  758. preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal)
  759. config = preparedCall.config
  760. } catch (error: unknown) {
  761. // A llm/stream listener may own and short-circuit a route with no
  762. // adapter. Terminal dispatch still raises NO_ADAPTER when none does.
  763. if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
  764. config = proposedConfig
  765. }
  766. signal.throwIfAborted()
  767. const header = canonicalHeader({
  768. config,
  769. ...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults },
  770. ...system ? { system } : {},
  771. ...tools.length > 0 ? { tools } : {},
  772. })
  773. const baseline = session.requestHeader()
  774. if (!this.requestHeaderLogged) {
  775. session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
  776. this.requestHeaderLogged = true
  777. } else if (baseline === undefined || !headerEquals(baseline, header)) {
  778. session.append('request/header', { header, reason: 'change' })
  779. }
  780. // TODO: This looks like code smell.
  781. // Context metadata for the route this request resolved to, recorded from the same
  782. // registration-bound lookup that prepared the call (no second resolve).
  783. // A route with unknown capacity is still recorded so it clears any older
  784. // denominator; an unchanged route logs nothing.
  785. const contextWindow = preparedCall?.context?.contextWindow
  786. const requestContext: RequestContext = {
  787. provider: config.provider,
  788. model: config.model,
  789. ...contextWindow === undefined ? {} : { contextWindow },
  790. }
  791. const previous = session.requestContext()
  792. if (previous?.provider !== requestContext.provider
  793. || previous.model !== requestContext.model
  794. || previous.contextWindow !== requestContext.contextWindow) {
  795. session.append('request/context', requestContext)
  796. }
  797. const request = markAgentLoopRequest(deepFreeze({
  798. ...header.config,
  799. messages: boundaryMessages,
  800. ...header.system !== undefined ? { system: header.system } : {},
  801. ...header.tools !== undefined ? { tools: header.tools } : {},
  802. sessionId: session.id,
  803. signal,
  804. }))
  805. return { request, ...preparedCall === undefined ? {} : { preparedCall } }
  806. }
  807. /** Commit one stable outbox prefix and retain tracked delivery until snapshot admission. */
  808. private drainOutbox(turn: number, limit = this.outbox.length): void {
  809. const batch = this.outbox.splice(0, limit)
  810. for (let index = 0; index < batch.length; index += 1) {
  811. const item = batch[index]
  812. /* v8 ignore next -- the index walks the exact array length. */
  813. if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during drain`)
  814. try {
  815. if (item.steering) {
  816. /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */
  817. if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`)
  818. emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item)
  819. this.session.append(
  820. 'steering/message',
  821. { turn, message: item.message },
  822. { surfaceOp: 'append' },
  823. )
  824. if (item.delivery !== undefined) this.pendingAdmissions.push(item.delivery)
  825. } else {
  826. this.session.append('user/message', item.message, { surfaceOp: 'append' })
  827. }
  828. } catch (error: unknown) {
  829. item.delivery?.settle({ status: 'rejected' })
  830. this.outbox.unshift(...batch.slice(item.steering ? index + 1 : index))
  831. throw error
  832. }
  833. }
  834. }
  835. /** Commit ordinary context while retaining provisional steering in order. */
  836. private drainOutboxContexts(): void {
  837. const pending = this.outbox
  838. this.outbox = []
  839. for (let index = 0; index < pending.length; index += 1) {
  840. const item = pending[index]
  841. /* v8 ignore next -- the index walks the exact array length. */
  842. if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during context drain`)
  843. if (item.steering) {
  844. this.outbox.push(item)
  845. continue
  846. }
  847. try {
  848. this.session.append('user/message', item.message, { surfaceOp: 'append' })
  849. } catch (error: unknown) {
  850. this.outbox.push(...pending.slice(index))
  851. throw error
  852. }
  853. }
  854. }
  855. /** Settle every committed steering item captured by this immutable request. */
  856. private admitPendingAdmissions(turn: number, step: number): void {
  857. const outcome: SteeringOutcome = { status: 'admitted', turn, step }
  858. for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle(outcome)
  859. }
  860. /** Reject committed steering that left the inbox without reaching a request. */
  861. private rejectPendingAdmissions(): void {
  862. for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle({ status: 'rejected' })
  863. }
  864. /** Discard uncommitted steering while retaining same-boundary injected context. */
  865. private discardOutboxSteering(): void {
  866. const contexts: typeof this.outbox = []
  867. const discarded: InboxItem[] = []
  868. for (const item of this.outbox) {
  869. if (!item.steering) {
  870. contexts.push(item)
  871. continue
  872. }
  873. item.delivery?.settle({ status: 'rejected' })
  874. /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */
  875. if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`)
  876. discarded.push(item.item)
  877. }
  878. this.outbox = contexts
  879. if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
  880. }
  881. /**
  882. * Give context-only input its ordinary idle placement when admission
  883. * produces no turn. Steering keeps the whole boundary staged so context
  884. * accepted beside it cannot split from the request it accompanies.
  885. */
  886. private flushRejectedAdmissionContexts(): void {
  887. if (this.outbox.some(item => item.steering)) return
  888. const contexts = this.outbox.splice(0)
  889. for (let index = 0; index < contexts.length; index += 1) {
  890. const item = contexts[index]
  891. /* v8 ignore next 2 -- the steering precheck proves this batch is context-only */
  892. if (item === undefined || item.steering) throw new Error('rejected-admission context batch changed')
  893. try {
  894. this.session.append('user/message', item.message, { surfaceOp: 'append' })
  895. } catch (error: unknown) {
  896. this.outbox.unshift(...contexts.slice(index))
  897. throw error
  898. }
  899. }
  900. }
  901. /**
  902. * The single settlement funnel: classify one turn failure (interruption
  903. * beats error) into the durable turn/end reason and live settlement report.
  904. */
  905. private settle(
  906. turn: number,
  907. step: number,
  908. error: unknown,
  909. signal: AbortSignal,
  910. failure?: LlmFailure,
  911. ): { reason: TurnEndReason; settleReason: SettleReason } {
  912. if (signal.aborted) {
  913. // Slot invariant, stated rather than re-validated: the turn controller
  914. // is machine-private and cancel() is its only aborter, always with one
  915. // frozen canonical cause as the reason.
  916. const interrupt = signal.reason as AgentInterruptReason
  917. return {
  918. reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' },
  919. settleReason: { kind: 'aborted' },
  920. }
  921. }
  922. if (failure !== undefined) {
  923. emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
  924. // The durable record renders the full cause chain: turn/end is the one
  925. // durable trace of the failure, so a wrapper message alone would lose
  926. // the transport detail the log exists to keep.
  927. const rendered = errorChain(error)
  928. return {
  929. reason: { kind: 'error', step, failure: { ...failure, ...rendered === '<unrenderable value>' ? {} : { message: rendered } } },
  930. settleReason: { kind: 'error', error, failure },
  931. }
  932. }
  933. emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
  934. return {
  935. reason: { kind: 'error', step, message: errorChain(error), ...isHarnessError(error) ? { code: error.code } : {} },
  936. settleReason: { kind: 'error', error },
  937. }
  938. }
  939. /** Continue with a waking prompt, or publish the idle status. */
  940. private continueOrIdle(): void {
  941. if (this.runnableWakingQueued) {
  942. this.kick()
  943. } else {
  944. // Every caller sits inside an admission or run whose install marked the
  945. // interval busy, so the flag is still set here.
  946. this.busy = false
  947. emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle')
  948. }
  949. }
  950. }