fix: reconcile agent loop merge integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -99,7 +99,7 @@ interface Config {
|
||||
|
||||
#### 模型所见
|
||||
|
||||
已接纳的 user 消息、assistant 消息、工具调用与结果、注入上下文和 steering 都会记录,并在后续步骤中发送。原始流分片、生命周期边界和其他仅写入日志的事件会被排除。
|
||||
已接纳的 user 消息、assistant 消息、工具调用与结果、注入上下文和 steering(中途引导)都会记录,并在后续步骤中发送。原始流分片、生命周期边界和其他仅写入日志的事件会被排除。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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]!)
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: a65c53b3e4edf2031f286d7d172e73357c66ff1e
|
||||
README.zh.md: 05da8a0a0d3ad2ed879b72e20eae1c4efaf711c8
|
||||
README.md: 1a16c8ad5a8b7d6e06d4ac36a7b9978fda64e7b5
|
||||
README.zh.md: 4e03816707ba6a3a707697db1e6d8b117aa87a6a
|
||||
|
||||
@@ -58,7 +58,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
每个插件面向的 handle:
|
||||
|
||||
- `agent.send(input, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`input` 是既有的 `UserMessageData { content, source }`,而 `SendOptions` 只要求路由策略 `target` 与 `wakeup`。它返回被接受消息的不透明 `AgentMessageId`,由该消息的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件携带,调用方可据此把排队项与其生命周期关联。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering,而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。
|
||||
- `agent.send(input, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`input` 是既有的 `UserMessageData { content, source }`,而 `SendOptions` 只要求路由策略 `target` 与 `wakeup`。它返回被接受消息的不透明 `AgentMessageId`,由该消息的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件携带,调用方可据此把排队项与其生命周期关联。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。
|
||||
- `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。
|
||||
- `agent.steer(input)`:`next-step`/wakeup 预设:轮次打开时,为其下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;空闲时委托给会唤醒的后续轮次。取消或 dispose 可能丢弃待处理 steering。
|
||||
- `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。轮次打开时,注入在 outbox 中等待下一个安全边界。空闲时,它立即追加而不开启轮次;持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。
|
||||
|
||||
Reference in New Issue
Block a user