agent.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. /**
  2. * The concrete Agent implementation: ReactLoopAgent plus its inbox. Everything
  3. * observable happens through session events and the agent/* event taxonomy —
  4. * plugins never need this class.
  5. *
  6. * @module dsh-agent-loop/agent
  7. */
  8. import type { Context } from 'cordis'
  9. import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
  10. import type { Agent } from '@deepseek-ai/dsh-agent'
  11. import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
  12. import type { Session } from '@deepseek-ai/dsh-session'
  13. import { Inbox } from './inbox.ts'
  14. import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
  15. /**
  16. * The concrete {@link Agent} implementation owned by the agent-loop plugin.
  17. *
  18. * Owns the inbox (queued + steering FIFOs), the per-step AbortController, and
  19. * the loop driver. Everything observable happens through session events and
  20. * the agent/* event taxonomy — plugins never need this class.
  21. */
  22. export class ReactLoopAgent implements Agent {
  23. readonly inbox = new Inbox()
  24. private _status: AgentStatus = 'idle'
  25. private currentAbort: AbortController | undefined
  26. /**
  27. * Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the
  28. * driver loop (via the LoopHandle) at every point a turn could start or
  29. * continue. Armed ONLY when there is something to cancel (a running turn, an
  30. * in-flight step, or queued/steering work), so an idle no-op cancel cannot
  31. * leave it set to wrongly drop a later prompt.
  32. */
  33. private cancelRequested = false
  34. /**
  35. * The resolved reason for the pending {@link cancel} (`reason ?? 'cancelled'`),
  36. * read by the driver loop's marker branches so a turn dropped in a
  37. * marker-only window (pre-step / continuation, where no `AbortController`
  38. * carries the reason) ends with the SAME `{kind:'aborted', reason}` the
  39. * mid-step abort path produces from `abort.signal.reason`. Without this the
  40. * caller's `cancel(reason)` would be silently replaced by the literal
  41. * 'cancelled' whenever the cancel landed outside a running step — making the
  42. * logged reason race-dependent and the public `reason?` param half-effective.
  43. */
  44. private cancelReason = 'cancelled'
  45. private disposed: Promise<void>
  46. private resolveDisposed!: () => void
  47. /** Resolves when the driver loop has fully exited (tests/disposal). */
  48. done: Promise<void> = Promise.resolve()
  49. /**
  50. * Pending {@link whenIdle} waiters, resolved by {@link settleIdleWaiters} when
  51. * the agent next settles out of `running`. Kept as internal agent state (NOT
  52. * an effect-scoped `ctx.on` listener) so a concurrent fiber disposal — which
  53. * runs the agent's own listeners' disposers — cannot drop the waiter before
  54. * the `disposed` transition fires and leave the promise hanging.
  55. */
  56. private idleWaiters: (() => void)[] = []
  57. constructor(
  58. private ctx: Context,
  59. public readonly id: AgentId,
  60. public readonly options: AgentOptions,
  61. public readonly session: Session,
  62. ) {
  63. const { promise, resolve } = Promise.withResolvers<void>()
  64. this.disposed = promise
  65. this.resolveDisposed = resolve
  66. }
  67. get status(): AgentStatus {
  68. return this._status
  69. }
  70. private setStatus(status: AgentStatus): void {
  71. if (this._status === status || this._status === 'disposed') return
  72. this._status = status
  73. // Release quiescence waiters on a transition OUT of running BEFORE emitting
  74. // (the disposer handles the disposed transition separately). Settling first
  75. // means a throwing `agent/status` subscriber cannot starve a `whenIdle()`
  76. // waiter (AGENTS.md "contain callback exceptions" — a lifecycle await must
  77. // not hang on one bad listener).
  78. if (status !== 'running') this.settleIdleWaiters()
  79. try {
  80. this.ctx.emit('agent/status', this, status)
  81. } catch (error: unknown) {
  82. this.ctx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`)
  83. }
  84. }
  85. /**
  86. * Resolve and clear all pending {@link whenIdle} waiters. Called on a
  87. * running→idle transition (from {@link setStatus}) and on disposal (from the
  88. * {@link start} disposer, which chains `done` for true loop-exit quiescence).
  89. */
  90. private settleIdleWaiters(): void {
  91. const waiters = this.idleWaiters
  92. this.idleWaiters = []
  93. for (const resolve of waiters) resolve()
  94. }
  95. private resolveSource(options?: SendOptions): MessageSource {
  96. return options?.source ?? { kind: 'user' }
  97. }
  98. send(content: ContentBlock[], options?: SendOptions): void {
  99. if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
  100. const source = this.resolveSource(options)
  101. this.inbox.enqueue({ content, source })
  102. this.ctx.emit('agent/queued', this, content, { source, steering: false })
  103. }
  104. steer(content: ContentBlock[], options?: SendOptions): void {
  105. if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
  106. if (this._status !== 'running') { this.send(content, options); return }
  107. const source = this.resolveSource(options)
  108. this.inbox.steer({ content, source })
  109. this.ctx.emit('agent/queued', this, content, { source, steering: true })
  110. }
  111. inject(content: ContentBlock[], options?: SendOptions): void {
  112. if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
  113. const source = this.resolveSource(options)
  114. if (isTurnOpen(this.session)) {
  115. // A turn is open in the LOG (decided from the log, not agent status —
  116. // status can be `running` with no turn open): the context/message is
  117. // turn-enclosed by that turn, so append it directly.
  118. this.session.append('context/message', { content, source })
  119. return
  120. }
  121. // No turn open: wrap the injection in a one-shot turn so every event stays
  122. // turn-enclosed (the durability/replay boundary is the turn).
  123. const turn = lastTurnNumber(this.session) + 1
  124. // Once turn/start enters the log, a turn/end is OWED no matter what — even
  125. // if a throwing `session/event` listener escapes from the turn/start append
  126. // (Session.append pushes the event BEFORE notifying listeners) or the
  127. // context/message append throws (non-serializable content, throwing
  128. // listener). The finally re-checks the log via isTurnOpen() and closes the
  129. // turn if one was actually opened, so the log never carries a permanently
  130. // open injection turn that would corrupt later turns/replay. (If the
  131. // turn/start append throws BEFORE pushing — non-serializable trigger, which
  132. // can't happen for our fixed trigger — no turn was opened and none is owed.)
  133. try {
  134. this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
  135. this.session.append('context/message', { content, source })
  136. } finally {
  137. // Close the turn if turn/start made it into the log. Contain a throwing
  138. // turn/end listener: Session.append pushes before notifying, so a throw
  139. // here still leaves turn/end in the log (the turn is balanced) — swallow
  140. // it so it neither replaces the original exception nor skips the flush
  141. // decision below. (It surfaces through the flush path is not needed; the
  142. // turn-balance contract is what matters and it holds.)
  143. if (isTurnOpen(this.session)) {
  144. try {
  145. this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
  146. } catch {
  147. // turn/end is already in the log (pushed before the listener threw),
  148. // so the turn is balanced; the throw is the listener's bug.
  149. }
  150. }
  151. // Decide the durability checkpoint from the LOG, not a flag: a turn was
  152. // recorded iff this turn's turn/start is logged (it may have been closed
  153. // by a throwing-listener turn/end above, which still counts). A
  154. // `turnRecorded` boolean set after append('turn/end') would be skipped by
  155. // a throwing turn/end listener, losing the flush for a balanced in-memory
  156. // turn (crash before the next turn/dispose would drop the idle injection).
  157. const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
  158. // Checkpoint the one-shot turn for durability, exactly as the loop does at
  159. // every turn/end. The loop is NOT running (we are idle), so nothing else
  160. // will flush this turn. Fire-and-forget with error containment: inject()
  161. // is synchronous, and a persistence backend failing must not throw into
  162. // the caller (e.g. a tool-bash task-done callback). Disposal still drains
  163. // independently, so a slow flush is safe. A flush failure is reported via
  164. // agent/error (step 0 — the idle-injection convention, there is no real
  165. // step) AND the logger, mirroring the loop's post-turn/end flush path so
  166. // plugins monitoring agent/error see idle-injection persistence failures
  167. // too. A throwing agent/error listener is contained.
  168. if (turnRecorded) {
  169. void Promise.resolve(this.ctx.parallel('session/flush', this.session)).catch((error: unknown) => {
  170. const err = error instanceof Error ? error : new Error(String(error))
  171. this.ctx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`)
  172. try {
  173. this.ctx.emit('agent/error', this, turn, 0, err)
  174. } catch {
  175. // contained: the failure is already logged; a throwing agent/error
  176. // listener must not escape this fire-and-forget catch.
  177. }
  178. })
  179. }
  180. }
  181. }
  182. cancel(reason?: string): void {
  183. // Arm-gate: only mark a cancellation when there is actually work to cancel —
  184. // a running turn, an in-flight step, or queued/steering work. An idle cancel
  185. // with nothing pending is a true no-op; arming the marker then would wrongly
  186. // drop the NEXT legitimate prompt (the marker is consumed only at the loop's
  187. // turn-decision points, which an idle parked loop does not reach until woken
  188. // by a real send()). Note the gate canNOT be `status === 'running'` alone:
  189. // the pre-step window (a send() queued but the loop not yet flipped to
  190. // running) has status `idle` with `hasQueued` true, and the marker exists
  191. // precisely to cover it.
  192. if (this._status === 'running' || this.currentAbort !== undefined || this.inbox.hasQueued || this.inbox.hasSteering) {
  193. this.cancelRequested = true
  194. // Capture the resolved reason for the marker-only windows (pre-step /
  195. // continuation). The mid-step path reads it from abort.signal.reason
  196. // below; the marker path reads it via the LoopHandle's cancelReason().
  197. this.cancelReason = reason ?? 'cancelled'
  198. }
  199. // Drop all pending queued + steering work (un-started prompts never run; the
  200. // cancelled turn's steering is not re-enqueued). Cleared directly even when
  201. // the loop is parked in waitForQueued — there is no turn to stop and nothing
  202. // left for the parked loop to run, so no wake is needed.
  203. this.inbox.clear()
  204. // Interrupt an in-flight step immediately (the running turn observes the
  205. // abort and ends `aborted`). The marker covers the windows where no step is
  206. // running (pre-step, continuation).
  207. this.currentAbort?.abort(reason ?? 'cancelled')
  208. }
  209. /**
  210. * Resolve once the agent has reached quiescence after settling out of
  211. * `running`. If it is already disposed, awaits {@link done} (the loop-exit
  212. * promise) — `agent/status('disposed')` fires in the disposer BEFORE the
  213. * driver loop has unwound, so it is NOT itself a quiescence signal. If it is
  214. * idle AND has no queued work, resolves immediately. Otherwise queues an
  215. * internal waiter (see {@link idleWaiters}) released on the next
  216. * running→idle/disposed transition, resolving on `idle` directly (the turn
  217. * fully ended) or chaining {@link done} on `disposed` (wait for the loop to
  218. * actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner
  219. * quiescence-observation hook, distinct from teardown (a lifecycle owner stops
  220. * and unregisters via `AgentHandle.dispose()`, which awaits {@link done}
  221. * directly, not through this).
  222. */
  223. whenIdle(): Promise<void> {
  224. if (this._status === 'disposed') return this.done
  225. if (this._status !== 'running' && !this.inbox.hasQueued) return Promise.resolve()
  226. // Register an internal waiter (resolved by settleIdleWaiters on the next
  227. // running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
  228. // a concurrent fiber disposal runs this agent's listener disposers, which
  229. // could remove a `ctx.on` waiter before the `disposed` transition fires and
  230. // hang the promise. On disposal the disposer settles the waiter AND we chain
  231. // `done` here for true loop-exit quiescence (status flips to disposed before
  232. // the loop unwinds); a plain idle transition resolves directly.
  233. return new Promise<void>((resolve) => {
  234. this.idleWaiters.push(() => {
  235. resolve(this._status === 'disposed' ? this.done : undefined)
  236. })
  237. })
  238. }
  239. /**
  240. * Start the driver loop. Returns a disposer: calling it sets status to
  241. * `disposed`, emits `agent/status('disposed')`, resolves the disposed
  242. * promise (unblocking the idle wait), releases any `whenIdle` waiters, and
  243. * aborts the current request if any. The returned `agent.done` promise
  244. * resolves once the loop exits.
  245. */
  246. start(): () => void {
  247. this.done = runLoop(this.ctx, this, {
  248. setStatus: (status) => { this.setStatus(status) },
  249. setAbort: controller => void (this.currentAbort = controller),
  250. disposed: this.disposed,
  251. isDisposed: () => this._status === 'disposed',
  252. isCancelled: () => this.cancelRequested,
  253. cancelReason: () => this.cancelReason,
  254. clearCancel: () => { this.cancelRequested = false },
  255. // Settle whenIdle() waiters WITHOUT a status transition — the pre-step
  256. // cancel-skip path drops the about-to-run turn and re-parks without ever
  257. // flipping running→idle, so a waiter registered in the pre-step window
  258. // (status idle, hasQueued was true) would otherwise hang. This emits no
  259. // agent/status, so an ACP agent/status listener never sees a spurious idle
  260. // that would resolve a freshly-queued prompt as cancelled.
  261. settleIdle: () => { this.settleIdleWaiters() },
  262. })
  263. // The disposer must be infallible: it runs inside the fiber's LIFO
  264. // disposal chain, where a throw would skip later disposers (e.g. the
  265. // registry unregistration) and leave `done` pending forever.
  266. return () => {
  267. if (this._status === 'disposed') return
  268. this._status = 'disposed'
  269. this.resolveDisposed()
  270. // Release whenIdle waiters BEFORE the (guarded) event emit — they are
  271. // internal state that must settle even if a listener throws below. Each
  272. // waiter chains `done`, so it resolves only once the loop actually exits.
  273. this.settleIdleWaiters()
  274. this.currentAbort?.abort('disposed')
  275. // setStatus refuses transitions out of 'disposed', so emit directly —
  276. // 'disposed' is part of the agent/status contract. Guarded: a throwing
  277. // listener must not break the disposal chain.
  278. try {
  279. this.ctx.emit('agent/status', this, 'disposed')
  280. } catch {
  281. // listener error during disposal — nothing safe left to do with it
  282. }
  283. }
  284. }
  285. }