agent.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. /**
  2. * Default Agent driver over queued turns and step-boundary input. Every request
  3. * is derived from the session log.
  4. * @module dsh-agent-loop/agent
  5. */
  6. import type {
  7. Agent,
  8. AgentCancelCause,
  9. AgentOptions,
  10. AgentStatus,
  11. CancelOptions,
  12. InboxTarget,
  13. RequestErrorAction,
  14. } from '@deepseek-ai/dsh-agent'
  15. import { Inbox, agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
  16. import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
  17. import {
  18. BlockAssembler,
  19. LlmError,
  20. createAssistantMessage,
  21. deepFreeze,
  22. errorChain,
  23. markAgentLoopRequest,
  24. } from '@deepseek-ai/dsh-llm'
  25. import type { Scope } from '@deepseek-ai/dsh-scope'
  26. import { createScope } from '@deepseek-ai/dsh-scope'
  27. import type { EpochHeader, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
  28. import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
  29. import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  30. import type { Context } from 'cordis'
  31. import { executeToolCalls } from './tool-calls.ts'
  32. type Phase =
  33. | { kind: 'idle'; lastTurn: number }
  34. | { kind: 'collecting'; abort: AbortController; lastTurn: number }
  35. | { kind: 'running'; abort: AbortController; turn: number; step: number }
  36. type Admission =
  37. | { kind: 'empty' }
  38. | { kind: 'admitted'; messages: UserMessage[] }
  39. | { kind: 'blocked' }
  40. type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }>
  41. /** Remove adapter-derived values before plugins propose the next request config. */
  42. function requestProposal(header: EpochHeader): LlmCallConfig {
  43. if (header.adapterDefaults === undefined) return header.config
  44. const proposal = { ...header.config }
  45. if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort
  46. if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens
  47. return proposal
  48. }
  49. /** Drives one session through turn and step boundaries. */
  50. export class ReactLoopAgent implements Agent {
  51. readonly inbox: Inbox
  52. private phase: Phase
  53. private driverDone: Promise<void> = Promise.resolve()
  54. /** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */
  55. readonly scope: Scope
  56. readonly ctx: Context
  57. /** Whether this loop instance has appended its initial/resume request anchor. */
  58. private requestHeaderLogged = false
  59. constructor(
  60. private loopCtx: Context,
  61. public readonly id: SessionId,
  62. public readonly options: AgentOptions,
  63. public readonly session: Session,
  64. ) {
  65. this.inbox = new Inbox(session)
  66. const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
  67. this.phase = { kind: 'idle', lastTurn }
  68. this.scope = createScope(loopCtx, this)
  69. this.ctx = this.scope.ctx.extend({ agent: this })
  70. }
  71. get status(): AgentStatus {
  72. return this.phase.kind === 'idle' ? 'idle' : 'running'
  73. }
  74. /** Commit a phase and publish its externally visible status transition. */
  75. private setPhase(next: Phase): void {
  76. const previousStatus = this.status
  77. this.phase = next
  78. const status = this.status
  79. if (status !== previousStatus) {
  80. emitAgentEvent(this.loopCtx, this, 'agent/status', status)
  81. }
  82. }
  83. send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
  84. // Waking input cannot join an aborted admission or turn, so it starts the next turn.
  85. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
  86. const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
  87. this.inbox.splice(resolvedTarget, Infinity, 0, [message])
  88. if (wakeup) this.scheduleKick()
  89. }
  90. followup(input: UserMessage): void {
  91. this.send(input, 'next-turn', true)
  92. }
  93. steer(input: UserMessage): void {
  94. this.send(input, 'next-step', true)
  95. }
  96. inject(input: UserMessage): void {
  97. this.send(input, 'next-step', false)
  98. }
  99. cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
  100. if (!options.keepInbox) {
  101. this.inbox.splice('next-step', 0, this.inbox.nextStep.length, [], 'canceled')
  102. this.inbox.splice('next-turn', 0, this.inbox.nextTurn.length, [], 'canceled')
  103. }
  104. if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
  105. }
  106. /** Reserve a driver before deferring idle admission. */
  107. private scheduleKick(): void {
  108. if (this.phase.kind !== 'idle') return
  109. const driver = Promise.withResolvers<void>()
  110. this.driverDone = driver.promise
  111. this.setPhase({ kind: 'collecting', abort: new AbortController(), lastTurn: this.phase.lastTurn })
  112. queueMicrotask(() => {
  113. this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject)
  114. })
  115. }
  116. async whenIdle(): Promise<void> {
  117. let driver: Promise<void>
  118. do {
  119. await (driver = this.driverDone)
  120. } while (driver !== this.driverDone)
  121. }
  122. /** Report one failure at its live boundary, then preserve it for driver containment. */
  123. private throwError(error: unknown): never {
  124. const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn
  125. const step = this.phase.kind === 'running' ? this.phase.step : 0
  126. emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
  127. throw error
  128. }
  129. private async kick(): Promise<void> {
  130. try {
  131. while (await this.turn()) {}
  132. } catch (_error) {
  133. // Admission and turn boundaries report before rethrowing; the driver only contains the rejection.
  134. } finally {
  135. if (this.phase.kind === 'running') {
  136. this.setPhase({ kind: 'idle', lastTurn: this.phase.turn })
  137. }
  138. }
  139. }
  140. private async admit(onTurnBoundary: boolean): Promise<Admission> {
  141. if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": admit outside running phase`)
  142. const signal = this.phase.abort.signal
  143. const claimed = [...this.inbox.nextStep]
  144. const outboxLength = claimed.length
  145. const queued = onTurnBoundary ? this.inbox.nextTurn[0] : undefined
  146. if (queued !== undefined) claimed.push(queued)
  147. if (claimed.length === 0) return { kind: 'empty' }
  148. const decision = await agentEvents(this.loopCtx, this).waterfall(
  149. 'agent/prompt-submit', claimed, signal,
  150. () => Promise.resolve({ kind: 'allow', messages: claimed }),
  151. )
  152. signal.throwIfAborted()
  153. if (decision.kind === 'allow') {
  154. this.inbox.splice('next-step', 0, outboxLength, [], 'admitted')
  155. if (queued !== undefined) this.inbox.splice('next-turn', 0, 1, [], 'admitted')
  156. return { kind: 'admitted', messages: decision.messages }
  157. }
  158. this.cancel({ kind: 'hook', reason: decision.reason }, { keepInbox: decision.keepInbox })
  159. return { kind: 'blocked' }
  160. }
  161. /** Admitted input stays unowned until `turn/start` commits. */
  162. private async turn(): Promise<boolean> {
  163. if (this.phase.kind === 'idle') {
  164. this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
  165. }
  166. const abort = this.phase.kind === 'collecting' ? this.phase.abort : new AbortController()
  167. const { signal } = abort
  168. const lastTurn = this.phase.kind === 'collecting' ? this.phase.lastTurn : this.phase.turn
  169. const phase = { kind: 'running' as const, abort, turn: lastTurn, step: 0 }
  170. this.setPhase(phase)
  171. if (signal.aborted) return this.inbox.hasPending
  172. let admission: Admission
  173. try {
  174. admission = await this.admit(true)
  175. if (admission.kind !== 'admitted') return false
  176. signal.throwIfAborted()
  177. } catch (error: unknown) {
  178. // oxlint-disable-next-line typescript/no-unnecessary-condition -- cancel may abort while admission awaits
  179. if (signal.aborted) return this.inbox.hasPending
  180. this.throwError(error)
  181. }
  182. const turn = ++phase.turn
  183. try {
  184. this.session.append('turn/start', { turn })
  185. } catch (error: unknown) {
  186. this.throwError(error)
  187. }
  188. let turnEnds: TurnEndReason | null = null
  189. try {
  190. while (true) {
  191. if (admission.kind === 'admitted') {
  192. for (const message of admission.messages) {
  193. this.session.append('user/message', message, { surfaceOp: 'append' })
  194. }
  195. }
  196. signal.throwIfAborted()
  197. const step = ++phase.step
  198. this.session.append('step/start', { turn, step })
  199. try {
  200. turnEnds = await this.step()
  201. } finally {
  202. this.session.append('step/end', { turn, step })
  203. }
  204. signal.throwIfAborted()
  205. if (turnEnds && this.inbox.nextStep.length === 0) {
  206. await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal)
  207. signal.throwIfAborted()
  208. }
  209. admission = await this.admit(false)
  210. if (admission.kind === 'blocked') {
  211. turnEnds = { kind: 'blocked' }
  212. return false
  213. }
  214. signal.throwIfAborted()
  215. if (admission.kind === 'empty' && turnEnds) break
  216. }
  217. } catch (error: unknown) {
  218. // oxlint-disable-next-line typescript/no-unnecessary-condition -- cancel may abort during any awaited turn operation
  219. if (signal.aborted) {
  220. turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
  221. } else {
  222. turnEnds = {
  223. kind: 'error',
  224. error: error instanceof LlmError ? error.failure : errorChain(error),
  225. }
  226. this.throwError(error)
  227. }
  228. } finally {
  229. try {
  230. // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending
  231. this.session.append('turn/end', { turn, reason: turnEnds! })
  232. } catch (error: unknown) {
  233. this.throwError(error)
  234. }
  235. }
  236. return this.inbox.hasPending
  237. }
  238. private async step(): Promise<StepEndReason | null> {
  239. if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)
  240. const { turn, step, abort: { signal } } = this.phase
  241. signal.throwIfAborted()
  242. await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal)
  243. signal.throwIfAborted()
  244. const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
  245. signal.throwIfAborted()
  246. const system = renderPrompt(assembly)
  247. while (true) {
  248. const { request, preparedCall } = await this.buildRequest(
  249. turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
  250. )
  251. const assembler = new BlockAssembler()
  252. const chunkSeqs: number[] = []
  253. const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
  254. signal.throwIfAborted()
  255. for await (const chunk of stream) {
  256. signal.throwIfAborted()
  257. chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
  258. assembler.push(chunk)
  259. }
  260. signal.throwIfAborted()
  261. const finish = assembler.finish
  262. if (finish.kind === 'error' || finish.kind === 'aborted') {
  263. const action = await this.loopCtx.waterfall(
  264. agentCarrier(this), 'agent/request-error', this, {
  265. turn,
  266. step,
  267. provider: request.provider,
  268. failure: finish.failure,
  269. retryPolicy: preparedCall?.retryPolicy,
  270. }, signal,
  271. () => Promise.resolve<RequestErrorAction>(undefined),
  272. )
  273. signal.throwIfAborted()
  274. if (action?.kind !== 'retry') {
  275. throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
  276. }
  277. continue
  278. }
  279. const message = createAssistantMessage({
  280. content: assembler.blocks(),
  281. source: {
  282. provider: request.provider,
  283. model: request.model,
  284. ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
  285. },
  286. })
  287. this.session.append(
  288. 'assistant/message',
  289. {
  290. turn,
  291. step,
  292. message,
  293. ...assembler.usage === undefined ? {} : { usage: assembler.usage },
  294. },
  295. { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
  296. )
  297. if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
  298. const toolCalls = message.content.filter(block => block.type === 'tool-call')
  299. if (toolCalls.length === 0) return { kind: 'completed' }
  300. const { concluded } = await executeToolCalls(
  301. this.loopCtx, turn, step, toolCalls, signal,
  302. context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
  303. )
  304. return concluded ? { kind: 'completed' } : null
  305. }
  306. }
  307. /**
  308. * Compose one frozen request and bind it to the adapter registration that
  309. * resolved its exact-model defaults.
  310. */
  311. private async buildRequest(
  312. turn: number,
  313. step: number,
  314. tools: GenerateOptions['tools'] & object,
  315. system: string,
  316. boundaryMessages: Message[],
  317. signal: AbortSignal,
  318. ): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
  319. const { session } = this
  320. // A loop instance starts from its declared route, restoring only an explicit
  321. // effort owned by that exact model. Later steps re-resolve marked defaults.
  322. const persistedHeader = session.requestHeader()
  323. const persistedConfig = persistedHeader?.config
  324. const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }
  325. const reasoningEffort = persistedConfig?.provider === route.provider
  326. && persistedConfig.model === route.model
  327. && persistedHeader?.adapterDefaults?.reasoningEffort !== true
  328. ? persistedConfig.reasoningEffort
  329. : undefined
  330. const maxTokens = this.options.maxTokens
  331. const seedConfig = deepFreeze(structuredClone(
  332. this.requestHeaderLogged
  333. // oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds
  334. ? requestProposal(persistedHeader!)
  335. : {
  336. ...route,
  337. ...reasoningEffort === undefined ? {} : { reasoningEffort },
  338. ...maxTokens === undefined ? {} : { maxTokens },
  339. },
  340. ))
  341. const proposedConfig = await this.loopCtx.waterfall(
  342. agentCarrier(this), 'agent/request', this, turn, step, signal,
  343. () => Promise.resolve(seedConfig),
  344. )
  345. signal.throwIfAborted()
  346. if (!proposedConfig.provider || !proposedConfig.model) {
  347. throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
  348. }
  349. let config: LlmCallConfig
  350. let preparedCall: PreparedLlmCall | undefined
  351. try {
  352. preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal)
  353. config = preparedCall.config
  354. } catch (error: unknown) {
  355. // Middleware may serve an unregistered route; terminal dispatch still requires an adapter.
  356. if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
  357. config = proposedConfig
  358. }
  359. signal.throwIfAborted()
  360. const header = canonicalHeader({
  361. config,
  362. ...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults },
  363. ...system ? { system } : {},
  364. ...tools.length > 0 ? { tools } : {},
  365. })
  366. const baseline = this.session.requestHeader()
  367. if (!this.requestHeaderLogged) {
  368. this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
  369. this.requestHeaderLogged = true
  370. } else if (baseline === undefined || !headerEquals(baseline, header)) {
  371. this.session.append('request/header', { header, reason: 'change' })
  372. }
  373. signal.throwIfAborted()
  374. const request = markAgentLoopRequest(deepFreeze({
  375. ...header.config,
  376. messages: boundaryMessages,
  377. ...header.system !== undefined ? { system: header.system } : {},
  378. ...header.tools !== undefined ? { tools: header.tools } : {},
  379. sessionId: this.session.id,
  380. signal,
  381. }))
  382. return { request, ...preparedCall === undefined ? {} : { preparedCall } }
  383. }
  384. }