loop.ts 42 KB

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