|
|
@@ -51,14 +51,6 @@ type PreparedStep =
|
|
|
| { kind: 'reject' }
|
|
|
| { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly }
|
|
|
|
|
|
-/** One live streaming attempt whose logged chunk prefix an abort can still finalize. */
|
|
|
-interface InterruptedAttempt {
|
|
|
- readonly assembler: BlockAssembler
|
|
|
- readonly chunkSeqs: number[]
|
|
|
- readonly provider: string
|
|
|
- readonly model: string
|
|
|
-}
|
|
|
-
|
|
|
/** Remove adapter-derived values before plugins propose the next request config. */
|
|
|
function requestProposal(header: EpochHeader): LlmCallConfig {
|
|
|
if (header.adapterDefaults === undefined) return header.config
|
|
|
@@ -290,6 +282,8 @@ export class ReactLoopAgent implements Agent {
|
|
|
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)
|
|
|
// max-tokens stays sticky: a later completed step must not
|
|
|
// downgrade the turn outcome.
|
|
|
@@ -342,17 +336,13 @@ export class ReactLoopAgent implements Agent {
|
|
|
signal.throwIfAborted()
|
|
|
const system = renderPrompt(assembly)
|
|
|
|
|
|
- // Keep the active attempt until it commits or fails so cancellation can
|
|
|
- // preserve the same streamed prefix in durable message history.
|
|
|
- let attempt: InterruptedAttempt | undefined
|
|
|
- try {
|
|
|
- while (true) {
|
|
|
- const { request, preparedCall } = await this.buildRequest(
|
|
|
- turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
|
|
|
- )
|
|
|
- const assembler = new BlockAssembler()
|
|
|
- const chunkSeqs: number[] = []
|
|
|
- attempt = { assembler, chunkSeqs, provider: request.provider, model: request.model }
|
|
|
+ while (true) {
|
|
|
+ const { request, preparedCall } = await this.buildRequest(
|
|
|
+ turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
|
|
|
+ )
|
|
|
+ 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) {
|
|
|
@@ -361,90 +351,72 @@ export class ReactLoopAgent implements Agent {
|
|
|
assembler.push(chunk)
|
|
|
}
|
|
|
signal.throwIfAborted()
|
|
|
- const finish = assembler.finish
|
|
|
- if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
|
- // Provider failures commit no assistant content. Clearing before the
|
|
|
- // recovery waterfall also prevents a cancellation during retry delay
|
|
|
- // from restoring the failed attempt after clients reset its stream.
|
|
|
- attempt = undefined
|
|
|
- const action = await this.dispatch.waterfall(
|
|
|
- 'agent/request-error', {
|
|
|
+ } catch (error: unknown) {
|
|
|
+ if (signal.aborted) {
|
|
|
+ const content = assembler.interruptedBlocks()
|
|
|
+ if (content.length > 0) {
|
|
|
+ this.session.append('assistant/message', {
|
|
|
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)
|
|
|
+ message: createAssistantMessage({
|
|
|
+ content,
|
|
|
+ source: { provider: request.provider, model: request.model },
|
|
|
+ }),
|
|
|
+ interrupted: true,
|
|
|
+ ...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
|
|
+ }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })
|
|
|
}
|
|
|
- continue
|
|
|
}
|
|
|
-
|
|
|
- const message = createAssistantMessage({
|
|
|
- content: assembler.blocks(),
|
|
|
- source: {
|
|
|
- provider: request.provider,
|
|
|
- model: request.model,
|
|
|
- ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
|
|
|
- },
|
|
|
- })
|
|
|
- attempt = undefined
|
|
|
- this.session.append(
|
|
|
- 'assistant/message',
|
|
|
- {
|
|
|
+ throw error
|
|
|
+ }
|
|
|
+ const finish = assembler.finish
|
|
|
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
|
+ const action = await this.dispatch.waterfall(
|
|
|
+ 'agent/request-error', {
|
|
|
turn,
|
|
|
step,
|
|
|
- message,
|
|
|
- ...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
|
|
+ provider: request.provider,
|
|
|
+ failure: finish.failure,
|
|
|
+ retryPolicy: preparedCall?.retryPolicy,
|
|
|
+ signal,
|
|
|
},
|
|
|
- { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
|
|
+ () => Promise.resolve<RequestErrorAction>(undefined),
|
|
|
)
|
|
|
- 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
|
|
|
- }
|
|
|
- } catch (error: unknown) {
|
|
|
- if (signal.aborted && attempt !== undefined) {
|
|
|
- this.appendInterruptedAssistant(turn, step, attempt)
|
|
|
+ signal.throwIfAborted()
|
|
|
+ if (action?.kind !== 'retry') {
|
|
|
+ throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
|
|
|
+ }
|
|
|
+ continue
|
|
|
}
|
|
|
- throw error
|
|
|
- }
|
|
|
- }
|
|
|
|
|
|
- /**
|
|
|
- * Append a cancelled attempt's delivered text and reasoning as an interrupted
|
|
|
- * assistant message. Undispatched tool calls and empty content are omitted;
|
|
|
- * the resulting durable history matches the prefix clients rendered.
|
|
|
- */
|
|
|
- private appendInterruptedAssistant(turn: number, step: number, attempt: InterruptedAttempt): void {
|
|
|
- const content = attempt.assembler.interruptedBlocks()
|
|
|
- if (content.length === 0) return
|
|
|
- const message = createAssistantMessage({
|
|
|
- content,
|
|
|
- source: { provider: attempt.provider, model: attempt.model },
|
|
|
- })
|
|
|
- this.session.append(
|
|
|
- 'assistant/message',
|
|
|
- {
|
|
|
- turn,
|
|
|
- step,
|
|
|
- message,
|
|
|
- interrupted: true,
|
|
|
- ...attempt.assembler.usage === undefined ? {} : { usage: attempt.assembler.usage },
|
|
|
- },
|
|
|
- { surfaceOp: 'append', sourceEventSeqs: attempt.chunkSeqs },
|
|
|
- )
|
|
|
+ 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
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|