← → 翻页 · 或滚轮 / 点击
01 / 14
CodeBuddy Agent 内核 · 深度架构解析

WorkBuddy 技术架构图解

从一次推理循环出发,往外长出整个 harness,最后才接到人。
全部结论来自本机 /Applications/WorkBuddy.app 与 ~/.workbuddy 的实际产物。
60
内置工具
(23 个延迟加载)
19
系统级 agent
(仅 4 个面向用户)
28
Hook 事件
(唯一对外开放的插座)
500
maxTurns 真实上限
(不是配置里的 100)
48
注册模型
以国产模型为主
123
提示词模板
全库最大 25KB
77
外部 MCP 工具
7 个 server
8
权限模式
从 Always Ask 到 Full Access
版本 5.6.2 · 包名 @genie/workbuddy-desktop共 14 页
阅读主线

整个 harness 是从裸 loop 的失效模式里长出来的

每一个子系统都不是凭空设计的功能模块,而是裸 loop 在某个具体失效模式上被打补丁,补丁长大成了模块。
裸 loop 只有五行
收消息→ 调模型→ 有没有 tool_use ?→ 执行工具↻
它跑得起来,但在真实任务里会在十个地方崩掉。
1. 上下文窗口有限 → Context 治理
2. 会话结束即失忆 → Memory
3. 工具越多 prompt 越大 → 延迟装载 + 缓存
4. 模型不会做领域任务 → Skill
5. 模型会做危险的事 → 权限 + 沙箱
6–8. 污染 / 即兴 / 写冲突 → Subagent · Workflow · Worktree
9. 进度不可见 → Task 10. 只能被人驱动 → 驱动源解耦
抽象成三招
① 把信息移出上下文
压缩、子 agent 隔离、worktree 隔离——让主线上下文只装推理必需品。
② 把知识移进上下文
Skill 加载、记忆召回、ToolSearch——让模型「恰好知道该知道的」,且按需。
③ 把决策移出模型
Workflow 用代码定控制流、权限用规则+LLM 双裁决、沙箱用 OS 强制——在模型不可靠的地方不依赖模型的自觉。
第 3 招最能说明这套系统的性格:它不信任模型。压缩提示词里「不要重做已完成的事」重复三次、workflow 脚本禁用 Date.now()、权限规则特意澄清引文不构成授权——全是同一条主线。
判据:改动它会不会改变「一次推理循环的形状」§0 · §0.2 · §0.3
核心 · 骨架

Loop 的三层结构:run / turn / 模型调用前

真正的循环体是 L2 那个 for(;;)。L3 只是循环体里一个写死的步骤——把「每轮跑一次的函数」误认成骨架,是最容易犯的错。
L1run 生命周期 · AgentService.run()每次 run 一次
鉴权→ runnerProvider.get()→ opts.maxTurns→ Interceptor 管线 sortSync()→ 工具表刷新→ sessionManager.run(...)
L2turn 循环 · Runner.run() 的 for(;;)每轮一次 ← 这才是 loop
beginTurn
_currentTurn++
→ #a() 准备输入→ model.getResponse()→ processModelResponse→ 执行工具→ applyTurnResult↻
final_output
返回最终结果
run_again
continue → 下一轮
handoff
换 agent 继续
interruption
返回可恢复状态
L3模型调用前 · callModelInputFilter(写死的五步)每轮一次,不可扩展
① flushHistory ② 死循环检测 ③ checkAutoCompact ④ 超大调用裁剪 ⑤ 注入
终止边界实测:DEFAULT_MAX_TURNS = 500(env CODEBUDDY_CODE_MAX_TURNS),子 agent 下限 200。配置里的 requestMaxStepLimit=100 全产物零引用,是死配置。
内嵌 OpenAI Agents SDK:RunState / next_step_* / AgentToolUseTracker§1 · §1.2 · §1.3 · §1.4
核心 · 插座

三类挂载机制:harness 插进 loop 的三个插座

不是「要么写死要么可配置」,而是三层并存,粒度完全不同:每次 run / 每轮 / 事件触发。
① 写死在循环里
挂在哪:L3 固定五步 + L2 工具执行 + maxTurns 终止

