跳转到内容

从 OpenCode 反推 mini coding agent

v1.18.16 源码提交 a3647eb025c7 · 官方 Release
状态已完成
难度中等
预计阅读60 分钟
  • 章节 ID:14-mini-coding-agent
  • 章节摘要:把 CLI、session、LLM、tool、permission 与 processor 的最小机制重组为一个可实现、可观察、可设安全边界的 mini coding agent。
  • 教程版本:v1.18.16
  • 源码基线:a3647eb025c7615159d417dcc49fc39fdaeba65b
  • 章节元数据:/versions/v1-18-16/data/chapters.json
  • 源码映射:/versions/v1-18-16/data/source-map.json
  • packages/opencode/src/cli/cmd/run.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/session/llm.ts
  • packages/opencode/src/tool/tool.ts
  • packages/opencode/src/session/tools.ts
  • packages/opencode/src/permission/index.ts
  • packages/opencode/src/tool/read.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/shell.ts
  • packages/opencode/src/session/processor.ts
  • packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts

源码基线:v1.18.16(提交 a3647eb025c7)。本章的 mini agent 是教学性设计;OpenCode 源码负责证明机制,但示例代码不是 OpenCode 的可直接运行摘录。

读完本章,你应该能:

  • 从成熟 OpenCode 中剥离出 coding agent 的最小闭环。
  • 画出消息账本、LLM、tool registry、permission 与 processor 的边界。
  • 沿着“读取 package.json 再回答”走完一轮 tool call。
  • 写出有停止条件、参数校验、审批和错误回填的 loop 伪代码。
  • 排出一个可交付 mini agent 的实现与测试顺序。

一个最小 coding agent 是一台有账本的循环机:模型从消息决定下一步,工具把外部世界的结果写回账本,循环根据明确条件继续或停止,权限闸门则阻止不该自动发生的动作。

中心问题是:从 OpenCode 删除 UI、MCP、插件、LSP、多 provider 和分布式接口之后,哪些机制再删一个,agent 就不再是真的?

  • 最新 loop 把 finish reason 与真实 ToolPart 一起判断,提醒 mini agent 不能只相信 provider 的一个字符串就退出。
  • Tool wrapper 已统一承担参数解码和输出截断,说明 mini agent 至少需要一个执行 adapter,而不是直接调用业务函数。
  • Permission 契约下沉到 core,runtime 用 Deferred 等待审批;这进一步验证“能力规则”与“交互界面”应当分层。
mini agent 的循环与退出证据 packages/opencode/src/session/prompt.ts:1081-1130

退出条件必须同时考虑 finish reason 和 tool 状态。

