用户输入与会话
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
02-session-message - 章节摘要:从 text + file prompt 出发,理解 HTTP payload 如何选择 agent/model、解析 typed parts、经插件后持久化,并在 noReply 与 agent loop 之间分流。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/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 源码版本
eec0843ce422为证据基线。我们沿用上一章的典型输入: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:689-731、packages/opencode/src/session/prompt.ts:1065-1117。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:689-731
689 const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {Effect 异步工作流。690 const agentName = input.agent691 const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()等待 Effect 结果。692 if (!ag) {按条件进入分支。693 const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。694 const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""695 const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })696 yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })广播状态变化。697 throw error失败时抛出错误。698 }699700 const current = Database.use((db) =>701 db702 .select({ agent: SessionTable.agent, model: SessionTable.model })选择模型或 provider。703 .from(SessionTable)704 .where(eq(SessionTable.id, input.sessionID))705 .get(),706 )707 const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))等待 Effect 结果。708 const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID选择模型或 provider。709 const full =710 !input.variant && ag.variant && same711 ? yield* provider选择模型或 provider。712 .getModel(model.providerID, model.modelID)选择模型或 provider。713 .pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))选择模型或 provider。714 : undefined715 const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)716717 const info: MessageV2.User = {会话消息片段结构。718 id: input.messageID ?? MessageID.ascending(),719 role: "user",720 sessionID: input.sessionID,721 time: { created: Date.now() },722 tools: input.tools,723 agent: ag.name,724 model: {选择模型或 provider。725 providerID: model.providerID,选择模型或 provider。726 modelID: model.modelID,选择模型或 provider。727 variant,728 },729 system: input.system,730 format: input.format,731 }
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1065-1117
1065 const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(Effect 异步工作流。1066 Effect.map((x) => x.flat().map(assign)),Effect 异步工作流。1067 )10681069 yield* plugin.trigger(调用插件扩展点。1070 "chat.message",1071 {1072 sessionID: input.sessionID,1073 agent: input.agent,1074 model: input.model,选择模型或 provider。1075 messageID: input.messageID,1076 variant: input.variant,1077 },1078 { message: info, parts: resolvedParts },1079 )10801081 const parts = yield* Effect.forEach(resolvedParts, (part) =>Effect 异步工作流。1082 part.type === "file" && part.mime.startsWith("image/")1083 ? image.normalize(part).pipe(1084 Effect.catchIf(Effect 异步工作流。1085 (error) => error instanceof Image.ResizerUnavailableError,1086 () => Effect.succeed(part),Effect 异步工作流。1087 ),1088 )1089 : Effect.succeed(part),Effect 异步工作流。1090 )10911092 const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" })1093 if (Exit.isFailure(parsed)) {按条件进入分支。1094 log.error("invalid user message before save", {1095 sessionID: input.sessionID,1096 messageID: info.id,1097 agent: info.agent,1098 model: info.model,选择模型或 provider。1099 cause: Cause.pretty(parsed.cause),1100 })1101 }1102 parts.forEach((part, index) => {1103 const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" })1104 if (Exit.isSuccess(p)) return按条件进入分支。1105 log.error("invalid user part before save", {1106 sessionID: input.sessionID,1107 messageID: info.id,1108 partID: part.id,1109 partType: part.type,1110 index,1111 cause: Cause.pretty(p.cause),1112 part,1113 })1114 })11151116 yield* sessions.updateMessage(info)等待 Effect 结果。1117 for (const part of parts) yield* sessions.updatePart(part)等待 Effect 结果。
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:1211-1229。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1211-1229
1211 const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn(会话消息片段结构。1212 "SessionPrompt.prompt",把输入交给会话主流程。1213 )(function* (input: PromptInput) {1214 const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)Effect 异步工作流。1215 yield* revert.cleanup(session)等待 Effect 结果。1216 const message = yield* createUserMessage(input)等待 Effect 结果。1217 yield* sessions.touch(input.sessionID)等待 Effect 结果。12181219 const permissions: Permission.Ruleset = []1220 for (const [t, enabled] of Object.entries(input.tools ?? {})) {遍历集合。1221 permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })1222 }1223 if (permissions.length > 0) {按条件进入分支。1224 session.permission = permissions1225 yield* sessions.setPermission({ sessionID: session.id, permission: permissions })等待 Effect 结果。1226 }12271228 if (input.noReply === true) return message按条件进入分支。1229 return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。
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:327-350。
packages/opencode/src/session/message-v2.ts
packages/opencode/src/session/message-v2.ts:327-350
327export const User = Schema.Struct({定义并校验数据形状。328 ...messageBase,329 role: Schema.Literal("user"),定义并校验数据形状。330 time: Schema.Struct({定义并校验数据形状。331 created: NonNegativeInt,332 }),333 format: Schema.optional(Format),定义并校验数据形状。334 summary: Schema.optional(定义并校验数据形状。335 Schema.Struct({定义并校验数据形状。336 title: Schema.optional(Schema.String),定义并校验数据形状。337 body: Schema.optional(Schema.String),定义并校验数据形状。338 diffs: Schema.Array(Snapshot.FileDiff),定义并校验数据形状。339 }),340 ),341 agent: Schema.String,定义并校验数据形状。342 model: Schema.Struct({定义并校验数据形状。343 providerID: ProviderID,选择模型或 provider。344 modelID: ModelID,选择模型或 provider。345 variant: Schema.optional(Schema.String),定义并校验数据形状。346 }),347 system: Schema.optional(Schema.String),定义并校验数据形状。348 tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),定义并校验数据形状。349}).annotate({ identifier: "UserMessage" })350export type User = Types.DeepMutable<Schema.Schema.Type<typeof User>>定义并校验数据形状。
为什么 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:352-378。
packages/opencode/src/session/message-v2.ts
packages/opencode/src/session/message-v2.ts:352-378
352export const Part = Schema.Union([定义并校验数据形状。353 TextPart,会话消息片段结构。354 SubtaskPart,355 ReasoningPart,356 FilePart,357 ToolPart,会话消息片段结构。358 StepStartPart,359 StepFinishPart,360 SnapshotPart,361 PatchPart,362 AgentPart,363 RetryPart,364 CompactionPart,365]).annotate({ discriminator: "type", identifier: "Part" })366export type Part =定义数据结构约束。367 | TextPart会话消息片段结构。368 | SubtaskPart369 | ReasoningPart370 | FilePart371 | ToolPart会话消息片段结构。372 | StepStartPart373 | StepFinishPart374 | SnapshotPart375 | PatchPart376 | AgentPart377 | RetryPart378 | CompactionPart
本章输入端只接受四类草稿:TextPartInput、FilePartInput、AgentPartInput、SubtaskPartInput。来源:packages/opencode/src/session/prompt.ts:1666-1688、packages/opencode/src/session/message-v2.ts:390-450。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1666-1688
1666export const PromptInput = Schema.Struct({定义并校验数据形状。1667 sessionID: SessionID,1668 messageID: Schema.optional(MessageID),定义并校验数据形状。1669 model: Schema.optional(ModelRef),定义并校验数据形状。1670 agent: Schema.optional(Schema.String),定义并校验数据形状。1671 noReply: Schema.optional(Schema.Boolean),定义并校验数据形状。1672 tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({定义并校验数据形状。1673 description:1674 "@deprecated tools and permissions have been merged, you can set permissions on the session itself now",1675 }),1676 format: Schema.optional(MessageV2.Format),定义并校验数据形状。1677 system: Schema.optional(Schema.String),定义并校验数据形状。1678 variant: Schema.optional(Schema.String),定义并校验数据形状。1679 parts: Schema.Array(定义并校验数据形状。1680 Schema.Union([定义并校验数据形状。1681 MessageV2.TextPartInput,会话消息片段结构。1682 MessageV2.FilePartInput,会话消息片段结构。1683 MessageV2.AgentPartInput,会话消息片段结构。1684 MessageV2.SubtaskPartInput,会话消息片段结构。1685 ]).annotate({ discriminator: "type" }),1686 ),1687})1688export type PromptInput = Schema.Schema.Type<typeof PromptInput>定义并校验数据形状。
packages/opencode/src/session/message-v2.ts
packages/opencode/src/session/message-v2.ts:390-450
390//391// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the392// ambient IDs (`messageID`, `sessionID`) that live on stored parts, and may393// omit `id` to let the server allocate one. These Schema-Struct variants394// carry that shape so prompt decoding can accept drafts without stored IDs.395396export const TextPartInput = Schema.Struct({定义并校验数据形状。397 id: Schema.optional(PartID),定义并校验数据形状。398 type: Schema.Literal("text"),定义并校验数据形状。399 text: Schema.String,定义并校验数据形状。400 synthetic: Schema.optional(Schema.Boolean),定义并校验数据形状。401 ignored: Schema.optional(Schema.Boolean),定义并校验数据形状。402 time: Schema.optional(定义并校验数据形状。403 Schema.Struct({定义并校验数据形状。404 start: NonNegativeInt,405 end: Schema.optional(NonNegativeInt),定义并校验数据形状。406 }),407 ),408 metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),定义并校验数据形状。409}).annotate({ identifier: "TextPartInput" })会话消息片段结构。410export type TextPartInput = Types.DeepMutable<Schema.Schema.Type<typeof TextPartInput>>定义并校验数据形状。411412export const FilePartInput = Schema.Struct({定义并校验数据形状。413 id: Schema.optional(PartID),定义并校验数据形状。414 type: Schema.Literal("file"),定义并校验数据形状。415 mime: Schema.String,定义并校验数据形状。416 filename: Schema.optional(Schema.String),定义并校验数据形状。417 url: Schema.String,定义并校验数据形状。418 source: Schema.optional(FilePartSource),定义并校验数据形状。419}).annotate({ identifier: "FilePartInput" })420export type FilePartInput = Types.DeepMutable<Schema.Schema.Type<typeof FilePartInput>>定义并校验数据形状。421422export const AgentPartInput = Schema.Struct({定义并校验数据形状。423 id: Schema.optional(PartID),定义并校验数据形状。424 type: Schema.Literal("agent"),定义并校验数据形状。425 name: Schema.String,定义并校验数据形状。426 source: Schema.optional(定义并校验数据形状。427 Schema.Struct({定义并校验数据形状。428 value: Schema.String,定义并校验数据形状。429 start: NonNegativeInt,430 end: NonNegativeInt,431 }),432 ),433}).annotate({ identifier: "AgentPartInput" })434export type AgentPartInput = Types.DeepMutable<Schema.Schema.Type<typeof AgentPartInput>>定义并校验数据形状。435436export const SubtaskPartInput = Schema.Struct({定义并校验数据形状。437 id: Schema.optional(PartID),定义并校验数据形状。438 type: Schema.Literal("subtask"),定义并校验数据形状。439 prompt: Schema.String,定义并校验数据形状。440 description: Schema.String,定义并校验数据形状。441 agent: Schema.String,定义并校验数据形状。442 model: Schema.optional(定义并校验数据形状。443 Schema.Struct({定义并校验数据形状。444 providerID: ProviderID,选择模型或 provider。445 modelID: ModelID,选择模型或 provider。446 }),447 ),448 command: Schema.optional(Schema.String),定义并校验数据形状。449}).annotate({ identifier: "SubtaskPartInput" })450export type SubtaskPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPartInput>>定义并校验数据形状。
这一区分很重要:存储模型比入口模型更丰富。工具状态、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:66
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:66
66export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))定义并校验数据形状。
endpoint 是 POST /session/:sessionID/message,成功类型为 MessageV2.WithParts。来源:packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:74-101、packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:312-324。
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:74-101
74export const SessionPaths = {对外暴露模块成员。75 list: root,76 status: `${root}/status`,77 get: `${root}/:sessionID`,78 children: `${root}/:sessionID/children`,79 todo: `${root}/:sessionID/todo`,80 diff: `${root}/:sessionID/diff`,81 messages: `${root}/:sessionID/message`,82 message: `${root}/:sessionID/message/:messageID`,83 create: root,84 remove: `${root}/:sessionID`,85 update: `${root}/:sessionID`,86 fork: `${root}/:sessionID/fork`,87 abort: `${root}/:sessionID/abort`,88 share: `${root}/:sessionID/share`,89 init: `${root}/:sessionID/init`,90 summarize: `${root}/:sessionID/summarize`,91 prompt: `${root}/:sessionID/message`,92 promptAsync: `${root}/:sessionID/prompt_async`,93 command: `${root}/:sessionID/command`,处理命令执行。94 shell: `${root}/:sessionID/shell`,处理命令执行。95 revert: `${root}/:sessionID/revert`,96 unrevert: `${root}/:sessionID/unrevert`,97 permissions: `${root}/:sessionID/permissions/:permissionID`,98 deleteMessage: `${root}/:sessionID/message/:messageID`,99 deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,100 updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,101} as const
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:312-324
312 HttpApiEndpoint.post("prompt", SessionPaths.prompt, {313 params: { sessionID: SessionID },314 query: WorkspaceRoutingQuery,315 payload: PromptPayload,316 success: described(MessageV2.WithParts, "Created message"),会话消息片段结构。317 error: [HttpApiError.BadRequest, ApiNotFoundError],318 }).annotateMerge(319 OpenApi.annotations({320 identifier: "session.prompt",把输入交给会话主流程。321 summary: "Send message",322 description: "Create and send a new message to a session, streaming the AI response.",323 }),324 ),
session.prompt 的 HTTP 合同
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:312-324
URL 提供 sessionID,payload 提供本次消息草稿,返回值是带 parts 的消息。
312 HttpApiEndpoint.post("prompt", SessionPaths.prompt, {313 params: { sessionID: SessionID },314 query: WorkspaceRoutingQuery,315 payload: PromptPayload,316 success: described(MessageV2.WithParts, "Created message"),会话消息片段结构。317 error: [HttpApiError.BadRequest, ApiNotFoundError],318 }).annotateMerge(319 OpenApi.annotations({320 identifier: "session.prompt",把输入交给会话主流程。321 summary: "Send message",322 description: "Create and send a new message to a session, streaming the AI response.",323 }),324 ),
这种派生减少了 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:279-293
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:279-293
279 const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {Effect 异步工作流。280 params: { sessionID: SessionID }281 payload: typeof PromptPayload.Type282 }) {283 yield* requireSession(ctx.params.sessionID)等待 Effect 结果。284 const message = yield* promptSvc等待 Effect 结果。285 .prompt({286 ...ctx.payload,287 sessionID: ctx.params.sessionID,288 })289 .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))Effect 异步工作流。290 return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {返回给上一层。291 contentType: "application/json",292 })293 })
这是一层薄 adapter。它不解析文件、不选择模型,也不运行循环。同步 endpoint 最终把完整 message JSON 作为 stream response 返回;异步 endpoint 则 fork 执行并立即返回 No Content。来源:packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:290-314。
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:290-314
290 return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {返回给上一层。291 contentType: "application/json",292 })293 })294295 const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: {Effect 异步工作流。296 params: { sessionID: SessionID }297 payload: typeof PromptPayload.Type298 }) {299 yield* requireSession(ctx.params.sessionID)等待 Effect 结果。300 yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(等待 Effect 结果。301 Effect.catchCause((cause) =>Effect 异步工作流。302 Effect.gen(function* () {Effect 异步工作流。303 yield* Effect.logError("prompt_async failed").pipe(Effect 异步工作流。304 Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),Effect 异步工作流。305 )306 yield* bus.publish(Session.Event.Error, {广播状态变化。307 sessionID: ctx.params.sessionID,308 error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),309 })310 }),311 ),312 Effect.forkIn(scope, { startImmediately: true }),Effect 异步工作流。313 )314 return HttpApiSchema.NoContent.make()定义并校验数据形状。
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:689-715。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:689-715
689 const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {Effect 异步工作流。690 const agentName = input.agent691 const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()等待 Effect 结果。692 if (!ag) {按条件进入分支。693 const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。694 const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""695 const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })696 yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })广播状态变化。697 throw error失败时抛出错误。698 }699700 const current = Database.use((db) =>701 db702 .select({ agent: SessionTable.agent, model: SessionTable.model })选择模型或 provider。703 .from(SessionTable)704 .where(eq(SessionTable.id, input.sessionID))705 .get(),706 )707 const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))等待 Effect 结果。708 const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID选择模型或 provider。709 const full =710 !input.variant && ag.variant && same711 ? yield* provider选择模型或 provider。712 .getModel(model.providerID, model.modelID)选择模型或 provider。713 .pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))选择模型或 provider。714 : undefined715 const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
选定 variant 后,代码创建 MessageV2.User,写入 message id、session id、agent、model、system、format 等。来源:packages/opencode/src/session/prompt.ts:717-731。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:717-731
717 const info: MessageV2.User = {会话消息片段结构。718 id: input.messageID ?? MessageID.ascending(),719 role: "user",720 sessionID: input.sessionID,721 time: { created: Date.now() },722 tools: input.tools,723 agent: ag.name,724 model: {选择模型或 provider。725 providerID: model.providerID,选择模型或 provider。726 modelID: model.modelID,选择模型或 provider。727 variant,728 },729 system: input.system,730 format: input.format,731 }
user message 固化 agent/model
packages/opencode/src/session/prompt.ts:689-731
重点看选择优先级,以及最终被保存的是解析后的执行身份。
689 const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {Effect 异步工作流。690 const agentName = input.agent691 const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()等待 Effect 结果。692 if (!ag) {按条件进入分支。693 const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。694 const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""695 const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })696 yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })广播状态变化。697 throw error失败时抛出错误。698 }699700 const current = Database.use((db) =>701 db702 .select({ agent: SessionTable.agent, model: SessionTable.model })选择模型或 provider。703 .from(SessionTable)704 .where(eq(SessionTable.id, input.sessionID))705 .get(),706 )707 const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))等待 Effect 结果。708 const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID选择模型或 provider。709 const full =710 !input.variant && ag.variant && same711 ? yield* provider选择模型或 provider。712 .getModel(model.providerID, model.modelID)选择模型或 provider。713 .pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))选择模型或 provider。714 : undefined715 const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)716717 const info: MessageV2.User = {会话消息片段结构。718 id: input.messageID ?? MessageID.ascending(),719 role: "user",720 sessionID: input.sessionID,721 time: { created: Date.now() },722 tools: input.tools,723 agent: ag.name,724 model: {选择模型或 provider。725 providerID: model.providerID,选择模型或 provider。726 modelID: model.modelID,选择模型或 provider。727 variant,728 },729 system: input.system,730 format: input.format,731 }
这里还会在 agent/model 与 session 当前值不同时发布切换事件。来源:packages/opencode/src/session/prompt.ts:733-754。这是 UI/事件层的可观察性,不改变上述选择优先级。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:733-754
733 if (current?.agent !== info.agent) {按条件进入分支。734 yield* events.publish(SessionEvent.AgentSwitched, {广播状态变化。735 sessionID: input.sessionID,736 timestamp: DateTime.makeUnsafe(info.time.created),737 agent: info.agent,738 })739 }740 if (按条件进入分支。741 current?.model?.providerID !== info.model.providerID ||选择模型或 provider。742 current.model.id !== info.model.modelID ||选择模型或 provider。743 (current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant744 ) {745 yield* events.publish(SessionEvent.ModelSwitched, {广播状态变化。746 sessionID: input.sessionID,747 timestamp: DateTime.makeUnsafe(info.time.created),748 model: {选择模型或 provider。749 id: ModelV2.ID.make(info.model.modelID),选择模型或 provider。750 providerID: ProviderV2.ID.make(info.model.providerID),选择模型或 provider。751 variant: ModelV2.VariantID.make(info.model.variant ?? "default"),752 },753 })754 }
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:758-762、packages/opencode/src/session/prompt.ts:1062-1067。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:758-762
758 type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never会话消息片段结构。759 const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({会话消息片段结构。760 ...part,761 id: part.id ? PartID.make(part.id) : PartID.ascending(),762 })
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1062-1067
1062 return [{ ...part, messageID: info.id, sessionID: input.sessionID }]返回给上一层。1063 })10641065 const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(Effect 异步工作流。1066 Effect.map((x) => x.flat().map(assign)),Effect 异步工作流。1067 )
这说明输入 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:844-888。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:844-888
844 const url = new URL(part.url)845 switch (url.protocol) {846 case "data:":847 if (part.mime === "text/plain") {按条件进入分支。848 return [返回给上一层。849 {850 messageID: info.id,851 sessionID: input.sessionID,852 type: "text",853 synthetic: true,854 text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,855 },856 {857 messageID: info.id,858 sessionID: input.sessionID,859 type: "text",860 synthetic: true,861 text: decodeDataUrl(part.url),862 },863 { ...part, messageID: info.id, sessionID: input.sessionID },864 ]865 }866 break867 case "file:": {868 log.info("file", { mime: part.mime })869 const filepath = fileURLToPath(part.url)870 const referenceContext = yield* referenceContextFromFilePart(part, filepath)等待 Effect 结果。871 const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime等待 Effect 结果。872873 const { read } = yield* registry.named()等待 Effect 结果。874 const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {875 const controller = new AbortController()用于中断运行任务。876 return read返回给上一层。877 .execute(args, {工具真正执行入口。878 sessionID: input.sessionID,879 abort: controller.signal,880 agent: input.agent!,881 messageID: info.id,882 extra: { bypassCwdCheck: true, ...extra },883 messages: [],884 metadata: () => Effect.void,Effect 异步工作流。885 ask: () => Effect.void,Effect 异步工作流。886 })887 .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))Effect 异步工作流。888 }
若是 text/plain,典型成功结果会产生:
- 一条 synthetic text,记录等价的 Read 调用参数;
- 一条 synthetic text,保存 read 工具输出;
- 原 file part,或 read 工具返回的附件;
- 若命中 reference,还会增加 reference context。
证据见 packages/opencode/src/session/prompt.ts:890-969。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:890-969
890 if (mime === "text/plain") {按条件进入分支。891 let offset: number | undefined892 let limit: number | undefined893 const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }894 if (range.start != null) {按条件进入分支。895 const filePathURI = part.url.split("?")[0]896 let start = parseInt(range.start)897 let end = range.end ? parseInt(range.end) : undefined898 if (start === end) {按条件进入分支。899 const symbols = yield* lsp.documentSymbol(filePathURI).pipe(Effect.catch(() => Effect.succeed([])))Effect 异步工作流。900 for (const symbol of symbols) {遍历集合。901 let r: LSP.Range | undefined处理语言服务诊断。902 if ("range" in symbol) r = symbol.range按条件进入分支。903 else if ("location" in symbol) r = symbol.location.range904 if (r?.start?.line && r?.start?.line === start) {按条件进入分支。905 start = r.start.line906 end = r?.end?.line ?? start907 break908 }909 }910 }911 offset = Math.max(start, 1)912 if (end) limit = end - (offset - 1)按条件进入分支。913 }914 const args = { filePath: filepath, offset, limit }915 const pieces: Draft<MessageV2.Part>[] = [会话消息片段结构。916 ...(referenceContext917 ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]918 : []),919 {920 messageID: info.id,921 sessionID: input.sessionID,922 type: "text",923 synthetic: true,924 text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,925 },926 ]927 const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(选择模型或 provider。928 Effect.flatMap((mdl) => execRead(args, { model: mdl })),选择模型或 provider。929 Effect.exit,Effect 异步工作流。930 )931 if (Exit.isSuccess(exit)) {按条件进入分支。932 const result = exit.value933 pieces.push({934 messageID: info.id,935 sessionID: input.sessionID,936 type: "text",937 synthetic: true,938 text: result.output,939 })940 if (result.attachments?.length) {按条件进入分支。941 pieces.push(942 ...result.attachments.map((a) => ({943 ...a,944 synthetic: true,945 filename: a.filename ?? part.filename,946 messageID: info.id,947 sessionID: input.sessionID,948 })),949 )950 } else {951 pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })952 }953 } else {954 const error = Cause.squash(exit.cause)955 log.error("failed to read file", { error })956 const message = error instanceof Error ? error.message : String(error)957 yield* bus.publish(Session.Event.Error, {广播状态变化。958 sessionID: input.sessionID,959 error: new NamedError.Unknown({ message }).toObject(),960 })961 pieces.push({962 messageID: info.id,963 sessionID: input.sessionID,964 type: "text",965 synthetic: true,966 text: `Read tool failed to read ${filepath} with the following error: ${message}`,967 })968 }969 return pieces返回给上一层。
本地文本附件怎样展开
packages/opencode/src/session/prompt.ts:867-969
不要只看 file part;真正进入上下文的还有 synthetic Read 记录和读取结果。
867 case "file:": {868 log.info("file", { mime: part.mime })869 const filepath = fileURLToPath(part.url)870 const referenceContext = yield* referenceContextFromFilePart(part, filepath)等待 Effect 结果。871 const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime等待 Effect 结果。872873 const { read } = yield* registry.named()等待 Effect 结果。874 const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {875 const controller = new AbortController()用于中断运行任务。876 return read返回给上一层。877 .execute(args, {工具真正执行入口。878 sessionID: input.sessionID,879 abort: controller.signal,880 agent: input.agent!,881 messageID: info.id,882 extra: { bypassCwdCheck: true, ...extra },883 messages: [],884 metadata: () => Effect.void,Effect 异步工作流。885 ask: () => Effect.void,Effect 异步工作流。886 })887 .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))Effect 异步工作流。888 }889890 if (mime === "text/plain") {按条件进入分支。891 let offset: number | undefined892 let limit: number | undefined893 const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }894 if (range.start != null) {按条件进入分支。895 const filePathURI = part.url.split("?")[0]896 let start = parseInt(range.start)897 let end = range.end ? parseInt(range.end) : undefined898 if (start === end) {按条件进入分支。899 const symbols = yield* lsp.documentSymbol(filePathURI).pipe(Effect.catch(() => Effect.succeed([])))Effect 异步工作流。900 for (const symbol of symbols) {遍历集合。901 let r: LSP.Range | undefined处理语言服务诊断。902 if ("range" in symbol) r = symbol.range按条件进入分支。903 else if ("location" in symbol) r = symbol.location.range904 if (r?.start?.line && r?.start?.line === start) {按条件进入分支。905 start = r.start.line906 end = r?.end?.line ?? start907 break908 }909 }910 }911 offset = Math.max(start, 1)912 if (end) limit = end - (offset - 1)按条件进入分支。913 }914 const args = { filePath: filepath, offset, limit }915 const pieces: Draft<MessageV2.Part>[] = [会话消息片段结构。916 ...(referenceContext917 ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]918 : []),919 {920 messageID: info.id,921 sessionID: input.sessionID,922 type: "text",923 synthetic: true,924 text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,925 },926 ]927 const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(选择模型或 provider。928 Effect.flatMap((mdl) => execRead(args, { model: mdl })),选择模型或 provider。929 Effect.exit,Effect 异步工作流。930 )931 if (Exit.isSuccess(exit)) {按条件进入分支。932 const result = exit.value933 pieces.push({934 messageID: info.id,935 sessionID: input.sessionID,936 type: "text",937 synthetic: true,938 text: result.output,939 })940 if (result.attachments?.length) {按条件进入分支。941 pieces.push(942 ...result.attachments.map((a) => ({943 ...a,944 synthetic: true,945 filename: a.filename ?? part.filename,946 messageID: info.id,947 sessionID: input.sessionID,948 })),949 )950 } else {951 pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })952 }953 } else {954 const error = Cause.squash(exit.cause)955 log.error("failed to read file", { error })956 const message = error instanceof Error ? error.message : String(error)957 yield* bus.publish(Session.Event.Error, {广播状态变化。958 sessionID: input.sessionID,959 error: new NamedError.Unknown({ message }).toObject(),960 })961 pieces.push({962 messageID: info.id,963 sessionID: input.sessionID,964 type: "text",965 synthetic: true,966 text: `Read tool failed to read ${filepath} with the following error: ${message}`,967 })968 }969 return pieces返回给上一层。
这里的 read.execute 使用 bypassCwdCheck: true,ask 也是空 Effect。准确的边界是:这是用户主动提交附件后的预解析路径,不是模型自主调用 read 工具的普通权限路径。来源:packages/opencode/src/session/prompt.ts:873-887。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:873-887
873 const { read } = yield* registry.named()等待 Effect 结果。874 const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {875 const controller = new AbortController()用于中断运行任务。876 return read返回给上一层。877 .execute(args, {工具真正执行入口。878 sessionID: input.sessionID,879 abort: controller.signal,880 agent: input.agent!,881 messageID: info.id,882 extra: { bypassCwdCheck: true, ...extra },883 messages: [],884 metadata: () => Effect.void,Effect 异步工作流。885 ask: () => Effect.void,Effect 异步工作流。886 })887 .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))Effect 异步工作流。
为什么不只保存路径?因为下一轮模型需要可消费的内容,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:1065-1079
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1065-1079
1065 const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(Effect 异步工作流。1066 Effect.map((x) => x.flat().map(assign)),Effect 异步工作流。1067 )10681069 yield* plugin.trigger(调用插件扩展点。1070 "chat.message",1071 {1072 sessionID: input.sessionID,1073 agent: input.agent,1074 model: input.model,选择模型或 provider。1075 messageID: input.messageID,1076 variant: input.variant,1077 },1078 { message: info, parts: resolvedParts },1079 )
插件拿到的是可修改对象,并且 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:1081-1117
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1081-1117
1081 const parts = yield* Effect.forEach(resolvedParts, (part) =>Effect 异步工作流。1082 part.type === "file" && part.mime.startsWith("image/")1083 ? image.normalize(part).pipe(1084 Effect.catchIf(Effect 异步工作流。1085 (error) => error instanceof Image.ResizerUnavailableError,1086 () => Effect.succeed(part),Effect 异步工作流。1087 ),1088 )1089 : Effect.succeed(part),Effect 异步工作流。1090 )10911092 const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" })1093 if (Exit.isFailure(parsed)) {按条件进入分支。1094 log.error("invalid user message before save", {1095 sessionID: input.sessionID,1096 messageID: info.id,1097 agent: info.agent,1098 model: info.model,选择模型或 provider。1099 cause: Cause.pretty(parsed.cause),1100 })1101 }1102 parts.forEach((part, index) => {1103 const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" })1104 if (Exit.isSuccess(p)) return按条件进入分支。1105 log.error("invalid user part before save", {1106 sessionID: input.sessionID,1107 messageID: info.id,1108 partID: part.id,1109 partType: part.type,1110 index,1111 cause: Cause.pretty(p.cause),1112 part,1113 })1114 })11151116 yield* sessions.updateMessage(info)等待 Effect 结果。1117 for (const part of parts) yield* sessions.updatePart(part)等待 Effect 结果。
校验并持久化消息与 parts
packages/opencode/src/session/prompt.ts:1102-1117
入库前会记录无效 part,随后 message 和每个 part 进入 Session.Service。
1102 parts.forEach((part, index) => {1103 const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" })1104 if (Exit.isSuccess(p)) return按条件进入分支。1105 log.error("invalid user part before save", {1106 sessionID: input.sessionID,1107 messageID: info.id,1108 partID: part.id,1109 partType: part.type,1110 index,1111 cause: Cause.pretty(p.cause),1112 part,1113 })1114 })11151116 yield* sessions.updateMessage(info)等待 Effect 结果。1117 for (const part of parts) yield* sessions.updatePart(part)等待 Effect 结果。
为什么分开保存?从数据形状可以确认,这允许 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:1211-1229
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1211-1229
1211 const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn(会话消息片段结构。1212 "SessionPrompt.prompt",把输入交给会话主流程。1213 )(function* (input: PromptInput) {1214 const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)Effect 异步工作流。1215 yield* revert.cleanup(session)等待 Effect 结果。1216 const message = yield* createUserMessage(input)等待 Effect 结果。1217 yield* sessions.touch(input.sessionID)等待 Effect 结果。12181219 const permissions: Permission.Ruleset = []1220 for (const [t, enabled] of Object.entries(input.tools ?? {})) {遍历集合。1221 permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })1222 }1223 if (permissions.length > 0) {按条件进入分支。1224 session.permission = permissions1225 yield* sessions.setPermission({ sessionID: session.id, permission: permissions })等待 Effect 结果。1226 }12271228 if (input.noReply === true) return message按条件进入分支。1229 return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。
所以 noReply 不是“不保存”,而是“只入账,不启动回复循环”。这对于批量导入、异步编排或先构造上下文很有价值。
7. 决定可靠性的四个边界
Section titled “7. 决定可靠性的四个边界”7.1 文件读取失败不会伪装成成功
Section titled “7.1 文件读取失败不会伪装成成功”本地文本读取失败时,代码发布 session error,并增加一条 synthetic text 描述读取失败;不会凭空制造文件内容。来源:packages/opencode/src/session/prompt.ts:953-968。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:953-968
953 } else {954 const error = Cause.squash(exit.cause)955 log.error("failed to read file", { error })956 const message = error instanceof Error ? error.message : String(error)957 yield* bus.publish(Session.Event.Error, {广播状态变化。958 sessionID: input.sessionID,959 error: new NamedError.Unknown({ message }).toObject(),960 })961 pieces.push({962 messageID: info.id,963 sessionID: input.sessionID,964 type: "text",965 synthetic: true,966 text: `Read tool failed to read ${filepath} with the following error: ${message}`,967 })968 }
这使失败本身也成为后续可见上下文,但调用方仍应把 session error 当成错误信号。
7.2 输入 union 限制“用户能声称什么”
Section titled “7.2 输入 union 限制“用户能声称什么””PromptInput 只接收 text/file/agent/subtask drafts,不能直接提交一个已经 completed 的 ToolPart。来源:packages/opencode/src/session/prompt.ts:1666-1688。这防止外部请求把 runtime 状态伪造成用户输入。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1666-1688
1666export const PromptInput = Schema.Struct({定义并校验数据形状。1667 sessionID: SessionID,1668 messageID: Schema.optional(MessageID),定义并校验数据形状。1669 model: Schema.optional(ModelRef),定义并校验数据形状。1670 agent: Schema.optional(Schema.String),定义并校验数据形状。1671 noReply: Schema.optional(Schema.Boolean),定义并校验数据形状。1672 tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({定义并校验数据形状。1673 description:1674 "@deprecated tools and permissions have been merged, you can set permissions on the session itself now",1675 }),1676 format: Schema.optional(MessageV2.Format),定义并校验数据形状。1677 system: Schema.optional(Schema.String),定义并校验数据形状。1678 variant: Schema.optional(Schema.String),定义并校验数据形状。1679 parts: Schema.Array(定义并校验数据形状。1680 Schema.Union([定义并校验数据形状。1681 MessageV2.TextPartInput,会话消息片段结构。1682 MessageV2.FilePartInput,会话消息片段结构。1683 MessageV2.AgentPartInput,会话消息片段结构。1684 MessageV2.SubtaskPartInput,会话消息片段结构。1685 ]).annotate({ discriminator: "type" }),1686 ),1687})1688export type PromptInput = Schema.Schema.Type<typeof PromptInput>定义并校验数据形状。
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:791-842、packages/opencode/src/session/prompt.ts:844-1040。它们最终都归一为 typed parts,但信任边界与失败信息不同。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:791-842
791 if (part.type === "file") {按条件进入分支。792 if (part.source?.type === "resource") {按条件进入分支。793 const { clientName, uri } = part.source794 log.info("mcp resource", { clientName, uri, mime: part.mime })795 const pieces: Draft<MessageV2.Part>[] = [会话消息片段结构。796 {797 messageID: info.id,798 sessionID: input.sessionID,799 type: "text",800 synthetic: true,801 text: `Reading MCP resource: ${part.filename} (${uri})`,802 },803 ]804 const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit)Effect 异步工作流。805 if (Exit.isSuccess(exit)) {按条件进入分支。806 const content = exit.value807 if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)按条件进入分支。808 const items = Array.isArray(content.contents) ? content.contents : [content.contents]809 for (const c of items) {遍历集合。810 if ("text" in c && c.text) {按条件进入分支。811 pieces.push({812 messageID: info.id,813 sessionID: input.sessionID,814 type: "text",815 synthetic: true,816 text: c.text,817 })818 } else if ("blob" in c && c.blob) {819 const mime = "mimeType" in c ? c.mimeType : part.mime820 pieces.push({821 messageID: info.id,822 sessionID: input.sessionID,823 type: "text",824 synthetic: true,825 text: `[Binary content: ${mime}]`,826 })827 }828 }829 pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })830 } else {831 const error = Cause.squash(exit.cause)832 log.error("failed to read MCP resource", { error, clientName, uri })833 const message = error instanceof Error ? error.message : String(error)834 pieces.push({835 messageID: info.id,836 sessionID: input.sessionID,837 type: "text",838 synthetic: true,839 text: `Failed to read MCP resource ${part.filename}: ${message}`,840 })841 }842 return pieces返回给上一层。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:844-1040
844 const url = new URL(part.url)845 switch (url.protocol) {846 case "data:":847 if (part.mime === "text/plain") {按条件进入分支。848 return [返回给上一层。849 {850 messageID: info.id,851 sessionID: input.sessionID,852 type: "text",853 synthetic: true,854 text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,855 },856 {857 messageID: info.id,858 sessionID: input.sessionID,859 type: "text",860 synthetic: true,861 text: decodeDataUrl(part.url),862 },863 { ...part, messageID: info.id, sessionID: input.sessionID },864 ]865 }866 break867 case "file:": {868 log.info("file", { mime: part.mime })869 const filepath = fileURLToPath(part.url)870 const referenceContext = yield* referenceContextFromFilePart(part, filepath)等待 Effect 结果。871 const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime等待 Effect 结果。872873 const { read } = yield* registry.named()等待 Effect 结果。874 const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {875 const controller = new AbortController()用于中断运行任务。876 return read返回给上一层。877 .execute(args, {工具真正执行入口。878 sessionID: input.sessionID,879 abort: controller.signal,880 agent: input.agent!,881 messageID: info.id,882 extra: { bypassCwdCheck: true, ...extra },883 messages: [],884 metadata: () => Effect.void,Effect 异步工作流。885 ask: () => Effect.void,Effect 异步工作流。886 })887 .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))Effect 异步工作流。888 }889890 if (mime === "text/plain") {按条件进入分支。891 let offset: number | undefined892 let limit: number | undefined893 const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }894 if (range.start != null) {按条件进入分支。895 const filePathURI = part.url.split("?")[0]896 let start = parseInt(range.start)897 let end = range.end ? parseInt(range.end) : undefined898 if (start === end) {按条件进入分支。899 const symbols = yield* lsp.documentSymbol(filePathURI).pipe(Effect.catch(() => Effect.succeed([])))Effect 异步工作流。900 for (const symbol of symbols) {遍历集合。901 let r: LSP.Range | undefined处理语言服务诊断。902 if ("range" in symbol) r = symbol.range按条件进入分支。903 else if ("location" in symbol) r = symbol.location.range904 if (r?.start?.line && r?.start?.line === start) {按条件进入分支。905 start = r.start.line906 end = r?.end?.line ?? start907 break908 }909 }910 }911 offset = Math.max(start, 1)912 if (end) limit = end - (offset - 1)按条件进入分支。913 }914 const args = { filePath: filepath, offset, limit }915 const pieces: Draft<MessageV2.Part>[] = [会话消息片段结构。916 ...(referenceContext917 ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]918 : []),919 {920 messageID: info.id,921 sessionID: input.sessionID,922 type: "text",923 synthetic: true,924 text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,925 },926 ]927 const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(选择模型或 provider。928 Effect.flatMap((mdl) => execRead(args, { model: mdl })),选择模型或 provider。929 Effect.exit,Effect 异步工作流。930 )931 if (Exit.isSuccess(exit)) {按条件进入分支。932 const result = exit.value933 pieces.push({934 messageID: info.id,935 sessionID: input.sessionID,936 type: "text",937 synthetic: true,938 text: result.output,939 })940 if (result.attachments?.length) {按条件进入分支。941 pieces.push(942 ...result.attachments.map((a) => ({943 ...a,944 synthetic: true,945 filename: a.filename ?? part.filename,946 messageID: info.id,947 sessionID: input.sessionID,948 })),949 )950 } else {951 pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })952 }953 } else {954 const error = Cause.squash(exit.cause)955 log.error("failed to read file", { error })956 const message = error instanceof Error ? error.message : String(error)957 yield* bus.publish(Session.Event.Error, {广播状态变化。958 sessionID: input.sessionID,959 error: new NamedError.Unknown({ message }).toObject(),960 })961 pieces.push({962 messageID: info.id,963 sessionID: input.sessionID,964 type: "text",965 synthetic: true,966 text: `Read tool failed to read ${filepath} with the following error: ${message}`,967 })968 }969 return pieces返回给上一层。970 }971972 if (mime === "application/x-directory") {按条件进入分支。973 const args = { filePath: filepath }974 const exit = yield* execRead(args).pipe(Effect.exit)Effect 异步工作流。975 if (Exit.isFailure(exit)) {按条件进入分支。976 const error = Cause.squash(exit.cause)977 log.error("failed to read directory", { error })978 const message = error instanceof Error ? error.message : String(error)979 yield* bus.publish(Session.Event.Error, {广播状态变化。980 sessionID: input.sessionID,981 error: new NamedError.Unknown({ message }).toObject(),982 })983 return [返回给上一层。984 ...(referenceContext985 ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]986 : []),987 {988 messageID: info.id,989 sessionID: input.sessionID,990 type: "text",991 synthetic: true,992 text: `Read tool failed to read ${filepath} with the following error: ${message}`,993 },994 ]995 }996 return [返回给上一层。997 ...(referenceContext998 ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]999 : []),1000 {1001 messageID: info.id,1002 sessionID: input.sessionID,1003 type: "text",1004 synthetic: true,1005 text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,1006 },1007 {1008 messageID: info.id,1009 sessionID: input.sessionID,1010 type: "text",1011 synthetic: true,1012 text: exit.value.output,1013 },1014 { ...part, mime, messageID: info.id, sessionID: input.sessionID },1015 ]1016 }10171018 return [返回给上一层。1019 ...(referenceContext ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] : []),1020 {1021 messageID: info.id,1022 sessionID: input.sessionID,1023 type: "text",1024 synthetic: true,1025 text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`,1026 },1027 {1028 id: part.id,1029 messageID: info.id,1030 sessionID: input.sessionID,1031 type: "file",1032 url:1033 `data:${mime};base64,` +1034 Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"),读写本地文件。1035 mime,1036 filename: part.filename!,1037 source: part.source,1038 },1039 ]1040 }
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:1118-1206。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1118-1206
1118 const nextPrompt = parts.reduce(1119 (result, part) => {1120 if (part.type === "text") {按条件进入分支。1121 if (part.synthetic) result.synthetic.push(part.text)按条件进入分支。1122 else result.text.push(part.text)1123 const reference = referencePromptMetadata(part.metadata?.reference)1124 if (reference) {按条件进入分支。1125 result.references.push(1126 new ReferenceAttachment({1127 name: reference.name,1128 kind: reference.kind,1129 uri: reference.path ? pathToFileURL(reference.path).href : undefined,1130 repository: reference.repository,1131 branch: reference.branch,1132 target: reference.target,1133 targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined,1134 problem: reference.problem,1135 source: new Source({1136 start: reference.source.start,1137 end: reference.source.end,1138 text: reference.source.value,1139 }),1140 }),1141 )1142 }1143 }1144 if (part.type === "file") {按条件进入分支。1145 result.files.push(1146 new FileAttachment({1147 uri: part.url,1148 mime: part.mime,1149 name: part.filename,1150 source: part.source1151 ? new Source({1152 start: part.source.text.start,1153 end: part.source.text.end,1154 text: part.source.text.value,1155 })1156 : undefined,1157 }),1158 )1159 }1160 if (part.type === "agent") {按条件进入分支。1161 result.agents.push(1162 new AgentAttachment({1163 name: part.name,1164 source: part.source1165 ? new Source({1166 start: part.source.start,1167 end: part.source.end,1168 text: part.source.value,1169 })1170 : undefined,1171 }),1172 )1173 }1174 return result返回给上一层。1175 },1176 {1177 text: [] as string[],1178 files: [] as FileAttachment[],1179 agents: [] as AgentAttachment[],1180 references: [] as ReferenceAttachment[],1181 synthetic: [] as string[],1182 },1183 )1184 // TODO(v2): Temporary dual-write while migrating session messages to v2 events.1185 if (flags.experimentalEventSystem) {按条件进入分支。1186 yield* events.publish(SessionEvent.Prompted, {广播状态变化。1187 sessionID: input.sessionID,1188 timestamp: DateTime.makeUnsafe(info.time.created),1189 prompt: {1190 text: nextPrompt.text.join("\n"),1191 files: nextPrompt.files,1192 agents: nextPrompt.agents,1193 references: nextPrompt.references,1194 },1195 })1196 }1197 for (const text of nextPrompt.synthetic) {遍历集合。1198 // TODO(v2): Temporary dual-write while migrating session messages to v2 events.1199 if (flags.experimentalEventSystem) {按条件进入分支。1200 yield* events.publish(SessionEvent.Synthetic, {广播状态变化。1201 sessionID: input.sessionID,1202 timestamp: DateTime.makeUnsafe(info.time.created),1203 text,1204 })1205 }1206 }
因此不能把这些实验事件描述成所有环境下唯一的消息存储。
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:867-969。
packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:867-969
867 case "file:": {868 log.info("file", { mime: part.mime })869 const filepath = fileURLToPath(part.url)870 const referenceContext = yield* referenceContextFromFilePart(part, filepath)等待 Effect 结果。871 const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime等待 Effect 结果。872873 const { read } = yield* registry.named()等待 Effect 结果。874 const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {875 const controller = new AbortController()用于中断运行任务。876 return read返回给上一层。877 .execute(args, {工具真正执行入口。878 sessionID: input.sessionID,879 abort: controller.signal,880 agent: input.agent!,881 messageID: info.id,882 extra: { bypassCwdCheck: true, ...extra },883 messages: [],884 metadata: () => Effect.void,Effect 异步工作流。885 ask: () => Effect.void,Effect 异步工作流。886 })887 .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))Effect 异步工作流。888 }889890 if (mime === "text/plain") {按条件进入分支。891 let offset: number | undefined892 let limit: number | undefined893 const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }894 if (range.start != null) {按条件进入分支。895 const filePathURI = part.url.split("?")[0]896 let start = parseInt(range.start)897 let end = range.end ? parseInt(range.end) : undefined898 if (start === end) {按条件进入分支。899 const symbols = yield* lsp.documentSymbol(filePathURI).pipe(Effect.catch(() => Effect.succeed([])))Effect 异步工作流。900 for (const symbol of symbols) {遍历集合。901 let r: LSP.Range | undefined处理语言服务诊断。902 if ("range" in symbol) r = symbol.range按条件进入分支。903 else if ("location" in symbol) r = symbol.location.range904 if (r?.start?.line && r?.start?.line === start) {按条件进入分支。905 start = r.start.line906 end = r?.end?.line ?? start907 break908 }909 }910 }911 offset = Math.max(start, 1)912 if (end) limit = end - (offset - 1)按条件进入分支。913 }914 const args = { filePath: filepath, offset, limit }915 const pieces: Draft<MessageV2.Part>[] = [会话消息片段结构。916 ...(referenceContext917 ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }]918 : []),919 {920 messageID: info.id,921 sessionID: input.sessionID,922 type: "text",923 synthetic: true,924 text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,925 },926 ]927 const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(选择模型或 provider。928 Effect.flatMap((mdl) => execRead(args, { model: mdl })),选择模型或 provider。929 Effect.exit,Effect 异步工作流。930 )931 if (Exit.isSuccess(exit)) {按条件进入分支。932 const result = exit.value933 pieces.push({934 messageID: info.id,935 sessionID: input.sessionID,936 type: "text",937 synthetic: true,938 text: result.output,939 })940 if (result.attachments?.length) {按条件进入分支。941 pieces.push(942 ...result.attachments.map((a) => ({943 ...a,944 synthetic: true,945 filename: a.filename ?? part.filename,946 messageID: info.id,947 sessionID: input.sessionID,948 })),949 )950 } else {951 pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })952 }953 } else {954 const error = Cause.squash(exit.cause)955 log.error("failed to read file", { error })956 const message = error instanceof Error ? error.message : String(error)957 yield* bus.publish(Session.Event.Error, {广播状态变化。958 sessionID: input.sessionID,959 error: new NamedError.Unknown({ message }).toObject(),960 })961 pieces.push({962 messageID: info.id,963 sessionID: input.sessionID,964 type: "text",965 synthetic: true,966 text: `Read tool failed to read ${filepath} with the following error: ${message}`,967 })968 }969 return pieces返回给上一层。
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 核心循环”。