谁可扩展:不可扩展

典型使用者:压缩水位判断与执行、死循环检测、超大调用裁剪
判据:必须在每次模型调用前无条件执行,且失败要兜底
② Interceptor 管线
挂在哪:L1 · 每次 run 一次,在循环外

谁可扩展:仅内部(DI 注册)

典型使用者:记忆召回、plugin runtime join、managed agent 隔离
判据:需要加工上下文,但一次 run 开头做一次就够
③ Hook 框架
挂在哪:L2 循环内的 28 个事件点

谁可扩展:用户 / 插件 / skill / agent frontmatter

典型使用者:Stop 记忆抽取、PostToolUse 新鲜度、PreCompact 会话摘要
判据:需要在循环内某个具体时刻观测或拦截,且对外开放
混合体是常态,不是例外
压缩 = 写死 + 派发事件:水位判断与执行写死在 L3 第 3 步,但执行前后通过通用 hook 框架派发 PreCompact / PostCompact——内置那个 priority=High 的摘要 hook,和用户自己写的 hook 走同一个调度器。
Memory = Interceptor + Hook:召回在 Interceptor(L1,一次 run 开头塞进上下文),抽取与新鲜度校验在 Hook(L2,不需要同步、可以异步)。
一句话:写死的管「每轮必须发生的」,Interceptor 管「每次 run 改一次上下文的」,Hook 管「循环内某时刻对外开放的」§2
全局分层

Core / Harness / Surface 同心圆

分层判据只有一条:改动它会不会改变「一次推理循环的形状」。
Core 推理循环本身 Harness Surface 改动会不会改变一次循环的形状?
Core — 会改变
loop 本身,加上它离不开的两样:模型路由与工具总线。
Harness — 不改变,只让它更好
压缩、记忆、Skill、MCP、权限与 Hook、Subagent/Workflow、沙箱、配置与观测。改它们只改变每一步看到什么、能做什么、留下什么。
Surface — 完全不参与
TUI、桌面 GUI、headless/协议服务、定时任务、IM 机器人。只负责把 loop 接到人或机器上。换宿主不影响前两层——这正是边界真实存在的证据。
文档顺序也是这个:Core → Harness → Surfaces(UI 放最后)§0 · §25
Harness · 装配总表

每个组件插在 loop 的哪一层、哪一步

装配顺序按真实请求时间线:外层 Interceptor → 内层写死骨架 → 序列化前 → 工具循环 → 收尾。
组件挂在 §1 哪层挂载机制生效时机对上下文做了什么
记忆召回L1Interceptor每次 run把相关记忆追加到 input 末尾
plugin runtime joinL1Interceptor每次 run合并插件提供的工具与提示
上下文压缩L3写死(第 3 步)+ 派发每轮命中水位就把历史换成结构化摘要
死循环检测L3写死(第 2 步)每轮重复调用达阈值即打断
system-reminder 注入L3写死(第 5 步)每轮动态提醒塞进用户消息尾部,不碰缓存前缀
权限裁决L2Hook · PreToolUse每次工具调用允许 / 拒绝 / 转交模型判断
沙箱执行L2写死 + OS 强制每次工具执行结果带 sandboxDenied 结构化字段回传
记忆抽取L2Hook · Stoprun 结束派生子 agent 写记忆文件(默认关闭,见 §11.10)
会话摘要L2Hook · PreCompact压缩前更新 summaryService
sync / async 判据
「它必须改变这一轮的输入吗?」是 → 写死或 Interceptor;能事后补 → Hook。
元操作一律隔离
压缩 agent 零工具、抽取 agent 5 工具 + runWithoutHistory()。
会变的东西排最后
volatile 工具排尾部、提醒塞进用户消息、动态目录寄生在工具描述里。
外层的 Interceptor 与内层的写死骨架是「嵌套」关系,不是两次独立的 pass§7 · §7.1
核心 · 落盘真相源

消息协议与会话树:一份能被撤销、归因、压缩、分叉的会话

