Coding Agent Harness 拆解 · 架构总览:内核与产品层的两层分工
《Coding Agent Harness 拆解》系列的第一篇。系列逐层拆解一个能日常使用的终端 AI 编程 agent(codebot)和它依赖的执行内核(agentcore)。本篇讲两层的划分:哪些代码属于内核,哪些属于产品层,为什么这么分。
模型之外的那一圈
大模型能判断下一步做什么,比如读哪个文件、改哪一行、跑什么命令,但它只能提出请求,具体的活儿都在模型之外:
- 执行工具:读写文件、跑命令、搜代码
- 把对话存盘,崩溃后能接着来
- 上下文写满时腾出空间
- 危险操作前拦一道,让人确认
- 把流式输出画到终端
模型之外补上这些的一整圈设施,就是 harness。本系列讲的是这一圈,不是模型。codebot 把这一圈和最核心的循环,分在了两个 Go module 里。
为什么切成两层
一个 coding agent 里变化最快的是外围:换 provider、加工具、改界面、装插件。核心循环却很稳定,永远是那五步:准备上下文、请求模型、执行工具、写回结果、判断是否继续。
如果把两者写在一起,外围每动一次都可能碰到循环。分层就是为了让下面这些变化都进不了循环:
| 会变的东西 | 混在一层的后果 | 分层后循环只依赖 |
| --- | --- | --- |
| 换模型 provider | 循环里到处是 provider 分支 | ChatModel 接口 |
| 加一种审批模式 | 循环里插满权限判断 | 一个 ToolGate 钩子 |
| 换前端(TUI / 管道 / 编辑器) | 循环里混进渲染代码 | 只往外吐 Event,谁消费都行 |
| 改上下文压缩策略 | 循环里改历史处理 | ContextManager 接口 |
agentcore 把这条边界写进了 doc.go:内核提供 agent loop、工具分发、上下文管理、一条事件流,把“用哪个模型、给哪些工具、何时停、怎么渲染”这类策略留给调用者。README 的一句话概括了分工:agentcore 负责执行,codebot 负责编排。
两个 module
flowchart TB
subgraph codebot["codebot —— 产品层 (harness)"]
UI["前端: TUI / print / ACP"]
Session["internal/agent.Session"]
Boot["internal/bootstrap: 装配"]
Policy["策略: 审批 / hooks / MCP / 技能 / 记忆 / 多 Agent"]
end
subgraph agentcore["agentcore —— 执行内核 (不引任何 LLM SDK)"]
Agent["Agent: 有状态包装"]
Loop["AgentLoop / runLoop: 纯循环"]
Event["Event: 单一事件流"]
Tool["Tool 分发 / 执行管线"]
Ctx["ContextManager 接口"]
Model["ChatModel 接口"]
end
UI --> Session
Boot --> Session
Policy --> Boot
Session --> Agent
Agent --> Loop
Loop --> Event
Loop --> Tool
Loop --> Ctx
Loop --> Model
Event --> Session
- agentcore(
github.com/voocel/agentcore)是执行内核。它的 go.mod 不依赖任何大模型 SDK,provider 全在 ChatModel 接口后面注入;它不判断权限、不选模型、不渲染。
- codebot(
github.com/voocel/codebot)把内核包装成终端工具,承担会话、审批、压缩、扩展、TUI 这些产品工程。
agentcore 是独立发布的 module,能被 codebot 之外的项目直接引用。这个约束反过来管住了内核:一旦它掺进 codebot 专有的假设,别的项目就用不了。
内核有哪几块
| 子包 | 关键类型 | 管什么 | 对应篇 |
| --- | --- | --- | --- |
| 根包 | AgentLoop / Agent / Event | 循环、工具执行、事件流 | 主循环 / 消息与事件流 |
| llm/ | LiteLLMAdapter | 把 ChatModel 接到具体 provider | 模型层 |
| context/ | ContextEngine | 投影、压缩、溢出恢复 | 上下文管理 |
| tools/ | read / edit / bash 等 | 内置工具 + 先读后写约束 | 工具系统 |
| subagent/ task/ team/ | subagent.Tool / task.Runtime | 子 agent、后台任务、多 agent 协作 | 多 Agent |
| permission/ | Engine / Classifier | 一份可选的权限策略 | 权限与审批门 |
permission/ 这块要单独点一句:它在内核里,内核却不会自动用它。它只是一份现成策略,等产品层把它接成 ToolGate 才生效。内核给机制、产品层决定用不用,这条分工后面反复出现,是理解整套设计的钥匙。
两条边界
读后面任何一篇,记住两条边界都省事。
flowchart LR
subgraph LLM边界
A["内部结构化消息"] -->|"ConvertToLLM"| B["provider 格式"]
B --> C["模型"]
C -->|"归一"| D["统一事件"]
end
subgraph 副作用边界
E["模型: 我想 bash(rm -rf)"] --> G{"ToolGate"}
G -->|"批准"| X["执行"]
G -->|"拒绝"| R["把'被拒绝'喂回"]
end
LLM 边界:进模型前,内部消息转成该 provider 的请求格式;出模型后,provider 的流式事件归一回内核事件。三家 provider(OpenAI、Anthropic、Gemini)的差异都被关在这道门里,循环不知道自己在跟谁说话。
副作用边界:模型不碰磁盘和命令,它只提出工具调用,副作用实际发生在本地工具里。因为副作用都收口在这一步,参数校验、危险拦截、人工审批才有唯一的地方可插。
从一句话到磁盘
两层合起来,从终端敲下一句话到磁盘写入一条记录,路径是这样:
sequenceDiagram
participant UI as TUI/print/ACP
participant Session as agent.Session
participant Agent as agentcore.Agent
participant Loop as runLoop
participant Model as ChatModel
participant Tool as 工具 + ToolGate
participant Store as storage
UI->>Session: Prompt(text)
Session->>Session: preflight: hooks / reminder / 组装用户消息
Session->>Agent: PromptMessages(...)
Agent->>Loop: 起循环
Loop->>Model: 流式请求(跨 LLM 边界)
Model-->>Loop: 流式事件
Loop->>Tool: 执行工具(跨副作用边界,经审批)
Tool-->>Loop: 工具结果
Loop-->>Agent: Event
Agent-->>Session: 事件回调
Session->>Store: 追加消息 entry
Session-->>UI: SessionEvent(驱动渲染)
internal/agent.Session 站在两层中间。它订阅内核的 Agent,把 Event 加工成自己的 SessionEvent 再抛给前端;持久化、reminder、压缩、模型切换这些产品能力都挂在它上面。后面每一篇拆的,都是这条链上的某一段。
常见误解
| 误解 | 实际情况 |
| --- | --- |
| coding agent 就是一个会调工具的大模型 | 模型只提调用;执行、存盘、审批、压缩都在 harness |
| 换个模型就得改 agent | 循环只依赖 ChatModel 接口,换 provider 不碰循环 |
| 循环应该自己把消息写盘 | 存盘归产品层,循环只产出事件和新消息,这样 TUI / 管道 / 编辑器才能共用一个循环 |
| permission 包在内核里,说明内核管权限 | 内核只提供机制,是否启用、按哪种模式,由产品层定 |
三问回顾
- 两层分工换来了什么? 外围(provider、前端、审批、压缩)的迭代都碰不到核心循环,循环得以稳定、可复用、可单测。
- 不分层会怎样? provider 分支、权限判断、渲染代码全渗进循环,换一家模型或加一种审批模式都要动循环本身。
- 接下来读什么? 内核怎么转看《Agent 主循环》,那条事件流看《消息与事件流》,provider 差异怎么抹平看《模型层》。
延伸阅读