agent.ts 33 KB

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