项目类型:Terminal Coding Agent / Agent Server / TUI
赏析目标:理解一个 Coding Agent 产品如何把 Agent 内核、Session、Provider、Tool、Permission 和用户交互组织成一个完整系统
核心问题:当 Coding Agent 不再是一个"后端 Runtime",而是一个"本地 Server + 终端 UI"的产品时,架构会变成什么样?

前面五篇赏析,我们已经把 Agent 的几种形态看遍了:

  • LangGraph —— Workflow 编排
  • OpenHands —— Coding Agent 的执行环境与 Runtime
  • browser-use —— 感知真实界面
  • DeerFlow 2.0 —— 长任务 SuperAgent 的容错
  • Hermes Agent —— 跨会话自我进化

这五篇有一个共同点:它们全是 Python 写的,而且都更关心"Agent 的后端能力"——怎么执行、怎么容错、怎么记忆、怎么成长。

但一个真正的 Coding Agent 产品,还有另一半:用户怎么和它交互

这就是 OpenCode 的位置。它和 OpenHands 同属 Coding Agent 赛道,但视角完全不同:

OpenHands
→ AI Software Engineering Agent
→ Runtime / Sandbox / Event(后端执行)

OpenCode
→ Terminal Coding Agent
→ Agent Loop / Session / Provider / Tool / Permission / TUI(产品形态)

而且 OpenCode 是这六篇里唯一一个用 TypeScript(而且是 Effect-TS)写的。这一点本身就值得专门赏析——因为它让你看到,同一个"Coding Agent"问题,用函数式依赖注入和用 Python 类,会长成两种完全不同的架构。

这篇我们就抓住用户最关心的七个核心结构——Session、Agent Loop、Provider、Tool、Permission、Context、TUI,最后再和 OpenHands 做一张架构对比图。


一、先别看代码:OpenCode 解决什么"OpenHands 不解决"的问题?

OpenHands 的核心问题是:Agent 怎么在真实环境里执行代码(Runtime / Sandbox / Event)。它的答案是"把 Agent 放进一个可执行环境"。

OpenCode 的核心问题不一样。它假设执行能力已经有了,然后问:

一个 Coding Agent 产品,怎么组织 Session、Session 里的多轮对话、用哪个模型、能调哪些工具、每个工具能不能执行、以及用户怎么在终端里看着它干活?

换句话说,OpenHands 关心"Agent 的手",OpenCode 关心"Agent 的全身 + 脸"。

这也解释了为什么 OpenCode 会有这么多 OpenHands 没有的模块:session/permission/tui/、models.dev 驱动的 provider/。这些都是"产品形态"的东西,不是"执行能力"的东西。

记住这个分工,后面看每个模块都会通。


二、最重要的一张图:OpenCode 是 Server / Client 架构

第一次看 OpenCode 的 packages/ 目录,你会看到 opencode/tui/sdk/server/app/web/ 一堆包。但它们的关系其实很简单:

OpenCode 把 Agent 内核做成了一个本地 HTTP Server,TUI、IDE、SDK 都是它的客户端。

证据在 packages/opencode/src/server/server.ts:73

export async function listen(opts: ListenOptions): Promise<Listener> {
  const listener = await Effect.runPromise(listenEffect(opts))
  ...
}

Server 用 Effect 的 HttpApiApp 暴露一整套 HTTP 接口(server/routes/),TUI 通过 @opencode-ai/sdk 连上去。

这意味着架构是这样的:

这和 OpenHands 的单体 Runtime 是根本性的架构差异

  • OpenHands:Agent、Runtime、Sandbox 在一个进程里,UI 是外挂的。
  • OpenCode:Agent 内核是一个独立 Server,UI(TUI)只是众多客户端之一。

这个选择带来一个巨大的好处:同一个 Agent 内核,可以同时被终端、IDE、Web、甚至另一个程序(SDK)使用。你在终端里开着的会话,IDE 插件能看到;你用 SDK 写的脚本,能驱动同一个 Agent。

