上篇我们深入了 project/workspace 管理——当一切正常时,opencode 如何组织工作单元。这篇来看另一面:当出问题时,一条错误从源码到用户终端的完整链路。

如果你要设计一个 AI Agent 的错误处理——模型超时、配置格式错、权限不足、插件崩溃——你会怎么组织?一个直观的想法是每个模块各自 try-catch,各写各的格式化逻辑。opencode 的作者也是这么想的开始——但最后做了一个关键决定:分散定义错误类型,集中格式化错误消息

场景:在终端中运行 opencode 命令时,任何环节的错误都必须转化为人类可读的提示,不泄露堆栈、不吞没信息、不暴露内部结构。 路径packages/opencode/src/cli/error.tspackages/opencode/src/cli/ui.tspackages/opencode/src/index.ts

【问题】— opencode 面对的 5 种失败

一个直观想法:各模块各自为政

如果每个命令自己 try-catch、自己决定如何显示错误,后果很清晰:文案风格参差、调试信息不一致、用户面对五花八门的错误格式。更隐蔽的问题是——当错误结构发生变化(比如某个 Error 类新增了字段),要搜遍全仓库改格式化代码。

opencode 的实际场景

opencode 作为一个 Agent 应用,错误来源天然多元:

  • CLI 层:参数缺失、session 找不到、配置 JSON 格式错
  • 业务层:模型不可用(ProviderModelNotFoundError)、provider 初始化失败、权限判定拒绝(DeniedError
  • 系统层:插件加载失败、SSE 流超时(ResponseStreamError)、配置路径拼写错误

这 5 种场景产出的错误数据结构完全不同——有的带 modelIDsuggestions,有的带 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,
}) {}

TaggedErrorClass 家族

_tag 的独特之处在于它由 Schema 自动生成,不可篡改。这意味着 FormatError 可以用字符串匹配 _tag 来精准路由——不依赖 instanceof,不依赖鸭子类型,一个纯函数就能覆盖全部已知错误。

路由层:FormatError 的二十路 switch

所有错误最终流入 cli/error.tsFormatError 函数。它的签名极其简单: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
}

FormatError dispatch 骨架

这里有一个值得注意的设计差别:部分 Error 直接改 process.exitCode,部分只返回字符串,部分返回空串让上层忽略。FormatError 不是纯显示函数,它还承担了错误严重级别的编码——有些错误需要非零退出码,有些不需要。

显示层:UI.error 与 ANSI

格式化后的文本送到 cli/ui.tsUI.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: /* ... */,
  }
}

Provider 错误解析

这里区分了两种语义: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 的结构化字段(modelIDsuggestionsissues[].path)在 FormatError 中被精确提取,转化为人类可读的建议字符串。如果用分散模式,这些字段要么被 toString 抹平,要么在 N 个地方重复提取。

一个反直觉的观察:FormatError 虽然是纯函数,但它隐含了错误级别的编码exitCode 的修改、空串返回、建议文本的拼接——这些行为分散在每一个 tag handler 中。如果未来需要全局变更错误输出格式(比如加 JSON 模式),FormatError 是唯一的修改点,这恰恰是集中路由的最大收益。

为什么不走 Effect 的 CatchAll?

Effect 系统内部有 Effect.catchAllEffect.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 运行时出错了谁来报告?那就是第四章要回答的。