智能体循环(agent loop) 是 harness 对公共 Agent 契约的默认、具体实现。公共 Agent 接口声明的都是契约;本页讲述的是机器本身——packages/core/agent-loop/src/agent.ts 中的 ReactLoopAgent 类。它是构成 core 主干链的包之一,专门放在 agent-loop 里是因为它是随产品发布的循环:扩展插件只依赖 agent(从不直接依赖 agent-loop),因此驱动可以随时替换。
| 包 | 负责 |
|---|---|
@deepseek-ai/dsh-agent | Agent 接口、运行时注册表、agent/* 事件、inbox、发起人作用域(ctx.agents) |
@deepseek-ai/dsh-agent-loop | 具体驱动、工具调用调度器、智能体工厂(ctx.agentLoop) |
@deepseek-ai/dsh-session | 只追加的 SessionEvent 日志(ctx.sessions) |
@deepseek-ai/dsh-system-prompt | 提示组装(.loopCtx.systemPrompt) |
两个嵌套单元:turn 与 step
循环使用两个嵌套边界,两者的开启与关闭都会写入持久的会话事件。
step(步骤) 是工作单元:一次模型请求加上该请求产生的那些工具执行。它以 step/start 事件开启,以 step/end 关闭。在一个 step 内循环逐块流式接收模型输出,如果助手产出了 tool-call 内容块,就执行这些工具调用。
turn(轮次) 是一次对所接收输入的排空:以 turn/start 开启,然后只要还有续接请求就运行一步或多步,最后以携带 TurnEndReason 的 turn/end 关闭。当唤醒输入被认领后 turn 开始,当模型不再欠响应(没有活跃的工具调用、也没有新的 steering)或触发终止策略时 turn 结束。ReactLoopAgent 中的阶段状态机是一个三态判别联合:
type Phase =
| { kind: 'idle'; lastTurn: number }
| { kind: 'maintenance'; abort: AbortController; lastTurn: number; wakeRequested: boolean }
| { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }status 对 idle 和 maintenance 阶段都返回 idle,排空期间返回 running。每次阶段转换都会发布 agent/status。
驱动的主流程
kick() 是驱动入口:它不断排空 turn,直到没有续接请求为止,然后回到 idle。turn() 开启一个 turn 并运行它的 step 循环。这是来自 agent.ts 的真实主流程:
private async kick(): Promise<void> {
try {
while (await this.turn()) {}
} catch (_error) {
// Reported failures and cancellation are contained at the driver boundary.
} finally {
if (this.phase.kind === 'running') {
const { turn, wakeRequested } = this.phase
this.setPhase({ kind: 'idle', lastTurn: turn })
if (wakeRequested && this.inbox.hasPending) this.wakeDriver()
}
}
}turn() 认领第一个提议的 step,写入 step/start,把认领到的消息记为 user/message 事件,运行 step(),写入 step/end,然后决定是否继续。续接策略与 agent/turn-stopping 拦截出现在该循环的中段:
while (true) {
signal.throwIfAborted()
const step = phase.step + 1
const decision = await this.preStep(target, { turn, step })
if (decision.kind === 'reject') { turnEnds = { kind: 'blocked' }; return false }
if (turnEnds && decision.messages.length === 0) break
if (phase.step === 0 && decision.messages.length === 0) {
turnEnds = { kind: 'completed' }; return false
}
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' })
}
const stepEnd = await this.step(decision.assembly)
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'
}在哪里调用模型
step(assembly) 渲染系统提示,然后进入请求循环。每次迭代调用 buildRequest(),打开提供方流,把原始块追加到日志,并组装内容块。工具结果以 user/message 上下文的形式追加到 next-step inbox,从而重新进入循环:
while (true) {
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
)
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
for await (const chunk of stream) {
signal.throwIfAborted()
chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
assembler.push(chunk)
}
// error/aborted finish → agent/request-error waterfall, maybe retry
const message = createAssistantMessage({ content: assembler.blocks(), source: { provider: request.provider, model: request.model } })
this.session.append('assistant/message', { turn, step, message }, { 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
}模型调用本身走 ctx.llm.stream() / prepareCall() 接缝(详见 model-selection.md);每个可观测事实都先追加到会话日志,因此回放是纯粹的重新推导。
工具结果如何重新进入
executeToolCalls(位于 packages/core/agent-loop/src/tool-calls.ts)是回到模型历史的桥梁。它为每个助手的 tool-call 块规划一个 ToolExecutionInput,按模型顺序经作用域工具注册表分发,并提交每个结果。提交时把它与对应调用事件关联起来——这正是下一步派生所用的数据:
function appendToolResult(session, turn, step, block, result, callSeq) {
const message = createToolResultMessage({ callId: block.id, content: result.content, isError: result.isError })
session.append('tool/result', {
turn, step, message,
...result.error?.info ? { error: result.error.info } : {},
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
}sourceEventSeqs: [callSeq] 引用所记录的 tool/call 事件——这种相邻关系正是回放所需要的。工具结果可能携带 concludesTurn,使 turn 在其 step 处结束;否则返回的 additionalContexts 会被拼接到 next-step inbox,让下一步把它们当作 user/message 上下文读取。完整流水线见 tool-presentation.md。
循环的终止策略
当 TurnEndReason 表明结束时,turn 停止。持久的终止原因(来自 packages/core/session/src/types.ts):
| 原因 | 含义 |
|---|---|
completed | 所有 step 正常结束;没有活跃的工具调用,也没有提交新的 steering |
max-tokens | 至少一个 step 达到其输出 token 上限;即使后续 step 正常完成也保持粘滞 |
aborted | 取消请求打断了活跃 turn(携带 TurnEndCancelCause) |
blocked | 第一个提议的 step 被 agent/pre-step 拒绝 |
error | step/turn 失败;永远是结构化的 LlmFailure(非 LlmError 抛错经 errorChain 扁平化为 UNKNOWN 码) |
interrupted | 持久化在重载时关闭了因崩溃而孤儿化的 turn——循环从不主动发出 |
循环中唯一由数据驱动的提前停止工具循环,而不是策略:携带 concludesTurn 的 tool/result 会让 turn 在该 step 处结束。反向钩子是 agent/turn-stopping,一个在边界提交前运行的串行监听器,它可能引入新的 steering 工作。
错误处理
失败都会汇聚到 throwError,先实时报告为 agent/error,再进入驱动包含层。模型请求失败进入 agent/request-error waterfall,监听器可以返回 { kind: 'retry' }(默认 undefined 表示终止)。LlmError 会把结构化的事实原样保留在持久的 turn/end 中;其它错误扁平化为 UNKNOWN 码的文本。取消也被包含:中止时给跳过的工具调用记录合成错误结果,从而保证回放仍然有效。
会话与作用域
循环直接向 this.session(只追加的 SessionEvent 日志)追加事件,并随时通过 this.session.deriveMessages() 派生历史——它自身不保存任何 transcript 状态。它让每个驱动都在 ctx.agents.withInitiator() 内运行,因此从循环内到达的扩展代码都能读到发起智能体。它的作用域是 createScope(loopCtx, this)——活跃的 ReactLoopAgent 对象既充当作用域键又充当发起智能体,每次组装都通过 assembleContextFor 传入 scope: agent。