`core/` 与 `utils/` 的分工约定:这条线不是按「业务和工具」划的
给一个桌面软件套命令行,代码往哪放,CLI-Anything 的方法论文档 cli-anything-plugin/HARNESS.md 里是写死的:core/ 和 utils/ 两个目录,各自点名了要放什么。我们采集时(2026-08-10,对应仓库快照 39634a6)69 个 agent-harness 目录里,有 core/ 的 64 个、有 utils/ 的 66 个。
多数人第一眼会把这理解成「主逻辑放 core、辅助函数放 utils」——那是 Python 项目里最常见的分法。但把四个 harness 的模块清单摆在一起看,这条线其实是按另一个维度划的,而且划完之后 utils/ 里那份最关键的文件,在依赖图上的位置可以差得非常远。
规范原文点了哪几个名
先把规范的措辞拿出来,这是后面所有对照的基准。
core/ 一节(cli-anything-plugin/HARNESS.md 685 到 690 行)要求按域拆模块,并写死三个固定角色:project.py 管工程的 create / open / save / info,export.py 管渲染管线与滤镜翻译,session.py 管有状态会话与 undo / redo。
utils/ 一节(691 到 694 行)只点了两份:<software>_backend.py,注释原文是 “Backend: invokes the real software”;repl_skin.py,注释是 “Unified REPL skin (copy from plugin)”。
把这两段并排读,分界就出来了:core/ 装的是这个软件的领域模型与状态——工程长什么样、有哪些对象、改了什么能不能撤销;utils/ 装的是两条朝外的边——向下那条通往宿主软件本体,向上那条通往坐在终端前的人。utils/ 在这里不是杂物袋,它只有两个住户,而且都是边界上的住户。
顺带一个数:session.py 我们采集时 69 个 harness 里只有 40 个有。规范点名的 core/ 三个固定角色之一,落地不到六成。
四个样本的模块清单
仓库里我们逐层读过的样本有四个:blender、audacity、adguardhome、browser。把它们的两个目录列出来对照(行数来自 wc -l):
| harness | core/ | utils/ |
|---|---|---|
| blender | scene 208 行 / objects 295 / materials 221 / modifiers 306 / lighting 404 / animation 263 / render 263 / preview 852 / session 155 | blender_backend.py 128 行、bpy_gen.py 577、preview_bundle.py 468、repl_skin.py 567 |
| audacity | project / tracks / clips / effects / labels / selection / export / media / session | audio_utils.py、file_io.py、sox_backend.py、repl_skin.py |
| adguardhome | blocking / clients / dhcp / filtering / log / rewrite / server / stats / project / session | adguardhome_backend.py 70 行、repl_skin.py |
| browser | fs.py / page.py / session.py | domshell_backend.py 1198 行、security.py 237、repl_skin.py |
这张表最该看的是重量落在哪一侧,以及为什么。
adguardhome 的 core/ 拆得最细,八个业务模块的名字几乎是照着宿主的 API tag 抄的;它的 utils/ 里那份 backend 只有 70 行。因为它对接的宿主是一个跑着的 HTTP 服务,那条向下的边薄得只剩拼 URL 和发请求,领域复杂度全在「有哪些 API 分组」这一侧。
browser 反过来:core/ 只有三个模块,utils/domshell_backend.py 却有 1198 行,是四个样本里最重的一份 utils。它还在 utils/ 里多养了一个规范没点名的住户 security.py(237 行),里面是 validate_url 的 scheme 白名单校验、无 scheme 的 URL 直接拒绝、可选的内网拦截,以及 sanitize_dom_text(text, max_length=10000)。这也是这条分工线的一个含义:凡是「外面进来的东西」要过的闸,也属于边界,也在 utils/。
blender 是两侧都重,且它的 utils/ 里有个容易看反的比例:真正起进程的 blender_backend.py 只有 128 行,而生成宿主脚本的 bpy_gen.py 有 577 行——四倍多。因为它那条向下的边不是「调一个 API」,而是「先写出一段宿主自己认的脚本,再把脚本喂过去」。脚本生成器跟着 backend 一起放在 utils/,没有放进 core/。
反直觉的那一处:backend 可以退到只有测试才碰它
utils/<software>_backend.py 是这套结构里位置最固定的一份文件——每个 harness 都在同一个目录、同一种命名、注释都写着 “invokes the real software”。但它在依赖图上的位置,三个样本完全不同。这是本篇最值得记住的一点。
你可以自己跑这三条命令核对(每条在对应 harness 目录下执行):
grep -rn 'adguardhome_backend' adguardhome/agent-harness --include='*.py'
grep -rn 'blender_backend' blender/agent-harness --include='*.py'
grep -rn 'sox_backend' audacity/agent-harness --include='*.py'
我们采集时得到的三种结果是:
- adguardhome:backend 被
core/下的一批业务模块(blocking / clients / dhcp / filtering / log / rewrite / server / stats)以及 CLI 主文件直接 import。它在主通路上,几乎每条命令都要经过。 - blender:
blender_backend只被core/preview.py与测试文件 import。也就是说除了预览这条路,其余命令不碰它。 - audacity:
sox_backend在生产代码路径中零引用,只有tests/test_full_e2e.py里 import 它。
同一个目录位置、同一种文件名、同一句注释,实际可以从「核心通路」一直退到「只有 E2E 测试才 import」。所以「这个 harness 的 utils/ 里有 backend」这件事,推不出「它会去驱动宿主软件」——这一步要靠上面那条 grep 自己确认。
跟这条相关,audacity 那份还有一处文档与代码的对不上,一并摆在这里:HARNESS.md 486 到 488 行写的是 “Do NOT reimplement rendering in Python. Do NOT gracefully degrade to a fallback library.”;而 audacity/agent-harness/AUDACITY.md 第 36 行与 143 行自述用 Python stdlib(wave、struct、math)处理 WAV I/O 与音频处理,基础效果(gain、fade、reverse、echo、filters)以纯 Python 实现,对应的 core/export.py 10 到 16 行也只 import os / wave / math / struct 与本地的 audio_utils。规范一处、该 harness 文档与代码一处,两者不一致;以我们实读的仓库状态为准,原因与取舍我们不做推断。
这里必须把话说明白:当 backend 那条边真的接上时,它做的事就是在你本机起子进程执行外部程序。blender 那份的做法是 shutil.which("blender") 定位,找不到直接抛 RuntimeError 并附上安装指令;找到了则 subprocess.run(...) 拉起 Blender,默认 timeout=300 秒。宿主软件要你自己装,装不装、装在哪、这条子进程调用在你的环境里合不合适,都得你自己判断。仓库里 69 个 harness 目录也好、registry.json 里 79 条记录也好,都只是「有这么一份代码」,不等于「装上就能用」。另外这些 harness 的打包文件自己也标了成熟度:我们采集时 45 处标的是 Development Status :: 4 - Beta、2 处标 3 - Alpha,没有任何一个标 5 - Production/Stable,blender、audacity、browser 三个样本都是 4 - Beta。
utils/ 的另一个住户:一份靠人工同步的共享皮肤
repl_skin.py 是这套结构里唯一被规范要求「从 plugin 复制」的文件(HARNESS.md 530 到 531 行)。也就是说 utils/ 里同时住着两类东西:每个 harness 自己写的 backend,和一份全仓共用、靠复制维护的终端外壳。
复制维护的代价是可以数出来的。我们采集时 62 份 repl_skin.py 里,49 份与 cli-anything-plugin/repl_skin.py 的 md5 完全一致(623dc43e9c2421a97231d398ea303f0c),其余 13 份分成 9 种不同的 md5;行数分布上 567 行的有 49 份,另有 87、165、500、518、521、522、532、568、703 行等变体。
核对动作很直接:
find . -path '*/agent-harness/*' -name repl_skin.py -exec md5sum {} \;
拿结果跟 cli-anything-plugin/repl_skin.py 的 md5 比一遍就知道手上这个 harness 的皮肤是不是最新的那份。规范写的是「从 plugin 复制」,仓库里存在 9 种其他 md5,这是两个可以各自核对的事实,我们只陈述到这里。实用含义只有一条:改 plugin 里那份不会自动传播到已有 harness。
core/ 里最不像 core 的那个模块
按「core 只放纯逻辑」的直觉,session.py 是有点跳的——它是四个样本里唯一直接摸文件系统和平台差异的 core 模块。
blender 的 Session 类字段是 project / project_path / _undo_stack / _redo_stack / _modified(core/session.py 41 到 46 行)。undo 上限是类常量 MAX_UNDO = 50(第 39 行),超限从栈底丢;每次 snapshot() 是把整个 project dict 做 copy.deepcopy,附上 description 与 timestamp(67 到 71 行),并清空 redo 栈、置 _modified = True。空栈时 undo / redo 会抛 RuntimeError("Nothing to undo.") / ("Nothing to redo.")。
落盘那段对 Windows 读者要单独看一眼:_locked_save_json 会尝试 fcntl.flock(..., LOCK_EX) 加排他锁,写前 seek(0) + truncate();而 ImportError / OSError 时静默跳过加锁(core/session.py 10 到 33 行)。Windows 上没有 fcntl,走的就是跳过加锁这条分支——文件照写,锁没有。这一点在源码里是明写的,不是我们的推断;至于跳过加锁在多进程并发下的实际影响,我们没有运行过任何 harness,给不出结论。
放在本篇的语境里,这个模块解释了为什么 session.py 归 core/ 而不是 utils/:它管的是领域对象的状态历史,落盘只是它的实现细节;而 utils/ 那两位管的是跨出这个 Python 进程的两条边。分界不在「重要 / 零碎」,在「向内 / 向外」。
规范之外,还有 harness 自己加了第三层
我们采集时有 2 个 harness 引入了规范里没有的 eval/ 层:audacity 与 rekordbox。audacity 的 eval/runner.py 299 行,提供 discover_tasks / run_eval / load_baseline / compare_baseline(回归基线对比)与 _report_to_markdown,任务放在 eval/tasks/ 下,共 4 个:effects_registry、export_wav、project_roundtrip、track_clip_flow。
它既不是 core/(不是领域模型),也不是 utils/(不是朝外的边),是一层跑任务并跟历史基线比对的回归设施。要给自己的 harness 加东西又不知道往哪塞时,这是个可以参考的先例:不属于那两类的,就别硬塞进那两个目录。
你怎么用这套分工
读一个陌生 harness 时,三步能把它的骨架看明白:
ls core/ utils/— 看重量落在哪一侧,据此判断这个宿主是「API 型」还是「起进程型」;grep -rn '<software>_backend' <harness 目录> --include='*.py'— 看那条向下的边到底接没接进生产代码;md5sum utils/repl_skin.py与 plugin 那份比对 — 看终端外壳是不是漂过。
自己写 harness 时,判定顺序反过来:先问这段代码要不要跨出 Python 进程。要跨(起子进程、发 HTTP、连外部服务、处理外面进来的输入)就进 utils/;只在内存里描述和改动工程状态,就进 core/,并按域拆开而不是按技术层拆。这两个目录之外的东西,仓库里已经有 harness 用新目录解决了,不必硬挤。
上面这些数字都是从文件系统与源码文本里数出来的,随上游更新会变;重要的不是记住 64 和 66,而是记住这条线是按「向内 / 向外」划的,以及 utils/ 里有 backend 不等于它被用上了。
本文依据 CLI-Anything 官方仓库(github.com/HKUDS/CLI-Anything)的 README、registry.json、
docs/ 与各 agent-harness/ 下的源码整理,核对日 2026-08-10,对应仓库快照 39634a6。
本文内容为仓库源码与文档口径,我们没有安装或运行过其中任何一个 harness,
也没有在本机驱动过任何一款宿主软件,因此不涉及实际操控效果的任何描述。
这类 harness 会在本机执行外部程序与脚本,是否使用请结合自身环境评估。
注册表与 harness 内容随上游更新而变动,请以仓库最新内容为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。