上篇我们深入了 project/workspace 管理——当一切正常时,opencode 如何组织工作单元。这篇来看另一面:当出问题时,一条错误从源码到用户终端的完整链路。
如果你要设计一个 AI Agent 的错误处理——模型超时、配置格式错、权限不足、插件崩溃——你会怎么组织?一个直观的想法是每个模块各自 try-catch,各写各的格式化逻辑。opencode 的作者也是这么想的开始——但最后做了一个关键决定:分散定义错误类型,集中格式化错误消息。
场景:在终端中运行 opencode 命令时,任何环节的错误都必须转化为人类可读的提示,不泄露堆栈、不吞没信息、不暴露内部结构。 路径:
packages/opencode/src/cli/error.ts→packages/opencode/src/cli/ui.ts→packages/opencode/src/index.ts
【问题】— opencode 面对的 5 种失败
一个直观想法:各模块各自为政
如果每个命令自己 try-catch、自己决定如何显示错误,后果很清晰:文案风格参差、调试信息不一致、用户面对五花八门的错误格式。更隐蔽的问题是——当错误结构发生变化(比如某个 Error 类新增了字段),要搜遍全仓库改格式化代码。
opencode 的实际场景
opencode 作为一个 Agent 应用,错误来源天然多元:
- CLI 层:参数缺失、session 找不到、配置 JSON 格式错
- 业务层:模型不可用(
ProviderModelNotFoundError)、provider 初始化失败、权限判定拒绝(DeniedError) - 系统层:插件加载失败、SSE 流超时(
ResponseStreamError)、配置路径拼写错误
这 5 种场景产出的错误数据结构完全不同——有的带 modelID 和 suggestions,有的带 issues 数组,有的只有一个 message 字符串。但它们最终的归宿都是同一个地方:终端用户看到的"Error: ..."一行字。
数据的多样性 + 显示的统一性 = 需要一个格式层来做映射。

【设计】— 三层错误架构
定义层:TaggedErrorClass 家族
opencode 的错误定义分布在各个模块中,但遵守相同的模式:每个自定义错误类继承自 Schema.TaggedErrorClass,自带一个 _tag 标签和一个结构化字段集。
// packages/opencode/src/provider/provider.ts L1075-1084
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {}
// packages/core/src/permission.ts L92-94
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
rules: PermissionSchema.Ruleset,
}) {}
// packages/core/src/session/error.ts L5-8
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}

_tag 的独特之处在于它由 Schema 自动生成,不可篡改。这意味着 FormatError 可以用字符串匹配 _tag 来精准路由——不依赖 instanceof,不依赖鸭子类型,一个纯函数就能覆盖全部已知错误。
路由层:FormatError 的二十路 switch
所有错误最终流入 cli/error.ts 的 FormatError 函数。它的签名极其简单:unknown → string | undefined。输入任何值,输出人类可读的字符串,或者返回 undefined 表示"我不认识这个错误"。
// packages/opencode/src/cli/error.ts L35-126(骨架)
export function FormatError(input: unknown): string | undefined {
if (isTaggedError(input, "CliError")) {
if (typeof input.exitCode === "number") process.exitCode = input.exitCode
return stringField(input, "message") ?? ""
}
if (NamedError.hasName(input, "MCPFailed")) { /* ... */ }
if (configData(input, "ProviderModelNotFoundError")) {
const suggestions = Array.isArray(input.suggestions)
? input.suggestions.filter(x => typeof x === "string")
: []
return [
`Model not found: ...`,
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
"Try: `opencode models` to list available models",
].join("\n")
}
if (configData(input, "ConfigInvalidError")) { /* ... */ }
if (isTaggedError(input, "UICancelledError")) return ""
return undefined
}