洞察 1:把 Agent 内核做成 Server 而不是库,"交互"就从"一个 UI"变成了"任意客户端"。这是 Coding Agent 从"工具"走向"产品"的关键一步。


三、闪光点 1:Effect-TS——用函数式 DI 重写 Agent 内核

OpenCode 最让我停下来的一点,是它全面用了 Effect-TS

前面五个 Python 项目,组织依赖的方式无非是"类 + 构造注入"或"模块级函数"。OpenCode 用的是 Effect 的 Service / Layer / Generator / Schema / TaggedError 全家桶。

SessionProcessor 是怎么声明依赖的(session/processor.ts:79 起的 Layer.effect):

const layer = Layer.effect(
  Service,
  Effect.gen(function* () {
    const session = yield* Session.Service
    const config = yield* Config.Service
    const snapshot = yield* Snapshot.Service
    const agents = yield* Agent.Service
    const llm = yield* LLM.Service
    const permission = yield* Permission.Service
    const plugin = yield* Plugin.Service
    const summary = yield* SessionSummary.Service
    const status = yield* SessionStatus.Service
    const image = yield* Image.Service
    const events = yield* EventV2Bridge.Service
    const database = yield* Database.Service
    // ...
  }),
)

而在文件末尾,nodeprocessor.ts:713)把这 13 个依赖显式列出

export const node = LayerNode.make({
  service: Service,
  layer: layer,
  deps: [
    Session.node, Config.node, Snapshot.node, Agent.node, LLM.node,
    Permission.node, Plugin.node, SessionSummary.node, SessionStatus.node,
    Image.node, EventV2Bridge.node, Database.node,
  ],
})

这带来三个 Python 项目很难同时做到的东西:

  1. 依赖关系完全显式:一个服务依赖谁,看 deps 数组一目了然,不用翻构造函数。
  2. 错误是类型Schema.TaggedErrorClass 让每种错误(ModelNotFoundErrorProviderInitErrorSessionBusyError)都是可辨识、可 exhaustively 处理的类型,而不是一个笼统的 Exception
  3. 数据模型即 Schemasession.ts:224Info = Schema.Struct({...})、所有 Input 都是 Schema,运行时校验和 TypeScript 类型是同一个东西。

代价也很明显:学习曲线陡峭。你第一次看 Effect.gen(function* () { const x = yield* Foo.Service }) 会一头雾水。但一旦理解,它比一个几百行的 Python __init__ 里塞十几个 self.xxx 要清晰得多。

洞察 2:当 Agent 内核的服务多到十几个时,函数式 DI(Effect Layer)比"类构造注入"更能让依赖关系显式化、错误类型化。这是 TypeScript 生态给 Agent 工程带来的独特选项。


四、闪光点 2:Agent Loop 是"流式处理 + 三态返回"

OpenCode 的 Agent Loop 在 SessionProcessorsession/processor.ts)。它的核心不是一个 while 循环,而是一个流式处理管道

主入口是 process()processor.ts:641):

const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
  ctx.needsCompaction = false
  ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true

  return yield* Effect.gen(function* () {
    yield* Effect.gen(function* () {
      ctx.currentText = undefined
      ctx.reasoningMap = {}
      yield* status.set(ctx.sessionID, { type: "busy" })
      const stream = llm.stream(streamInput)

      yield* stream.pipe(
        Stream.tap((event) => handleEvent(event)),   // 逐事件处理
        Stream.takeUntil(() => ctx.needsCompaction), // 需要压缩就停
        Stream.runDrain,
      )
    }).pipe(
      Effect.onInterrupt(/* abort 处理 */),
      Effect.catchCauseIf(/* 非中断错误 */),
      Effect.retry(SessionRetry.policy({ ... })),     // 带状态反馈的重试
      Effect.catch(halt),
      Effect.ensuring(cleanup()),
    )

    if (ctx.needsCompaction) return "compact"   // ① 需要压缩
    if (ctx.blocked || ctx.assistantMessage.error) return "stop"  // ② 被阻止/出错
    return "continue"                            // ③ 继续下一轮
  })
})

