mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
A steer or follow-up accepted while a turn is closing is now claimed by a fresh turn at the driver's clean exit instead of stranding in the inbox; cancellation and pre-step rejection still park accepted work. Continuable subagent follow-ups accept image parts: the wire is upload-shaped, the Host admits and persists each batch before inbox acceptance, and delivery is refused when the child model declines image input. The queue dock renders durable image thumbnails instead of an [image] text marker. Fixes #3186
585 lines
23 KiB
TypeScript
585 lines
23 KiB
TypeScript
/**
|
|
* Default Agent driver over queued turns and step-boundary input. Every request
|
|
* is derived from the session log.
|
|
* @module dsh-agent-loop/agent
|
|
*/
|
|
|
|
import type {
|
|
Agent,
|
|
AgentCancelCause,
|
|
AgentEventDispatch,
|
|
AgentOptions,
|
|
AgentStatus,
|
|
CancelOptions,
|
|
InboxTarget,
|
|
PreStepDecision,
|
|
RequestErrorAction,
|
|
} from '@deepseek-ai/dsh-agent'
|
|
import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
|
import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
|
|
import {
|
|
BlockAssembler,
|
|
LlmError,
|
|
createAssistantMessage,
|
|
deepFreeze,
|
|
errorChain,
|
|
markAgentLoopRequest,
|
|
} from '@deepseek-ai/dsh-llm'
|
|
import type { Scope } from '@deepseek-ai/dsh-scope'
|
|
import { createScope } from '@deepseek-ai/dsh-scope'
|
|
import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
|
|
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
|
import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
|
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import { RuntimeContextProjection } from './runtime-context.ts'
|
|
import { executeToolCalls } from './tool-calls.ts'
|
|
|
|
type Phase =
|
|
| { kind: 'idle'; lastTurn: number }
|
|
| {
|
|
kind: 'maintenance'
|
|
abort: AbortController
|
|
lastTurn: number
|
|
wakeRequested: boolean
|
|
}
|
|
| { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }
|
|
|
|
type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }>
|
|
|
|
type PreparedStep =
|
|
| { kind: 'reject' }
|
|
| {
|
|
kind: 'enter'
|
|
messages: UserMessage[]
|
|
startsRequestSeries?: true
|
|
assembly: PromptAssembly
|
|
}
|
|
|
|
/** Remove adapter-derived values before plugins propose the next request config. */
|
|
function requestProposal(header: EpochHeader): LlmCallConfig {
|
|
if (header.adapterDefaults === undefined) return header.config
|
|
const proposal = { ...header.config }
|
|
if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort
|
|
if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens
|
|
return proposal
|
|
}
|
|
|
|
/** Drives one session through turn and step boundaries. */
|
|
export class ReactLoopAgent implements Agent {
|
|
readonly inbox: Inbox
|
|
private phase: Phase
|
|
private activityDone: Promise<void> = Promise.resolve()
|
|
/**
|
|
* Identities of waking sends still awaiting a claim. Claim and discard
|
|
* notifications prune the set, and {@link cancel} clears it because a
|
|
* cancellation parks accepted-but-unclaimed input for a later waking send.
|
|
* A non-empty set at driver exit therefore means a steer or follow-up lost
|
|
* the race with a normally or erroneously closing turn, and the exit must
|
|
* start a fresh driver to deliver it. Injected context never enters the
|
|
* set, so it keeps waiting for a waking message instead of opening a turn
|
|
* by itself.
|
|
*/
|
|
private readonly pendingWakes = new Set<UserMessage['id']>()
|
|
|
|
/** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */
|
|
readonly scope: Scope
|
|
readonly ctx: Context
|
|
|
|
/** Fused dispatcher, built once in the constructor so hot-path dispatches never allocate. */
|
|
private readonly dispatch: AgentEventDispatch
|
|
|
|
/** Whether this loop instance has appended its initial/resume request anchor. */
|
|
private requestHeaderLogged = false
|
|
/** Surface generation of the preceding built request. */
|
|
private requestSurfaceGeneration: number | undefined
|
|
private readonly runtimeContext: RuntimeContextProjection
|
|
|
|
constructor(
|
|
private loopCtx: Context,
|
|
public readonly id: SessionId,
|
|
public readonly options: AgentOptions,
|
|
public readonly session: Session,
|
|
) {
|
|
this.dispatch = agentEvents(loopCtx, this)
|
|
this.inbox = new Inbox(session, {
|
|
inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) },
|
|
discarded: (message) => {
|
|
this.pendingWakes.delete(message.id)
|
|
this.dispatch.emit('agent/inbox/discarded', { message })
|
|
},
|
|
claimed: (message, turn) => {
|
|
this.pendingWakes.delete(message.id)
|
|
this.dispatch.emit('agent/inbox/claimed', { message, turn })
|
|
},
|
|
})
|
|
const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
|
|
this.phase = { kind: 'idle', lastTurn }
|
|
this.scope = createScope(loopCtx, this)
|
|
this.ctx = this.scope.ctx.extend({ agent: this })
|
|
this.runtimeContext = new RuntimeContextProjection(this.ctx, session)
|
|
}
|
|
|
|
get status(): AgentStatus {
|
|
return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running'
|
|
}
|
|
|
|
/** Commit a phase and publish its externally visible status transition. */
|
|
private setPhase(next: Phase): void {
|
|
const previousStatus = this.status
|
|
this.phase = next
|
|
const status = this.status
|
|
if (status !== previousStatus) {
|
|
this.dispatch.emit('agent/status', { status })
|
|
}
|
|
}
|
|
|
|
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
|
|
// Waking input cannot join an aborted activity, so it starts the next turn.
|
|
// Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it.
|
|
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
|
|
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
|
|
// Registered before the splice so a reentrant discard inside the splice
|
|
// dispatch still prunes it; a refused splice never leaves an entry behind.
|
|
if (wakeup) this.pendingWakes.add(message.id)
|
|
try {
|
|
this.inbox.splice(resolvedTarget, Infinity, 0, [message])
|
|
} catch (error: unknown) {
|
|
this.pendingWakes.delete(message.id)
|
|
throw error
|
|
}
|
|
if (wakeup) this.wakeDriver(wakingAfterAbort)
|
|
}
|
|
|
|
followup(input: UserMessage): void {
|
|
this.send(input, 'next-turn', true)
|
|
}
|
|
|
|
steer(input: UserMessage): void {
|
|
this.send(input, 'next-step', true)
|
|
}
|
|
|
|
inject(input: UserMessage): void {
|
|
this.send(input, 'next-step', false)
|
|
}
|
|
|
|
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
|
|
if (!options.keepInbox) {
|
|
this.inbox.clear()
|
|
if (this.phase.kind !== 'idle') this.phase.wakeRequested = false
|
|
}
|
|
// Cancellation consumes outstanding wakes: kept inbox work parks until
|
|
// the next waking send resumes the queue, and a cleared inbox has nothing
|
|
// left to deliver.
|
|
this.pendingWakes.clear()
|
|
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
|
|
}
|
|
|
|
runMaintenance<T>(job: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
if (this.phase.kind !== 'idle') throw new Error(`agent "${this.id}" already has active work`)
|
|
const done = Promise.withResolvers<void>()
|
|
const maintenance: Phase = {
|
|
kind: 'maintenance',
|
|
abort: new AbortController(),
|
|
lastTurn: this.phase.lastTurn,
|
|
wakeRequested: false,
|
|
}
|
|
this.setPhase(maintenance)
|
|
this.activityDone = done.promise
|
|
return (async () => {
|
|
try {
|
|
return await job(maintenance.abort.signal)
|
|
} finally {
|
|
this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn })
|
|
if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver()
|
|
done.resolve()
|
|
}
|
|
})()
|
|
}
|
|
|
|
/**
|
|
* Start one driver, or latch its wake behind maintenance or an aborted
|
|
* activity. A wake sent while idle always opens its turn boundary, even
|
|
* when its message was cleared; only a latched replay is suppressed when
|
|
* the queue no longer holds the wake.
|
|
* @param wakeAfterAbort - the {@link send} classification, captured before
|
|
* the inbox insertion so a reentrant cancel cannot reclassify it.
|
|
*/
|
|
private wakeDriver(wakeAfterAbort = false): void {
|
|
if (this.phase.kind !== 'idle') {
|
|
// Maintenance and aborted drivers cannot deliver the wake: latch it for
|
|
// replay at convergence. Live drivers claim queued work themselves;
|
|
// disposal never latches, so teardown waits on no model turn.
|
|
const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined
|
|
if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) {
|
|
this.phase.wakeRequested = true
|
|
}
|
|
return
|
|
}
|
|
const driver = Promise.withResolvers<void>()
|
|
this.activityDone = driver.promise
|
|
this.setPhase({
|
|
kind: 'running',
|
|
abort: new AbortController(),
|
|
turn: this.phase.lastTurn,
|
|
step: 0,
|
|
wakeRequested: false,
|
|
})
|
|
this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject)
|
|
}
|
|
|
|
async whenIdle(): Promise<void> {
|
|
let activity: Promise<void>
|
|
do {
|
|
await (activity = this.activityDone)
|
|
} while (activity !== this.activityDone)
|
|
}
|
|
|
|
/** Report one failure at its live boundary, then preserve it for driver containment. */
|
|
private throwError(error: unknown): never {
|
|
const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn
|
|
const step = this.phase.kind === 'running' ? this.phase.step : 0
|
|
this.dispatch.emit('agent/error', { turn, step, error })
|
|
throw error
|
|
}
|
|
|
|
private async kick(): Promise<void> {
|
|
// Set only when the turn loop returns without throwing: an abort or driver
|
|
// failure parks unclaimed waking input for the next waking send, while a
|
|
// clean exit must deliver a steer or follow-up that lost the race with the
|
|
// closing turn (its send saw a live driver, so no wake was latched).
|
|
let cleanExit = false
|
|
try {
|
|
while (await this.turn()) {}
|
|
cleanExit = true
|
|
} catch (_error) {
|
|
// Reported failures and cancellation are contained at the driver boundary.
|
|
} finally {
|
|
/* v8 ignore next -- kick owns a running phase until this driver boundary */
|
|
if (this.phase.kind === 'running') {
|
|
const { turn, wakeRequested } = this.phase
|
|
this.setPhase({ kind: 'idle', lastTurn: turn })
|
|
if ((wakeRequested || (cleanExit && this.pendingWakes.size > 0)) && this.inbox.hasPending) {
|
|
this.wakeDriver()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> {
|
|
/* v8 ignore next -- private callers establish the running phase before proposing a step */
|
|
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
|
|
const signal = this.phase.abort.signal
|
|
const claimed = this.inbox.claim(target, position.turn)
|
|
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
|
|
signal.throwIfAborted()
|
|
const sections = renderContextSections(assembly)
|
|
const context = this.runtimeContext.project(joinContextSections(sections), sections)
|
|
const decision = await this.dispatch.waterfall(
|
|
'agent/pre-step', { messages: claimed, ...position, signal },
|
|
(): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
|
|
kind: 'enter',
|
|
messages: context === undefined ? claimed : [...claimed, context],
|
|
}),
|
|
)
|
|
signal.throwIfAborted()
|
|
return decision.kind === 'reject' ? decision : { ...decision, assembly }
|
|
}
|
|
|
|
/** Open one turn before claiming its first proposed step. */
|
|
private async turn(): Promise<boolean> {
|
|
if (this.phase.kind !== 'running') {
|
|
this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
|
|
}
|
|
const phase = this.phase
|
|
const { signal } = phase.abort
|
|
signal.throwIfAborted()
|
|
const turn = phase.turn + 1
|
|
try {
|
|
this.session.append('turn/start', { turn })
|
|
} catch (error: unknown) {
|
|
this.throwError(error)
|
|
}
|
|
phase.turn = turn
|
|
let turnEnds: TurnEndReason | null = null
|
|
let target: InboxTarget = 'next-turn'
|
|
try {
|
|
while (true) {
|
|
signal.throwIfAborted()
|
|
const step = phase.step + 1
|
|
const decision = await this.preStep(target, { turn, step })
|
|
if (decision.kind === 'reject') {
|
|
// The rejecting listener owns resumption: input staged behind the
|
|
// rejected claim parks until the next waking send, exactly like a
|
|
// cancellation, instead of being re-offered to the same policy.
|
|
this.pendingWakes.clear()
|
|
turnEnds = { kind: 'blocked' }
|
|
return false
|
|
}
|
|
if (turnEnds && decision.messages.length === 0) break
|
|
// A removed waking message or an enter decision rewritten to empty
|
|
// still owns the initial turn boundary, but it spends no model call.
|
|
if (phase.step === 0 && decision.messages.length === 0) {
|
|
turnEnds = { kind: 'completed' }
|
|
return false
|
|
}
|
|
signal.throwIfAborted()
|
|
this.session.append('step/start', { turn, step })
|
|
phase.step = step
|
|
try {
|
|
for (const message of decision.messages) {
|
|
this.session.append('user/message', message, { surfaceOp: 'append' })
|
|
}
|
|
// max-tokens is sticky: once any step hits the ceiling, later steps
|
|
// that complete normally must not downgrade the turn outcome.
|
|
const stepEnd = await this.step(decision.assembly, decision.startsRequestSeries === true)
|
|
// max-tokens stays sticky: a later completed step must not
|
|
// downgrade the turn outcome.
|
|
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
|
|
} finally {
|
|
this.session.append('step/end', { turn, step })
|
|
}
|
|
signal.throwIfAborted()
|
|
if (turnEnds && this.inbox.nextStep.length === 0) {
|
|
await this.dispatch.serial('agent/turn-stopping', { turn, signal })
|
|
signal.throwIfAborted()
|
|
}
|
|
if (turnEnds && this.inbox.nextStep.length === 0) break
|
|
target = 'next-step'
|
|
}
|
|
} catch (error: unknown) {
|
|
if (signal.aborted) {
|
|
turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
|
|
throw error
|
|
}
|
|
// Every failure is structured: an `LlmError` keeps its facts, anything
|
|
// else flattens to `errorChain` text under the `UNKNOWN` code.
|
|
turnEnds = {
|
|
kind: 'error',
|
|
error: error instanceof LlmError
|
|
? error.failure
|
|
: { message: errorChain(error), code: 'UNKNOWN' },
|
|
}
|
|
this.throwError(error)
|
|
} finally {
|
|
try {
|
|
// oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending
|
|
this.session.append('turn/end', { turn, reason: turnEnds! })
|
|
} catch (error: unknown) {
|
|
this.throwError(error)
|
|
}
|
|
}
|
|
if (!this.inbox.hasPending) return false
|
|
phase.abort = new AbortController()
|
|
// A fresh controller makes a latch set on the old one stale: the live driver claims the queue itself.
|
|
phase.wakeRequested = false
|
|
phase.step = 0
|
|
return true
|
|
}
|
|
|
|
private async step(assembly: PromptAssembly, startsRequestSeries: boolean): Promise<StepEndReason | null> {
|
|
/* v8 ignore next -- private callers establish the running phase before executing a step */
|
|
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)
|
|
const { turn, step, abort: { signal } } = this.phase
|
|
signal.throwIfAborted()
|
|
const system = renderPrompt(assembly)
|
|
|
|
while (true) {
|
|
const surfaceGeneration = this.session.surface.replaceGeneration
|
|
const { request, preparedCall } = await this.buildRequest(
|
|
turn,
|
|
step,
|
|
assembly.tools,
|
|
system,
|
|
this.session.deriveMessages(),
|
|
startsRequestSeries,
|
|
surfaceGeneration,
|
|
signal,
|
|
)
|
|
startsRequestSeries = false
|
|
const assembler = new BlockAssembler()
|
|
const chunkSeqs: number[] = []
|
|
try {
|
|
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
|
|
signal.throwIfAborted()
|
|
for await (const chunk of stream) {
|
|
signal.throwIfAborted()
|
|
chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
|
|
assembler.push(chunk)
|
|
}
|
|
signal.throwIfAborted()
|
|
} catch (error: unknown) {
|
|
if (signal.aborted) {
|
|
const content = assembler.interruptedBlocks()
|
|
if (content.length > 0) {
|
|
this.session.append('assistant/message', {
|
|
turn,
|
|
step,
|
|
message: createAssistantMessage({
|
|
content,
|
|
source: { provider: request.provider, model: request.model },
|
|
}),
|
|
interrupted: true,
|
|
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
|
}, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
const finish = assembler.finish
|
|
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
const action = await this.dispatch.waterfall(
|
|
'agent/request-error', {
|
|
turn,
|
|
step,
|
|
provider: request.provider,
|
|
failure: finish.failure,
|
|
retryPolicy: preparedCall?.retryPolicy,
|
|
signal,
|
|
},
|
|
() => Promise.resolve<RequestErrorAction>(undefined),
|
|
)
|
|
signal.throwIfAborted()
|
|
if (action?.kind !== 'retry') {
|
|
throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
|
|
}
|
|
continue
|
|
}
|
|
|
|
const message = createAssistantMessage({
|
|
content: assembler.blocks(),
|
|
source: {
|
|
provider: request.provider,
|
|
model: request.model,
|
|
...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
|
|
},
|
|
})
|
|
this.session.append(
|
|
'assistant/message',
|
|
{
|
|
turn,
|
|
step,
|
|
message,
|
|
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
|
},
|
|
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
|
)
|
|
if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
|
|
|
|
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
|
if (toolCalls.length === 0) return { kind: 'completed' }
|
|
const { concluded } = await executeToolCalls(
|
|
this.loopCtx, turn, step, toolCalls, signal,
|
|
context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
|
|
)
|
|
return concluded ? { kind: 'completed' } : null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compose one frozen request and bind it to the adapter registration that
|
|
* resolved its exact-model defaults.
|
|
*/
|
|
private async buildRequest(
|
|
turn: number,
|
|
step: number,
|
|
tools: GenerateOptions['tools'] & object,
|
|
system: string,
|
|
boundaryMessages: Message[],
|
|
startsRequestSeries: boolean,
|
|
surfaceGeneration: number,
|
|
signal: AbortSignal,
|
|
): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
|
|
const { session } = this
|
|
|
|
// A loop instance starts from its declared route, restoring only an explicit
|
|
// effort owned by that exact model. Later steps re-resolve marked defaults.
|
|
const persistedHeader = session.requestHeader()
|
|
const persistedConfig = persistedHeader?.config
|
|
const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }
|
|
const persistedReasoningEffort = persistedConfig?.provider === route.provider
|
|
&& persistedConfig.model === route.model
|
|
&& persistedHeader?.adapterDefaults?.reasoningEffort !== true
|
|
? persistedConfig.reasoningEffort
|
|
: undefined
|
|
const reasoningEffort = this.options.reasoningEffort ?? persistedReasoningEffort
|
|
const maxTokens = this.options.maxTokens
|
|
const seedConfig = deepFreeze(structuredClone(
|
|
this.requestHeaderLogged
|
|
// oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds
|
|
? requestProposal(persistedHeader!)
|
|
: {
|
|
...route,
|
|
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
|
...maxTokens === undefined ? {} : { maxTokens },
|
|
},
|
|
))
|
|
const proposedConfig = await this.dispatch.waterfall(
|
|
'agent/request', { turn, step, signal },
|
|
() => Promise.resolve(seedConfig),
|
|
)
|
|
signal.throwIfAborted()
|
|
if (!proposedConfig.provider || !proposedConfig.model) {
|
|
throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
|
|
}
|
|
let config: LlmCallConfig
|
|
let preparedCall: PreparedLlmCall | undefined
|
|
try {
|
|
preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal)
|
|
config = preparedCall.config
|
|
} catch (error: unknown) {
|
|
// Middleware may serve an unregistered route; terminal dispatch still requires an adapter.
|
|
if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
|
|
config = proposedConfig
|
|
}
|
|
signal.throwIfAborted()
|
|
|
|
const header = canonicalHeader({
|
|
config,
|
|
...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults },
|
|
...system ? { system } : {},
|
|
...tools.length > 0 ? { tools } : {},
|
|
})
|
|
const baseline = this.session.requestHeader()
|
|
const startsSeries = startsRequestSeries
|
|
|| this.requestSurfaceGeneration !== surfaceGeneration
|
|
if (!this.requestHeaderLogged) {
|
|
this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
|
|
this.requestHeaderLogged = true
|
|
} else if (baseline === undefined || !headerEquals(baseline, header)) {
|
|
this.session.append('request/header', {
|
|
header,
|
|
reason: 'change',
|
|
...startsSeries ? { startsSeries: true } : {},
|
|
})
|
|
} else if (startsSeries) {
|
|
this.session.append('request/header', { header, reason: 'series' })
|
|
}
|
|
this.requestSurfaceGeneration = surfaceGeneration
|
|
|
|
const contextWindow = preparedCall?.context?.contextWindow
|
|
const requestContext: RequestContext = {
|
|
provider: config.provider,
|
|
model: config.model,
|
|
...contextWindow === undefined ? {} : { contextWindow },
|
|
}
|
|
const previousContext = session.requestContext()
|
|
if (previousContext?.provider !== requestContext.provider
|
|
|| previousContext.model !== requestContext.model
|
|
|| previousContext.contextWindow !== requestContext.contextWindow) {
|
|
session.append('request/context', requestContext)
|
|
}
|
|
signal.throwIfAborted()
|
|
|
|
const request = markAgentLoopRequest(deepFreeze({
|
|
...header.config,
|
|
messages: boundaryMessages,
|
|
...header.system !== undefined ? { system: header.system } : {},
|
|
...header.tools !== undefined ? { tools: header.tools } : {},
|
|
sessionId: this.session.id,
|
|
signal,
|
|
}))
|
|
return { request, ...preparedCall === undefined ? {} : { preparedCall } }
|
|
}
|
|
}
|