# 从 OpenCode 反推 mini coding agent

> 源码基线：`eec0843ce422`。本章的 mini agent 是教学性设计；OpenCode 源码负责证明机制，但示例代码不是 OpenCode 的可直接运行摘录。

## 0. 本章学习目标

读完本章，你应该能：

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

## 1. 一句话讲明白

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

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

## 2. 先做减法：内核与产品层

```text
必须保留的内核

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 必须能让模型观察工具结果后继续决策。

## 3. 最小架构地图

| 模块 | 最小职责 | OpenCode 证据 |
|---|---|---|
| CLI | 解析输入，调用 session service | `packages/opencode/src/cli/cmd/run.ts:768-803` |
| Session | 保存 user/assistant/tool 消息 | `packages/opencode/src/session/prompt.ts:1211-1230` |
| Agent loop | 选择继续、停止或进入工具轮 | `packages/opencode/src/session/prompt.ts:1248-1276` |
| LLM gateway | 接收 messages、system、tools 并流式返回 | `packages/opencode/src/session/llm.ts` |
| Tool registry | 暴露 schema 与 execute | `packages/opencode/src/tool/tool.ts:16-45` |
| Permission | allow/deny/ask，等待用户回复 | `packages/opencode/src/permission/index.ts:161-195` |
| Processor | 把流事件收敛成 message/tool 状态 | `packages/opencode/src/session/processor.ts:376-520` |

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

## 4. 最小机制：先写对循环，再加框架

```text
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:1248-1386`。

## 5. 先定义稳定的数据形状

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

```ts
type Message =
  | { role: "user"; content: string }
  | { role: "assistant"; content: string; toolCalls?: ToolCall[] }
  | { role: "tool"; callID: string; name: string; output: string; isError?: boolean }

type ToolCall = {
  id: string
  name: string
  args: unknown
}

type Tool = {
  name: string
  description: string
  validate(args: unknown): Record<string, unknown>
  execute(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>
}
```

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

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

## 6. 一条具体旅程：读取 `package.json` 再回答

用户输入：

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

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

### 第一步：CLI 把输入交给 session

OpenCode 非交互 CLI 组装 text/file parts，然后调用 `client.session.prompt(...)`，见 `packages/opencode/src/cli/cmd/run.ts:768-803`。

mini agent 的 CLI 同样应保持薄：

```ts
const session = await sessions.create()
const answer = await agent.prompt(session.id, userText)
process.stdout.write(answer)
```

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

### 第二步：先落 user message，再进入 loop

`SessionPrompt.prompt` 读取 session、清理 revert 状态、创建 user message、更新 session，然后才调用 loop，见 `packages/opencode/src/session/prompt.ts:1211-1230`。

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

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

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

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

随后创建 assistant message、processor handle，并调用 `SessionTools.resolve(...)`，见 `packages/opencode/src/session/prompt.ts:1332-1386`。

### 第四步：模型看到 read 工具 schema

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

模型可能返回：

```json
{
  "id": "call_1",
  "name": "read",
  "args": { "filePath": "package.json" }
}
```

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

### 第五步：权限在执行路径内，而不是 UI 提示框里

`SessionTools.resolve` 创建的 tool context 把 agent 与 session ruleset 合并，并通过 Permission service 执行 `ask`，见 `packages/opencode/src/session/tools.ts:42-72`。

Permission service 对每个 pattern 求值：

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

见 `packages/opencode/src/permission/index.ts:161-195`。

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

### 第六步：执行 read，并把结果绑定到 call id

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

mini agent 第一版可以不做 hooks，但必须保留：

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

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

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

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

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

```text
pending -> running -> completed | error
```

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

### 第八步：结果回到模型，模型才生成答案

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

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

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

### 7.1 正常完成

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

### 7.2 达到步数上限

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

### 7.3 权限拒绝

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

### 7.4 工具参数错误

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

### 7.5 重复工具调用

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

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

### 7.6 取消

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

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

| 第一版保留 | 可以延后 | 原因 |
|---|---|---|
| 内存 message ledger | 数据库与 session 分享 | 先证明闭环 |
| 一个 provider adapter | 多 provider transform | 先稳定内部协议 |
| read + 明确审批的 shell | edit/write/patch/LSP | 降低不可逆风险 |
| schema validation | MCP/plugin tool | 先稳定 registry |
| max steps + cancel + duplicate guard | compaction/subagent | 先防失控 |
| text event logger | TUI/Desktop | 先获得可观测性 |

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

## 9. 一个可实现的目录骨架

```text
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. 实现顺序：每一步都形成可验证能力

### 里程碑一：无工具的消息闭环

- 定义 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

- 默认拒绝危险 shell。
- allow/deny/ask 三条路径。
- 审批只允许本次与持久批准分开。
- stdout/stderr/exit code 明确建模。

### 里程碑五：真实 provider 与事件

- 用 adapter 接真实流式 API。
- 将 provider events 归一化。
- 事件 logger 展示 step、call、result、finish，不泄漏 secret。

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

## 11. 最小测试矩阵

| 场景 | 关键断言 |
|---|---|
| 模型直接回答 | 只调用一次 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 的选择：哪些不应照抄

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

照抄的代价是：你会在还没跑通 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 正确性。

## 13. 可以带走的方法

### 方法一：用账本驱动循环

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

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

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

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

验证问题：新增 shell 时，是否天然经过与 read 相同的 validation 与 event 路径？

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

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

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

## 14. 费曼复述与最终练习

请关闭本章，用两分钟讲清：

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

最终练习：

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

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

## 最后复盘：小，但必须是真的

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

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

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

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