主循环 SessionPrompt
四个入口、用户输入的展开、runLoop 的每一步,以及“一个会话只跑一个循环”的并发模型。
packages/opencode/src/session/prompt.ts 是 V1 会话引擎的调度中心。它不直接和模型对话——那是流处理器的事——而是决定:这一步用哪个模型、带哪些工具、先压缩还是先跑子任务、什么时候停。
入口
SessionPrompt.Interface(prompt.ts:102)的方法与服务端接口大体一一对应:
| 方法 | 服务端接口 | 用途 |
|---|---|---|
prompt | /session/:id/message、/prompt_async | 写入 user 消息,默认接着跑循环 |
command | /session/:id/command | 展开斜杠命令模板后转给 prompt |
shell | /session/:id/shell | 用户直接执行 shell 命令 |
loop | 无 | 不写新消息,从当前历史继续跑 |
cancel | /session/:id/abort | 中断会话 |
prompt 本身很薄(prompt.ts:1052):若会话处于撤销状态,先用 revert.cleanup 删掉被撤销的消息;再 createUserMessage;noReply 为真就此返回,否则进入 loop。
createUserMessage:把输入展开成 Part
先定 agent 与模型:模型按“输入 → agent 配置 → 会话当前模型”兜底(prompt.ts:646),与会话头记录不同时调用 setAgentModel 更新。
接着逐个解析输入 Part。这里有个关键设计:用户 @ 的文件被伪装成一次已经发生过的 Read 工具调用。
- 文本文件:真的执行一次 Read 工具(支持行范围),生成两条 synthetic 文本——“Called the Read tool with the following input: …”和工具输出——再附上原文件 Part。
- 目录:同样走 Read 工具,得到目录列表。
- 图片、PDF 等二进制:读成 base64 的
data:URL;MCP 资源同理展开,其二进制内容上限 10 MB。 agentPart(@ 某个子代理):保留原 Part,再追加一句 synthetic 提示,让模型调用 task 工具派发。
随后触发插件钩子 chat.message,图片统一缩放,schema 校验失败只记日志、不拒收,最后先写消息、再写 Part。命令模板里的 @路径 则由简化版的 resolvePromptParts 展开。
loop:一个会话只跑一个循环
loop(prompt.ts:1343)只做一件事:把 runLoop 交给 SessionRunState.ensureRunning。SessionRunState(run-state.ts)为每个会话维护一个 Runner(packages/opencode/src/effect/runner.ts),它是四态状态机:Idle、Running、Shell、ShellThenRun。
ensureRunning在Idle时启动runLoop;在Running时不另起循环,而是等待现有循环的结果;在Shell时把循环排到 shell 命令之后。startShell只在Idle时接受,否则返回SessionBusyError。cancel中断正在跑的 fiber,等待者拿到最近一条 assistant 消息;回到Idle时 Runner 把会话状态置为idle,而busy由runLoop每一步开头自己设置。
因此“连发几条消息”不需要显式队列:新 user 消息先落库,循环每一步都重新读取历史,下一步就会看到它。
runLoop:每一步做什么
runLoop(prompt.ts:1081)每一步:
- 读历史:
filterCompactedEffect返回压缩感知的历史,MessageV2.latest从中找出最后的 user 与 assistant,以及尚未处理的compaction/subtask任务。 - 判断是否结束,条件见下方摘录。
- 首步杂务:第 1 步 fork 出标题生成与差异统计,都不阻塞主流程。
- 取模型:找不到时发布带“Did you mean”建议的
session.error后终止。 - 先办任务:
subtask交给handleSubtask,compaction交给上下文压缩;上一条已完成的 assistant 用量已超限时,写入自动压缩请求后重来。 - 准备这一步:判断是否已到最大步数;
SessionReminders.apply注入 plan 模式等提醒(见系统提示词);新建 assistant 消息与处理器;SessionTools.resolve汇总注册表工具与 MCP 工具,要求结构化输出时再加StructuredOutput工具。 - 组装请求:system 依次是环境信息、指令文件、MCP 说明、Skills 列表(模型家族提示词稍后在 LLM 层前置);历史经
toModelMessagesEffect转成 AI SDK 消息。 - 调用并收尾:
handle.process返回"compact" | "stop" | "continue"。拿到结构化结果即退出;finish为content-filter时记ContentFilterError退出;stop退出;compact写入压缩请求,请求真正溢出(finish为空)时带上overflow标记。
循环退出后 fork 一次 compaction.prune,返回最后一条 assistant 消息。第 2 步的结束判断(prompt.ts:1103):
// Some providers return "stop" even when the assistant message contains
// tool calls. Keep the loop running so tool results can be sent back to
// the model, but ignore cleanup-marked interrupted orphans.
const hasToolCalls =
lastAssistantMsg?.parts.some(
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
) ?? false
if (
lastAssistant?.finish &&
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastAssistant.parentID === lastUser.id
) {四个条件同时成立才退出:有结束原因;结束原因不是 tool-calls 或 unknown;没有待回传结果的本地工具调用(注释说明有的提供方发起工具调用后仍返回 stop);这条 assistant 回应的正是最后一条 user 消息,保证新到的消息一定会被处理。
最大步数
agent.steps 默认无穷大。到达上限的那一步,工具表照常传入,只在消息末尾追加一条 assistant 角色的 MAX_STEPS_PROMPT(定义在 packages/core/src/session/runner/max-steps.ts),要求模型只用文字总结进度——约束完全靠提示词:
messages: [
...modelMsgs,
...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : []),
],(packages/opencode/src/session/prompt.ts:1279)
子任务与标题
handleSubtask(prompt.ts:255)不经过主会话的模型:直接新建 assistant 消息和一个 running 的 task 工具 Part,调用 task 工具执行子代理,再按结果改成 completed 或 error。来自斜杠命令的子任务还会追加一条 synthetic user 消息“Summarize the task tool output above and continue with your task.”。子代理见子代理、待办与提问。
ensureTitle(prompt.ts:193)只处理标题仍为默认值、且恰好有一条真实(非 synthetic)user 消息的根会话:用隐藏的 title agent 和提供方的小模型生成,去掉 <think> 块后取第一行,超过 100 字符截断。
shell 与 command
shellImpl(prompt.ts:451)服务于 TUI 输入框的 shell 模式:写一条 synthetic user 消息“The following tool was executed by the user”,再写 assistant 消息和一个 running 的 shell 工具 Part,以 TERM=dumb 执行命令并把输出边读边写进 Part。于是下一轮模型看到的,是一次“用户替它执行过”的工具调用。
command(prompt.ts:1356)展开斜杠命令:用 $1 到 $N 与 $ARGUMENTS 替换参数(最后一个编号占位符吞掉剩余参数),模板里感叹号加反引号包裹的 shell 片段先执行、再替换成输出。目标 agent 是 subagent(且未显式关闭)或命令声明 subtask: true 时,整条命令被包成一个 subtask Part 交给 handleSubtask。写法见Agent、命令与 Skills。
取消
cancel 先逐层取消会话及其子会话名下的后台任务,再中断 Runner;被中断的 assistant 消息由 finalizeInterruptedAssistant 补上 MessageAbortedError 与完成时间。
下一篇:流处理器与工具调用生命周期——handle.process 如何把模型事件流变成 Part。