项目地址:https://github.com/nothiny/mini-harness Rust 1.85+,MIT。从零实现,全部测试离线可跑。
先从一个只讲了一半的伪代码说起
现在聊 Agent,大部分文章会给你一段这样的伪代码:
1
2
3
4
5
6
7
8
while True:
response = model.chat(messages, tools)
if response.tool_calls:
for call in response.tool_calls:
result = execute(call)
messages.append(result)
else:
return response.text
这段代码是对的,但它只解释了 Agent 的一半。
真实跑起来之后,出事的地方几乎全在这段循环之外:
- 用户按了 Ctrl-C,那个正在跑的
bash进程的子孙进程怎么样了? - 进程崩溃重启,日志里最后一条是
tool.started,这个工具到底执行了没有?有没有已经改了文件? - 模型一次返回了 8 个工具调用,其中一个需要人工审批,另外 7 个要不要等?
- 输出有 200MB,是全读进内存,还是截断?从哪个字节截才能不切断 UTF-8?
- 两个进程同时往同一个 JSONL 追加,谁赢了?输的那个怎么知道自己该停?
这些问题的答案,构成了 harness——模型循环之外的全部系统:工具执行与授权、进程管理、事件日志、崩溃恢复、协议与 UI。
一个把边界显式化的实现
mini-harness 是一个从零实现的 agent harness 运行时。它的目标不是复刻某个成熟产品,而是把上面这些边界逐条显式地砌出来,让每一条不变量都有对应的测试钉住。
它刻意保持单个 Rust crate——不为了「架构完整」提前拆模块,是项目明确做出的取舍。
一句话概括:可取消、可崩溃恢复、可接真实模型的最小 agent harness 运行时。
它到底做了什么
- 事件溯源内核:JSONL 事件日志是唯一事实,确定性 reducer 从事实重建全部状态;重放两次必然同态。
- 单写者 session actor:所有状态迁移和事件追加由唯一写者完成,顺序是「先验证、再持久化、后提交」。
- 真实的进程执行:进程组级取消(连子孙进程一起清理)、stdout/stderr 并发排空、三层输出上限、UTF-8 安全截断。
- 崩溃恢复语义:严格区分「确定失败」和
OutcomeUnknown(可能已经产生副作用);checkpoint 原子写入,损坏时回退全量重放。 - durable 输入队列:turn 运行期间的输入作为事实排队,FIFO 自动执行;队列上限的拒绝发生在持久化之前。
- 全局进程并发上限:executor 层共享信号量,超限返回结构化错误,而不是无限排队。
- provider 可替换:确定性 MockProvider 与 OpenAI / DeepSeek 走同一套行为契约测试。
- JSONL 控制协议:stdout 只归协议所有;事件通知是安全投影,工具的原始输入输出不会漏出协议层。
三个核心设计决策
1. 取消是一种持久化状态迁移,而非简单的进程终止
在多数实现中,取消等价于向运行中的进程发送终止信号,进程退出即视为取消完成。这种处理在单进程、单会话场景下足够,但无法在跨进程与跨重启的前提下保持语义一致。
mini-harness 将取消建模为一次状态迁移:cancel 命令首先向事件日志追加一条终态事件。正在执行任务的组件在下一次尝试追加事件时,会检测到日志序号已经改变,随即以 durable 错误终止自身操作。其不变量是:任何执行者都不得覆盖其他写者已经记录的事实,宁可失败也不破坏日志一致性。
由此,取消语义在 serve --stdio 协议客户端与 CLI 上保持一致,并且在进程重启后依然成立。
2. 将不确定性建模为显式状态
崩溃恢复中最危险的情形不是可判定的失败,而是无法判定某次执行是否已经产生了副作用:例如 bash 工具可能已经修改了磁盘文件,但对应事件尚未落盘。
mini-harness 不为这种情形伪造确定性。它定义了显式的 ToolOutcomeUnknown 事件,并提供 inspect、recover、abandon-turn 等命令,用于分类未完成的工作、标记未知结果或主动放弃 turn。不承诺外部副作用的 exactly-once 语义,是项目明确划定的非目标。
3. UI 与事实状态解耦
CLI 与 TypeScript Ink TUI 是两个相互独立的客户端,共享同一个 mini-harness serve --stdio 后端。客户端仅承担两项职责:发送命令(session.create、turn.start/wait/cancel、approval.respond),以及消费事件投影。
事实状态由 runtime 与事件日志持有。因此客户端崩溃不会影响会话的可恢复性——runtime 仍可根据事件日志重建会话状态。ui/ 目录下的 Ink 实现即该原则在 TypeScript 侧的对应实现:同一套协议,同一套边界。
设计中最容易出错的地方
上面三条决策背后,是一组相当反直觉的陷阱:
- 事件的顺序即语义。把「提交状态」写在「追加事件」之前,代码照样能跑;但崩溃时会得到一个领先于事实的内存状态。正确顺序是「先验证、再持久化、后提交」,这个顺序只有写成不变量、再用测试钉住,才守得住。
- 承认「不知道」比假装知道更难。最容易的写法是把
tool.started当成「确定失败」直接重试或回滚。但外部副作用无法凭空撤销,正确做法是标记成OutcomeUnknown,把决定权交回给恢复流程和人。设计一个「拒绝给出答案」的状态,前提是先接受系统无法知道一切。 - 确定性是恢复的 oracle。重放两次必须同态、mock 与真实 provider 跑同一套契约测试——只有先把 oracle 立起来,恢复、取消、并发这些最难测的部分才变成可验证的对象。
- 系统层的细节只能靠真实测试暴露。进程组、信号语义、文件锁、UTF-8 截断、跨平台差异,文档和直觉都给不出保证,只有回归测试能。
这些判断无法从「让代码跑起来」里自然得到。它们要么被想清楚,要么就会以「偶尔丢一次事件」「崩溃后状态对不上」的形式隐藏很久。
架构长这样
1
2
3
4
5
6
7
8
9
10
11
CLI / Ink UI ──(JSONL over stdio)──▶ Session actor(单写者)
│
┌─────────────────────┼──────────────────┐
Provider Agent loop Policy
mock / OpenAI (事务性 append) allow/ask/deny
└─────────────────────┼──────────────────┘
│
Executor
workspace 边界 · 进程组 · 输出上限 · 并发信号量
│
Event log(事实)+ Checkpoint(快照)
5 分钟跑起来
1
2
3
4
5
git clone https://github.com/nothiny/mini-harness.git
cd mini-harness
cargo test # 全部测试离线可跑,无网络依赖
cargo run -- demo read README.md # 离线观察一次完整工具调用链
接真实模型(以 DeepSeek 为例,默认模型 deepseek-flash,走 OpenAI 兼容的 Chat Completions):
1
export DEEPSEEK_API_KEY=sk-...
这里有一个容易踩的坑:run 是单次、非交互命令,而在默认策略下 edit_workspace 和 bash 都是 ask。一旦模型提出写文件或执行命令,运行时会写入一条 ToolApprovalRequested 事件并返回 approval pending —— 但 run 没有应答审批的入口,于是命令只会以错误结束,工具从未真正执行。
要在非交互场景下跑通,需要显式放宽策略。在项目根目录写一个 mini-harness.toml(存在时会被自动加载),或用 --config 指定:
1
2
3
4
5
6
7
8
[model]
provider = "deepseek"
name = "deepseek-flash"
[permissions]
read_workspace = "allow"
edit_workspace = "allow"
bash = "allow"
配置会被自动加载,之后直接运行即可:
1
2
3
cargo run -- run "总结一下这个项目" --workspace .
# 也可以显式指定配置:
# cargo run -- --config mini-harness.toml run "..." --workspace .
注意:
bash = "allow"意味着模型可以不经确认直接执行任意命令,仅建议在受控环境或一次性容器里使用。
如果希望保留人工审批、由使用者决定是否执行,用 ui/ 下的 TypeScript Ink TUI(首次运行需 cd ui && npm install),之后一条命令即可启动,工具触发审批时交互确认后才会执行:
1
make tui
默认走 deepseek-flash,支持函数调用,read/edit/bash 工具链可用。项目同时支持 OpenAI Responses 协议——两种 wire 格式,按 provider 自动选择。
那些容易被忽略的边界
除了上面三条,还有一批细节是这个项目花了大力气去钉住的:
- 审批流有独立事实:策略
ask的工具调用会持久化挂起,等approval.respond决定后续;策略拒绝记录独立的tool.policy_denied事件,和用户拒绝严格区分。 - ID 分层:
ToolCallId(模型提出的调用)和ExecutionId(runtime 真正启动的执行)是不同类型,不能互相传递,为将来的重试和恢复留了空间。 - 协议只暴露安全投影:
v2事件通知不含工具的原始输入/输出,stdout 被协议独占,诊断日志只写 stderr。 - 配置分层且严格:TOML 分五个 section,未知字段按行号拒绝,优先级是 CLI > 文件 > 默认值;时长类限制零值表示「关闭」,计数类零值表示立即拒绝。
测试
1
2
3
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
覆盖面包括:provider 行为契约(mock 与 OpenAI 跑同一套六场景)、重放确定性 property 测试(种子化随机脚本)、跨进程追加/取消/修复探针、审批与队列的全相位边缘、进程组清理与输出上限回归。CI 在 macOS 与 Linux 上跑同一组检查。
适合谁看
- 已经会调 API、想搞清楚 harness/runtime 边界的应用开发者;
- 在做 coding agent、想参考一套崩溃恢复和取消语义的工程师;
- 想学「事件溯源 + 单写者 actor + 真实进程管理」如何落地到一个具体系统的后端/系统方向开发者;
- 想读一份有测试、有文档、能跑起来的 Rust 中型项目的人。
最后
项目还在往前走,下一个深度专题是 OS 级 sandbox(macOS Seatbelt / Linux Landlock,fail-closed)。如果这些边界对你有用:
- GitHub 求个 star ⭐:https://github.com/nothiny/mini-harness
- 欢迎在评论区聊一个更根本的问题:harness 到底该做到哪一层?
模型只提出建议,harness 决定哪些建议可以变成现实。真正难的部分,从来不在那段 while 循环里。
评论