agent.ts 38 KB

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