loop.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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 { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
  11. import { BlockAssembler } from '@deepseek-ai/dsh-llm'
  12. import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
  13. import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  14. import type {} from '@deepseek-ai/dsh-tools'
  15. import type { LoopAgent } from './agent.ts'
  16. /** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
  17. type CodedError = Error & { code?: string }
  18. /** Normalize an arbitrary thrown value into a (possibly coded) Error. */
  19. function toError(error: unknown): CodedError {
  20. return error instanceof Error ? error : new Error(String(error))
  21. }
  22. /**
  23. * Build the `{ message, code? }` part of an error payload, omitting the
  24. * `code` key entirely when absent (exactOptionalPropertyTypes-correct).
  25. */
  26. function errorData(err: CodedError): { message: string; code?: string } {
  27. return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
  28. }
  29. /**
  30. * Ambient handles the loop driver receives from the agent. Decouples the
  31. * pure function `runLoop` from the mutable LoopAgent fields, making the
  32. * loop testable without a real agent.
  33. */
  34. export interface LoopHandle {
  35. setStatus(status: 'idle' | 'running'): void
  36. setAbort(controller: AbortController | undefined): void
  37. /** Resolves when the agent is disposed — unblocks the idle wait. */
  38. disposed: Promise<void>
  39. isDisposed(): boolean
  40. }
  41. /**
  42. * The agent loop. One invocation drives one agent for its whole lifetime:
  43. *
  44. * ```
  45. * forever:
  46. * wait for queued messages (idle)
  47. * TURN (error-contained — a throwing plugin ends the turn, never the loop):
  48. * drain queued → session('user/message'…) → 'turn/start' → emit agent/turn-start
  49. * STEP loop:
  50. * drain steering → session('steering/message') ⟵ catches late steering
  51. * emit agent/step-start
  52. * assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
  53. * req = {model, system, tools, messages: session.deriveMessages(), signal}
  54. * req = waterfall agent/request ⟵ hooks/compaction/model-switch
  55. * stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks)
  56. * session('assistant/chunk'); emit agent/stream-chunk
  57. * msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
  58. * session('assistant/message','usage') session records what actually ran
  59. * each tool-call in msg (sequential, abort-checked):
  60. * session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/execute
  61. * session('tool/result')
  62. * drain steering → session('steering/message'); emit agent/steering
  63. * emit agent/step-end
  64. * cont = waterfall agent/turn-continuation(default = hadToolCalls || steered)
  65. * if !cont && steering arrived from step-end/continuation listeners: cont = true
  66. * if !cont: break
  67. * session('turn/end'); emit agent/turn-end
  68. * await ctx.parallel('session/flush', session) ⟵ durability checkpoint
  69. * re-enqueue leftover steering as queued ⟵ steering is never stranded
  70. * idle (emit agent/status) unless more queued
  71. * ```
  72. */
  73. export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle): Promise<void> {
  74. const { session } = agent
  75. let turn = lastTurnNumber(session) // seeded/forked sessions continue numbering
  76. while (!handle.isDisposed()) {
  77. await agent.inbox.waitForQueued(handle.disposed)
  78. if (handle.isDisposed()) break
  79. handle.setStatus('running')
  80. turn += 1
  81. try {
  82. await runTurn(ctx, agent, handle, turn)
  83. } catch (error: unknown) {
  84. // Backstop: a throwing emit listener (turn boundaries) or a broken
  85. // finalizer must not kill the driver. Record what we can and move on.
  86. try {
  87. const err = toError(error)
  88. session.append('error', { turn, step: 0, ...errorData(err) })
  89. ctx.emit('agent/error', agent, turn, 0, err)
  90. } catch { /* the error path itself is broken; nothing left to do */ }
  91. }
  92. // Steering that arrived too late to join this turn (turn-end listeners,
  93. // flush) becomes a queued message — it must never be stranded.
  94. for (const message of agent.inbox.drainSteering()) {
  95. agent.inbox.enqueue(message)
  96. }
  97. if (!agent.inbox.hasQueued) handle.setStatus('idle')
  98. }
  99. }
  100. async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn: number): Promise<void> {
  101. const { session } = agent
  102. // Drain queued messages into the session — they trigger this turn.
  103. const queued = agent.inbox.drainQueued()
  104. const first = queued[0]
  105. /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
  106. if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
  107. const trigger: TurnTrigger = { kind: 'message', source: first.source }
  108. for (const message of queued) {
  109. session.append('user/message', { content: message.content, source: message.source })
  110. }
  111. session.append('turn/start', { turn, trigger })
  112. ctx.emit('agent/turn-start', agent, turn)
  113. let reason: TurnEndReason = { kind: 'completed' }
  114. let step = 0
  115. while (true) {
  116. step += 1
  117. // Steering from the previous round's step-end/continuation listeners
  118. // (or turn-start listeners on the first step) joins before the request.
  119. drainSteering(ctx, agent, turn)
  120. ctx.emit('agent/step-start', agent, turn, step)
  121. session.append('step/start', { turn, step })
  122. const abort = new AbortController()
  123. handle.setAbort(abort)
  124. let stepOutcome: { hadToolCalls: boolean } | { error: Error }
  125. try {
  126. stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
  127. } catch (error: unknown) {
  128. stepOutcome = { error: error instanceof Error ? error : new Error(String(error)) }
  129. } finally {
  130. handle.setAbort(undefined)
  131. }
  132. if ('error' in stepOutcome) {
  133. // Steering that arrived during the failed step stays in the inbox —
  134. // runLoop re-enqueues it as a queued message, so an abort-then-steer
  135. // starts a fresh turn instead of being silently consumed.
  136. session.append('step/end', { turn, step })
  137. ctx.emit('agent/step-end', agent, turn, step)
  138. const { error } = stepOutcome
  139. if (handle.isDisposed()) {
  140. reason = { kind: 'disposed' }
  141. } else if (abort.signal.aborted) {
  142. /* v8 ignore next -- abort.signal.reason always set by agent.abort() which provides a default */
  143. reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
  144. } else {
  145. const coded = error as CodedError
  146. session.append('error', { turn, step, ...errorData(coded) })
  147. ctx.emit('agent/error', agent, turn, step, error)
  148. reason = { kind: 'error', ...errorData(coded) }
  149. }
  150. break
  151. }
  152. // Steering that arrived during streaming/tool execution.
  153. const steered = drainSteering(ctx, agent, turn)
  154. session.append('step/end', { turn, step })
  155. ctx.emit('agent/step-end', agent, turn, step)
  156. const defaultDecision = stepOutcome.hadToolCalls || steered
  157. let shouldContinue: boolean
  158. try {
  159. shouldContinue = await ctx.waterfall(
  160. 'agent/turn-continuation', agent, turn, defaultDecision,
  161. () => Promise.resolve(defaultDecision),
  162. )
  163. } catch (error: unknown) {
  164. // A broken continuation plugin ends the turn, not the loop.
  165. const err = toError(error)
  166. session.append('error', { turn, step, ...errorData(err) })
  167. ctx.emit('agent/error', agent, turn, step, err)
  168. reason = { kind: 'error', ...errorData(err) }
  169. break
  170. }
  171. // Steering from step-end/continuation listeners (the /goal pattern)
  172. // demands the model see it — it overrides a negative decision; the
  173. // next iteration's drain records it.
  174. if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
  175. if (!shouldContinue || handle.isDisposed()) {
  176. /* v8 ignore next -- disposal during continuation-decision window is a narrow race; error-path disposal is covered elsewhere */
  177. if (handle.isDisposed()) reason = { kind: 'disposed' }
  178. break
  179. }
  180. }
  181. session.append('turn/end', { turn, reason })
  182. ctx.emit('agent/turn-end', agent, turn, reason)
  183. // Durability checkpoint: persistence plugins drain write-behind buffers.
  184. // A failing persistence plugin is reported but doesn't kill the agent.
  185. try {
  186. await ctx.parallel('session/flush', session)
  187. } catch (error: unknown) {
  188. const err = toError(error)
  189. session.append('error', { turn, step, ...errorData(err) })
  190. ctx.emit('agent/error', agent, turn, step, err)
  191. }
  192. }
  193. /** Drain the steering queue into the session. Returns whether any arrived. */
  194. function drainSteering(ctx: Context, agent: LoopAgent, turn: number): boolean {
  195. const messages = agent.inbox.drainSteering()
  196. for (const message of messages) {
  197. agent.session.append('steering/message', { turn, content: message.content, source: message.source })
  198. ctx.emit('agent/steering', agent, turn, message.content, message.source)
  199. }
  200. return messages.length > 0
  201. }
  202. /** One step: assemble request → stream model → record → execute tools. */
  203. async function runStep(
  204. ctx: Context,
  205. agent: LoopAgent,
  206. turn: number,
  207. step: number,
  208. signal: AbortSignal,
  209. ): Promise<{ hadToolCalls: boolean }> {
  210. const { session, options } = agent
  211. // --- Request assembly ---
  212. const assembly = await ctx.systemPrompt.assemble()
  213. const system = [renderPrompt(assembly), options.systemPrompt ?? '']
  214. .filter(text => text.length > 0)
  215. .join('\n\n')
  216. let request: GenerateOptions = {
  217. model: options.model ?? '',
  218. messages: session.deriveMessages(),
  219. ...system ? { system } : {},
  220. ...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
  221. signal,
  222. }
  223. request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request))
  224. if (!request.model) {
  225. throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
  226. }
  227. // --- Model call (streaming-first; raw chunks are the replay record) ---
  228. const assembler = new BlockAssembler()
  229. for await (const chunk of ctx.llm.stream(request)) {
  230. /* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */
  231. if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
  232. session.append('assistant/chunk', { turn, step, chunk })
  233. ctx.emit('agent/stream-chunk', agent, turn, step, chunk)
  234. assembler.push(chunk)
  235. }
  236. // The step-result waterfall runs BEFORE the session append so the log (the
  237. // source of truth for derived history and replay) records the message that
  238. // tool dispatch actually uses.
  239. let message: Message = assembler.message()
  240. message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
  241. session.append('assistant/message', { turn, step, content: message.content })
  242. if (assembler.usage) {
  243. session.append('usage', { turn, step, usage: assembler.usage })
  244. }
  245. // --- Tool execution (sequential; parallel execution is a TODO) ---
  246. // ToolRegistry.execute converts tool failures (including aborts) into
  247. // isError results, so abort is re-checked around every call here.
  248. const toolCalls = message.content.filter(block => block.type === 'tool-call')
  249. for (const call of toolCalls) {
  250. /* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */
  251. if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
  252. session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
  253. let parsedArguments: unknown
  254. try {
  255. parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
  256. } catch {
  257. parsedArguments = call.arguments
  258. }
  259. const result = await ctx.tools.execute({
  260. callId: call.id,
  261. name: call.name,
  262. arguments: parsedArguments,
  263. agent,
  264. signal,
  265. })
  266. session.append('tool/result', {
  267. turn, step,
  268. callId: result.callId,
  269. content: result.content,
  270. isError: result.isError,
  271. })
  272. // signal CAN flip during the await above (abort() inside a tool);
  273. // the analyzer can't see through the await boundary.
  274. // signal can flip during the await above (abort() inside a tool);
  275. // the analyzer can't see through the await boundary.
  276. /* v8 ignore start -- signal.reason default unreachable via agent.abort() */
  277. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  278. if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
  279. /* v8 ignore stop */
  280. }
  281. return { hadToolCalls: toolCalls.length > 0 }
  282. }
  283. /** The last turn number in a (possibly seeded) session log, or 0. */
  284. function lastTurnNumber(session: Session): number {
  285. const lastStart = session.events.findLast(event => event.type === 'turn/start')
  286. return lastStart?.data.turn ?? 0
  287. }