裸 loop 的 messages[] 有五件事做不到——这份协议的两个字段(id / parentId)+ 一组旁挂类型 + 一块 providerData 全补上了。
一次 turn 落盘成什么(真实截取)
message · role=user    ← 用户这一轮输入
file-history-snapshot ← 无 parentId,旁挂
reasoning       ← 思维链独立成 record
message · role=assistant
function_call · Bash ┐ 两个并行调用
function_call · Bash ┘ 串成链,不是并列
function_call_result ×2
reasoning       ← 下一轮开始
反直觉:并行调用在树里是串成链(99 次)。树的分叉是留给「人的后悔」,不是留给并行的。
一次真实分叉(本会话实证)
父 · message <cb_summary> ← 第 5 次压缩摘要
├ message · 原用户消息  废弃支:2 个节点
├ resend-fork-notice  editedUserItemId → 原消息
└ message · 改后消息  新支:234 个节点
用户编辑已发送消息并重发:旧内容一个字节没动,新内容作为兄弟节点挂上去。getActiveHistory() 从 lastMessageId 回溯主链——删历史变成了挪指针。
压缩会切断物理父 → 于是有 logicalParentId。7 次压缩 ↔ 7 条只带逻辑父的记录,id 与时间戳逐一对应。这棵树有两种边。
8
record 类型(1,737 条快照)
9
「非对话项」类型不进上下文
(快照 138 条却零上下文成本)
1
唯一分叉点:废弃 2 vs 新支 234
345
工具结果带结构化返回面
sandboxDenied 等
rawResponse 的正确位置是 providerData.toolResult.rawResponse,不在 output 下§3 · §3.2 · §3.3 · §3.4
Harness · 上下文治理

Context 治理:唯一「不处理就一定会死」的组件

它是唯一被写死进 L3(每轮模型调用前必过)的组件。检查发生在发请求之前,不是之后。
水位线(tokenUsageThresholds 实测)
0.15 摘要
0.4 压缩
0.5 发消息前预检
0.9 紧急
0上下文窗口1.0
deepseek 的 compact 阈值被单独放宽到 0.5——阈值是按模型上下文特性调过的。摘要水位(0.15)远低于压缩(0.4):摘要是持续增量维护,压缩是最后兜底的一次性大动作。
context_compact
常规压缩:preMessage 水位命中即触发,输出 6 维摘要,<1000 词。
agent:compact · tools: []
context_summary_pre_message
每次发消息前的轻量摘要维护,对应 0.15 那个低水位。
持续增量,不是一次性动作
context_summary_max_token
濒死恢复:逼近窗口硬顶的兜底,输出 9 维,要求逐字引用断点原文。
多出 Pending Tasks / Current Work / Next Step
checkAutoCompact() 的四道实证细节
水位怎么算
优先用上次 API 返回的 inputTokens;为 0 且有历史,退回本地估算。
尾部补偿
把尚未计入 usage 的尾部工具返回单独估出来加上——避免刚跑完大 Grep 却误判安全。
去重保护
上次压缩后没有实质新内容就跳过。
极端恢复
工具结果被排除(excluding tool results)——此时重要的是意图与断点,不是过程。
三个触发点是「常态维护 → 常规压缩 → 濒死恢复」的三级递进,不是二选一§8 · §8.1 · §8.2 · §8.3
Harness · 成本

让「会变的工具集」不炸掉 prompt cache

按需装载解决了「工具太多」,却带来新问题:工具集在会话中途变化,凭什么不炸掉缓存前缀?
输入是一份五桶账本(不是一坨字符串)
systemPrompt
conversation
tools
mcp
skills
后两桶最巧:MCP 工具清单和技能目录不在 system prompt 里,它们是「寄生在工具描述里的动态内容」——靠两条正则从 ToolSearch 与 Skill 的描述里抠出来单列统计。系统提示是缓存前缀,不能被会变的东西污染。
① volatile 自声明
会变的工具(如 Skill 目录)标记 promptCacheStability: volatile,序列化前剥离。
② 稳定者排前面
orderToolsForPromptCache:稳定在前,volatile 排到尾部。
③ 单一断点
cache_control 只打在最后一条系统消息的最后一块,且条件化。
④ defer_loading
23 个工具延迟下发;若搜索工具缺失则全部回退为 false。
延迟加载不绕过治理——DeferExecuteTool 原文:The target tool's permission checks and hooks are applied normally. 延迟的只是 schema 的下发时机,权限裁决与 hook 照常执行。
命中多少可直接从 usage.inputTokensDetails.cached_tokens 读出§9 · §9.1 · §9.2 · §13.3
Harness · 记忆

