Coding Agent Harness 拆解 · 装配全景:bootstrap 如何把一切焊起来(十)
本文是《Coding Agent Harness 拆解》系列的一篇,可以独立阅读。整个系列逐个拆开一个真实在用的终端 AI 编程 agent(codebot),以及它底下的执行内核 agentcore。前面几篇把零件一个个拆开看过了:模型协议、Agent 主循环、工具管线、审批引擎、Session 运行层、上下文引擎,它们各自都能读懂。但有一个问题一直没回答:这些零件是谁、在什么顺序、用什么手法拼成一个能跑的东西的?本篇回答它。
谁负责总装
答案是 internal/bootstrap。它是整个 harness 的总装线:把内核提供的“空挂钩点”和 harness 攒好的“产品策略”焊在一起,交出一个可运行的 Runtime。
读它还有一层额外的收益:本系列反复念叨的那句“内核提供机制、harness 决定策略”,在这里有一个具体的物理落点:buildAgent。每一项产品策略(用哪个模型、给哪些工具、谁来审批、怎么压缩、缓存键是什么)都在这个函数里,或紧随其后,被注入 agentcore.Agent。抓住这个落点,就抓住了两层如何拼合。
Boot 的装配序列
Boot(internal/bootstrap/boot.go)本身短得出奇:它只是几个函数的顺序调用,外加一个“出错就关掉 session store”的 defer 兜底:
func Boot(opts Options) (*Runtime, error) {
input, err := resolveInput(opts) // 1. converge the outside world
// ... defer: close sessionStore if a later step fails ...
services, err := buildServices(input) // 2. stand up the policy services
assembly, err := buildSessionAssembly(input, services, opts.ToolFactories, opts.WorkspaceFS)
// 3. model / toolset / system prompt
rt, err := assembleRuntime(input, services, assembly)
// 4. weld into the kernel, produce a Runtime
rt.telemetryShutdown = input.telemetryShutdown
if err := wireLifecycle(rt, input); err != nil { ... } // 5. plan / goal / cron
closeStoreOnError = false
go localtools.CleanOldOutputs()
go rt.CleanWorktreeOrphans()
return rt, nil
}
这几步是有依赖顺序的:后一步吃前一步的产物。resolveInput 收敛了“外部世界”(cwd、配置、会话、模型工厂、遥测),buildServices 立起了“策略服务”(审批、技能、插件、MCP、各种 store),buildSessionAssembly 把这些揉成“一次请求需要的模型 + 工具 + 系统提示”,最后 assembleRuntime 才把它们焊进内核。
flowchart TB
Opts["Options{Cwd, Continue, ApprovalMode,<br/>ToolFactories, ModelFactory, WorkspaceFS}"]
Opts --> RI["resolveInput<br/>settings / registry / modelFactory<br/>sessionStore / snapshot / telemetry"]
RI --> BS["buildServices<br/>审批引擎 / 技能 / 插件 / MCP<br/>task·roster·transcript store"]
BS --> SA["buildSessionAssembly<br/>resolveActiveModel → buildToolset<br/>(applyToolSearch) → buildSystemParts"]
SA --> AR["assembleRuntime<br/>buildContextEngine · <b>buildAgent</b><br/>restoreAgentState · buildSession<br/>wireSessionRuntime · wireDream"]
AR --> WL["wireLifecycle<br/>plan / goal / cron"]
WL --> RT["*Runtime(含 Session,可运行)"]
第一步:resolveInput,把外部世界收敛成一个结构体
resolveInput(internal/bootstrap/input.go)的职责是把一切“来自外部、可能失败”的东西一次性解析完,装进一个 resolvedInput,后面几步就只跟这个纯数据结构打交道,不再碰环境。它做这几件事:
| 解析项 | 来源 | 关键点 |
| --- | --- | --- |
| cwd | opts.Cwd 或 os.Getwd() | 一切路径的锚点 |
| settings | config.ResolveAllStrict(cwd) | 全局 + 项目 settings 合并,strict 校验 |
| registry | provider.NewModelRegistry() | 后台 StartPricingRefresh 拉价格表 |
| modelFactory | opts.ModelFactory 或遥测装配 | 见下 tip |
| sessionStore | resolveSession(continue/resume/create) | 决定是新开、续上一个、还是选一个会话 |
| sessionSnapshot | 续接时 sessionStore.BuildSnapshot() | 历史消息、provider/model、reasoning、plan/goal 态 |
ensureProviderSetup 是这里的一处产品逻辑:有 settings 就校验凭证是否配齐;没有 settings 时,先看环境变量 API key,再 DetectEnvProvider 探测(探到就顺手把 settings.Provider/Model 改成对应默认),非交互模式直接报错,交互模式才拉起 config.RunSetup 引导。这套“配置优先、环境兜底、交互补齐”的顺序,是 envHint 那行提示(“Using XXX from environment”)的由来。
::: tip modelFactory 为什么在这一步就定?
modelFactory 是“怎么造一个 ChatModel”的策略,Boot 把它前置到 resolveInput,是因为遥测要缝在这一层:当调用方没注入自定义工厂时,telemetry.Setup 会返回一个 litellm hook,provider.NewModelFactory(litellm.WithHook(hook)) 把它包进工厂,于是每一次模型调用都自动带上 trace span。工厂一旦定下来,主 agent 和每个子 agent 造模型都走它,遥测不必再逐处接线。
:::
第二步:buildServices,把“策略服务”立起来
buildServices(internal/bootstrap/services.go)负责实例化所有产品策略的承载对象,它们此刻还不认识内核,只是各自成型:
- 审批引擎
newApprovalEngine:把 settings.Permissions 的 allow/deny 规则编译进 approval.Engine,并 SetFilesystemRoots 声明读写根。特别地,auto-memory 目录和 plan 目录被登记为 InternalReadable/Writable:这让模型在计划模式下能改写计划文件与会话记忆,而不必逐路径弹审批。这是审批门的策略数据源。
- 技能 / 插件:
plugin.LoadAll 先加载插件,插件的 Contributions() 能贡献技能目录、MCP server,所以插件必须先于技能与 MCP 装配。
- MCP:
buildMCPServices 合并 settings 与插件贡献的 server 列表,然后无条件建一个 Manager(哪怕零 server),好让 /mcp 重载和 before-prompt 刷新钩子始终操作同一个稳定实例。
- 各种 store:
newTaskStore、newTeamStores(roster + transcripts)都按 SessionID 落到会话专属目录,用 SetDir 懒创建:从没组队的会话不留下空目录。
这一步产出的 bootServices,是“一堆等着被接线的策略”。它们要生效,得等第四步注入内核。
第三步:buildSessionAssembly,组装模型、工具集、三块系统提示
buildSessionAssembly(internal/bootstrap/assemble_session.go)把 input 与 services 揉成“发起一次请求所需要的三样东西”:一个 ChatModel、一份工具列表、一组系统提示块。
resolveActiveModel 先定模型。它让会话快照优先于 settings:续接会话时沿用上次的 provider/model,不取配置里的默认值。拿到模型后,用 registry 解析真实上下文窗口写回 settings.ContextWindow,再套一层用户上限:effective = min(detected, CompactWindow)。这个数字直接喂给下一步的上下文引擎。
buildToolset 拼工具集,顺序本身就是一种设计:
flowchart LR
A["buildTools<br/>read/write/edit/bash/grep/glob/ls"] --> B["+ web / plan / goal<br/>ask_user(仅 TTY) / cron"]
B --> C["WrapWithOutputLimit<br/>大输出转存文件"]
C --> D["+ subagent 工具<br/>(捕获 AllTools 快照)"]
D --> E["+ skill 工具<br/>SetForkExecutor"]
E --> F["+ enter/exit worktree<br/>(仅 leader,不进子池)"]
F --> G["applyToolSearch<br/>拆 visible / deferred"]
注意 subagent 工具在链条中段追加:它此刻捕获一份 AllTools 快照,之后追加的 worktree 工具就进不了子 agent 的工具池,这就是 leader-only 操作的隔离手法。
applyToolSearch 是给 Anthropic 系模型(claude ≥ 4.5、非 haiku)开的优化:把 coreToolNames 里的高频工具留在主 prompt 里,其余工具塞进一个 tool_search 工具背后延迟加载。这样基础提示保持精简,模型第一轮就能直接调核心工具,罕用工具按需检索。OpenAI 系被刻意关掉:它们不支持 defer_loading/tool_reference,中途改工具集会击穿 24 小时前缀缓存,反而更贵。
buildSystemParts 组装系统提示,切成三块是为了缓存:
| 块 | 内容 | CacheControl | 谁重算 |
| --- | --- | --- | --- |
| block 1 identity | BuildFrozenSystemParts 的身份段 | ephemeral | 进程内永不重算 |
| block 2 instructions | 冻结指令 + 本地工具清单 | ephemeral | 进程内永不重算 |
| block 3 dynamic | MCP 工具清单 + overlay | 无 | MCP 刷新 / 计划模式切换时重算 |
前两块冻结、带缓存断点;第三块因为会变而不带 cache_control。frozenIdentity/frozenInstructions 会原样传给 Session,让它后续只重建动态尾巴,也让队友 agent 复用同一段字节,从而命中同一个 Anthropic 缓存条目。
第四步:assembleRuntime,buildAgent 是那个焊点
前三步都在“准备零件”,assembleRuntime(internal/bootstrap/assemble_runtime.go)才是焊接。它先建上下文引擎,再进 buildAgent,这一节的主角。
buildContextEngine:四段压缩策略叠成一条流水线
buildContextEngine 把上下文管理那套策略实例化成一个有序列表,喂给 agentcore/context 的 NewEngine:
Strategies: []agentctx.Strategy{
toolCompact, // 1. microcompact old tool results, keep the last 5
trimCompact, // 2. light trim
memoryCompact, // 3. reuse session-memory.md if present, avoid a sync summary
summaryCompact,// 4. fallback: full LLM summarization
}
顺序即优先级:能用便宜手段(微压缩、裁剪、复用记忆文档)解决就不动用最贵的 LLM 摘要。这个 engine 实现了内核的 ContextManager 接口,马上就要被注入。
buildAgent:每一项策略在这里进内核
这是那句口号的物理落点。内核 agentcore.NewAgent 只认一串 With* 选项,buildAgent 做的就是把 harness 攒了三步的每一项策略,翻译成一个 With* 选项塞进去:
opts := []agentcore.AgentOption{
agentcore.WithModel(assembly.chatModel), // policy: which model
agentcore.WithSystemBlocks(assembly.systemBlocks), // policy: the three system blocks
agentcore.WithTools(tools...), // policy: which tools
agentcore.WithMaxTurns(assembly.settings.MaxTurns), // policy: how many turns
agentcore.WithMaxRetries(5),
agentcore.WithMaxToolErrors(3),
agentcore.WithMaxToolConcurrency(4),
agentcore.WithContextManager(contextEngine), // policy: how to compact (4-stage pipeline)
agentcore.WithToolGate(services.approvalEngine.AsToolGate()), // policy: who approves
agentcore.WithCacheLastMessage("ephemeral"), // policy: message-level cache breakpoint
agentcore.WithPromptCacheKey(sessionID), // policy: cache routing identity
}
// telemetry + hooks injected as tool middleware
if len(middlewares) > 0 {
opts = append(opts, agentcore.WithMiddlewares(middlewares...))
}
return agentcore.NewAgent(opts...), nil
对照着看就一目了然:内核提供的是一排空的挂钩点(WithToolGate/WithContextManager/WithMiddlewares…),harness 把自己的策略实现一个个挂上去:
flowchart LR
subgraph H["harness 攒好的策略"]
M["chatModel(resolveActiveModel)"]
SB["systemBlocks(buildSystemParts)"]
T["tools(buildToolset)"]
CE["contextEngine(四段策略)"]
GATE["approvalEngine.AsToolGate()"]
MW["telemetry + hook 中间件"]
KEY["sessionID 作缓存键"]
end
subgraph K["agentcore.Agent(内核机制)"]
A["NewAgent(With*...)"]
end
M -->|WithModel| A
SB -->|WithSystemBlocks| A
T -->|WithTools| A
CE -->|WithContextManager| A
GATE -->|WithToolGate| A
MW -->|WithMiddlewares| A
KEY -->|WithPromptCacheKey / WithCacheLastMessage| A
WithToolGate 尤其能说明问题:审批引擎明明是 agentcore/permission 的现成实现,内核却从不主动调用它;是 harness 用 AsToolGate() 把它适配成循环需要的 ToolGate 接口,在这里注入,副作用边界才生效。“看起来该在内核里却没接线的东西”,都在 buildAgent 这一刻被 harness 接上。
::: tip 两处缓存策略为什么并存
WithCacheLastMessage("ephemeral") 和 WithPromptCacheKey(sessionID) 服务两类 provider:前者在最新一条非系统消息上放一个消息级缓存断点,让工具循环里每次调用都能从缓存读到上一对 tool_use+tool_result(Anthropic 风格);后者是给 OpenAI 风格 provider 的缓存路由键,一次会话一个键。适配器按能力选用其一;Reset/SwitchSession 时必须用 SetPromptCacheKey 重新指向新会话,否则缓存会串。
:::
焊完之后:回放、缝合、后台
buildAgent 之后紧跟着几步,把内核 Agent 接入产品运行层:
| 步骤 | 函数 | 干什么 |
| --- | --- | --- |
| 回放快照 | restoreAgentState | SetMessages 灌历史消息、ReactivateDeferred 复活续接会话里已激活的延迟工具、恢复 reasoning 档位 |
| 建 Session | buildSession | agent.NewSession(...):把 Agent、上下文引擎、store、冻结提示、技能等全塞进 SessionConfig,这是编排层的诞生点 |
| 缝合运行时 | wireSessionRuntime | 把 Session 的钩子回接到内核:SetProjectHook/SetRecoverHook 挂上下文改写、subagent/bash 工具挂 FollowUp 与后台输出、MCP 挂 SetBeforePrompt 刷新、跑 RunSessionStart hook |
| 后台造梦 | wireDream | 建一个私有 subagent 跑后台记忆固化,session.SetIdleHook(dreamer.MaybeStart);print 模式不启用 |
这里还顺手预建了一个默认 team、起了 leader inbox pump goroutine、把 worktree enter/exit 工具的后端绑到 Runtime(wireWorktreeTools),都是“Agent 已成型、再往它周围补产品能力”的收尾动作。
第五步:wireLifecycle,接 plan / goal / cron
wireLifecycle(internal/bootstrap/lifecycle.go)装配与前端无关的那部分会话生命周期,好让任何前端(TUI / print / ACP)跑起来之前,plan/goal 状态就已从快照恢复:
plan.NewManager 建计划管理器,Restore 回放快照里的 PlanPhase/PlanSlug/PlanPreMode。
goal.NewManager 建目标管理器,SetSuspender(计划模式期间挂起目标预算),Restore 回放目标态,WireGoalTools 把 create_goal/get_goal/update_goal 三个工具的回调接到管理器。
resolveCronStore 找到 cron_create 背后的 store,把它的持久化文件锚到当前会话目录。
前端只在这之上补交互回调(审批弹窗、ask_user、通知、事件渲染)。这条分界线是 codebot 能同时服务 TUI / print / ACP 三种前端的原因:生命周期在 bootstrap 就定好,前端只管“怎么显示、怎么问人”。
拆卸也是装配的一部分:Close 的逆序
Runtime.Close(internal/bootstrap/boot.go)值得一读,它按依赖关系逆序拆卸,每一步都有清晰的理由:
HookRunner.RunSessionEnd(...) // 1. run session-end hook first (needs everything alive)
TaskRuntime.StopAll() // 2. stop background tasks
stopTeamPump() // 3. stop the leader inbox pump before the mailbox closes
TeamRegistry.DeleteTeam() // 4. close all mailboxes (only if a team exists)
MCPManager.Close() // 5. drop MCP connections
telemetryShutdown(ctx 5s) // 6. flush trace spans, bounded by a timeout
Session.Close() // 7. close the Session last
顺序不是随意的:hook 要在资源还在时跑;pump 要在 team 拆除前停,避免竞态;遥测 flush 用 5 秒超时兜底,防一个卡死的 OTLP 后端拖住退出。装配讲究依赖顺序,拆卸就讲究它的逆序,这是一个装配良好系统的对称之美。
三问回顾
- 这个机制解决了什么痛点? 前面拆开的零件各自独立,总得有一处把它们按正确顺序、用正确手法拼成可运行的整体。Bootstrap 就是这条总装线,而
buildAgent 是“策略注入机制”的唯一物理落点:读懂它,就读懂了内核与 harness 两层如何焊在一起。
- 如果没有它,会在哪里崩? 装配顺序一乱就全盘皆输:插件没先于技能/MCP 加载则贡献丢失,模型没先解析则上下文窗口算不准,审批门没注入则副作用失控,缓存键没设对则会话间串缓存。这些依赖被
Boot 的几步顺序一次性理顺。
- 它和别处怎么接上? 向前,它消费各章的产物(模型、循环、工具、审批、Session、上下文);向内,它用
agentcore.With* 选项把这些产物注入内核划定的那条机制/策略边界;向上,它交出一个带 Session 的 Runtime,前端拿去即可运行。它是整条主线闭合的地方。
延伸阅读