这个设计有三个值得拆的点:

① 流式而不是轮询。 llm.stream() 返回一个 Stream.Stream<LLMEvent>llm.ts:55),Agent Loop 用 Stream.tap(handleEvent) 逐事件消费(step-start/step-finish/finish/tool-call 等)。模型一边生成,Loop 一边处理——这就是为什么你能在终端里看到 Agent "边想边做"。

② 三态返回,把"循环控制"交还给外层。 process() 返回 "compact" | "stop" | "continue",而不是自己在内部死循环。外层根据这个返回值决定:是触发上下文压缩(compact)、是停止(stop)、还是继续下一轮(continue)。循环的"节奏"被显式建模成了数据,而不是藏在控制流里。

③ 容错是一个组合管道,不是一堆 try/except。 注意那串 .pipe(...)onInterrupt 处理用户中断、retry 处理可重试错误(还带 status.set({type:"retry"}) 的状态反馈)、ensuring(cleanup) 保证清理。这是声明式的错误处理栈。

另外,工具调用的生命周期也被精细管理了:ensureToolCall(创建 pending)→ updateToolCall(流式更新)→ completeToolCall/failToolCall(完结)→ settleToolCall(用 Deferred 释放等待者)。而且 create()processor.ts:98)在 LLM stream 启动之前就预捕获 snapshot.track()——因为 AI SDK 可能在发出 start-step 事件前就执行了工具,checkpoint 必须提前。

洞察 3:把 Agent Loop 建模成"流式管道 + 三态返回 + 组合容错栈",比一个大 while 循环更能让"什么时候压缩、什么时候停、什么时候重试"变成可推理的数据。


五、闪光点 3:Permission——"这个工具到底能不能执行"的裁决器

这是用户最想看的点:一个 Terminal Agent 怎么决定某个工具能不能执行?

OpenCode 的答案在 permission/index.ts,核心是 evaluate()(:28):

export function evaluate(permission: string, pattern: string, ...rulesets): PermissionV1.Rule {
  return rulesets
    .flat()
    .findLast((rule) =>
      Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)
    ) ?? { action: "ask", pattern: "*" }   // 默认问用户
}

拆解这个设计:

① 规则 = 通配符匹配。 每条规则是 { permission, action, pattern },比如 { permission: "bash", action: "allow", pattern: "git *" }。用 Wildcard.match 双向匹配工具名和参数模式。findLast 意味着最后声明的规则优先(后面覆盖前面)。

② 三种裁决:allow / deny / ask。 这是关键——Permission 不是简单的"能/不能",而是三态:

  • allow:直接放行
  • deny:直接拒绝
  • ask停下来问用户

ask 是交互式的。 ask()permission/index.ts:67Effect.fn)遍历所有 patterns,遇到 deny 就抛 RejectedError,遇到 allow 就放行,否则发起一个交互式询问,等用户在终端里确认。还有"always allow"机制(:145),用户可以说"这类操作以后都允许"。

④ 权限检查嵌在工具的 Context 里。 回看 tool/tool.ts:36Context

export type Context<M extends Metadata = Metadata> = {
  sessionID: SessionID
  agent: string
  abort: AbortSignal
  messages: SessionV1.WithParts[]
  metadata(input): Effect<void>
  ask(input): Effect<void>   // ← 工具通过它请求权限
}

每个工具执行时拿到的 ctx.ask,就是它向 Permission 系统请求裁决的通道。比如 bash 工具执行一条命令前,会先 ask({ permission: "bash", patterns: [command] }),由 Permission 决定放行、拒绝还是问用户。

洞察 4:Terminal Agent 的权限不是"事前白名单",而是"通配符规则 + 三态裁决 + 运行时交互"。这让 Agent 既不会乱来(deny/ask),又不会每一步都打断你(allow/always)。


六、闪光点 4:Tool——惰性 init + 声明式 Def

