mini-harness:Agent 真正难的部分,都在模型循环之外

项目地址:https://github.com/nothiny/mini-harness Rust 1.85+,MIT。从零实现,全部测试离线可跑。

Posted by nothin on September 27, 2026

项目地址: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 循环里。



评论