这里有一个值得注意的设计差别:部分 Error 直接改 process.exitCode,部分只返回字符串,部分返回空串让上层忽略。FormatError 不是纯显示函数,它还承担了错误严重级别的编码——有些错误需要非零退出码,有些不需要。
显示层:UI.error 与 ANSI
格式化后的文本送到 cli/ui.ts 的 UI.error():
// packages/opencode/src/cli/ui.ts L121-126
export function error(message: string) {
if (message.startsWith("Error: ")) {
message = message.slice("Error: ".length)
}
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
它做的事不多但关键:去掉重复前缀,加上 ANSI 红色粗体标记,输出到 stderr。显示层对错误内容零假设——格式化已经在 FormatError 里完成了。

【源码】— FormatError 格式塔
处理链优先级:从特定到通用
FormatError 的处理链体现了从特定到通用的优先级。CliError 排第一(最常出现),然后是 Provider 系列、Config 系列、权限系列,最后是 UICancelledError(用户取消,静默处理)。每一路都做三件事:提取结构化字段 → 组装人类语言 → 附加可操作建议。
Provider 错误的特殊处理
FormatError 只处理"业务层"错误。Provider 层面的错误(LLM API 调用失败、SSE 流中断)有自己的两阶段解析系统,位于 packages/opencode/src/provider/error.ts:
// packages/opencode/src/provider/error.ts L165-185
export function parseAPICallError(input: {
providerID: ProviderV2.ID
error: APICallError
}): ParsedAPICallError {
const m = message(input.providerID, input.error)
const body = json(input.error.responseBody)
if (isContextOverflow(m) || input.error.statusCode === 413
|| body?.error?.code === "context_length_exceeded") {
return { type: "context_overflow", message: m }
}
return {
type: "api_error", message: m,
statusCode: input.error.statusCode,
isRetryable: /* ... */,
}
}

这里区分了两种语义:context_overflow(token 超限,需要截断或换模型)和 api_error(网络/认证/限流等可重试或不可重试的错误)。这个分类在 Agent 循环中决定了后续行为——是自动截断重试,还是终止并展示给用户。
【权衡】— 为什么集中路由?
三个方案对比
| 维度 | A. 分散式 | B. 集中式 (FormatError) | C. 全局 catch |
|---|---|---|---|
| 文案统一 | ❌ N 个模块 N 种风格 | ✅ 一个维护点 | ⚠️ 只改格式不变内容 |
| 新增 Error 成本 | 只改本模块 | 改本模块 + cli/error.ts | 不改(格式固定) |
| 语义保留 | ❌ 易丢失结构化字段 | ✅ 按 tag 精准提取 | ❌ 全变字符串 |
| 可测试性 | 分散难测 | ✅ 纯函数,单测集中 | ⚠️ e2e 级别 |
| 错误类型可见性 | 散落仓库 | ✅ cli/error.ts 即目录 | ❌ 隐式 |

opencode 选 B 的决策点在于:语义 lossless 的价值超过了两处修改的代价。每个 Error 的结构化字段(modelID、suggestions、issues[].path)在 FormatError 中被精确提取,转化为人类可读的建议字符串。如果用分散模式,这些字段要么被 toString 抹平,要么在 N 个地方重复提取。
一个反直觉的观察:FormatError 虽然是纯函数,但它隐含了错误级别的编码。exitCode 的修改、空串返回、建议文本的拼接——这些行为分散在每一个 tag handler 中。如果未来需要全局变更错误输出格式(比如加 JSON 模式),FormatError 是唯一的修改点,这恰恰是集中路由的最大收益。
为什么不走 Effect 的 CatchAll?
Effect 系统内部有 Effect.catchAll 和 Effect.catchTag,但 FormatError 的定位在它们之外——它处理的是 yargs 边界抛出的异常,而非 Effect 内的 typed failure。fail("...") 抛出 CliError 后经过 AppRuntime 的 runPromise 逃逸到 index.ts 的 try-catch,然后才进入 FormatError。所以 FormatError 不是 Effect 错误处理的替代,而是 TTY 边界上最后一道防线。
【锚点】
错误处理是一层独立架构
错误处理暴露了你对系统稳定性的真实态度。opencode 的选择(分散定义 + 集中格式化 + 语义分类)给出了一条可复用的思路:不要让错误处理成为散落各处的 if-else,把它当作一层独立的架构来设计。
下篇我们将进入第四章的核心——Agent 定义体系。你会看到 opencode 如何用 Info Schema 定义"什么是 agent",以及这套 schema 如何成为可扩展的协议。有了错误体系的兜底,下章 agent 运行时出错了谁来报告?那就是第四章要回答的。