OpenCode 的工具系统很克制。核心在 tool/tool.ts,一个工具的完整定义是 Def(:55):

export interface Def<Parameters, M> {
  id: string
  description: string
  parameters: Parameters                       // Schema.Decoder
  jsonSchema?: JSONSchema7
  execute(args, ctx: Context): Effect<ExecuteResult<M>>
  formatValidationError?(error): string
}

而工具的"注册形态"是 Info(:71):

export interface Info<Parameters, M> {
  id: string
  init: () => Effect<DefWithoutID<Parameters, M>>   // 惰性初始化
}

两个设计点:

① 工具是惰性初始化的。 init: () => Effect<Def> 意味着工具的实际定义(尤其是它的 execute)是用到才构造的。这让工具可以在 init 时读取当前 session、配置、环境,生成"上下文相关"的工具。比如 DynamicDescriptiontool.ts:16)就是一个 (agent) => Effect<string>,描述可以随 agent 变化。

② 每个工具配一个 .txt 描述文件。tool/ 目录:bash.tsbash.txtedit.tsedit.txtread.tsread.txt。工具的"给模型看的描述"被单独放在文本文件里,而不是硬编码在代码里——这让调优工具描述(你专栏第 40 篇讲的 Tool Description 问题)不用改代码。

工具注册在 tool/registry.ts:89ToolRegistry Service,统一收集所有 Info,按需 init


七、闪光点 5:Provider——models.dev 目录 + AI SDK 抽象

OpenCode 支持几十种模型,Provider 抽象在 provider/provider.tsInterface(:1191):

export interface Interface {
  readonly list: () => Effect<Record<ProviderV2.ID, Info>>
  readonly getProvider: (providerID) => Effect<Info>
  readonly getModel: (providerID, modelID) => Effect<Model, ModelNotFoundError>
  readonly getLanguage: (model) => Effect<LanguageModelV3, ModelNotFoundError>
  readonly closest: (providerID, query: string[]) => Effect<{...} | undefined>
  readonly getSmallModel: (providerID) => Effect<Model | undefined>
  readonly defaultModel: () => Effect<{providerID, modelID}, DefaultModelError>
}

两个值得注意的点:

① 模型元数据来自 models.dev 目录。 fromModelsDevProvider()provider.ts:1318)把 models.dev 的模型目录转成内部 Info,包括成本(cost(),:1217,连 cache read/write、tiers 都建模了)。这意味着新模型上线,OpenCode 不用改代码,只要 models.dev 更新目录。

② 统一抽象到 AI SDK 的 LanguageModelV3 getLanguage 把任意 provider 的模型统一成 Vercel AI SDK 的 LanguageModelV3。这和 Hermes 的 ProviderRegistry 思路一致,但 OpenCode 更进一步:模型不只是"能调",还带了成本、能力、上下文窗口等元数据,供上层做压缩预算、成本估算。

closest()getSmallModel() 也很有产品味:模糊匹配模型名(用户输错了能找到最接近的)、为小任务(如生成标题)挑便宜的小模型。


八、闪光点 6:Context 管理——修剪 + 压缩的双阈值

Coding Agent 最大的成本是上下文。OpenCode 的上下文管理在 session/compaction.ts,用了两个 token 阈值

export const PRUNE_MINIMUM = 20_000   // 低于此不用管
export const PRUNE_PROTECT = 40_000   // 保护最近的 40K token 不被剪

工作流程是:Agent Loop 里 ctx.needsCompaction 一旦被置位,process() 就返回 "compact",外层触发压缩。压缩不是简单截断,而是 message-v2.ts 里的 filterCompacted()(:521)把已压缩的消息过滤掉、toModelMessages()(:417)把剩余消息转成模型输入。

这套"修剪(prune)+ 压缩(compaction)"的双层机制,和 Hermes 的 context_engine、DeerFlow 的 durable_context 是同一个问题的不同实现:怎么在 token 预算内,既保住近期上下文,又不丢掉早期关键信息。OpenCode 的特点是把它做成了显式的阈值 + 显式的三态返回值,而不是藏在某个中间件里。


九、闪光点 7:TUI——SolidJS + OpenTUI,交互本身即产品

这是 OpenCode 和 OpenHands 最直观的差异,也是用户特别强调的一点:

Agent 不只是后端 Runtime,交互本身也是 Agent 产品的一部分。

OpenCode 的 TUI 在 packages/tui/,技术栈是 SolidJS + OpenTUItui/package.json):

{
  "name": "@opencode-ai/tui",
  "dependencies": {
    "@opentui/core": "catalog:",
    "@opentui/solid": "catalog:",
    "@opentui/keymap": "catalog:",
    "solid-js": "catalog:",
    "effect": "catalog:",
    "fuzzysort": "catalog:",
    ...
  }
}

这意味着 OpenCode 的终端 UI 是声明式、组件化的(SolidJS 是响应式框架),而不是传统的命令行 print。你可以有实时刷新的消息流、可交互的权限确认弹窗、模糊搜索(fuzzysort)、键盘映射(keymap)。

而且因为它通过 SDK 连 Server(第二节的架构),TUI 只是"一个客户端"——同一套交互能力,理论上可以被 IDE 插件、Web 复用

洞察 5:把 TUI 做成声明式组件(SolidJS)而不是命令行 print,让"终端里的 Agent 交互"也能有实时流、弹窗、搜索这些现代 UI 能力。交互不是事后加的壳,是产品的一部分。


十、和 OpenHands 的架构对比图

用户特别要求这一节。两个 Coding Agent 放在一起看,差异非常清晰:

维度OpenHandsOpenCode
语言/范式Python(类 + 方法)TypeScript(Effect-TS 函数式 DI)
架构形态单体 Runtime + 外挂 UIServer/Client 分离(内核=本地 Server)
核心问题Agent 怎么执行真实动作Agent 产品怎么组织 + 用户怎么交互
Agent Loop自建 reasoning-action loop流式 process + 三态返回(compact/stop/continue)
决策/执行分离Agent(决策) vs Runtime(执行)Server(内核) vs Client(TUI/IDE/SDK)
权限模型SecurityAnalyzer 多层防御Permission 通配符规则 + allow/deny/ask
工具系统Runtime 内执行 Bash/文件/浏览器Tool 惰性 init + ctx.ask 内嵌权限
模型抽象LLM 适配器Provider(models.dev 目录 + LanguageModelV3)
执行环境Docker/Remote/Modal/Runloop Runtime本地 shell(TUI 直接跑)
UIWeb UI(外挂)SolidJS + OpenTUI(声明式终端)
状态/事件Event Stream(不可变追加)Session message-v2 + GlobalBus

画成对比图:

       OpenHands                          OpenCode
┌──────────────────────┐        ┌──────────────────────────┐
│        Agent         │        │   TUI / IDE / SDK / Web   │
│      (决策层)         │        │      (多个客户端)        │
└─────────┬────────────┘        └────────────┬─────────────┘
          │ Action                           │ HTTP / SSE
          ▼                                  ▼
┌──────────────────────┐        ┌──────────────────────────┐
│   SecurityAnalyzer   │        │   Agent 内核 Server       │
│   (多层防御)        │        │  ┌────────────────────┐  │
└─────────┬────────────┘        │  │ SessionProcessor   │  │
          │                     │  │ (流式 Loop+三态)   │  │
          ▼                     │  └───────┬────────────┘  │
┌──────────────────────┐        │    ┌─────┼─────┬────────┐ │
│   Runtime(执行层)   │        │    ▼     ▼     ▼        ▼ │
│  Docker/Remote/Modal │        │ Session Provider Tool  │
└─────────┬────────────┘        │  Permission  Compaction │
          │                     └──────────────────────────┘
          ▼
   Sandbox(隔离执行)

一句话总结这个对比:

OpenHands 的边界画在"决策 vs 执行"(Agent vs Runtime),OpenCode 的边界画在"内核 vs 客户端"(Server vs TUI/IDE/SDK)。前者是为了安全地执行,后者是为了灵活地交互。


