refactor(agent-loop): 缩小取消前缀收尾范围
This commit is contained in:
parent
c91672a44a
commit
9620a752c8
4 changed files with 71 additions and 99 deletions
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
|
||||
2026-08-10-cancelled-stream-prefix-finalize.md: 722d5531820efe5640068f11582aef6485f1f9e1
|
||||
2026-08-10-cancelled-stream-prefix-finalize.zh.md: a48790fe5883e341148cd82053d4ca9008e4fe81
|
||||
2026-08-10-cancelled-stream-prefix-finalize.md: 0cae25b786922fba8204d68ca9c0a669e43d76a0
|
||||
2026-08-10-cancelled-stream-prefix-finalize.zh.md: e961ea6a51f74dcc244e4ad8970eae4cbe4c9a6c
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ The model history must contain assistant content that remains visible to the use
|
|||
|
||||
## Decision
|
||||
|
||||
`ReactLoopAgent.step()` retains the active `BlockAssembler`, logged chunk seqs, and provider route until the attempt commits or fails. Cancellation of an uncommitted attempt appends its delivered prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`.
|
||||
`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, logged chunk seqs, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`.
|
||||
|
||||
`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. An attempt ending with an `error` or `aborted` finish is cleared before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed attempt.
|
||||
`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. Provider `error` and `aborted` finishes leave the stream-consumption scope before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed request.
|
||||
|
||||
Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provenance. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ Status: implemented
|
|||
|
||||
## Decision
|
||||
|
||||
`ReactLoopAgent.step()` 会保留活跃的 `BlockAssembler`、已记录的分片 seq 和提供方路由,直到尝试提交或失败。取消未提交的尝试时,循环把已送达前缀追加为该 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`。
|
||||
`ReactLoopAgent.step()` 在消费模型流期间捕捉取消,此时 `BlockAssembler`、已记录的分片 seq 和提供方路由可以确定已送达前缀。循环把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`。
|
||||
|
||||
`BlockAssembler.interruptedBlocks()` 按流顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` 块。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时不追加 assistant 消息。以 `error` 或 `aborted` finish 结束的尝试会在 `agent/request-error` 前清空,因此提供方故障和恢复期间的取消都不会提交失败尝试的内容。
|
||||
`BlockAssembler.interruptedBlocks()` 按流顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` 块。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时不追加 assistant 消息。提供方的 `error` 和 `aborted` finish 会在 `agent/request-error` 前离开流消费范围,因此提供方故障和恢复期间的取消都不会提交失败请求的内容。
|
||||
|
||||
Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`。Chat 渲染 Stopped 标记,Trajectory 则在 `step/end` 后把提供方请求保持在 error 生命周期,并保留持久结果 seq 和提供方信息。工具执行期间的取消遵循工具调度器约定,因为 assistant 消息已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。
|
||||
|
||||
|
|
|
|||
|
|
@ -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,92 +351,74 @@ 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' }
|
||||
signal.throwIfAborted()
|
||||
if (action?.kind !== 'retry') {
|
||||
throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
throw error
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one frozen request and bind it to the adapter registration that
|
||||
* resolved its exact-model defaults.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue