对应问题 Q3:逐行追踪
prompt.ts:1081-1632的runLoop一次完整while(true)迭代,覆盖消息加载、latest 判断、工具组装、指令发现、流处理、三个 continue 站点与 break 条件。 勘察依据:packages/opencode/src/session/prompt.ts、processor.ts、message-v2.ts、tools.ts、instruction.ts、compaction.ts。
一、runLoop 函数签名与入口
ts
// 1081:1086:opencode/packages/opencode/src/session/prompt.ts
const runLoop: (sessionID: SessionID) => Effect.Effect<SessionV1.WithParts> = Effect.fn("SessionPrompt.run")(
function* (sessionID: SessionID) {
const ctx = yield* InstanceState.context
let structured: unknown
let step = 0
const session = yield* sessions.get(sessionID).pipe(Effect.orDie)
Effect.fn("SessionPrompt.run")------ 带追踪命名的入口函数,日志和 span 中可见InstanceState.context------ 获取当前工作区上下文(directory + worktree)structured------ 捕获结构化输出(json_schema 模式)step------ 循环步数计数器,从 0 开始session------ 加载会话元信息(permission、parentID 等)
调用链:CLI.RunCommand → session.prompt() → loop() → state.ensureRunning(sessionID, lastAssistant, runLoop(sessionID)) → runLoop 进入 while(true)。
二、while(true) 迭代的 7 个阶段
ts
// 1088:1130:opencode/packages/opencode/src/session/prompt.ts
while (true) {
yield* status.set(sessionID, { type: "busy" })
yield* Effect.logInfo("loop", { "session.id": sessionID, step })
let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe(
Effect.provideService(Database.Service, database),
)
const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
const lastAssistantMsg = msgs.findLast(
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
)
const hasToolCalls =
lastAssistantMsg?.parts.some(
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
) ?? false
if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
const orphan = lastAssistantMsg?.parts.find(
(part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
)
if (orphan) {
yield* Effect.logWarning("loop exit with orphaned interrupted tool", { ... })
}
yield* Effect.logInfo("exiting loop", { "session.id": sessionID })
break
}
阶段 1:状态置 busy + 加载消息历史
(1) MessageV2.filterCompactedEffect 如何加载消息历史
ts
// 574:576:opencode/packages/opencode/src/session/message-v2.ts
export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) {
return filterCompacted(yield* stream(sessionID))
})
它做两件事:
第一步:stream(sessionID) ------ 从 SQLite 增量加载会话全部消息(按时间序),每条消息带 info(元信息)和 parts(内容片段:text/reasoning/tool/step-start/step-finish/patch/compaction/subtask)。
第二步:filterCompacted(msgs) ------ 重排消息以适应 compaction 后的模型消费。核心逻辑:
ts
// 521:572:opencode/packages/opencode/src/session/message-v2.ts
export function filterCompacted(msgs: Iterable<WithParts>) {
const result = [] as WithParts[]
const completed = new Set<string>()
let retain: MessageID | undefined
for (const msg of msgs) {
result.push(msg)
if (retain) {
if (msg.info.id === retain) break
continue
}
if (msg.info.role === "user" && completed.has(msg.info.id)) {
const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")
if (!part) continue
if (!part.tail_start_id) break
retain = part.tail_start_id
if (msg.info.id === retain) break
continue
}
// ...
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
completed.add(msg.info.parentID)
}
result.reverse()
// 重排:[compaction-user, summary, ...retained tail..., continue-user]
// ...
}
重排后的消息结构为 [compaction-user, summary, ...retained tail..., continue-user]------compaction 摘要放前面,保留的近期 tail 放中间,最新用户消息放最后。这让模型看到的上下文是"摘要 + 近期对话"而非"完整历史"。
为什么用 fnUntraced :每次 while(true) 迭代都调用,是热路径,不需要 span 开销。
阶段 2:latest() 提取关键消息
(2) lastUser/lastAssistant/lastFinished/tasks 的用途
ts
// 585:601:opencode/packages/opencode/src/session/message-v2.ts
export function latest(msgs: WithParts[]) {
let user: User | undefined
let assistant: Assistant | undefined
let finished: Assistant | undefined
for (const msg of msgs) {
const info = msg.info
if (info.role === "user" && (!user || info.id > user.id)) user = info
if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info
if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info
}
const tasks = msgs.flatMap((m) =>
finished && m.info.id <= finished.id
? []
: m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"),
)
return { user, assistant, finished, tasks }
}
为什么用 max id 而非数组位置 :filterCompacted 重排了消息顺序,数组位置不再等于时间顺序。MessageID.ascending() 保证 ID 单调递增,所以用 info.id > user.id 找最新。
四个返回值的用途:
| 返回值 | 含义 | 用途 |
|---|---|---|
lastUser |
最新用户消息 | 提取 agent/model/format 配置,决定本轮用什么 Agent 和模型 |
lastAssistant |
最新助手消息 | 判断 finish 状态,决定是否 break 退出循环 |
lastFinished |
最新已完成的助手消息(有 finish 字段) | 判断 token 是否 overflow,触发压缩 |
tasks |
未处理的 compaction/subtask 部分(比 finished 更新的) |
驱动三个 continue 站点中的两个 |
tasks 的精确定义 :finished 之后的消息中,类型为 compaction 或 subtask 的 part。即"最新已完成助手消息之后还堆积的待处理工作"。tasks 是数组,pop() 从尾部取最新的。
阶段 3:break 退出判断
ts
// 1106:1130:opencode/packages/opencode/src/session/prompt.ts
const hasToolCalls =
lastAssistantMsg?.parts.some(
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
) ?? false
if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
// ... orphan check ...
yield* Effect.logInfo("exiting loop", { "session.id": sessionID })
break
}
(7) break 退出的完整条件
break 需要四个条件同时满足:
lastAssistant?.finish------ 最新助手消息有 finish 状态(非 undefined)!["tool-calls"].includes(lastAssistant.finish)------ finish 不是"tool-calls"(tool-calls 表示模型要调工具,需要继续循环)!hasToolCalls------ 助手消息中没有待执行的工具调用(排除 provider 执行的 和 orphaned interrupted 的)lastUser.id < lastAssistant.id------ 最新助手消息比最新用户消息更新(即助手已经响应当前用户消息)
hasToolCalls 的排除项:
!part.metadata?.providerExecuted------ 排除 provider 端执行的工具(如 OpenAI web_search)!isOrphanedInterruptedTool(part)------ 排除中断遗留的孤儿工具调用
orphan 检查:如果 break 时发现孤儿工具,记录 warning 但仍然 break------这是清理路径,不阻塞退出。
阶段 4:step 计数 + 模型解析 + task 分发
ts
// 1132:1168:opencode/packages/opencode/src/session/prompt.ts
step++
if (step === 1)
yield* title({ session, modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, history: msgs })
.pipe(Effect.ignore, Effect.forkIn(scope))
const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)
const task = tasks.pop()
if (task?.type === "subtask") {
yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs })
continue
}
if (task?.type === "compaction") {
const result = yield* compaction.process({
messages: msgs, parentID: lastUser.id, sessionID, auto: task.auto, overflow: task.overflow,
})
if (result === "stop") break
continue
}
if (
lastFinished &&
lastFinished.summary !== true &&
(yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
) {
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
continue
}
step === 1时异步生成会话标题(Effect.forkIn不阻塞循环)getModel解析 provider + modelID 到具体模型实例tasks.pop()取最新的待处理 task
(6) 三个 continue 站点的触发条件
站点 1:subtask continue(第 1146 行)
ts
// 1144:1147:opencode/packages/opencode/src/session/prompt.ts
if (task?.type === "subtask") {
yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs })
continue
}
触发条件 :tasks 中有 type === "subtask" 的 part------这是 TaskTool 创建的子 Agent 任务(前台/后台模式)。handleSubtask 创建助手消息、执行 TaskTool、更新 part 状态,然后 continue 让循环重新加载消息并检查后续状态。
站点 2:compaction process continue(第 1158 行)
ts
// 1149:1159:opencode/packages/opencode/src/session/prompt.ts
if (task?.type === "compaction") {
const result = yield* compaction.process({
messages: msgs, parentID: lastUser.id, sessionID, auto: task.auto, overflow: task.overflow,
})
if (result === "stop") break
continue
}
触发条件 :tasks 中有 type === "compaction" 的 part------这是之前 compaction.create() 插入的压缩任务。compaction.process 用 compaction agent 生成摘要,返回 "stop"(压缩失败,break)或继续。continue 让循环用压缩后的消息重新开始。
站点 3:overflow create continue(第 1167 行)
ts
// 1161:1168:opencode/packages/opencode/src/session/prompt.ts
if (
lastFinished &&
lastFinished.summary !== true &&
(yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
) {
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
continue
}
触发条件 :lastFinished(最新已完成的助手消息)存在,且不是 summary 消息,且 isOverflow 判断 token 超限。compaction.create 只插入一个 compaction part(不立即执行),continue 让循环在下一轮的"站点 2"处理它。这是"检测 → 创建任务 → 下一轮执行"的两步模式。
isOverflow 的判断逻辑:
ts
// 168:178:opencode/packages/opencode/src/session/compaction.ts
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input) {
return overflow({
cfg: yield* config.get(),
tokens: input.tokens,
model: input.model,
outputTokenMax: flags.outputTokenMax,
})
})
它检查 tokens(input + output + cache)是否超过模型上下文窗口的可用容量。
阶段 5:Agent 解析 + 提醒注入 + 助手消息创建
ts
// 1170:1219:opencode/packages/opencode/src/session/prompt.ts
const agent = yield* agents.get(lastUser.agent)
// ... agent not found error handling ...
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(...)
const msg: SessionV1.Assistant = {
id: MessageID.ascending(),
parentID: lastUser.id,
role: "assistant",
mode: agent.name,
agent: agent.name,
// ... cost/tokens/model/time ...
}
yield* sessions.updateMessage(msg)
const finalizeInterruptedAssistant = Effect.gen(function* () {
if (msg.time.completed) return
msg.error ??= MessageV2.fromError(new DOMException("Aborted", "AbortError"), { ... aborted: true })
msg.time.completed = Date.now()
yield* sessions.updateMessage(msg)
})
const handle = yield* processor.create({ assistantMessage: msg, sessionID, model })
.pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant))
agents.get(lastUser.agent)------ 获取用户消息指定的 Agent 配置maxSteps/isLastStep------ Agent 步数限制,最后一步注入MAX_STEPS_PROMPT并禁工具SessionReminders.apply------ 注入结构化提醒(Plan 模式、步数限制提示等)- 创建助手消息
msg(初始 cost=0、tokens=0),写入 SQLite finalizeInterruptedAssistant------ 中断时的清理闭包,标记为 AbortedErrorprocessor.create------ 创建 Processor Handle,携带onInterrupt钩子
阶段 6:工具组装 + 指令发现 + LLM 流处理
ts
// 1221:1286:opencode/packages/opencode/src/session/prompt.ts
const outcome: "break" | "continue" = yield* Effect.gen(function* () {
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
const promptOps = yield* ops()
const tools = yield* SessionTools.resolve({
agent, session, model, processor: handle, bypassAgentCheck, messages: msgs, promptOps,
}).pipe(...)
if (lastUser.format?.type === "json_schema") {
tools["StructuredOutput"] = createStructuredOutputTool({ schema: lastUser.format.schema, onSuccess(output) { structured = output } })
}
// ... summary fork ...
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
sys.skills(agent),
sys.environment(model),
instruction.system().pipe(Effect.orDie),
sys.mcp(agent, session.permission),
MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [...env, ...instructions, ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : [])]
const format = lastUser.format ?? { type: "text" as const }
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
const result = yield* handle.process({
user: lastUser, agent, permission: session.permission, sessionID,
parentSessionID: session.parentID, system, messages: [...modelMsgs, ...(isLastStep ? [MAX_STEPS_PROMPT_MSG] : [])],
tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined,
})
(3) SessionTools.resolve 如何根据 Agent 配置组装工具
ts
// 41:134:opencode/packages/opencode/src/session/tools.ts
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
agent: Agent.Info
model: Provider.Model
session: Session.Info
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
bypassAgentCheck: boolean
messages: SessionV1.WithParts[]
promptOps: TaskPromptOps
}) {
const tools: Record<string, AITool> = {}
// ...
for (const item of yield* registry.tools({
modelID: ModelV2.ID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
permission: input.session.permission,
})) {
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
tools[item.id] = tool({
description: item.description,
inputSchema: jsonSchema(schema),
execute(args, options) {
return run.promise(Effect.gen(function* () {
const ctx = context(args, options)
yield* plugin.trigger("tool.execute.before", { tool: item.id, ... }, { args })
const result = yield* item.execute(args, ctx)
// ... plugin.trigger("tool.execute.after") ...
return output
}))
},
})
}
// ... MCP resource tools ...
工具组装的四步流程:
registry.tools({ modelID, providerID, agent, permission })------ 从工具注册表查询可用工具。按 Agent 配置(agent.tools白名单/黑名单)和会话权限过滤ProviderTransform.schema------ 按模型 provider 转换工具 schema(不同 provider 的 JSON Schema 细节差异)tool({ description, inputSchema, execute })------ 包装成 AI SDK 的AITool对象。execute内部通过EffectBridge把 Effect 工具执行桥接到 Promise- MCP 资源工具 ------ 如果有 MCP 服务器支持 resources,额外注入
list_mcp_resources、read_mcp_resource等工具
context 工厂 为每个工具执行提供 Tool.Context:sessionID、abortSignal、messageID、callID、agent、messages、metadata 回调、ask(权限请求)。权限通过 Permission.merge(input.agent.permission, input.session.permission) 合并 Agent 级和会话级权限规则。
结构化输出 :如果 lastUser.format?.type === "json_schema",额外注入 StructuredOutput 工具,强制模型调用它返回结构化数据,onSuccess 回调把结果存入 structured 变量。
(4) instruction.system() 如何发现 AGENTS.md
ts
// 155:169:opencode/packages/opencode/src/session/instruction.ts
const system = Effect.fn("Instruction.system")(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter((item) => item.startsWith("https://") || item.startsWith("http://"))
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
return [
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
})
发现路径 (systemPaths 函数):
ts
// 60:68:opencode/packages/opencode/src/session/instruction.ts
const globalFiles = [
path.join(global.config, "AGENTS.md"),
...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
]
const instructionFiles = [
"AGENTS.md",
...(!flags.disableClaudeCodePrompt ? ["CLAUDE.md"] : []),
"CONTEXT.md", // deprecated
]
三层发现:
- 全局层 :
~/.config/opencode/AGENTS.md(或~/.claude/CLAUDE.md),全局指令 - 项目层 :从
ctx.directory向上findUp查找AGENTS.md/CLAUDE.md/CONTEXT.md,第一个匹配即停止(不叠加祖先目录) - 配置层 :
opencode.json的instructions数组,支持本地路径(glob)和远程 URL(http/https)
读取 :本地文件 fs.readFileString(并发 8),远程 URL http.execute + 5 秒超时(并发 4)。返回 ["Instructions from: <path>\n<content>", ...] 字符串数组。
systemPaths 的"第一个项目级匹配 wins"原则:
ts
// 122:132:opencode/packages/opencode/src/session/instruction.ts
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of instructionFiles) {
const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree).pipe(Effect.catch(() => Effect.succeed([])))
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break
}
}
}
system prompt 组装顺序 :[...env, ...instructions, ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : [])]------环境信息 + AGENTS.md 指令 + MCP 指令 + Skill 指令。
阶段 7:handle.process() 流处理与结果判断
(5) processor 如何按事件类型驱动
handle.process() 把 LLM 流交给 processor.ts。核心是 handleEvent 函数,按 LLMEvent.type 分发:
ts
// 627:683:opencode/packages/opencode/src/session/processor.ts
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
ctx.needsCompaction = false
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
return yield* Effect.gen(function* () {
yield* Effect.gen(function* () {
ctx.currentText = undefined
ctx.reasoningMap = {}
yield* status.set(ctx.sessionID, { type: "busy" })
const stream = llm.stream(streamInput)
yield* stream.pipe(
Stream.tap((event) => handleEvent(event)),
Stream.takeUntil(() => ctx.needsCompaction),
Stream.runDrain,
)
}).pipe(
Effect.onInterrupt(() => Effect.gen(function* () { aborted = true; if (!ctx.assistantMessage.error) yield* halt(...) })),
Effect.catchCauseIf((cause) => !Cause.hasInterruptsOnly(cause), (cause) => Effect.fail(Cause.squash(cause))),
Effect.retry(SessionRetry.policy({ provider: input.model.providerID, parse, set: ... })),
Effect.catch(halt),
Effect.ensuring(cleanup()),
)
if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
return "continue"
})
})
流处理管道:
llm.stream(streamInput)------ 调用 LLM 获取事件流Stream.tap(handleEvent)------ 每个事件交给handleEvent处理Stream.takeUntil(() => ctx.needsCompaction)------ 检测到 overflow 时提前终止流Stream.runDrain------ 消费完整个流Effect.retry(SessionRetry.policy)------ 指数退避重试(provider 错误)Effect.catch(halt)------ 错误处理(ContextOverflowError 设needsCompaction)Effect.ensuring(cleanup)------ 清理(快照 patch、未完成工具标记 error)
handleEvent 的事件类型驱动 (processor.ts:278-537):
ts
// 278:537:opencode/packages/opencode/src/session/processor.ts
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
switch (value.type) {
case "reasoning-start": // 创建 reasoning part,写入 SQLite
case "reasoning-delta": // 增量追加文本,updatePartDelta
case "reasoning-end": // 结束 reasoning,设 time.end
case "tool-input-start": // ensureToolCall(创建/更新 tool part 为 pending)
case "tool-input-delta": // ensureToolCall(流式工具输入)
case "tool-input-end": // ensureToolCall(工具输入完成)
case "tool-call": // 更新 tool part 为 running + input + DOOM_LOOP 检测
case "tool-result": // completeToolCall(设 completed + output + attachments)
case "tool-error": // failToolCall(设 error 状态)
case "provider-error": // throw new Error(value.message)
case "step-start": // 捕获快照,写入 step-start part
case "step-finish": // 捕获快照,计算 usage/cost,写入 step-finish part + patch,异步 summary,检测 overflow
case "text-start": // 创建 text part
case "text-delta": // 增量追加文本
case "text-end": // 触发 plugin "experimental.text.complete",结束 text part
case "finish": // 空操作(finish 信息在 step-finish 中处理)
}
})
关键事件处理细节:
tool-call 的 DOOM_LOOP 检测:
ts
// 356:380:opencode/packages/opencode/src/session/processor.ts
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) // DOOM_LOOP_THRESHOLD = 3
if (
recentParts.length !== DOOM_LOOP_THRESHOLD ||
!recentParts.every(
(part) =>
part.type === "tool" &&
part.tool === value.name &&
part.state.status !== "pending" &&
JSON.stringify(part.state.input) === JSON.stringify(input),
)
) {
return
}
const agent = yield* agents.get(ctx.assistantMessage.agent)
yield* permission.ask({
permission: "doom_loop",
patterns: [value.name],
sessionID: ctx.assistantMessage.sessionID,
metadata: { tool: value.name, input },
always: [value.name],
ruleset: agent.permission,
})
如果最近 3 个工具调用都是同名工具 + 相同输入 + 非 pending ,触发 doom_loop 权限询问------防止 Agent 陷入无限循环。
step-finish 的 overflow 检测:
ts
// 477:483:opencode/packages/opencode/src/session/processor.ts
if (
!ctx.assistantMessage.summary &&
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
) {
ctx.needsCompaction = true
}
step-finish 时检查 token 是否 overflow,如果是则设 ctx.needsCompaction = true,Stream.takeUntil 会在下一个事件前终止流。
process 的返回值:
ts
// 679:681:opencode/packages/opencode/src/session/processor.ts
if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
return "continue"
"compact"------ overflow 了,需要压缩"stop"------ 权限拒绝(blocked)或出错(error)"continue"------ 正常完成,继续下一轮
阶段 8:outcome 判断与循环控制
ts
// 1288:1336:opencode/packages/opencode/src/session/prompt.ts
if (structured !== undefined) {
handle.message.structured = structured
handle.message.finish = handle.message.finish ?? "stop"
yield* sessions.updateMessage(handle.message)
return "break" as const
}
const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
if (finished && !handle.message.error) {
if (handle.message.finish === "content-filter") {
handle.message.error = new SessionV1.ContentFilterError({ ... }).toObject()
yield* sessions.updateMessage(handle.message)
yield* events.publish(Session.Event.Error, { sessionID, error: handle.message.error })
return "break" as const
}
if (format.type === "json_schema") {
handle.message.error = new SessionV1.StructuredOutputError({ ... }).toObject()
yield* sessions.updateMessage(handle.message)
return "break" as const
}
}
if (result === "stop") return "break" as const
if (result === "compact") {
yield* compaction.create({
sessionID, agent: lastUser.agent, model: lastUser.model, auto: true, overflow: !handle.message.finish,
})
}
return "continue" as const
outcome 的判断顺序(短路求值):
structured !== undefined→ break ------ 结构化输出成功捕获content-filter→ break ------ 内容被 provider 过滤,报错json_schema未产出结构化 → break ------ 结构化输出失败result === "stop"→ break ------ processor 返回 stop(权限拒绝/错误)result === "compact"→ 创建 compaction task → continue ------ processor 检测 overflow- 默认 → continue ------ 正常继续下一轮
ts
// 1330:1336:opencode/packages/opencode/src/session/prompt.ts
}).pipe(
Effect.ensuring(instruction.clear(handle.message.id)),
Effect.onInterrupt(() => finalizeInterruptedAssistant),
)
if (outcome === "break") break
continue
Effect.ensuring(instruction.clear(handle.message.id))------ 清理指令文件声明跟踪Effect.onInterrupt(finalizeInterruptedAssistant)------ 中断时标记 AbortedErroroutcome === "break"→break退出 while- 否则
continue进入下一轮
阶段 9:循环退出后
ts
// 1338:1339:opencode/packages/opencode/src/session/prompt.ts
yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope))
return yield* lastAssistant(sessionID)
compaction.prune------ 异步清理过期 compaction(Effect.ignore忽略错误,Effect.forkIn不阻塞)lastAssistant(sessionID)------ 返回最新助手消息(带 parts)
三、完整迭代流程图
bash
runLoop(sessionID) 入口
│
├── 加载 ctx / session / step=0
│
└── while(true) ──────────────────────────────────────────────────────┐
│ │
├── [1] status.set(busy) + logInfo("loop") │
├── [1] msgs = MessageV2.filterCompactedEffect(sessionID) │
│ └── stream() 从 SQLite 加载 → filterCompacted() 重排 │
│ │
├── [2] {lastUser, lastAssistant, lastFinished, tasks} = latest() │
│ └── 按 max id 找最新 user/assistant/finished │
│ │
├── [3] break 判断: │
│ lastAssistant.finish && ≠"tool-calls" && !hasToolCalls │
│ && lastUser.id < lastAssistant.id │
│ → break ──────────────────────────────────────► 退出循环 │
│ │
├── [4] step++ + (step==1 时异步 title) + getModel + task=pop() │
│ │
├── [4a] task?.type === "subtask" │
│ → handleSubtask() → continue ──────────────────────────► │
│ │
├── [4b] task?.type === "compaction" │
│ → compaction.process() → result=="stop"?break:continue ─> │
│ │
├── [4c] lastFinished && !summary && isOverflow │
│ → compaction.create() → continue ───────────────────────► │
│ │
├── [5] agents.get(lastUser.agent) + maxSteps + SessionReminders │
│ + 创建助手消息 msg + processor.create(handle) │
│ │
├── [6] SessionTools.resolve(agent, session, model, permission) │
│ → registry.tools() 过滤 → ProviderTransform.schema → │
│ → tool() 包装 + MCP 资源工具 + StructuredOutput 工具 │
│ │
├── [6] instruction.system() 发现 AGENTS.md/CLAUDE.md/CONTEXT.md │
│ → 全局层 + 项目层(findUp 第一个匹配) + 配置层(URL/glob) │
│ │
├── [6] handle.process({ system, messages, tools, model }) │
│ └── llm.stream() → Stream.tap(handleEvent) │
│ ├── reasoning-start/delta/end → reasoning part │
│ ├── text-start/delta/end → text part │
│ ├── tool-input-start/delta/end → ensureToolCall │
│ ├── tool-call → 更新 running + DOOM_LOOP 检测 │
│ ├── tool-result → completeToolCall │
│ ├── tool-error → failToolCall │
│ ├── step-start → 快照 │
│ ├── step-finish → usage/cost + patch + overflow 检测 │
│ └── provider-error → throw │
│ └── takeUntil(needsCompaction) + retry + catch(halt) │
│ └── 返回 "compact" | "stop" | "continue" │
│ │
├── [7] outcome 判断: │
│ structured? → break │
│ content-filter? → break │
│ json_schema 失败? → break │
│ result=="stop"? → break │
│ result=="compact"? → compaction.create() → continue │
│ 默认 → continue │
│ │
└── outcome=="break" ? break : continue ──────────────────────────┘
│
break ──► compaction.prune() + return lastAssistant(sessionID)
四、关键设计决策
1. "消息重排而非截断"的 compaction 策略
filterCompacted 不删除旧消息,而是把 compaction 摘要放前面、保留近期 tail 放中间、最新消息放后面。这让模型始终看到"摘要 + 近期上下文",比简单截断保留更多有效信息。
2. "检测 → 创建 task → 下一轮执行"的两步 compaction
overflow 检测(站点 3)只 compaction.create() 插入一个 part,不立即执行。continue 后下一轮迭代在站点 2 才真正 compaction.process()。这种分离让"检测"和"执行"解耦,避免在 overflow 状态下还强行执行压缩。
3. "DOOM_LOOP_THRESHOLD = 3"的轻量反思
没有 Claude Code 的异步复盘系统,但通过检测"连续 3 次同名工具 + 相同输入"触发 doom_loop 权限询问,提供最基本的循环检测。这是 V1 反思层的全部。
4. "事件类型驱动"的 processor 架构
handleEvent 用 switch(value.type) 按事件类型分发,每个事件类型独立处理。这让 LLM 流的解析与业务逻辑解耦------新增事件类型只需加一个 case,不影响其他事件处理。
5. "中断安全"的双重保障
finalizeInterruptedAssistant(阶段 5 创建)+ Effect.onInterrupt(阶段 7 注册)确保循环被中断时助手消息被正确标记为 AbortedError,不会留下半完成状态。cleanup()(Effect.ensuring)确保未完成工具被标记 error,快照 patch 被写入。