十一、不足:Effect-TS 是把双刃剑

赏析要诚实。

① Effect-TS 的学习曲线是真实成本。 Effect.genyield*LayerSchema.TaggedErrorClass——这一套对没接触过函数式的开发者是硬门槛。OpenCode 的代码可读性,高度依赖读者是否懂 Effect。这直接抬高了贡献门槛。

② 抽象层很厚。InterfaceServiceLayernode.deps,一个简单的"调模型"要穿三四层抽象。对"快速看懂数据怎么流"不友好。

③ Server/Client 增加了部署复杂度。 单体 Runtime 跑起来就一个进程;OpenCode 要起 Server、连客户端。对"我就想在笔记本上跑个 Coding Agent"的用户,这是额外的复杂度(虽然换来了多客户端的灵活性)。

所以老规矩:不要抄 OpenCode,要学 OpenCode。它的 Effect-TS + Server/Client 是为"多客户端、强类型、可维护的大型 Coding Agent 产品"准备的。你的项目如果只是一个脚本,用不上这套。


十二、七篇赏析全景:六种 Agent 形态

加上 OpenCode,光谱又扩了一格:

项目核心命题语言/范式架构形态
LangGraph任务怎么编排Python声明式图
OpenHandsAgent 怎么执行真实动作Python单体 Runtime + Sandbox
browser-useAgent 怎么感知界面Python事件驱动 + watchdog
DeerFlow 2.0长任务怎么跑不死PythonHarness + LangGraph
Hermes AgentAgent 怎么成长Python状态机 Loop + Curator
OpenCodeAgent 产品怎么组织 + 交互TypeScript(Effect)Server/Client + TUI

到这一步,你已经从六个角度看过 Agent 了:

Workflow Agent    (LangGraph)
       ↓
Coding Agent 执行  (OpenHands)
       ↓
感知 Agent        (browser-use)
       ↓
SuperAgent 长跑    (DeerFlow 2.0)
       ↓
Self-improving    (Hermes Agent)
       ↓
Terminal Agent 产品 (OpenCode)

下一个 Goose(MCP-native Agent)会补上最后一块:当 Agent 把 MCP 当成核心扩展机制,架构会变成什么样


十三、要记住的图

如果这篇只留一张图,是这张——OpenCode 的"内核 Server + 多客户端":

            ┌──────────── 客户端层 ────────────┐
            │  TUI(SolidJS)  IDE   SDK   Web    │
            └───────────────┬───────────────────┘
                            │ HTTP / SSE
            ┌───────────────▼───────────────────┐
            │        Agent 内核 Server           │
            │  ┌─────────────────────────────┐  │
            │  │ SessionProcessor            │  │
            │  │  llm.stream → tap → 三态返回 │  │
            │  └──┬──────┬──────┬──────┬─────┘  │
            │     ▼      ▼      ▼      ▼        │
            │  Session Provider Tool Permission │
            │  (消息)  (模型)  (执行)  (裁决)     │
            │     │      │      │      │        │
            │     └──────┴──Compaction──────────┤
            │           (上下文修剪/压缩)         │
            └───────────────────────────────────┘

如果你能理解"内核 Server vs 多客户端""流式 Loop + 三态返回""Permission 三态裁决"这三件事,这篇赏析就达到了目的。


发布提示:本文基于 OpenCode 官方仓库 sst/opencode(commit e207624,2026-09-06,main 分支)源码赏析。引用的文件名与行号均来自该版本。发 CSDN 前建议:

  1. 两张架构图(Mermaid + ASCII 对比图)用 draw.io / ProcessOn 重画为正式图(满足"图片 ≥ 2 种类型");
  2. Effect-TS 部分对不熟悉函数式的读者偏硬核,发布前可补一段"30 秒理解 Effect 的 Service/Layer"的科普框;
  3. 行号基于 grep 锚定的定义位置(如 processor.ts:641process),如需精确到行可作最终核对。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