Memory:四个挂载点,两条路径,七道门禁

它是观察「同一个组件如何按三层分拆到不同挂载点」的最佳样本。
A · 召回 L1
run 开始前,Interceptor 挑相关记忆追加到 input 末尾。每次 run 一次。
lite 模型挑 ≤5 个文件
B · 常驻 L3
系统提示里常驻三层说明 + 当前 MEMORY.md 内容,每轮重建。
所以 MEMORY.md 必须极小
C · 新鲜度 L2
PostToolUse + matcher=Read,仅当读记忆文件且 >1 天时警告。
记忆会腐坏
D · 抽取 L2
Stop hook,派生 -memory-extractor 子 agent 自动写。
5 工具 · maxTurns 5 · 不进主历史
长期 / 短期:两套机制 + 两条单向桥
常见说法对应实现容量腐坏
短期(工作记忆)上下文窗口:历史 + 压缩摘要模型窗口不会
长期 · 语义MEMORY.md + 独立 .md + 云端画像1e4 / 4e3 字符会
长期 · 情景YYYY-MM-DD.md 日志 + conversation_search30 天蒸馏会
长期 · 程序Skill(程序性知识常驻磁盘)按需加载会
长期记忆比短期小一个数量级,因为它常驻系统提示、每轮都要付一次费。30 天蒸馏 = 情景 → 语义的巩固。
更新的七道门禁(doExtract 实证)
① isEnabled() 默认关 ② inProgress 防重入 ③ 需要 session.state ④ 记忆目录必须已存在
⑤ 新增消息 < 2 不抽 ⑥ 主 agent 已写过 → 跳过 ⑦ 推进游标
⑥ 最巧:让「模型显式写」与「自动抽取」互斥而非叠加——主动写了抽取就退位。本机 memory.memoryExtraction 未配置,自动抽取此刻是关的,所有记忆都靠显式写。
MEMORY.md 是索引不是记忆本身(原文:MEMORY.md is an index, not a memory)§11 · §11.9 · §11.10
Harness · 安全

权限是四段流水线,沙箱是「先跑后判」

同一个失效模式(模型会做危险的事)的两层解法:一个靠判断,一个靠强制,互为兜底。
上:权限四段(全部发生在工具执行之前)
① 权限模式
8 种 · 先决定要不要问
→ ② 规则裁决→ ③ 模型裁决
autoModeClassifier
→ ④ OS 强制
沙箱
default / acceptEdits / plan
Always Ask · Accept Edits · 只分析不改
auto
AI 分类器审查本会弹窗的动作;分类器不可用→降级弹窗,弹不了就拒
bypassPermissions / fullAccess
两档:跳过提示 / 连危险命令检查也跳过
delegate
权限由父会话托管(子 agent 场景)
下:沙箱不是「先判能不能跑」,而是「先在沙箱跑一遍,看它想碰什么」
① 沙箱首跑→ ② 收集被拦记录→ ③ 归因:actionable / 噪声 / 可忽略→ ④ 分流:放行 / 补问 / 硬停 / 脱沙箱重跑
为什么必须这样
一条 shell 命令会碰什么,事前根本判不准——npm install 写哪几个目录、git 钩子执行什么、脚本里有没有 curl。
归因是关键
「只碰元数据且命令成功」不升级为拦截(metadata-only denial kept as non-escalating),否则弹窗会淹没用户。
降级路径暴露优先级
broker 只在 macOS 启、启动失败继续跑、查询 300ms 超时走回退 → 沙箱挂了是「无沙箱继续」,这恰好证明上面的判断层才是真正安全网。
sandboxDenied 是一等字段:模型能据此改道,而不是撞上一个抛错§14 · §14.1 · §16 · §16.1
反直觉的事实

harness 里塞满了「看不见的」次级 LLM 调用

