fix: reconcile agent loop merge integration

This commit is contained in:
_Kerman
2026-07-27 17:04:22 +08:00
parent bd40eec770
commit 749927ecd8
20 changed files with 197 additions and 177 deletions

View File

@@ -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 packages/core/agent-loop/README.md
README.md: 6ad982ffff7b73e16ee39f9c29da787e37547de4
README.zh.md: c72df198774f0f8009cc5ab43932e69187757745
README.md: 0edb9bf3fb73fd1d1e7c5000770eca87d4f58fcd
README.zh.md: 5d0ebc605e6c5ce29786b7113adaab3d5671b92e

View File

@@ -99,7 +99,7 @@ interface Config {
#### 模型所见
已接纳的 user 消息、assistant 消息、工具调用与结果、注入上下文和 steering 都会记录,并在后续步骤中发送。原始流分片、生命周期边界和其他仅写入日志的事件会被排除。
已接纳的 user 消息、assistant 消息、工具调用与结果、注入上下文和 steering(中途引导)都会记录,并在后续步骤中发送。原始流分片、生命周期边界和其他仅写入日志的事件会被排除。
#### Token 影响

View File

@@ -27,7 +27,7 @@ import type {
import {
BlockAssembler, LlmError, assertNever, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { Session, SessionId, TurnEndReason, TurnTrigger, UserMessageData } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
@@ -68,6 +68,8 @@ export class ReactLoopAgent implements Agent {
/** Whether the session log is owed a matching turn end event. */
private turnOpen = false
private stepOpen = false
/** Whether this loop instance has appended its initial/resume request anchor. */
private requestHeaderLogged = false
constructor(
private loopCtx: Context,
@@ -445,11 +447,13 @@ export class ReactLoopAgent implements Agent {
this.stepOpen = true
signal.throwIfAborted()
const request = await this.buildRequest(turn, step, assembly.tools, system, boundaryMessages, signal)
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, boundaryMessages, signal,
)
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = this.loopCtx.llm.stream(request)
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
try {
for await (const chunk of stream) {
signal.throwIfAborted()
@@ -518,9 +522,8 @@ export class ReactLoopAgent implements Agent {
}
/**
* Compose one frozen request: the `agent/request` config waterfall, the
* canonical logged header, then the header plus the boundary snapshot,
* byte-for-byte.
* Compose one frozen request and bind it to the adapter registration that
* resolved its exact-model defaults.
*/
private async buildRequest(
turn: number,
@@ -529,40 +532,69 @@ export class ReactLoopAgent implements Agent {
system: string,
boundaryMessages: Message[],
signal: AbortSignal,
): Promise<GenerateOptions> {
): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
const { session } = this
// Seed from the logged header when the log has one (the log is the
// truth, across resumes too), else from agent options; freeze so
// listeners must return a replacement.
const loggedConfig = session.requestHeader()?.config
const initialProvider = this.options.provider ?? ''
const initialModel = this.options.model ?? ''
const initialConfig: LlmCallConfig = {
provider: initialProvider,
model: initialModel,
...loggedConfig?.provider === initialProvider
&& loggedConfig.model === initialModel
&& loggedConfig.reasoningEffort !== undefined
? { reasoningEffort: loggedConfig.reasoningEffort }
: {},
}
// A loop instance starts from its declared route, restoring only an opaque
// effort owned by that exact model. Later steps fold the config it logged.
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(
session.requestHeader()?.config
?? { provider: this.options.provider ?? '', model: this.options.model ?? '' }))
const config = await this.loopCtx.waterfall(
this.requestHeaderLogged
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- a logged instance anchor guarantees the fold
? session.requestHeader()!.config
: initialConfig,
))
const proposedConfig = await this.loopCtx.waterfall(
agentCarrier(this), 'agent/request', this, turn, step, signal,
() => Promise.resolve(seedConfig),
)
signal.throwIfAborted()
if (!config.provider || !config.model) {
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) {
// A llm/stream listener may own and short-circuit a route with no
// adapter. Terminal dispatch still raises NO_ADAPTER when none does.
if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
config = proposedConfig
}
signal.throwIfAborted()
const header = canonicalHeader({
config,
...system ? { system } : {},
...tools.length > 0 ? { tools } : {},
})
// Log the header the request will use only when it differs
// from the folded baseline — reconstruction folds the log, so an
// unchanged header needs no new snapshot.
const baseline = session.requestHeader()
if (baseline === undefined || !headerEquals(baseline, header)) {
session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'change' })
if (!this.requestHeaderLogged) {
session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
this.requestHeaderLogged = true
} else if (baseline === undefined || !headerEquals(baseline, header)) {
session.append('request/header', { header, reason: 'change' })
}
return markAgentLoopRequest(deepFreeze({
const request = markAgentLoopRequest(deepFreeze({
provider: header.config.provider,
model: header.config.model,
...header.config.reasoningEffort !== undefined
? { reasoningEffort: header.config.reasoningEffort }
: {},
messages: boundaryMessages,
...header.system !== undefined ? { system: header.system } : {},
...header.tools !== undefined ? { tools: header.tools } : {},
@@ -572,6 +604,7 @@ export class ReactLoopAgent implements Agent {
sessionId: session.id,
signal,
}))
return { request, ...preparedCall === undefined ? {} : { preparedCall } }
}
/** Commit the outbox and report whether it contained steering. */

View File

@@ -234,7 +234,7 @@ describe('request stability across the loop', () => {
await handle.dispose()
expect(signal.aborted).toBe(true)
expect(handle.agent.status).toBe('disposed')
expect(handle.agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
expect(handle.agent.session.events.some(event => event.type === 'request/header')).toBe(false)
})
@@ -252,7 +252,9 @@ describe('request stability across the loop', () => {
}([])
const ctx = await harness(adapter)
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), {
provider: 'mock',
model: 'mock',
@@ -395,7 +397,8 @@ describe('request stability across the loop', () => {
await waitForIdle(ctx2, agent2)
const snapshots = agent2.session.events.filter(e => e.type === 'request/header')
expect(snapshots).toHaveLength(1)
expect(snapshots).toHaveLength(2)
expect(snapshots[1]?.data.reason).toBe('resume')
// Identical header across the restart: byte-identical continuation.
expect(adapter2.requests[0]!.system).toEqual(adapter.requests[0]!.system)
expectPrefixExtension(adapter.requests[0]!, adapter2.requests[0]!)