agent.ts 18 KB

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