1081    const runLoop: (sessionID: SessionID) => Effect.Effect<SessionV1.WithParts> = Effect.fn("SessionPrompt.run")(agent 核心循环。1082      function* (sessionID: SessionID) {定义一段可复用逻辑。1083        const ctx = yield* InstanceState.context等待 Effect 结果。1084        let structured: unknown1085        let step = 01086        const session = yield* sessions.get(sessionID).pipe(Effect.orDie)Effect 异步工作流。10871088        while (true) {agent 核心循环。1089          yield* status.set(sessionID, { type: "busy" })等待 Effect 结果。1090          yield* Effect.logInfo("loop", { "session.id": sessionID, step })Effect 异步工作流。10911092          let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe(会话消息片段结构。1093            Effect.provideService(Database.Service, database),Effect 异步工作流。1094          )10951096          const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)会话消息片段结构。10971098          if (!lastUser) throw new Error("No user message found in stream. This should never happen.")按条件进入分支。10991100          const lastAssistantMsg = msgs.findLast(1101            (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,1102          )1103          // Some providers return "stop" even when the assistant message contains1104          // tool calls. Keep the loop running so tool results can be sent back to1105          // the model, but ignore cleanup-marked interrupted orphans.1106          const hasToolCalls =1107            lastAssistantMsg?.parts.some(1108              (part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),选择模型或 provider。1109            ) ?? false11101111          if (按条件进入分支。1112            lastAssistant?.finish &&1113            !["tool-calls"].includes(lastAssistant.finish) &&1114            !hasToolCalls &&1115            lastAssistant.parentID === lastUser.id1116          ) {1117            const orphan = lastAssistantMsg?.parts.find(1118              (part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),会话消息片段结构。1119            )1120            if (orphan) {按条件进入分支。1121              yield* Effect.logWarning("loop exit with orphaned interrupted tool", {Effect 异步工作流。1122                "session.id": sessionID,1123                messageID: lastAssistant.id,1124                tool: orphan.tool,1125                callID: orphan.callID,1126              })1127            }1128            yield* Effect.logInfo("exiting loop", { "session.id": sessionID })Effect 异步工作流。1129            break1130          }
mini tool adapter 的证据 packages/opencode/src/tool/tool.ts:102-171

schema、执行、截断和 metadata 是稳定边界。

102  truncate: Truncate.Interface,103  agents: Agent.Interface,104) {105  return () =>返回给上一层。106    Effect.gen(function* () {Effect 异步工作流。107      const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }等待 Effect 结果。108      // Compile the parser closure once per tool init; `decodeUnknownEffect`109      // allocates a new closure per call, so hoisting avoids re-closing it for110      // every LLM tool invocation.111      const decode = Schema.decodeUnknownEffect(toolInfo.parameters)定义并校验数据形状。112      const execute = toolInfo.execute113      toolInfo.execute = (args, ctx) => {114        const attrs = {115          "tool.name": id,116          "session.id": ctx.sessionID,117          "message.id": ctx.messageID,118          ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),119        }120        return Effect.gen(function* () {Effect 异步工作流。121          const decoded = yield* decode(args).pipe(等待 Effect 结果。122            Effect.mapError(Effect 异步工作流。123              (error) =>124                new InvalidArgumentsError({125                  tool: id,126                  detail: toolInfo.formatValidationError ? toolInfo.formatValidationError(error) : String(error),127                }),128            ),129          )130          const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)工具真正执行入口。131          if (result.metadata.truncated !== undefined) {按条件进入分支。132            return result返回给上一层。133          }134          const agent = yield* agents.get(ctx.agent)等待 Effect 结果。135          const truncated = yield* truncate.output(result.output, {}, agent)等待 Effect 结果。136          return {返回给上一层。137            ...result,138            output: truncated.content,139            metadata: {140              ...result.metadata,141              truncated: truncated.truncated,142              ...(truncated.truncated && { outputPath: truncated.outputPath }),143            },144          }145        }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs }))Effect 异步工作流。146      }147      return toolInfo返回给上一层。148    })149}150151export function define<对外暴露模块成员。152  Parameters extends Schema.Decoder<unknown>,定义并校验数据形状。153  Result extends Metadata,154  R,155  ID extends string = string,156>(157  id: ID,158  init: Effect.Effect<Init<Parameters, Result>, never, R>,Effect 异步工作流。159): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {Effect 异步工作流。160  return Object.assign(返回给上一层。161    Effect.gen(function* () {Effect 异步工作流。162      const resolved = yield* init等待 Effect 结果。163      const truncate = yield* Truncate.Service等待 Effect 结果。164      const agents = yield* Agent.Service等待 Effect 结果。165      return { id, init: wrap(id, resolved, truncate, agents) }返回给上一层。166    }),167    { id },168  )169}170171export function init<P extends Schema.Decoder<unknown>, M extends Metadata>(定义并校验数据形状。
必须保留的内核
User Input
-> Message Ledger
-> LLM(messages, tool schemas)
-> text OR tool calls
-> validate + permission + execute
-> tool results back to ledger
-> continue / stop
OpenCode 的成熟产品层
多入口、持久化数据库、多 provider、插件、MCP、LSP、compaction、
subagent、结构化输出、事件兼容迁移、成本统计、分享、桌面 UI……

“mini”不等于写成一次 llm(prompt)。只调用一次模型的是聊天封装;coding agent 必须能让模型观察工具结果后继续决策。

模块最小职责OpenCode 证据
CLI解析输入,调用 session servicepackages/opencode/src/cli/cmd/run.ts
Session保存 user/assistant/tool 消息packages/opencode/src/session/prompt.ts
Agent loop选择继续、停止或进入工具轮packages/opencode/src/session/prompt.ts
LLM gateway接收 messages、system、tools 并流式返回packages/opencode/src/session/llm.ts
Tool registry暴露 schema 与 executepackages/opencode/src/tool/tool.ts
Permissionallow/deny/ask,等待用户回复packages/opencode/src/permission/index.ts
Processor把流事件收敛成 message/tool 状态packages/opencode/src/session/processor.ts

Java 类比可以帮助定位:CLI 像 Controller,Session 像 aggregate repository,LLM/Tool 像 outbound ports,loop 像状态机。但模型输出是流,tool call 会把控制权在模型与程序之间往返,不能简单等同一次 service transaction。

4. 最小机制:先写对循环,再加框架

Section titled “4. 最小机制:先写对循环,再加框架”
append(userMessage)
for step in 1..maxSteps:
response = llm(messages, toolSchemas)
append(response.text and toolCalls)
if response has no tool calls:
return finalAnswer
for call in response.toolCalls:
tool = registry.require(call.name)
args = tool.validate(call.args)
permission.check(tool, args)
result = tool.execute(args)
append(toolResult(call.id, result))
return stoppedBecauseMaxSteps

这个骨架故意没有 streaming UI、MCP 和 plugin。它仍然保留四个不能省的约束:

  1. tool call 与 result 用 call.id 对应;
  2. 参数先校验再执行;
  3. 工具结果进入下一轮 messages;
  4. 循环有确定的停止上限。

OpenCode 的 loop 更复杂,但 while (true)、完成条件、step 与 tool resolution 清晰可见于 packages/opencode/src/session/prompt.ts

教学版不必复制 OpenCode 的全部 MessageV2,可以从下面开始:

1type Message =定义数据结构约束。2  | { role: "user"; content: string }3  | { role: "assistant"; content: string; toolCalls?: ToolCall[] }4  | { role: "tool"; callID: string; name: string; output: string; isError?: boolean }56type ToolCall = {定义数据结构约束。7  id: string8  name: string9  args: unknown10}1112type Tool = {定义数据结构约束。13  name: string14  description: string15  validate(args: unknown): Record<string, unknown>16  execute(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>工具真正执行入口。17}

OpenCode 的真实工具接口还带 session/message/agent、AbortSignal、metadata 更新和 ask(...),并要求返回 title、metadata、output 与可选 attachments,见 packages/opencode/src/tool/tool.ts

为什么 context 不能只有 cwd?因为一次工具执行还必须能被取消、归属某个 call/message、发起权限请求,并把进度写回正确位置。

6. 一条具体旅程:读取 package.json 再回答

Section titled “6. 一条具体旅程:读取 package.json 再回答”

用户输入:

读取 package.json,告诉我项目使用什么包管理器。

下面是根据 OpenCode 机制整理的典型路径,不代表本章真的向模型发过这条请求。

OpenCode 非交互 CLI 组装 text/file parts,然后调用 client.session.prompt(...),见 packages/opencode/src/cli/cmd/run.ts

mini agent 的 CLI 同样应保持薄:

1const session = await sessions.create()2const answer = await agent.prompt(session.id, userText)3process.stdout.write(answer)

不要在 CLI 里解析模型 tool call,否则未来 Web/API 入口只能复制这段循环。

第二步:先落 user message,再进入 loop

Section titled “第二步:先落 user message,再进入 loop”

SessionPrompt.prompt 读取 session、清理 revert 状态、创建 user message、更新 session,然后才调用 loop,见 packages/opencode/src/session/prompt.ts

顺序保证:即使模型失败,用户请求仍在账本里,诊断与重试有依据。

第三步:loop 选择模型、agent 与工具

Section titled “第三步:loop 选择模型、agent 与工具”

OpenCode 每轮读取压缩后的消息,判断上一条 assistant 是否真的完成。源码特别处理 provider 声称 stop、但 message 中仍有本地 tool call 的情况,见 packages/opencode/src/session/prompt.ts

这是一个重要停止条件:不能只信 finish 字符串,还要检查有没有尚需回填的 tool call。

随后创建 assistant message、processor handle,并调用 SessionTools.resolve(...),见 packages/opencode/src/session/prompt.ts

第四步:模型看到 read 工具 schema

Section titled “第四步:模型看到 read 工具 schema”

Tool definition 提供 id、description、parameters schema 与 execute,见 packages/opencode/src/tool/tool.ts。OpenCode wrapper 会在执行前 decode args,失败时给出“重写输入以满足 schema”的错误,见 packages/opencode/src/tool/tool.ts

模型可能返回:

1{2  "id": "call_1",3  "name": "read",4  "args": { "filePath": "package.json" }5}

这只是教学示例。真正字段名和可用 schema 应以 packages/opencode/src/tool/read.ts 当前定义为准。

第五步:权限在执行路径内,而不是 UI 提示框里

Section titled “第五步:权限在执行路径内,而不是 UI 提示框里”

SessionTools.resolve 创建的 tool context 把 agent 与 session ruleset 合并,并通过 Permission service 执行 ask,见 packages/opencode/src/session/tools.ts

Permission service 对每个 pattern 求值:

  • 任一 deny 立即失败;
  • 全部 allow 直接返回;
  • 存在 ask 就发布事件并等待 Deferred reply。

packages/opencode/src/permission/index.ts

因此,UI 只是审批的一个呈现者。真正闸门必须在工具执行路径里,否则 CLI/API 可绕过安全提示。

第六步:执行 read,并把结果绑定到 call id

Section titled “第六步:执行 read,并把结果绑定到 call id”

OpenCode 为 registry tool 包装 plugin before/after hooks,再调用 item.execute(args, ctx),见 packages/opencode/src/session/tools.ts

mini agent 第一版可以不做 hooks,但必须保留:

validate(call.args)
-> permission.check
-> read.execute
-> ToolResult(call.id, output)

不要把 read 输出拼进一段 system prompt;它应成为与 call_1 对应的 tool message,让模型和日志都知道这是谁的结果。

第七步:processor 把 tool 流事件收敛成账本状态

Section titled “第七步:processor 把 tool 流事件收敛成账本状态”

OpenCode 在 tool-call 事件把 part 设为 running,并检测重复同一工具/输入的 doom loop;在 tool-result 事件规范化附件并完成对应 call,见 packages/opencode/src/session/processor.ts

mini agent 即使不做 token streaming,也应该让 tool call 经历:

pending -> running -> completed | error

否则取消、重试、UI 展示和崩溃恢复都没有可靠状态。

第八步:结果回到模型,模型才生成答案

Section titled “第八步:结果回到模型,模型才生成答案”

加入 tool result 后重新调用 LLM。第二轮模型看到 package.json 内容,才回答包管理器。没有这次回环,工具执行只是旁路脚本,不是 agent observation。

如果第二轮不再产生 tool call,loop 返回 final answer;如果继续调用工具,就重复以上过程,直到完成、失败、取消或达到 max steps。

7. 停止与失败:最小 agent 也必须认真处理

Section titled “7. 停止与失败:最小 agent 也必须认真处理”

没有本地待处理 tool call,模型给出非 tool-calls finish。OpenCode 的判断见 packages/opencode/src/session/prompt.ts

OpenCode 读取 agent.steps,在最后一步向模型追加限制提示,见 packages/opencode/src/session/prompt.ts:1435-1439。mini agent 可更简单:达到上限就停止并返回结构化原因,不能无限循环。

拒绝不是空字符串结果。它应该成为明确 error/denied 状态,让模型知道动作没有发生,也让用户知道文件或 shell 没被执行。

schema validation 必须早于副作用。把可修复错误写回模型,模型可能重试;连续相同错误则应计入循环保护。

OpenCode 检查最近若干 part 是否对同一工具重复同样 input,并通过 doom_loop permission 决定是否继续,见 packages/opencode/src/session/processor.ts

mini agent 可以先用更透明的策略:同一 name + stableJson(args) 连续出现 N 次就停止,并报告重复轨迹。

Tool context 带 AbortSignal,见 packages/opencode/src/tool/tool.ts。mini agent 应把同一个 signal 传给 LLM 与工具;只让 UI 显示“已取消”而后台进程继续运行是错误实现。

8. 第一版应该保留什么,延后什么

Section titled “8. 第一版应该保留什么,延后什么”
第一版保留可以延后原因
内存 message ledger数据库与 session 分享先证明闭环
一个 provider adapter多 provider transform先稳定内部协议
read + 明确审批的 shelledit/write/patch/LSP降低不可逆风险
schema validationMCP/plugin tool先稳定 registry
max steps + cancel + duplicate guardcompaction/subagent先防失控
text event loggerTUI/Desktop先获得可观测性

注意:如果目标明确要求“coding agent 能修改代码”,那么 edit 不能永远延后;但可以在只读闭环验证后再加入,并把 diff/审批/原子写入作为独立里程碑。

src/
cli.ts # 输入输出,不含 loop
agent-loop.ts # 状态机与停止条件
messages.ts # ledger 类型与 append
llm.ts # provider-neutral port
permissions.ts # allow/deny/ask
tools/
tool.ts # schema + execute contract
registry.ts
read.ts
shell.ts
events.ts # 可观察但不拥有真相
test/
agent-loop.test.ts
permission.test.ts
read.test.ts
shell.test.ts

这里的关键不是文件数量,而是依赖方向:CLI 依赖 loop,loop 依赖 ports,具体 provider/tool 实现 ports;任何 UI 都不应被 tool import。

10. 实现顺序:每一步都形成可验证能力

Section titled “10. 实现顺序:每一步都形成可验证能力”
  • 定义 Message 与 LLM port。
  • user message 落账后调用 fake deterministic LLM。
  • 断言 assistant message 也落账。
  • 定义 Tool 与 registry。
  • LLM 返回 read call。
  • 参数校验、执行、result 回填、第二轮回答。
  • max steps。
  • unknown tool。
  • invalid args。
  • repeated identical call。
  • AbortSignal。
  • 默认拒绝危险 shell。
  • allow/deny/ask 三条路径。
  • 审批只允许本次与持久批准分开。
  • stdout/stderr/exit code 明确建模。
  • 用 adapter 接真实流式 API。
  • 将 provider events 归一化。
  • 事件 logger 展示 step、call、result、finish,不泄漏 secret。

这种顺序让每一步都可演示、可测试。先做漂亮 TUI 会掩盖循环与安全边界尚未成立的问题。

场景关键断言
模型直接回答只调用一次 LLM,loop stop
read 后回答两次 LLM,tool result 带原 call id
参数非法工具未执行,错误可观察
permission deny副作用未发生,状态为 denied/error
permission ask回复前暂停,回复后只继续一次
相同调用重复达阈值停止,不无限循环
用户取消LLM/tool 收到 abort,账本记录 interrupted
工具抛错error result 回填或按策略终止

尽量用真实临时目录测试 read/shell,而不是 mock 掉文件系统后只测试自己复制的逻辑。这个思想也与 OpenCode AGENTS.md 的“测试真实实现、尽量少 mock”一致,但 mini 项目仍需根据速度和隔离选择测试层次。

12. OpenCode 的选择:哪些不应照抄

Section titled “12. OpenCode 的选择:哪些不应照抄”

OpenCode 当前 loop 还处理 compaction、subtask、结构化输出、plugin transform、provider executed tools、summary、事件迁移等,见 packages/opencode/src/session/prompt.ts

照抄的代价是:你会在还没跑通 read loop 前,就背负成熟产品的兼容与扩展复杂度。

OpenCode 机制mini agent 决策
Effect service/layer可先用显式构造函数依赖注入
持久化 MessageV2先内存 ledger,类型保留扩展空间
plugin + MCP tools先单一 registry
compaction先限制最大历史/token,明确报错
SSE/UI event system先 callback/async iterator 日志
多级 permission rules先按 tool 的 allow/deny/ask

“先简化”不等于删掉安全与停止条件;它只删产品规模,不删 agent 正确性。

每次决定都从 messages 恢复,而不是依赖散落的局部变量。这样重试、回放和 UI 投影才有基础。

验证问题:进程在 tool result 写入后重启,能否知道下一步该把什么发给模型?

方法二:把副作用封进统一 Tool contract

Section titled “方法二:把副作用封进统一 Tool contract”

schema、permission、abort、result/error 状态必须包住每个工具,而不是由工具作者自由发挥。

验证问题:新增 shell 时,是否天然经过与 read 相同的 validation 与 event 路径?

方法三:停止条件与能力一起设计

Section titled “方法三:停止条件与能力一起设计”

每增加一种“继续”能力,就增加对应上限:工具轮需要 max steps,自动重试需要 retry budget,压缩需要失败出口。

验证问题:模型持续产生合法但无进展的调用时,系统在哪里停止?

请关闭本章,用两分钟讲清:

  1. chat wrapper 与 coding agent 的分界是什么?
  2. 为什么 tool result 必须进入 message ledger?
  3. permission 为什么必须在 execute path 内?
  4. mini agent 哪些 OpenCode 能力可以延后,哪些不能?

最终练习:

  • 第一关:画出“读 package.json”的八步序列,并标出两次 LLM 调用。
  • 第二关:写出 ToolMessagerunLoop 三个最小接口。
  • 第三关:补齐 invalid args、deny、abort、重复调用四个测试。
  • 第四关:加入 shell,但证明未批准命令绝不会启动进程。
  • 迁移关:选择 OpenCode 的一个产品能力,说明它应在哪个边界加入,而不是塞进 loop。

如果你的实现还不能回答“工具失败后账本里有什么”,请先不要做 UI:回到第 5–7 节补齐状态模型。

最终闭环只有一句伪代码:

用户消息入账 -> 模型决定 -> 工具受控执行 -> 结果入账 -> 模型再决定 -> 明确停止

你从 OpenCode 学到的不是一份可缩写的文件清单,而是三条架构纪律:消息是可恢复的事实,工具是受控副作用,循环必须证明为什么继续以及为什么停止。

教程到这里结束,但源码学习的下一步很具体:亲手实现 read 闭环,然后用真实 trace 对照 SessionPromptSessionToolsSessionProcessor。当你的设计与 OpenCode 不同时,先问“我省略的是产品规模,还是正确性边界?”