loop.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  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 trigger: TurnTrigger = { kind: 'message', source: queued[0]!.source }
  105. for (const message of queued) {
  106. session.append('user/message', { content: message.content, source: message.source })
  107. }
  108. session.append('turn/start', { turn, trigger })
  109. ctx.emit('agent/turn-start', agent, turn)
  110. let reason: TurnEndReason = { kind: 'completed' }
  111. let step = 0
  112. while (true) {
  113. step += 1
  114. // Steering from the previous round's step-end/continuation listeners
  115. // (or turn-start listeners on the first step) joins before the request.
  116. drainSteering(ctx, agent, turn)
  117. ctx.emit('agent/step-start', agent, turn, step)
  118. session.append('step/start', { turn, step })
  119. const abort = new AbortController()
  120. handle.setAbort(abort)
  121. let stepOutcome: { hadToolCalls: boolean } | { error: Error }
  122. try {
  123. stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
  124. } catch (error: unknown) {
  125. stepOutcome = { error: error instanceof Error ? error : new Error(String(error)) }
  126. } finally {
  127. handle.setAbort(undefined)
  128. }
  129. if ('error' in stepOutcome) {
  130. // Steering that arrived during the failed step stays in the inbox —
  131. // runLoop re-enqueues it as a queued message, so an abort-then-steer
  132. // starts a fresh turn instead of being silently consumed.
  133. session.append('step/end', { turn, step })
  134. ctx.emit('agent/step-end', agent, turn, step)
  135. const { error } = stepOutcome
  136. if (handle.isDisposed()) {
  137. reason = { kind: 'disposed' }
  138. } else if (abort.signal.aborted) {
  139. reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
  140. } else {
  141. const coded = error as CodedError
  142. session.append('error', { turn, step, ...errorData(coded) })
  143. ctx.emit('agent/error', agent, turn, step, error)
  144. reason = { kind: 'error', ...errorData(coded) }
  145. }
  146. break
  147. }
  148. // Steering that arrived during streaming/tool execution.
  149. const steered = drainSteering(ctx, agent, turn)
  150. session.append('step/end', { turn, step })
  151. ctx.emit('agent/step-end', agent, turn, step)
  152. const defaultDecision = stepOutcome.hadToolCalls || steered
  153. let shouldContinue: boolean
  154. try {
  155. shouldContinue = await ctx.waterfall(
  156. 'agent/turn-continuation', agent, turn, defaultDecision,
  157. async () => defaultDecision,
  158. )
  159. } catch (error: unknown) {
  160. // A broken continuation plugin ends the turn, not the loop.
  161. const err = toError(error)
  162. session.append('error', { turn, step, ...errorData(err) })
  163. ctx.emit('agent/error', agent, turn, step, err)
  164. reason = { kind: 'error', ...errorData(err) }
  165. break
  166. }
  167. // Steering from step-end/continuation listeners (the /goal pattern)
  168. // demands the model see it — it overrides a negative decision; the
  169. // next iteration's drain records it.
  170. if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
  171. if (!shouldContinue || handle.isDisposed()) {
  172. if (handle.isDisposed()) reason = { kind: 'disposed' }
  173. break
  174. }
  175. }
  176. session.append('turn/end', { turn, reason })
  177. ctx.emit('agent/turn-end', agent, turn, reason)
  178. // Durability checkpoint: persistence plugins drain write-behind buffers.
  179. // A failing persistence plugin is reported but doesn't kill the agent.
  180. try {
  181. await ctx.parallel('session/flush', session)
  182. } catch (error: unknown) {
  183. const err = toError(error)
  184. session.append('error', { turn, step, ...errorData(err) })
  185. ctx.emit('agent/error', agent, turn, step, err)
  186. }
  187. }
  188. /** Drain the steering queue into the session. Returns whether any arrived. */
  189. function drainSteering(ctx: Context, agent: LoopAgent, turn: number): boolean {
  190. const messages = agent.inbox.drainSteering()
  191. for (const message of messages) {
  192. agent.session.append('steering/message', { turn, content: message.content, source: message.source })
  193. ctx.emit('agent/steering', agent, turn, message.content, message.source)
  194. }
  195. return messages.length > 0
  196. }
  197. /** One step: assemble request → stream model → record → execute tools. */
  198. async function runStep(
  199. ctx: Context,
  200. agent: LoopAgent,
  201. turn: number,
  202. step: number,
  203. signal: AbortSignal,
  204. ): Promise<{ hadToolCalls: boolean }> {
  205. const { session, options } = agent
  206. // --- Request assembly ---
  207. const assembly = await ctx.systemPrompt.assemble()
  208. const system = [renderPrompt(assembly), options.systemPrompt ?? '']
  209. .filter(text => text.length > 0)
  210. .join('\n\n')
  211. let request: GenerateOptions = {
  212. model: options.model ?? '',
  213. messages: session.deriveMessages(),
  214. ...system ? { system } : {},
  215. ...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
  216. signal,
  217. }
  218. request = await ctx.waterfall('agent/request', agent, turn, step, request, async () => request)
  219. if (!request.model) {
  220. throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
  221. }
  222. // --- Model call (streaming-first; raw chunks are the replay record) ---
  223. const assembler = new BlockAssembler()
  224. for await (const chunk of ctx.llm.stream(request)) {
  225. if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
  226. session.append('assistant/chunk', { turn, step, chunk })
  227. ctx.emit('agent/stream-chunk', agent, turn, step, chunk)
  228. assembler.push(chunk)
  229. }
  230. // The step-result waterfall runs BEFORE the session append so the log (the
  231. // source of truth for derived history and replay) records the message that
  232. // tool dispatch actually uses.
  233. let message: Message = assembler.message()
  234. message = await ctx.waterfall('agent/step-result', agent, turn, step, message, async () => message)
  235. session.append('assistant/message', { turn, step, content: message.content })
  236. if (assembler.usage) {
  237. session.append('usage', { turn, step, usage: assembler.usage })
  238. }
  239. // --- Tool execution (sequential; parallel execution is a TODO) ---
  240. // ToolRegistry.execute converts tool failures (including aborts) into
  241. // isError results, so abort is re-checked around every call here.
  242. const toolCalls = message.content.filter(block => block.type === 'tool-call')
  243. for (const call of toolCalls) {
  244. if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
  245. session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
  246. let parsedArguments: unknown
  247. try {
  248. parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
  249. } catch {
  250. parsedArguments = call.arguments
  251. }
  252. const result = await ctx.tools.execute({
  253. callId: call.id,
  254. name: call.name,
  255. arguments: parsedArguments,
  256. agent,
  257. signal,
  258. })
  259. session.append('tool/result', {
  260. turn, step,
  261. callId: result.callId,
  262. content: result.content,
  263. isError: result.isError,
  264. })
  265. if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
  266. }
  267. return { hadToolCalls: toolCalls.length > 0 }
  268. }
  269. /** The last turn number in a (possibly seeded) session log, or 0. */
  270. function lastTurnNumber(session: Session): number {
  271. for (let index = session.events.length - 1; index >= 0; index--) {
  272. const event = session.events[index]!
  273. if (event.type === 'turn/start') return event.data.turn
  274. }
  275. return 0
  276. }