用户输入与会话
a3647eb025c7 · 官方 Release
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
02-session-message - 章节摘要:从 text + file prompt 出发,理解 HTTP payload 如何选择 agent/model、解析 typed parts、经插件后持久化,并在 noReply 与 agent loop 之间分流。
- 教程版本:
v1.18.16 - 源码基线:
a3647eb025c7615159d417dcc49fc39fdaeba65b - 章节元数据:/versions/v1-18-16/data/chapters.json
- 源码映射:/versions/v1-18-16/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/server/routes/instance/httpapi/groups/session.tspackages/opencode/src/server/routes/instance/httpapi/handlers/session.tspackages/opencode/src/session/prompt.tspackages/opencode/src/session/session.tspackages/opencode/src/session/message-v2.ts
本章以 OpenCode 源码版本
v1.18.16(提交a3647eb025c7)为证据基线。我们沿用上一章的典型输入:CLI 向一个 session 发送“解释这个项目”,并附带package.json。这里追踪的是源码允许的一条典型路径,不是一次真实会话录屏。
0. 本章学习目标
Section titled “0. 本章学习目标”学完这一章,你应该能够:
- 画出
HTTP payload -> SessionPrompt.prompt -> user message + parts -> agent loop的位置图。 - 区分 session、message、part 与 prompt input 四种数据。
- 解释 agent/model 为什么被记录在 user message,而不是只存在请求参数中。
- 沿源码追踪 text part 与本地 file part 怎样被解析、扩展并持久化。
- 说明
noReply、插件修改、文件读取失败和实验事件双写的边界。 - 为自己的 mini agent 设计一份可恢复的会话账本。
1. 一句话讲明白
Section titled “1. 一句话讲明白”用户输入与会话层把一次短暂的外部请求,转换成 session 中可持久化、可重放的 user message 与 typed parts,再把这份稳定事实交给 agent loop。
本章的中心问题是:
CLI 发来的文本和附件,为什么不能原样塞进一个字符串就交给模型?
因为后续循环需要知道这条输入属于哪个 session、选择了哪个 agent/model、哪些内容来自用户、哪些是文件解析产生的 synthetic context,还要支持恢复、插件、事件与多种附件。证据入口见 packages/opencode/src/session/prompt.ts、packages/opencode/src/session/prompt.ts。
v1.18.16 相比旧基线改变了什么
Section titled “v1.18.16 相比旧基线改变了什么”prompt仍坚持“先保存 user message 与 parts,再决定是否进入 loop”,并把临时tools开关转换成 session permission rules。- message 读取接口现在更明确地提供
page、stream、parts与latest:账本既支持分页恢复,也支持按 session 顺序消费。 - 消息契约进一步下沉到
SessionV1公共类型;教程因此不再把 runtime 本地文件误当成唯一 schema 来源。
先持久化,再进入 loop
packages/opencode/src/session/prompt.ts:1046-1071
noReply 和临时工具权限都在持久化之后处理。
1046 yield* sessions.updateMessage(info)等待 Effect 结果。1047 for (const part of parts) yield* sessions.updatePart(part)等待 Effect 结果。10481049 return { info, parts }返回给上一层。1050 }, Effect.scoped)Effect 异步工作流。10511052 const prompt: (input: PromptInput) => Effect.Effect<SessionV1.WithParts, Image.Error> = Effect.fn(Effect 异步工作流。1053 "SessionPrompt.prompt",把输入交给会话主流程。1054 )(function* (input: PromptInput) {1055 const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)Effect 异步工作流。1056 yield* revert.cleanup(session)等待 Effect 结果。1057 const message = yield* createUserMessage(input)等待 Effect 结果。1058 yield* sessions.touch(input.sessionID)等待 Effect 结果。10591060 const permissions: PermissionV1.Rule[] = []1061 for (const [t, enabled] of Object.entries(input.tools ?? {})) {遍历集合。1062 permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })1063 }1064 if (permissions.length > 0) {按条件进入分支。1065 session.permission = permissions1066 yield* sessions.setPermission({ sessionID: session.id, permission: permissions })等待 Effect 结果。1067 }10681069 if (input.noReply === true) return message按条件进入分支。1070 return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。1071 })
消息分页、流与 parts
packages/opencode/src/session/message-v2.ts:425-492
这些接口共同构成可恢复的 session 账本读取面。
425export const page = Effect.fn("MessageV2.page")(function* (input: {会话消息片段结构。426 sessionID: SessionID427 limit: number428 before?: string429}) {430 const { db } = yield* Database.Service等待 Effect 结果。431 const before = input.before ? cursor.decode(input.before) : undefined432 const where = before433 ? and(eq(MessageTable.session_id, input.sessionID), older(before))434 : eq(MessageTable.session_id, input.sessionID)435 const rows = yield* db等待 Effect 结果。436 .select()437 .from(MessageTable)438 .where(where)439 .orderBy(desc(MessageTable.time_created), desc(MessageTable.id))440 .limit(input.limit + 1)441 .all()442 .pipe(Effect.orDie)Effect 异步工作流。443 if (rows.length === 0) {按条件进入分支。444 const row = yield* db等待 Effect 结果。445 .select({ id: SessionTable.id })446 .from(SessionTable)447 .where(eq(SessionTable.id, input.sessionID))448 .get()449 .pipe(Effect.orDie)Effect 异步工作流。450 if (!row) return yield* new NotFoundError({ message: `Session not found: ${input.sessionID}` })等待 Effect 结果。451 return {返回给上一层。452 items: [] as WithParts[],453 more: false,454 }455 }456457 const more = rows.length > input.limit458 const slice = more ? rows.slice(0, input.limit) : rows459 const items = yield* hydrate(db, slice)等待 Effect 结果。460 items.reverse()461 const tail = slice.at(-1)462 return {返回给上一层。463 items,464 more,465 cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined,466 }467})468469export function stream(sessionID: SessionID) {对外暴露模块成员。470 const size = 50471 return Effect.gen(function* () {Effect 异步工作流。472 const result = [] as WithParts[]473 let before: string | undefined474 while (true) {持续循环到退出条件。475 const next = yield* page({ sessionID, limit: size, before }).pipe(等待 Effect 结果。476 Effect.catchIf(NotFoundError.isInstance, () =>Effect 异步工作流。477 Effect.succeed({ items: [] as WithParts[], more: false, cursor: undefined }),Effect 异步工作流。478 ),479 )480 if (next.items.length === 0) break按条件进入分支。481 for (let i = next.items.length - 1; i >= 0; i--) {遍历集合。482 const item = next.items[i]483 if (item) result.push(item)按条件进入分支。484 }485 if (!next.more || !next.cursor) break按条件进入分支。486 before = next.cursor487 }488 return result返回给上一层。489 })490}491492export function parts(messageID: MessageID) {对外暴露模块成员。
2. 为什么会话层是 Agent 的“账本入口”
Section titled “2. 为什么会话层是 Agent 的“账本入口””一次 HTTP 请求会结束,一次 CLI 进程也会退出;coding agent 的任务却可能跨越多轮模型请求、多个工具调用,甚至被中断后继续。
如果只把最后一句字符串留在内存中,会立刻遇到这些问题:
- 工具结果属于哪次模型回应?
- 恢复 session 时使用哪个 agent 和 model?
- 文件、图片、MCP resource 与普通文本怎样区分?
- UI 如何看到 part 的增量变化?
- 插件应在何处修改输入,才不会绕过持久化?
所以 OpenCode 先“记账”,再“干活”。这一顺序由 SessionPrompt.prompt 的控制流直接证明:createUserMessage 与 sessions.touch 发生在 loop(...) 之前。来源:packages/opencode/src/session/prompt.ts。
3. 先画地图:四层数据不要混在一起
Section titled “3. 先画地图:四层数据不要混在一起”CLI / SDK / 其他客户端 | vPromptPayload / PromptInput 一次调用的输入草稿 | vSessionPrompt.createUserMessage 选择 agent/model,解析 parts | +------> MessageV2.User 这一轮用户消息的元信息 | +------> MessageV2.Part[] 文本、文件、agent、subtask... | v Session.Service 持久化并发布更新 | v SessionPrompt.loop 消费整段历史| 概念 | 最短定义 | 生命周期 |
|---|---|---|
| session | 一个可持续推进的任务档案 | 跨多条消息、可继续或 fork |
| message | user 或 assistant 的一轮记录 | 属于一个 session |
| part | message 内的一块 typed content/state | 可独立更新 |
| prompt input | 外部调用提交的草稿 | 只存在于入口转换阶段 |
Java 可以把 session 暂时类比为 aggregate root,message/part 类似子实体。但不要把它直接等同于 JPA 聚合:OpenCode 的 Schema 还承担运行时校验,message parts 又与流事件更新紧密相连。
4. 最小机制:先看从草稿到账本的骨架
Section titled “4. 最小机制:先看从草稿到账本的骨架”1async function prompt(input: PromptInput) {定义一段可复用逻辑。2 const session = await sessions.get(input.sessionID)3 const user = chooseAgentAndModel(input, session)4 const parts = await Promise.all(input.parts.map(resolvePart))并行等待多个任务。56 await plugins.beforeSave({ message: user, parts })7 await sessions.saveMessage(user)8 await sessions.saveParts(parts)910 if (input.noReply) return { info: user, parts }按条件进入分支。11 return agentLoop(input.sessionID)返回给上一层。12}
这是教学骨架,不是源码逐行翻译。真实代码还会处理图片规范化、引用、MCP resource、agent mention、旧 tools 字段、实验事件系统与 revert cleanup。
5. 读源码前,只补两个数据模型
Section titled “5. 读源码前,只补两个数据模型”5.1 Message 保存“这一轮是谁、用什么身份工作”
Section titled “5.1 Message 保存“这一轮是谁、用什么身份工作””MessageV2.User 的核心字段包括:
1{2 id,3 sessionID,4 role: "user",5 time,6 agent,7 model: { providerID, modelID, variant },选择模型或 provider。8 system?,9 tools?,10 format?11}
真实 Schema 见 packages/opencode/src/session/message-v2.ts。
为什么 model 与 agent 要落在 message 上?因为历史可能跨越模型/agent 切换;下一轮若只看 session 的“当前配置”,就无法准确解释旧消息是在什么执行身份下产生的。
5.2 Part 保存“这一轮具体有哪些内容和状态”
Section titled “5.2 Part 保存“这一轮具体有哪些内容和状态””MessageV2.Part 是以 type 为判别字段的 union,包含 text、file、tool、reasoning、step、patch、agent、compaction 等。来源:packages/opencode/src/session/message-v2.ts。
本章输入端只接受四类草稿:TextPartInput、FilePartInput、AgentPartInput、SubtaskPartInput。来源:packages/opencode/src/session/prompt.ts、packages/opencode/src/session/message-v2.ts。
这一区分很重要:存储模型比入口模型更丰富。工具状态、reasoning 等由后续 runtime 产生,不该伪装成用户可以随意提交的 part。
6. 追一条典型源码旅程:文本加本地文件
Section titled “6. 追一条典型源码旅程:文本加本地文件”入口收到的典型 payload 可以抽象成:
1{2 agent: "build",3 model: { providerID: "...", modelID: "..." },选择模型或 provider。4 parts: [5 { type: "file", url: "file:///project/package.json", filename: "package.json", mime: "text/plain" },6 { type: "text", text: "解释这个项目" },7 ],8}
具体 provider/model 值由用户配置决定;这里不假设某一家模型。
6.1 第一站:API payload 从内部 input 派生
Section titled “6.1 第一站:API payload 从内部 input 派生”HTTP 层没有另写一份易漂移的 DTO,而是从 SessionPrompt.PromptInput 去掉路径中已经提供的 sessionID:
1export const PromptPayload = Schema.Struct(定义并校验数据形状。2 Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]),3)
来源:packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
endpoint 是 POST /session/:sessionID/message,成功类型为 MessageV2.WithParts。来源:packages/opencode/src/server/routes/instance/httpapi/groups/session.ts、packages/opencode/src/server/routes/instance/httpapi/groups/session.ts。
这种派生减少了 API schema 与 service input 漂移的机会,但也让读者必须追到内部 Schema 才能看全请求形状。
6.2 第二站:handler 只补齐边界数据
Section titled “6.2 第二站:handler 只补齐边界数据”handler 先确认 session 存在,再把 path param 中的 sessionID 与 payload 合并:
1const message = yield* promptSvc.prompt({等待 Effect 结果。2 ...ctx.payload,3 sessionID: ctx.params.sessionID,4})
来源:packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
这是一层薄 adapter。它不解析文件、不选择模型,也不运行循环。同步 endpoint 最终把完整 message JSON 作为 stream response 返回;异步 endpoint 则 fork 执行并立即返回 No Content。来源:packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts。
6.3 第三站:createUserMessage 固化 agent 与 model
Section titled “6.3 第三站:createUserMessage 固化 agent 与 model”createUserMessage 先解析 agent;若 agent 不存在,会发布 session error 并抛错。然后按以下优先级决定 model:
input.model ?? agent.model ?? currentModel(sessionID)来源:packages/opencode/src/session/prompt.ts。
选定 variant 后,代码创建 MessageV2.User,写入 message id、session id、agent、model、system、format 等。来源:packages/opencode/src/session/prompt.ts。
这里还会在 agent/model 与 session 当前值不同时发布切换事件。来源:packages/opencode/src/session/prompt.ts。这是 UI/事件层的可观察性,不改变上述选择优先级。
6.4 第四站:text part 只补 ambient IDs
Section titled “6.4 第四站:text part 只补 ambient IDs”普通 text part 不需要 I/O。resolvePart 的兜底分支把输入字段保留,并补上 messageID 与 sessionID;随后 assign 在缺少 part id 时生成新 ID。来源:packages/opencode/src/session/prompt.ts、packages/opencode/src/session/prompt.ts。
这说明输入 part 是 draft,存储 part 是带完整归属信息的事实。
6.5 第五站:file part 在入账前展开成模型可读上下文
Section titled “6.5 第五站:file part 在入账前展开成模型可读上下文”对 file: URL,代码把 URL 转成本地路径,判断真实 MIME/目录类型,并取得 registry 中的 read 工具。来源:packages/opencode/src/session/prompt.ts。
若是 text/plain,典型成功结果会产生:
- 一条 synthetic text,记录等价的 Read 调用参数;
- 一条 synthetic text,保存 read 工具输出;
- 原 file part,或 read 工具返回的附件;
- 若命中 reference,还会增加 reference context。
证据见 packages/opencode/src/session/prompt.ts。
这里的 read.execute 使用 bypassCwdCheck: true,ask 也是空 Effect。准确的边界是:这是用户主动提交附件后的预解析路径,不是模型自主调用 read 工具的普通权限路径。来源:packages/opencode/src/session/prompt.ts。
为什么不只保存路径?因为下一轮模型需要可消费的内容,session 恢复也不能依赖模型自己“知道去读”。同时保留 file part,又能让 UI 与支持附件的 provider 识别原始附件语义。
6.6 第六站:所有 parts 并发解析,再交给插件
Section titled “6.6 第六站:所有 parts 并发解析,再交给插件”Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }) 并发解析输入,再 flatten 并分配 ID。之后触发 chat.message:
1yield* plugin.trigger(调用插件扩展点。2 "chat.message",3 { sessionID, agent, model, messageID, variant },4 { message: info, parts: resolvedParts },5)
来源:packages/opencode/src/session/prompt.ts
插件拿到的是可修改对象,并且 hook 发生在保存之前。因此它是消息入账边界上的扩展点,而不是保存后的旁路补丁。
6.7 第七站:先保存 message,再逐个保存 parts
Section titled “6.7 第七站:先保存 message,再逐个保存 parts”图片经过可选规范化后,源码执行:
1yield* sessions.updateMessage(info)等待 Effect 结果。2for (const part of parts) yield* sessions.updatePart(part)等待 Effect 结果。
来源:packages/opencode/src/session/prompt.ts
为什么分开保存?从数据形状可以确认,这允许 part 独立更新;工具状态与流式文本正需要这种粒度。更底层具体数据库事务语义不在本章所列证据中,因此这里不推断它们必然原子提交。
6.8 第八站:记账后才决定是否运行
Section titled “6.8 第八站:记账后才决定是否运行”SessionPrompt.prompt 先清理 revert、创建消息、touch session,再把旧 input.tools 映射为 session permission。最后:
1if (input.noReply === true) return message按条件进入分支。2return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。
来源:packages/opencode/src/session/prompt.ts
所以 noReply 不是“不保存”,而是“只入账,不启动回复循环”。这对于批量导入、异步编排或先构造上下文很有价值。
7. 决定可靠性的四个边界
Section titled “7. 决定可靠性的四个边界”7.1 文件读取失败不会伪装成成功
Section titled “7.1 文件读取失败不会伪装成成功”本地文本读取失败时,代码发布 session error,并增加一条 synthetic text 描述读取失败;不会凭空制造文件内容。来源:packages/opencode/src/session/prompt.ts。
这使失败本身也成为后续可见上下文,但调用方仍应把 session error 当成错误信号。
7.2 输入 union 限制“用户能声称什么”
Section titled “7.2 输入 union 限制“用户能声称什么””PromptInput 只接收 text/file/agent/subtask drafts,不能直接提交一个已经 completed 的 ToolPart。来源:packages/opencode/src/session/prompt.ts。这防止外部请求把 runtime 状态伪造成用户输入。
7.3 MCP resource 与本地文件是两条读取路径
Section titled “7.3 MCP resource 与本地文件是两条读取路径”若 file part 的 source 是 resource,resolvePart 调 mcp.readResource;本地 file: URL 则走 registry 的 read 工具。来源:packages/opencode/src/session/prompt.ts、packages/opencode/src/session/prompt.ts。它们最终都归一为 typed parts,但信任边界与失败信息不同。
7.4 Prompted / Synthetic 当前是条件双写
Section titled “7.4 Prompted / Synthetic 当前是条件双写”持久化后,代码把普通文本、文件、agent、reference 与 synthetic text 聚合;只有 experimentalEventSystem 开启时才发布 SessionEvent.Prompted 和 SessionEvent.Synthetic。源码注释明确称这是迁移期间的 temporary dual-write。来源:packages/opencode/src/session/prompt.ts。
因此不能把这些实验事件描述成所有环境下唯一的消息存储。
8. OpenCode 的选择:替代方案与代价
Section titled “8. OpenCode 的选择:替代方案与代价”| 设计问题 | OpenCode 的选择 | 可选做法 | 收益与代价 |
|---|---|---|---|
| 一条消息怎样建模 | message 元信息 + typed parts | 单一 content 字符串 | 可表达附件与状态;模型和转换更复杂 |
| API schema 从哪里来 | 从 PromptInput omit sessionID | 维护独立 DTO | 减少漂移;外部合同与内部类型耦合更紧 |
| 附件何时读取 | user message 创建阶段展开 | 等模型自己调用 read | 首轮就有确定上下文;入口阶段 I/O 更重 |
| 插件何时修改 | 解析后、保存前 | 保存后再补写 | 入账内容一致;插件可影响核心输入,需谨慎治理 |
| 先保存还是先运行 | 先持久化,再进 loop | 在内存里跑完再保存 | 可恢复、可观察;要处理部分完成和失败记录 |
表中的行为由源码直接证明;收益与代价是基于结构的设计解释。
9. TypeScript / Effect:只学会挡路的三处
Section titled “9. TypeScript / Effect:只学会挡路的三处”9.1 discriminated union
Section titled “9.1 discriminated union”part.type === "file" 后,TypeScript 能把 part 缩小到 FilePartInput。可以类比 Java sealed interface + pattern matching;不同点是 Effect Schema 同时做运行时解码。
9.2 Struct.omit
Section titled “9.2 Struct.omit”它从现有 Schema 的 fields 派生新结构,不只是 TypeScript 编译期的 Omit<T, K>。因此 API 层获得的仍是运行时可校验 Schema。
9.3 Effect.scoped
Section titled “9.3 Effect.scoped”createUserMessage 被包在 scope 中,并注册 finalizer 清理 instruction 状态。可以暂时理解为结构化资源作用域;不要把它等同于普通 finally,因为 scope 可管理多个 Effect 资源。
10. 可以带走的方法
Section titled “10. 可以带走的方法”方法一:把外部输入转换成稳定领域事实
Section titled “方法一:把外部输入转换成稳定领域事实”先解析默认值、分配 ID、补归属信息,再持久化;不要让后续 loop 反复解释原始 HTTP DTO。
验证问题:换成 IDE 客户端后,agent loop 是否仍只读取同一种 message/part?
方法二:让入口类型小于内部状态空间
Section titled “方法二:让入口类型小于内部状态空间”用户只能提交允许的 drafts;tool/reasoning/step 状态只能由 runtime 产生。
验证问题:外部调用者能否伪造一条“工具已成功执行”的记录?
方法三:把可恢复性设计进写入顺序
Section titled “方法三:把可恢复性设计进写入顺序”先保存用户委托,再启动长时间运行的 agent loop。
验证问题:模型请求前进程中断,重启后还能否看见并继续这条委托?
11. 费曼复述与练习阶梯
Section titled “11. 费曼复述与练习阶梯”11.1 60 秒复述
Section titled “11.1 60 秒复述”不要看上文,补全:
session 是 ______,message 是 ______,part 是 ______。HTTP payload 从 ______ 派生。
createUserMessage先选择 ______,再解析 ______。本地文本附件会产生 ______,保存完成后,除非 ______,否则进入 agent loop。
如果你在“file part 是否就是文件正文”处犹豫,请回看 packages/opencode/src/session/prompt.ts。
11.2 读源码练习
Section titled “11.2 读源码练习”- 入门:列出
PromptInput.parts允许的四种输入类型。 - 进阶:画出 model 的三层选择优先级,并解释 variant 何时继承 agent 默认值。
- 辨析:比较 stored FilePart 与解析产生的 synthetic TextPart。
- 失败路径:追踪本地文件读取失败后,bus event 与 parts 分别发生什么变化。
11.3 小实现
Section titled “11.3 小实现”写一个内存版会话层:输入只支持 text/file drafts,保存 UserMessage 与 Part[];file 读取成功和失败都必须产生可审计 part;noReply 时只保存不调用假模型。
12. 最后复盘:账本已经准备好,谁来推进它
Section titled “12. 最后复盘:账本已经准备好,谁来推进它”本章主链是:
PromptInput -> 选择 agent/model -> 解析 typed parts -> plugin -> 保存 -> loop你现在应该能回答中心问题:文本和附件没有被揉成一个字符串,而是被转换为带身份、归属、来源与类型的持久化事实;后续 agent 才能从同一本账本重建上下文。
下一章要解决的悬念是:账本已经有了 user message,模型第一次只选择工具而不回答时,谁负责执行、回填并再次调用模型?这就是“Agent 核心循环”。