agent.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  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. AgentEventDispatch,
  10. AgentOptions,
  11. AgentStatus,
  12. CancelOptions,
  13. InboxTarget,
  14. PreStepDecision,
  15. RequestErrorAction,
  16. } from '@deepseek-ai/dsh-agent'
  17. import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
  18. import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
  19. import {
  20. BlockAssembler,
  21. LlmError,
  22. createAssistantMessage,
  23. deepFreeze,
  24. errorChain,
  25. markAgentLoopRequest,
  26. } from '@deepseek-ai/dsh-llm'
  27. import type { Scope } from '@deepseek-ai/dsh-scope'
  28. import { createScope } from '@deepseek-ai/dsh-scope'
  29. import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
  30. import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
  31. import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  32. import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
  33. import type { Context } from '@deepseek-ai/cordis'
  34. import { RuntimeContextProjection } from './runtime-context.ts'
  35. import { executeToolCalls } from './tool-calls.ts'
  36. type Phase =
  37. | { kind: 'idle'; lastTurn: number }
  38. | {
  39. kind: 'maintenance'
  40. abort: AbortController
  41. lastTurn: number
  42. wakeRequested: boolean
  43. }
  44. | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }
  45. type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }>
  46. type PreparedStep =
  47. | { kind: 'reject' }
  48. | { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly }
  49. /** Remove adapter-derived values before plugins propose the next request config. */
  50. function requestProposal(header: EpochHeader): LlmCallConfig {
  51. if (header.adapterDefaults === undefined) return header.config
  52. const proposal = { ...header.config }
  53. if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort
  54. if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens
  55. return proposal
  56. }
  57. /** Drives one session through turn and step boundaries. */
  58. export class ReactLoopAgent implements Agent {
  59. readonly inbox: Inbox
  60. private phase: Phase
  61. private activityDone: Promise<void> = Promise.resolve()
  62. /** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */
  63. readonly scope: Scope
  64. readonly ctx: Context
  65. /** Fused dispatcher, built once in the constructor so hot-path dispatches never allocate. */
  66. private readonly dispatch: AgentEventDispatch
  67. /** Whether this loop instance has appended its initial/resume request anchor. */
  68. private requestHeaderLogged = false
  69. private readonly runtimeContext: RuntimeContextProjection
  70. constructor(
  71. private loopCtx: Context,
  72. public readonly id: SessionId,
  73. public readonly options: AgentOptions,
  74. public readonly session: Session,
  75. ) {
  76. this.dispatch = agentEvents(loopCtx, this)
  77. this.inbox = new Inbox(session, {
  78. inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) },
  79. discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) },
  80. claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) },
  81. })
  82. const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
  83. this.phase = { kind: 'idle', lastTurn }
  84. this.scope = createScope(loopCtx, this)
  85. this.ctx = this.scope.ctx.extend({ agent: this })
  86. this.runtimeContext = new RuntimeContextProjection(this.ctx, session)
  87. }
  88. get status(): AgentStatus {
  89. return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running'
  90. }
  91. /** Commit a phase and publish its externally visible status transition. */
  92. private setPhase(next: Phase): void {
  93. const previousStatus = this.status
  94. this.phase = next
  95. const status = this.status
  96. if (status !== previousStatus) {
  97. this.dispatch.emit('agent/status', { status })
  98. }
  99. }
  100. send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
  101. // Waking input cannot join an aborted activity, so it starts the next turn.
  102. // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it.
  103. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
  104. const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
  105. this.inbox.splice(resolvedTarget, Infinity, 0, [message])
  106. if (wakeup) this.wakeDriver(wakingAfterAbort)
  107. }
  108. followup(input: UserMessage): void {
  109. this.send(input, 'next-turn', true)
  110. }
  111. steer(input: UserMessage): void {
  112. this.send(input, 'next-step', true)
  113. }
  114. inject(input: UserMessage): void {
  115. this.send(input, 'next-step', false)
  116. }
  117. cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
  118. if (!options.keepInbox) {
  119. this.inbox.clear()
  120. if (this.phase.kind !== 'idle') this.phase.wakeRequested = false
  121. }
  122. if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
  123. }
  124. runMaintenance<T>(job: (signal: AbortSignal) => Promise<T>): Promise<T> {
  125. if (this.phase.kind !== 'idle') throw new Error(`agent "${this.id}" already has active work`)
  126. const done = Promise.withResolvers<void>()
  127. const maintenance: Phase = {
  128. kind: 'maintenance',
  129. abort: new AbortController(),
  130. lastTurn: this.phase.lastTurn,
  131. wakeRequested: false,
  132. }
  133. this.setPhase(maintenance)
  134. this.activityDone = done.promise
  135. return (async () => {
  136. try {
  137. return await job(maintenance.abort.signal)
  138. } finally {
  139. this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn })
  140. if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver()
  141. done.resolve()
  142. }
  143. })()
  144. }
  145. /**
  146. * Start one driver, or latch its wake behind maintenance or an aborted
  147. * activity. A wake sent while idle always opens its turn boundary, even
  148. * when its message was cleared; only a latched replay is suppressed when
  149. * the queue no longer holds the wake.
  150. * @param wakeAfterAbort - the {@link send} classification, captured before
  151. * the inbox insertion so a reentrant cancel cannot reclassify it.
  152. */
  153. private wakeDriver(wakeAfterAbort = false): void {
  154. if (this.phase.kind !== 'idle') {
  155. // Maintenance and aborted drivers cannot deliver the wake: latch it for
  156. // replay at convergence. Live drivers claim queued work themselves;
  157. // disposal never latches, so teardown waits on no model turn.
  158. const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined
  159. if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) {
  160. this.phase.wakeRequested = true
  161. }
  162. return
  163. }
  164. const driver = Promise.withResolvers<void>()
  165. this.activityDone = driver.promise
  166. this.setPhase({
  167. kind: 'running',
  168. abort: new AbortController(),
  169. turn: this.phase.lastTurn,
  170. step: 0,
  171. wakeRequested: false,
  172. })
  173. this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject)
  174. }
  175. async whenIdle(): Promise<void> {
  176. let activity: Promise<void>
  177. do {
  178. await (activity = this.activityDone)
  179. } while (activity !== this.activityDone)
  180. }
  181. /** Report one failure at its live boundary, then preserve it for driver containment. */
  182. private throwError(error: unknown): never {
  183. const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn
  184. const step = this.phase.kind === 'running' ? this.phase.step : 0
  185. this.dispatch.emit('agent/error', { turn, step, error })
  186. throw error
  187. }
  188. private async kick(): Promise<void> {
  189. try {
  190. while (await this.turn()) {}
  191. } catch (_error) {
  192. // Reported failures and cancellation are contained at the driver boundary.
  193. } finally {
  194. /* v8 ignore next -- kick owns a running phase until this driver boundary */
  195. if (this.phase.kind === 'running') {
  196. const { turn, wakeRequested } = this.phase
  197. this.setPhase({ kind: 'idle', lastTurn: turn })
  198. if (wakeRequested && this.inbox.hasPending) this.wakeDriver()
  199. }
  200. }
  201. }
  202. private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> {
  203. /* v8 ignore next -- private callers establish the running phase before proposing a step */
  204. if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
  205. const signal = this.phase.abort.signal
  206. const claimed = this.inbox.claim(target, position.turn)
  207. const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
  208. signal.throwIfAborted()
  209. const sections = renderContextSections(assembly)
  210. const context = this.runtimeContext.project(joinContextSections(sections), sections)
  211. const decision = await this.dispatch.waterfall(
  212. 'agent/pre-step', { messages: claimed, ...position, signal },
  213. (): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
  214. kind: 'enter',
  215. messages: context === undefined ? claimed : [...claimed, context],
  216. }),
  217. )
  218. signal.throwIfAborted()
  219. return decision.kind === 'reject' ? decision : { ...decision, assembly }
  220. }
  221. /** Open one turn before claiming its first proposed step. */
  222. private async turn(): Promise<boolean> {
  223. if (this.phase.kind !== 'running') {
  224. this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
  225. }
  226. const phase = this.phase
  227. const { signal } = phase.abort
  228. signal.throwIfAborted()
  229. const turn = phase.turn + 1
  230. try {
  231. this.session.append('turn/start', { turn })
  232. } catch (error: unknown) {
  233. this.throwError(error)
  234. }
  235. phase.turn = turn
  236. let turnEnds: TurnEndReason | null = null
  237. let target: InboxTarget = 'next-turn'
  238. try {
  239. while (true) {
  240. signal.throwIfAborted()
  241. const step = phase.step + 1
  242. const decision = await this.preStep(target, { turn, step })
  243. if (decision.kind === 'reject') {
  244. turnEnds = { kind: 'blocked' }
  245. return false
  246. }
  247. if (turnEnds && decision.messages.length === 0) break
  248. // A removed waking message or an enter decision rewritten to empty
  249. // still owns the initial turn boundary, but it spends no model call.
  250. if (phase.step === 0 && decision.messages.length === 0) {
  251. turnEnds = { kind: 'completed' }
  252. return false
  253. }
  254. signal.throwIfAborted()
  255. this.session.append('step/start', { turn, step })
  256. phase.step = step
  257. try {
  258. for (const message of decision.messages) {
  259. this.session.append('user/message', message, { surfaceOp: 'append' })
  260. }
  261. // max-tokens is sticky: once any step hits the ceiling, later steps
  262. // that complete normally must not downgrade the turn outcome.
  263. const stepEnd = await this.step(decision.assembly)
  264. // max-tokens stays sticky: a later completed step must not
  265. // downgrade the turn outcome.
  266. if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
  267. } finally {
  268. this.session.append('step/end', { turn, step })
  269. }
  270. signal.throwIfAborted()
  271. if (turnEnds && this.inbox.nextStep.length === 0) {
  272. await this.dispatch.serial('agent/turn-stopping', { turn, signal })
  273. signal.throwIfAborted()
  274. }
  275. if (turnEnds && this.inbox.nextStep.length === 0) break
  276. target = 'next-step'
  277. }
  278. } catch (error: unknown) {
  279. if (signal.aborted) {
  280. turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
  281. throw error
  282. }
  283. // Every failure is structured: an `LlmError` keeps its facts, anything
  284. // else flattens to `errorChain` text under the `UNKNOWN` code.
  285. turnEnds = {
  286. kind: 'error',
  287. error: error instanceof LlmError
  288. ? error.failure
  289. : { message: errorChain(error), code: 'UNKNOWN' },
  290. }
  291. this.throwError(error)
  292. } finally {
  293. try {
  294. // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending
  295. this.session.append('turn/end', { turn, reason: turnEnds! })
  296. } catch (error: unknown) {
  297. this.throwError(error)
  298. }
  299. }
  300. if (!this.inbox.hasPending) return false
  301. phase.abort = new AbortController()
  302. // A fresh controller makes a latch set on the old one stale: the live driver claims the queue itself.
  303. phase.wakeRequested = false
  304. phase.step = 0
  305. return true
  306. }
  307. private async step(assembly: PromptAssembly): Promise<StepEndReason | null> {
  308. /* v8 ignore next -- private callers establish the running phase before executing a step */
  309. if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)
  310. const { turn, step, abort: { signal } } = this.phase
  311. signal.throwIfAborted()
  312. const system = renderPrompt(assembly)
  313. while (true) {
  314. const { request, preparedCall } = await this.buildRequest(
  315. turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
  316. )
  317. const assembler = new BlockAssembler()
  318. const chunkSeqs: number[] = []
  319. try {
  320. const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
  321. signal.throwIfAborted()
  322. for await (const chunk of stream) {
  323. signal.throwIfAborted()
  324. chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
  325. assembler.push(chunk)
  326. }
  327. signal.throwIfAborted()
  328. } catch (error: unknown) {
  329. if (signal.aborted) {
  330. const content = assembler.interruptedBlocks()
  331. if (content.length > 0) {
  332. this.session.append('assistant/message', {
  333. turn,
  334. step,
  335. message: createAssistantMessage({
  336. content,
  337. source: { provider: request.provider, model: request.model },
  338. }),
  339. interrupted: true,
  340. ...assembler.usage === undefined ? {} : { usage: assembler.usage },
  341. }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })
  342. }
  343. }
  344. throw error
  345. }
  346. const finish = assembler.finish
  347. if (finish.kind === 'error' || finish.kind === 'aborted') {
  348. const action = await this.dispatch.waterfall(
  349. 'agent/request-error', {
  350. turn,
  351. step,
  352. provider: request.provider,
  353. failure: finish.failure,
  354. retryPolicy: preparedCall?.retryPolicy,
  355. signal,
  356. },
  357. () => Promise.resolve<RequestErrorAction>(undefined),
  358. )
  359. signal.throwIfAborted()
  360. if (action?.kind !== 'retry') {
  361. throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
  362. }
  363. continue
  364. }
  365. const message = createAssistantMessage({
  366. content: assembler.blocks(),
  367. source: {
  368. provider: request.provider,
  369. model: request.model,
  370. ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
  371. },
  372. })
  373. this.session.append(
  374. 'assistant/message',
  375. {
  376. turn,
  377. step,
  378. message,
  379. ...assembler.usage === undefined ? {} : { usage: assembler.usage },
  380. },
  381. { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
  382. )
  383. if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
  384. const toolCalls = message.content.filter(block => block.type === 'tool-call')
  385. if (toolCalls.length === 0) return { kind: 'completed' }
  386. const { concluded } = await executeToolCalls(
  387. this.loopCtx, turn, step, toolCalls, signal,
  388. context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
  389. )
  390. return concluded ? { kind: 'completed' } : null
  391. }
  392. }
  393. /**
  394. * Compose one frozen request and bind it to the adapter registration that
  395. * resolved its exact-model defaults.
  396. */
  397. private async buildRequest(
  398. turn: number,
  399. step: number,
  400. tools: GenerateOptions['tools'] & object,
  401. system: string,
  402. boundaryMessages: Message[],
  403. signal: AbortSignal,
  404. ): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
  405. const { session } = this
  406. // A loop instance starts from its declared route, restoring only an explicit
  407. // effort owned by that exact model. Later steps re-resolve marked defaults.
  408. const persistedHeader = session.requestHeader()
  409. const persistedConfig = persistedHeader?.config
  410. const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }
  411. const reasoningEffort = persistedConfig?.provider === route.provider
  412. && persistedConfig.model === route.model
  413. && persistedHeader?.adapterDefaults?.reasoningEffort !== true
  414. ? persistedConfig.reasoningEffort
  415. : undefined
  416. const maxTokens = this.options.maxTokens
  417. const seedConfig = deepFreeze(structuredClone(
  418. this.requestHeaderLogged
  419. // oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds
  420. ? requestProposal(persistedHeader!)
  421. : {
  422. ...route,
  423. ...reasoningEffort === undefined ? {} : { reasoningEffort },
  424. ...maxTokens === undefined ? {} : { maxTokens },
  425. },
  426. ))
  427. const proposedConfig = await this.dispatch.waterfall(
  428. 'agent/request', { turn, step, signal },
  429. () => Promise.resolve(seedConfig),
  430. )
  431. signal.throwIfAborted()
  432. if (!proposedConfig.provider || !proposedConfig.model) {
  433. throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
  434. }
  435. let config: LlmCallConfig
  436. let preparedCall: PreparedLlmCall | undefined
  437. try {
  438. preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal)
  439. config = preparedCall.config
  440. } catch (error: unknown) {
  441. // Middleware may serve an unregistered route; terminal dispatch still requires an adapter.
  442. if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
  443. config = proposedConfig
  444. }
  445. signal.throwIfAborted()
  446. const header = canonicalHeader({
  447. config,
  448. ...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults },
  449. ...system ? { system } : {},
  450. ...tools.length > 0 ? { tools } : {},
  451. })
  452. const baseline = this.session.requestHeader()
  453. if (!this.requestHeaderLogged) {
  454. this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
  455. this.requestHeaderLogged = true
  456. } else if (baseline === undefined || !headerEquals(baseline, header)) {
  457. this.session.append('request/header', { header, reason: 'change' })
  458. }
  459. const contextWindow = preparedCall?.context?.contextWindow
  460. const requestContext: RequestContext = {
  461. provider: config.provider,
  462. model: config.model,
  463. ...contextWindow === undefined ? {} : { contextWindow },
  464. }
  465. const previousContext = session.requestContext()
  466. if (previousContext?.provider !== requestContext.provider
  467. || previousContext.model !== requestContext.model
  468. || previousContext.contextWindow !== requestContext.contextWindow) {
  469. session.append('request/context', requestContext)
  470. }
  471. signal.throwIfAborted()
  472. const request = markAgentLoopRequest(deepFreeze({
  473. ...header.config,
  474. messages: boundaryMessages,
  475. ...header.system !== undefined ? { system: header.system } : {},
  476. ...header.tools !== undefined ? { tools: header.tools } : {},
  477. sessionId: this.session.id,
  478. signal,
  479. }))
  480. return { request, ...preparedCall === undefined ? {} : { preparedCall } }
  481. }
  482. }