loop.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829
  1. /**
  2. * The agent loop driver: one `runLoop()` invocation drives one agent for its
  3. * whole lifetime. Error-contained at the turn level — a throwing plugin ends
  4. * the turn, never kills the loop. See the JSDoc on `runLoop()` for the full
  5. * lifecycle pseudo-code.
  6. *
  7. * @module dsh-agent-loop/loop
  8. */
  9. import type { Context } from 'cordis'
  10. import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
  11. import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
  12. import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
  13. import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
  14. import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  15. import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
  16. import type {} from '@deepseek-ai/dsh-tools'
  17. import type { ReactLoopAgent } from './agent.ts'
  18. /** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
  19. type CodedError = Error & { code?: string }
  20. /**
  21. * Normalize an arbitrary thrown value into a coded Error. A real Error passes
  22. * through (its `code`, if any, is preserved by {@link errorData}); a non-Error
  23. * throw is wrapped in a {@link HarnessError} with code `UNKNOWN` and the
  24. * original value chained as `cause`, so a bad throw still carries a routable
  25. * code instead of degrading to a bare message.
  26. */
  27. function toError(error: unknown): CodedError {
  28. return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
  29. }
  30. /**
  31. * Map a model-call {@link FinishReason} to the step error it should raise, or
  32. * `undefined` when the step completed normally.
  33. *
  34. * Adapters report provider/transport failures one of two sanctioned ways (see
  35. * the StreamChunk contract in dsh-llm): throw from `stream()` (handled by the
  36. * caller's try/catch), OR end the stream with a finish-error/aborted chunk
  37. * (the only option for adapters that can't throw mid-stream, e.g.
  38. * library-backed ones). This translates the latter into a thrown step error
  39. * so the turn ends error/aborted (the failure recorded on `turn/end.reason`),
  40. * never as a normal `completed` assistant message.
  41. *
  42. * `FinishReason` is merge-extensible (plugins/adapters can add `kind`s), so
  43. * the switch handles the known terminal-failure kinds and treats every other
  44. * kind — `stop`, `tool-calls`, `max-tokens`, future additions — as success.
  45. */
  46. function finishError(finish: FinishReason): CodedError | undefined {
  47. switch (finish.kind) {
  48. case 'error': {
  49. const error: CodedError = new Error(finish.message)
  50. if (finish.code !== undefined) error.code = finish.code
  51. return error
  52. }
  53. case 'aborted': {
  54. const error: CodedError = new Error('model stream aborted')
  55. error.code = 'ABORTED'
  56. return error
  57. }
  58. // stop / tool-calls / max-tokens / plugin-added kinds → not a failure.
  59. default:
  60. return undefined
  61. }
  62. }
  63. /**
  64. * Build the `{ message, code? }` part of an error payload, omitting the
  65. * `code` key entirely when absent (exactOptionalPropertyTypes-correct).
  66. */
  67. function errorData(err: CodedError): { message: string; code?: string } {
  68. return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
  69. }
  70. /**
  71. * The turn-end contribution of a step's *successful* finish, or `undefined`
  72. * when the step finished ordinarily (a plain `completed`).
  73. *
  74. * {@link finishError} has already converted `error`/`aborted` finishes into
  75. * thrown step errors, so the finishes that reach here are `stop`,
  76. * `tool-calls`, `max-tokens`, or a future merge-extensible kind. Only
  77. * `max-tokens` carries forward as a distinct {@link TurnEndReason}: a step that
  78. * hit the output-token ceiling ended the turn cut-short rather than by the
  79. * model's choice. `stop`/`tool-calls`/unknown kinds contribute nothing beyond
  80. * the default `completed`. {@link runTurn} applies this with the rule "any
  81. * `max-tokens` step in the turn makes the turn end `max-tokens`".
  82. */
  83. function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
  84. switch (finish.kind) {
  85. case 'max-tokens':
  86. return { kind: 'max-tokens' }
  87. // stop / tool-calls / plugin-added kinds → no turn-end contribution
  88. // beyond the default `completed`. FinishReason is merge-extensible, so a
  89. // default (not assertNever) handles unknown kinds as ordinary success.
  90. default:
  91. return undefined
  92. }
  93. }
  94. /**
  95. * Ambient handles the loop driver receives from the agent. Decouples the
  96. * pure function `runLoop` from the mutable ReactLoopAgent fields, making the
  97. * loop testable without a real agent.
  98. */
  99. export interface LoopHandle {
  100. setStatus(status: 'idle' | 'running'): void
  101. setAbort(controller: AbortController | undefined): void
  102. /** Resolves when the agent is disposed — unblocks the idle wait. */
  103. disposed: Promise<void>
  104. isDisposed(): boolean
  105. /**
  106. * Whether a `cancel()` is pending for the current turn. The driver checks this
  107. * at every decision point where a turn could start or continue (right after
  108. * the idle wait, after the `running` flip, before each step, and at the
  109. * continuation gate) and drops the about-to-run / continuing turn. Reset once
  110. * per loop iteration via {@link clearCancel} after the turn returns, so the
  111. * marker governs exactly one cancellation and never leaks to a later prompt.
  112. */
  113. isCancelled(): boolean
  114. /**
  115. * The resolved reason for the pending cancel (`reason ?? 'cancelled'`), read
  116. * by the marker branches (pre-step / continuation) so a turn dropped where no
  117. * `AbortController` carries the reason still records the caller's
  118. * `cancel(reason)` value — matching the mid-step abort path. Only meaningful
  119. * when {@link isCancelled} is true.
  120. */
  121. cancelReason(): string
  122. /** Clear the cancel marker (called once per iteration after the turn returns). */
  123. clearCancel(): void
  124. /**
  125. * Settle pending `whenIdle()` waiters WITHOUT a status transition. Used by the
  126. * pre-step cancel-skip path: it drops the about-to-run turn and re-parks at the
  127. * idle wait, so no `running→idle` transition fires to settle a `whenIdle()`
  128. * waiter that was registered in the pre-step window — this settles it directly
  129. * (it emits no `agent/status`, so an ACP `agent/status` listener never sees a
  130. * spurious idle that would resolve a freshly-queued prompt as cancelled).
  131. */
  132. settleIdle(): void
  133. }
  134. /**
  135. * The agent loop. One invocation drives one agent for its whole lifetime:
  136. *
  137. * ```
  138. * create agent → emit agent/session-start(source) ⟵ once, before turn 1
  139. * forever:
  140. * wait for queued messages (idle)
  141. * TURN (error-contained — a throwing plugin ends the turn, never the loop):
  142. * 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror)
  143. * allow → session('user/message'…) (+ inject additionalContext) | block → drop
  144. * every prompt blocked → 'turn/end'(rejected), 0 steps
  145. * STEP loop:
  146. * drain steering → session('steering/message') ⟵ catches late steering
  147. * assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt
  148. * (persona section + {{variables}}) IS the full prompt
  149. * await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step
  150. * session('step/start') ⟵ durable step boundary (no agent/* mirror)
  151. * req = {model, system, tools, messages: session.deriveMessages(), signal}
  152. * req = waterfall agent/request ⟵ hooks/model-switch
  153. * stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks)
  154. * session('assistant/chunk')
  155. * msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
  156. * session('assistant/message' {content, usage?}) session records what actually ran
  157. * each tool-call in msg (sequential, abort-checked):
  158. * session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask)
  159. * → dispatch → tools/post-execute
  160. * session('tool/result')
  161. * append buffered post-execute additionalContext → session('context/message')(s)
  162. * drain steering → session('steering/message')
  163. * session('step/end') ⟵ durable step boundary (no agent/* mirror)
  164. * cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
  165. * {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
  166. * recorded as next-step steering
  167. * if action==stop && steering arrived (step/end/continuation listeners): continue anyway
  168. * if action==stop: break
  169. * session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
  170. * await ctx.parallel('session/flush', session) ⟵ durability checkpoint
  171. * re-enqueue leftover steering as queued ⟵ steering is never stranded
  172. * idle (emit agent/status) unless more queued
  173. * ```
  174. */
  175. export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
  176. const { session } = agent
  177. while (!handle.isDisposed()) {
  178. await agent.inbox.waitForQueued(handle.disposed)
  179. if (handle.isDisposed()) break
  180. // Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
  181. // idle wait but before we flip to `running`. The cancelled queued/steering
  182. // work is already cleared by `cancel()`. Clear the marker, then:
  183. // - if NOTHING new is queued, drop the about-to-run turn and re-park,
  184. // settling any `whenIdle()` waiter DIRECTLY (no running→idle transition
  185. // fires here to settle it) and WITHOUT emitting `agent/status` (an ACP
  186. // listener must not see a spurious idle that resolves a freshly-queued
  187. // prompt as cancelled);
  188. // - if a NEW prompt was queued AFTER the cancel (a send() that raced in
  189. // before the loop resumed), the marker was for the cancelled work only —
  190. // fall through and run the new prompt's turn. Do NOT settle waiters here:
  191. // a whenIdle() waiter must wait for that new turn's running→idle, not
  192. // resolve before it runs (the quiescence contract).
  193. if (handle.isCancelled()) {
  194. handle.clearCancel()
  195. if (!agent.inbox.hasQueued) {
  196. handle.settleIdle()
  197. continue
  198. }
  199. }
  200. handle.setStatus('running')
  201. // Pre-step cancel (window 2): `setStatus('running')` emits `agent/status`
  202. // SYNCHRONOUSLY, so a `running` listener can `cancel()` in the gap between the
  203. // check above and `runTurn`. Mirror window 1: clear the marker, then
  204. // - if NOTHING new is queued, drop the about-to-run turn and transition
  205. // back to `idle` (`running` was already emitted, so a real idle
  206. // transition balances the status AND settles `whenIdle()` waiters);
  207. // - if a NEW prompt was queued AFTER the cancel (a `running` listener that
  208. // cancels then sends), the marker was for the cancelled work only — fall
  209. // through and run the new prompt's turn (status is already `running`), so
  210. // a `whenIdle()` waiter resolves on THAT turn's running→idle, not before
  211. // it runs. Settling here would resolve quiescence while the replacement
  212. // is still queued and unrun (the same early-resolve race window 1 fixes).
  213. if (handle.isCancelled()) {
  214. handle.clearCancel()
  215. if (!agent.inbox.hasQueued) {
  216. handle.setStatus('idle')
  217. continue
  218. }
  219. }
  220. // Re-derive the turn number from the log each iteration (do NOT keep a local
  221. // counter): an idle `agent.inject()` can append its own one-shot turn while
  222. // the loop waits above, so the next real turn must continue from whatever
  223. // turn number is actually last in the log — a stale counter would collide.
  224. const turn = lastTurnNumber(session) + 1
  225. try {
  226. await runTurn(ctx, agent, handle, turn)
  227. } catch (error: unknown) {
  228. // Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard
  229. // before turn/start) — no turn/start was appended, so no turn is open and
  230. // none is owed. A session `error` here would land outside any turn (after
  231. // the previous turn/end), where the persistence backend drops it as a
  232. // crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the
  233. // driver survives and moves on.
  234. const err = toError(error)
  235. ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
  236. try {
  237. ctx.emit('agent/error', agent, turn, 0, err)
  238. } catch { /* contained: a throwing agent/error listener must not kill the driver */ }
  239. }
  240. // Reset the cancel marker UNCONDITIONALLY here, after the turn returns and
  241. // before the next iteration's idle wait. NOT gated on the idle transition
  242. // below: a `send()` that lands during the cancelled turn's flush window makes
  243. // `hasQueued` true at the `setStatus('idle')` guard, so an idle-gated reset
  244. // would never fire and the stale marker would wrongly drop that next prompt's
  245. // turn. Resetting per iteration scopes the marker to exactly the turn that was
  246. // cancelled.
  247. handle.clearCancel()
  248. // Steering that arrived too late to join this turn (turn-end listeners,
  249. // flush) becomes a queued message — it must never be stranded. (A cancelled
  250. // turn already cleared its steering, so there is nothing to re-enqueue.)
  251. for (const message of agent.inbox.drainSteering()) {
  252. agent.inbox.enqueue(message)
  253. }
  254. if (!agent.inbox.hasQueued) handle.setStatus('idle')
  255. }
  256. }
  257. async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, turn: number): Promise<void> {
  258. const { session } = agent
  259. // --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
  260. // turn/start has not been appended — so it propagates to runLoop's backstop
  261. // untouched. The queued messages are drained here but appended AFTER
  262. // turn/start (below), so every event in the log lives inside a turn.
  263. const queued = agent.inbox.drainQueued()
  264. const first = queued[0]
  265. /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
  266. if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
  267. const trigger: TurnTrigger = { kind: 'message', source: first.source }
  268. let reason: TurnEndReason = { kind: 'completed' }
  269. let step = 0
  270. let stepOpen = false
  271. let errorReported = false
  272. // Close the open step exactly once (idempotent via stepOpen). Step boundaries
  273. // are durable session events only — there is no agent/* step emit to mirror
  274. // them (see the agent event-domain rule). A throwing step/end session-event
  275. // listener must not abort finalization and strand the turn open (turn/end
  276. // balance > notifying one bad listener); it is contained and surfaced as a
  277. // turn error below.
  278. const closeStep = (): boolean => {
  279. if (!stepOpen) return false
  280. stepOpen = false
  281. // Session.append pushes step/end BEFORE notifying session/event listeners,
  282. // so a throwing listener leaves step/end in the log (balance holds) but
  283. // would otherwise abort finalization. Contain it and surface it as a turn
  284. // error below.
  285. let failure: unknown
  286. try {
  287. session.append('step/end', { turn, step })
  288. } catch (error: unknown) {
  289. failure = error
  290. }
  291. // A throwing step/end session-event listener surfaces as a turn error via
  292. // failTurn (idempotent). This prevents a throwing listener from producing a
  293. // silent "completed" turn when the step itself succeeded, AND keeps
  294. // finalization going when closeStep runs from the outer catch.
  295. if (failure !== undefined) {
  296. failTurn(toError(failure))
  297. return true
  298. }
  299. return false
  300. }
  301. // Record a step/turn failure exactly once: set the error reason (carrying the
  302. // failing `step` — the durable failure lives entirely on turn/end.reason, there
  303. // is no separate session error event) and emit agent/error (contained — trap: a
  304. // throwing agent/error listener must not re-escape and strand the turn).
  305. // Disposal and abort set `reason` directly without calling this (they are not
  306. // failures).
  307. const failTurn = (err: CodedError): void => {
  308. if (errorReported) return
  309. errorReported = true
  310. // The turn is always still open here: the only failure that can reach
  311. // failTurn once turn/end is appended would be a throwing turn-boundary
  312. // listener, and turn boundaries are durable session events with no agent/*
  313. // mirror to throw. A throwing `turn/end` session-event listener is already
  314. // contained inside closeTurn (append pushes before notifying, so the
  315. // boundary is durable). So set the error reason for closeTurn to append.
  316. reason = { kind: 'error', step, ...errorData(err) }
  317. try {
  318. ctx.emit('agent/error', agent, turn, step, err)
  319. } catch {
  320. // contained: the error is already captured on `reason`; a throwing
  321. // agent/error listener must not prevent the turn from closing.
  322. }
  323. }
  324. // Close the turn. Called exactly once per turn — the normal loop exit and the
  325. // outer catch are mutually exclusive paths, and this never throws (the append
  326. // is contained below), so there is no re-entry to guard against (unlike
  327. // closeStep, which the cancel branches and the outer catch can both reach).
  328. // Turn boundaries are durable session events only — there is no agent/* turn
  329. // emit to mirror them (see the agent event-domain rule).
  330. const closeTurn = (): void => {
  331. // Session.append pushes turn/end BEFORE notifying session/event listeners,
  332. // so a throwing listener leaves turn/end in the log (the turn is balanced)
  333. // but would otherwise escape — from the outer catch it would propagate to
  334. // the runLoop backstop. Contain it: the boundary is durable either way, and
  335. // finalization must not abort on a bad listener.
  336. try {
  337. session.append('turn/end', { turn, reason })
  338. } catch (error: unknown) {
  339. ctx.logger.warn(`agent "${agent.id}": session/event listener threw on turn/end at turn ${turn}: ${toError(error).message}`)
  340. }
  341. }
  342. try {
  343. // --- Turn boundary. Once turn/start is appended, a turn/end is owed no
  344. // matter what throws below; the catch + closeTurn guarantee it (the catch
  345. // decides "owed" from the log via isTurnOpen, so even a throwing turn/start
  346. // listener — append pushes before notifying — still gets its turn/end).
  347. session.append('turn/start', { turn, trigger })
  348. // Each drained queued message runs the `agent/prompt-submit` waterfall before
  349. // it becomes a `user/message` — a hook can rewrite the prompt or block it.
  350. // Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
  351. // turn/end is now owed, so a throwing prompt-submit listener (the waterfall
  352. // throws) is caught below and the turn still closes.
  353. let anyAllowed = false
  354. // Seeded with a floor (only observable if the batch were empty, which
  355. // runTurn never allows — it is called with ≥1 queued message); each `block`
  356. // decision carries a required `reason` and overwrites it, so a fully-blocked
  357. // batch always reports the last vetoing reason.
  358. let lastBlockReason = 'prompt blocked by hook'
  359. for (const message of queued) {
  360. const decision = await ctx.waterfall(
  361. 'agent/prompt-submit', agent, message.content, message.source,
  362. () => Promise.resolve<PromptDecision>({ kind: 'allow' }),
  363. )
  364. if (decision.kind === 'block') {
  365. lastBlockReason = decision.reason
  366. // Record the veto durably: `PromptDecision.reason` is the durable record
  367. // of why a prompt was blocked, but a fully-blocked batch's `rejected`
  368. // turn/end only preserves the LAST reason, and a MIXED batch (this prompt
  369. // blocked, another allowed) does not end `rejected` at all — so without
  370. // this append a blocked prompt would vanish from the log whenever any
  371. // sibling prompt is allowed. `prompt/blocked` sits in the open turn in
  372. // place of the `user/message` this prompt would have become.
  373. session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
  374. continue
  375. }
  376. anyAllowed = true
  377. // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
  378. const content = decision.content ?? message.content
  379. session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
  380. // `allow.additionalContext` is a SEPARATE context/message the next request
  381. // also sees. The turn is open, so inject() appends it into THIS turn.
  382. if (decision.additionalContext) {
  383. agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source })
  384. }
  385. }
  386. while (true) {
  387. // A fully-blocked batch (every prompt vetoed by prompt-submit) opens a
  388. // zero-step turn that ends `rejected`: break BEFORE the first step so the
  389. // boundary stays balanced (turn/start → turn/end) and the block is a
  390. // durable in-turn fact. `anyAllowed` never changes inside the loop, so this
  391. // only ever fires on the first iteration.
  392. if (!anyAllowed) {
  393. reason = { kind: 'rejected', reason: lastBlockReason }
  394. break
  395. }
  396. step += 1
  397. // Steering from the previous round's continuation listeners joins before
  398. // the request.
  399. drainSteering(agent, turn)
  400. // The step's AbortController exists BEFORE any async pre-step work so a
  401. // dispose() or cancel() — in a synchronous turn-start listener or an
  402. // async listener whose effect fires before we block — always has an armed
  403. // abort to cancel against. isDisposed below covers disposal, which does
  404. // NOT set the cancel marker. Cleared on every exit path below.
  405. const abort = new AbortController()
  406. handle.setAbort(abort)
  407. // Assemble the system prompt for this step. Done HERE (before step/start)
  408. // because the pre-step seam needs it: compaction measures token pressure
  409. // against the system prompt (it counts toward the budget). runStep reuses
  410. // this same assembly for the request, so the prompt is assembled once per
  411. // step. renderPrompt IS the full prompt — the persona is the order-0
  412. // section (registered by the AgentLoop plugin) and `{{variable}}`
  413. // interpolation happens in the render, so there is no separate join.
  414. const assembly = await ctx.systemPrompt.assemble({ agent })
  415. const fullSystemPrompt = renderPrompt(assembly)
  416. // Interruption landing after assembly: dispose() or cancel() in a
  417. // turn-start listener (or a listener whose promise resolved before the
  418. // await above) arms either handle.isDisposed() or handle.isCancelled().
  419. // The Abort was created first, so any concurrent abort also lands on it.
  420. // Drop the about-to-start step WITHOUT running the seam — no step is open
  421. // yet, so end the turn accordingly (disposed wins for an unambiguous
  422. // reason).
  423. if (handle.isCancelled() || handle.isDisposed()) {
  424. handle.setAbort(undefined)
  425. reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
  426. break
  427. }
  428. // Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the
  429. // step: after `turn/start` (and the prior step's close) but before
  430. // `step/start`, so a compaction's log-only `compact/*` records and its
  431. // replacement node land cleanly outside any step (honest structure that
  432. // crash-safety relies on — a dangling `compact/start` sits before the
  433. // synthetic `turn/end` repair appends). Serial (awaited, in order, no
  434. // veto): each listener completes its surface mutation before the next, so
  435. // concurrent listeners cannot interleave their `session.append`s. A
  436. // throwing listener escapes to the outer catch, which closes the (not-yet-
  437. // open) step as a no-op and ends the turn via failTurn — a broken
  438. // pre-step plugin ends the turn, not the loop.
  439. await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal)
  440. // Interruption landing during the pre-step seam: do not open an empty step.
  441. if (handle.isCancelled() || handle.isDisposed()) {
  442. handle.setAbort(undefined)
  443. reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
  444. break
  445. }
  446. // Mark the step open BEFORE the append: Session.append pushes the event
  447. // to the log before notifying session/event listeners, so a THROWING
  448. // step/start listener leaves step/start in the log. Setting stepOpen first
  449. // means the outer catch's closeStep() then appends the balancing step/end
  450. // (turn stays enclosed) instead of stranding an open step under turn/end.
  451. stepOpen = true
  452. session.append('step/start', { turn, step })
  453. // Cancel landing in the step-start window: a synchronous `session/event`
  454. // step/start listener can cancel after the step is already open. Check
  455. // AFTER the step/start append and before `runStep`: drop the step, end the
  456. // turn accordingly. closeStep balances the already-appended step/start.
  457. if (handle.isCancelled() || handle.isDisposed()) {
  458. handle.setAbort(undefined)
  459. reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
  460. closeStep()
  461. break
  462. }
  463. let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
  464. try {
  465. stepOutcome = await runStep(ctx, agent, turn, step, assembly, fullSystemPrompt, abort.signal)
  466. } catch (error: unknown) {
  467. stepOutcome = { error: toError(error) }
  468. } finally {
  469. handle.setAbort(undefined)
  470. }
  471. if ('error' in stepOutcome) {
  472. // Steering that arrived during the failed step stays in the inbox —
  473. // runLoop re-enqueues it as a queued message, so an abort-then-steer
  474. // starts a fresh turn instead of being silently consumed.
  475. closeStep()
  476. const { error } = stepOutcome
  477. if (handle.isDisposed()) {
  478. reason = { kind: 'disposed' }
  479. } else if (abort.signal.aborted) {
  480. /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
  481. reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
  482. } else {
  483. failTurn(error)
  484. }
  485. break
  486. }
  487. // The successful step's finish reason carries forward: a `max-tokens`
  488. // step makes the whole turn end `max-tokens` (the ACP RFC's rule "any
  489. // max-tokens step surfaces as max-tokens"). `stepFinishReason` returns
  490. // `max-tokens` or `undefined`, so a later ordinary step never resets a
  491. // max-tokens turn back to completed, and a never-truncated turn keeps the
  492. // default `completed`. The disposal/abort/error branches above and the
  493. // continuation-window disposal check below override this — they win.
  494. const stepReason = stepFinishReason(stepOutcome.finish)
  495. if (stepReason) reason = stepReason
  496. // Steering that arrived during streaming/tool execution.
  497. const steered = drainSteering(agent, turn)
  498. if (closeStep()) break
  499. const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
  500. let decision: ContinuationDecision
  501. try {
  502. decision = await ctx.waterfall(
  503. 'agent/turn-continuation', agent, turn, defaultDecision,
  504. () => Promise.resolve(defaultDecision),
  505. )
  506. } catch (error: unknown) {
  507. // A broken continuation plugin ends the turn, not the loop.
  508. failTurn(toError(error))
  509. break
  510. }
  511. // A forced `continue` may carry model-facing context: record it as
  512. // next-STEP steering (the steering channel), so the continued turn's next
  513. // iteration drains it before its request — the typed twin of the /goal
  514. // step/end-steer pattern.
  515. if (decision.action === 'continue' && decision.reason) {
  516. agent.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
  517. }
  518. let shouldContinue = decision.action === 'continue'
  519. // Steering from step/end session-event or continuation listeners (the
  520. // /goal pattern) demands the model see it — it overrides a stop decision;
  521. // the next iteration's drain records it.
  522. if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
  523. // A cancel that landed during the continuation window — after the step's
  524. // AbortController was cleared (setAbort(undefined)) but before the next
  525. // step starts — has no controller to observe it, so the turn-scoped marker
  526. // ends the turn here. cancel() also cleared the steering FIFO, so the
  527. // override above did not re-arm continuation.
  528. if (handle.isCancelled()) {
  529. reason = { kind: 'aborted', reason: handle.cancelReason() }
  530. break
  531. }
  532. if (!shouldContinue || handle.isDisposed()) {
  533. /* v8 ignore next -- disposal during continuation-decision window is a narrow race; error-path disposal is covered elsewhere */
  534. if (handle.isDisposed()) reason = { kind: 'disposed' }
  535. break
  536. }
  537. }
  538. // Normal / inline-error loop exit: close the turn.
  539. closeTurn()
  540. } catch (error: unknown) {
  541. // Decide whether this turn was ever opened from the LOG, not a flag.
  542. // Session.append pushes the event BEFORE notifying session/event listeners,
  543. // so a throwing listener on the `turn/start` append leaves turn/start in the
  544. // log even though execution never reached the lines after that append.
  545. // Gating on a "turn started" boolean would skip turn/end and leave a
  546. // permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We
  547. // check the log for THIS turn's turn/start: present means a turn/end is owed
  548. // and the normal-exit `closeTurn()` did NOT run (we are here because a throw
  549. // preceded it — the two `closeTurn()` sites are on mutually exclusive paths),
  550. // so this catch appends turn/end with the disposed/error reason chosen below.
  551. // `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run
  552. // already in a step branch, so running it again is a safe no-op. Absent
  553. // turn/start means the append threw BEFORE its push (a non-serializable
  554. // trigger — impossible for our fixed trigger); nothing was opened, so rethrow
  555. // to the runLoop backstop.
  556. const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
  557. if (!turnStartLogged) throw error
  558. closeStep()
  559. // Choose the close reason. Disposal wins only if no error was already
  560. // reported: a turn disposed mid-step sets reason=disposed in the step-error
  561. // branch (without reporting an error), so preserve disposed rather than
  562. // overwrite it. Otherwise a mid-step throw on a live agent is a real
  563. // failure → failTurn. (errorReported is mutated only inside the failTurn
  564. // closure, which the analyzer can't follow, hence the inline lint-disable.)
  565. if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
  566. reason = { kind: 'disposed' }
  567. } else {
  568. failTurn(toError(error))
  569. }
  570. closeTurn()
  571. }
  572. // Durability checkpoint: persistence plugins drain write-behind buffers.
  573. // A failing persistence plugin is reported but doesn't kill the agent.
  574. try {
  575. await ctx.parallel('session/flush', session)
  576. } catch (error: unknown) {
  577. // The turn is already closed (turn/end appended above) and flush must run
  578. // AFTER turn/end to be a checkpoint — so there is no in-turn position left
  579. // for a session `error` event. Appending one here would land it after the
  580. // last turn/end, where the persistence backend treats it as a crash tail
  581. // and drops it on resume (the turn-enclosure RFC: every event is turn-enclosed). Report
  582. // the failure via agent/error + the logger only; persistence keeps the
  583. // buffered events for the next flush/dispose, so nothing is lost.
  584. const err = toError(error)
  585. ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
  586. try {
  587. ctx.emit('agent/error', agent, turn, step, err)
  588. } catch {
  589. // contained: a throwing agent/error listener must not escape the loop.
  590. }
  591. }
  592. }
  593. /** Drain the steering queue into the session. Returns whether any arrived. */
  594. function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
  595. const messages = agent.inbox.drainSteering()
  596. for (const message of messages) {
  597. agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
  598. }
  599. return messages.length > 0
  600. }
  601. /** One step: derive request from the (already pre-step-mutated) surface →
  602. * stream model → record → execute tools. The caller assembles the system prompt
  603. * and fires the `agent/pre-step` seam BEFORE opening the step, then passes the
  604. * resulting `assembly`/`system` here, so the surface this step derives from
  605. * already reflects any compaction. */
  606. async function runStep(
  607. ctx: Context,
  608. agent: ReactLoopAgent,
  609. turn: number,
  610. step: number,
  611. assembly: PromptAssembly,
  612. system: string,
  613. signal: AbortSignal,
  614. ): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
  615. const { session, options } = agent
  616. let request: GenerateOptions = {
  617. model: options.model ?? '',
  618. messages: session.deriveMessages(),
  619. ...system ? { system } : {},
  620. ...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
  621. sessionId: session.id,
  622. signal,
  623. }
  624. request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request))
  625. if (!request.model) {
  626. throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
  627. }
  628. // --- Model call (streaming-first; raw chunks are the replay record) ---
  629. const assembler = new BlockAssembler()
  630. const chunkSeqs: number[] = []
  631. for await (const chunk of ctx.llm.stream(request)) {
  632. /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
  633. if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
  634. const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
  635. chunkSeqs.push(chunkEvent.seq)
  636. assembler.push(chunk)
  637. }
  638. // Adapters report provider/transport failures one of two sanctioned ways
  639. // (see the StreamChunk contract in dsh-llm): throw from stream() — already
  640. // handled by the caller's try/catch — OR end the stream with a
  641. // finish-error/aborted chunk. finishError() maps the latter to the step
  642. // error to raise (turn ends error/aborted, not a normal completed message).
  643. const stepError = finishError(assembler.finish)
  644. if (stepError) throw stepError
  645. if (assembler.finish.kind === 'max-tokens') {
  646. let message: Message = withoutToolCalls(assembler.message())
  647. message = withoutToolCalls(await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message)))
  648. // Fire the assistant/message when there is content OR usage: a max-tokens
  649. // step can be cut off with empty content but still carry token accounting,
  650. // and assistant/message is the only host for usage (there is no standalone
  651. // usage event). An empty-content assistant/message is skipped by
  652. // deriveMessages(), so hosting usage on it never injects a spurious assistant
  653. // turn into derived history.
  654. if (message.content.length > 0 || assembler.usage) {
  655. // A max-tokens finish is itself a streamed `finish` chunk, so chunkSeqs is
  656. // never empty here — pass the provenance unconditionally.
  657. session.append(
  658. 'assistant/message',
  659. { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
  660. { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
  661. )
  662. }
  663. return { hadToolCalls: false, finish: assembler.finish }
  664. }
  665. // The step-result waterfall runs BEFORE the session append so the log (the
  666. // source of truth for derived history and replay) records the message that
  667. // tool dispatch actually uses.
  668. let message: Message = assembler.message()
  669. message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
  670. // Same content-or-usage guard as the max-tokens branch: a step that finishes
  671. // with neither assembled content nor usage (e.g. a bare `stop` finish that
  672. // streamed nothing) records no assistant/message — an empty-content message
  673. // exists only to host usage, and deriveMessages() skips it either way, so
  674. // appending one with no usage would be a pure trace-only row.
  675. //
  676. // sourceEventSeqs records the assistant/chunk provenance, but is omitted when
  677. // no chunks streamed (the surface invariant rejects an empty sourceEventSeqs).
  678. if (message.content.length > 0 || assembler.usage) {
  679. session.append(
  680. 'assistant/message',
  681. { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
  682. { surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) },
  683. )
  684. }
  685. // --- Tool execution (sequential; parallel execution is a TODO) ---
  686. // ToolRegistry.execute converts tool failures (including aborts) into
  687. // isError results, so abort is re-checked around every call here.
  688. const toolCalls = message.content.filter(block => block.type === 'tool-call')
  689. // Per-step buffer of `additionalContext` attached by tools/post-execute
  690. // listeners. Appended as context/message(s) only AFTER every tool/result for
  691. // the step, so a multi-call step keeps tool-call/result adjacency
  692. // (interleaving context between a call's result and the next call's would
  693. // break the pairing the next model request relies on).
  694. const pendingContext: HookContext[] = []
  695. for (const call of toolCalls) {
  696. /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
  697. if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
  698. const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
  699. let parsedArguments: unknown
  700. try {
  701. parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
  702. } catch {
  703. parsedArguments = call.arguments
  704. }
  705. // TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite
  706. // `arguments` — tool/call (the audit record) and assistant/message (the
  707. // model-history source) are logged BEFORE execute, and live consumers (ACP,
  708. // tool-bash presentation) read the pre-execution args, so an execution-only
  709. // rewrite would desync the UI from what ran. Designing that consistently is
  710. // its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md).
  711. const result = await ctx.tools.execute({
  712. callId: call.id,
  713. name: call.name,
  714. arguments: parsedArguments,
  715. agent,
  716. signal,
  717. })
  718. session.append('tool/result', {
  719. turn, step,
  720. // The correlation id MUST be the loop's authoritative call.id (the
  721. // model-transcript id that deriveMessages turns into toolCallId), NOT
  722. // result.callId — a post-execute waterfall listener returning a
  723. // mismatched id would otherwise orphan the call↔result pairing in the
  724. // next model request. A listener-internal id, if ever needed, belongs in
  725. // a separate diagnostic field, never overloaded onto callId.
  726. callId: call.id,
  727. content: result.content,
  728. isError: result.isError,
  729. ...result.error ? { error: result.error } : {},
  730. // The tool's private presentation payload (e.g. a result-time diff),
  731. // persisted so a UI bridge reproduces the card on replay.
  732. ...result.meta !== undefined ? { meta: result.meta } : {},
  733. }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
  734. // Buffer (don't append yet) any post-execute additionalContext for this call.
  735. if (result.additionalContext) pendingContext.push(result.additionalContext)
  736. // signal CAN flip during the await above (abort() inside a tool);
  737. // the analyzer can't see through the await boundary.
  738. /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
  739. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  740. if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
  741. /* v8 ignore stop */
  742. }
  743. // Append buffered post-execute context AFTER every tool/result, preserving
  744. // tool-call/result adjacency across the whole batch. inject() appends into the
  745. // open turn (a context/message at its chronological position).
  746. for (const context of pendingContext) {
  747. agent.inject(context.content, { source: context.source })
  748. }
  749. return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
  750. }
  751. function withoutToolCalls(message: Message): Message {
  752. return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
  753. }
  754. /** The last turn number in a (possibly seeded) session log, or 0. */
  755. export function lastTurnNumber(session: Session): number {
  756. const lastStart = session.events.findLast(event => event.type === 'turn/start')
  757. return lastStart?.data.turn ?? 0
  758. }
  759. /**
  760. * Whether a turn is currently open in the session log (a `turn/start` with no
  761. * matching later `turn/end`). Decided from the LOG, not agent status: status
  762. * can be `running` while no turn is open (an `agent/status` listener firing
  763. * before `turn/start`, or the post-`turn/end` flush window before status
  764. * returns to idle), so status is not a reliable open-turn signal. Used by
  765. * `inject()` to choose between appending into an open turn vs. wrapping the
  766. * injection in its own one-shot turn (the turn-enclosure RFC).
  767. */
  768. export function isTurnOpen(session: Session): boolean {
  769. const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')
  770. return last?.type === 'turn/start'
  771. }