agent.ts 23 KB

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