除了主循环那次推理,还藏着十几类打杂的模型调用。它们跑在隔离会话里、不计入主历史,却占掉了可观的延迟与成本。
19 个注册 agent,真正给用户干活的只有 4 个
面向用户 · 4
cli
general-purpose
Explore
Plan
元能力 · 15
compact · contextSummary
memorySelector · summaryGenerator
autoModeClassifier · promptHookEvaluator
insightsAnalyzer · terminalTitleGenerator
contentAnalyzer · enhance-prompt …
内核自己就有这个概念的名字:classifyTrace() 里一个硬编码 17 名的 Set,命中即返回 "auxiliary"。
七道成本约束(它们永远都在)
① lite 档模型 ② 输出硬顶 256/8192  ③ temperature: 0 ④ maxTurns 1
⑤ 两级级联(快判通过就不进深思) ⑥ 超时 60s/120s ⑦ 熔断 + kill switch
stopSequences:["</block>"] · 元 agent 多数 tools: []
可回放:失败时 dumpError 落盘,供事后复现
但规则永远在模型前面:确定性投影为空则直接 allow(零调用)、超 token 预算直接抛错、模型输出不可解析时 fail closed。诚实的表述是——规则定义边界,模型仲裁灰区,规则兜住模型的失败。
22
runOneTime 调用点
25,466
字符 · 全库最大模板(权限分类器)
0
主历史里元调用的记录数
(产物在,过程不在)
1 / 5
分类器 / 抽取器的 maxTurns
实证:transcript 里 providerData.agent 全是 cli,但压缩摘要与会话标题确实出现了§7.2
Harness · 隔离

三种「上下文隔离」,解决三种不同的污染

看起来无关的三件事——历史太长、中间结果太噪、并行写冲突——解法都指向同一个动作:把一部分上下文从主线里挪出去。
时间隔离
要隔离:历史太长装不下
机制:压缩,把过去替换成结构化摘要
边界:仍是同一条主线,只是内容被重写
§8
空间隔离
要隔离:几十次 Grep 的噪声
机制:Subagent / Workflow,独立上下文、只回传结论
边界:主线看不到子 agent 的探索过程
§15
文件系统隔离
要隔离:并行 agent 改同一批文件互相覆盖
机制:EnterWorktree / LeaveWorktree
边界:物理文件不共享,改完再合回
§15
Agent 与 Workflow 是嵌套而非并列(都是空间隔离,只是控制流归属不同)
Agent tool(委派)Workflow tool(编排)
本质一次调用 = 派生一个子 loop一次调用 = 提交一段 JS 脚本
控制流仍由模型决定由代码决定(for/while/if、fan-out 拓扑)
递归不受限(工具权限 *)嵌套仅一层;子 agent 禁止再派生
返回模型最终文本runId 异步 + task-notification 回流
手册原话:Use this tool for multi-step orchestration where control flow should be deterministic rather than model-driven. 支持 Journal resume,所以脚本里 Date.now() / Math.random() 直接抛错(会破坏确定性)。
Team 是「多个平级 agent 协作」的另一种形态:每个 teammate 一个 loop,靠消息互通而非共享历史§15
收尾

把全文放回一句话

骨架 → 插座 → 装配 → 兜底 → 拦截。
整个系统就是一个 for(;;), 外面套一次性 run 生命周期(L1),里面藏五个写死的准备步骤(L3);
剩下的十几个子系统,全是插在这三层上的补丁—— 分别回答「装不下」「记不住」「工具太多」「不会做」「会做危险的事」「一个人干不完」。
最反直觉的三点
① callModelInputFilter 是步骤不是骨架
② 沙箱是「先跑后判」不是「先判后跑」
③ 长期记忆比短期记忆小一个数量级
最容易踩错的三处
① maxTurns 是 500 不是配置里的 100
② rawResponse 在 providerData.toolResult 下
③ Interceptor 是每次 run,不是每轮
最能说明性格的一条
它不信任模型:重复三次的警告、禁用的 Date.now()、特意澄清「引文不构成授权」——在系统不可靠的地方,一律不依赖模型的自觉。
60
内置工具
77
MCP 工具
28
Hook 事件
48
模型
500
maxTurns
完整论述见《WorkBuddy-架构深度解析.md》(2,600 行,含全部源码证据与交叉引用)14 / 14