AI in Harness(二)

书接上文

Error Recovery - 从错误中恢复

让 agent loop 在面对 LLM 三类常见故障(输出截断 / context 太长 / 后端过载)时自我恢复,而不是把异常一路抛回出去。

对于一个 Java 程序员处理异常那就太熟悉不过了。给 LLM 的调用方法加上 try catch。

目前最常见的错误,输出太长,输入太长,调用太快,模型压力太大。

输出太长:

  1. 第一次:把 max_tokens 从默认 8000 → 升级到 64000(escalated),丢掉截断的输出,重新跑这一轮。模型重生成,通常这次能说完
  2. 第二次还截断:已经 64k 还说不完 → 真的需要”分两次说”。append 截断输出 + 注入 Continue. user message,让模型续写
  3. 超过 maxRecoveryRetries=3 次 continuation:放弃,Fatal

输入太长:

  1. 可以直接调用 reactive_compact 压缩模式。使用比较激进的压缩模式,只保留最近的若干条消息。

调用太快、模型压力过大:

  1. 调用 LLM 的时候才用指数退避的方式重试。若干次后依然报错,则抛出异常。

Task System - 更强的规划能力

让 LLM 自己用文件级任务图把”大目标”拆成”有 DAG 依赖、可被 claim/complete 跟踪、跨 agent loop 持久化”的小任务,而不是把所有计划塞 todo_write 在内存里。

已经有 todo_write 了 —— 内存里一个三态 todo 列表,LLM 用它做计划。但 todo_write 有 4 个致命缺陷,只要任务复杂一点就暴露:

todo_write task system
只活在当前 session,重启 → 全没 文件级持久化(.tasks/{id}.json)
只有”线性列表”,没法表达”B 必须等 A 完成才能做” blockedBy DAG 依赖, canStart 计算
只有 owner 隐式(“当前 agent”),没 claim 概念 claim_task 显式 owner + 状态机
整体替换语义(每次都重写整个列表) 单条 update(完成 A 不动 B/C)

更重要的是 task system 让子 agent 也能参与—— 父 agent 派子 agent 干活时,子可以读到 task 列表知道全局计划,而不是”接到 description 就盲跑”。

核心 API:

实际上 harness 只暴露了 4 个原语,claim/complete/block 都是 TaskUpdate 的不同用法 —— 靠状态字段区分,而不是独立接口。

API 作用 关键参数 返回
TaskCreate 建节点 subject, description, activeForm 新任务 id(初始 status=pending, owner=””)
TaskList 看队列 所有任务的摘要(id/subject/status/owner/blockedBy)
TaskGet 读详情 taskId 单个任务的完整字段(含 description、blocks/blockedBy 双向边)
TaskUpdate 改一切 taskId + 变更字段 更新后的任务

为什么没有独立的 claim / complete / block ? 因为它们都能用 TaskUpdate 的字段组合表达,合并成一个接口反而简化了状态机:

语义动作 对应的 TaskUpdate 调用
claim(认领) {taskId, owner: "self", status: "in_progress"}
complete(完成) {taskId, status: "completed"}
建依赖(我等 X) {taskId, addBlockedBy: [X]}
建依赖(我挡 Y) {taskId, addBlocks: [Y]}
让出(放弃 owner) {taskId, owner: ""}
删除 {taskId, status: "deleted"}

设计要点:

  • TaskCreate 只建节点,不连边 —— 依赖必须先拿到 ID 再用 TaskUpdate 补
  • TaskList 是决策入口 —— agent 每轮 tick 先 List 找可 claim 的(owner=”” && blockedBy=[])
  • TaskGet 是执行前的最后一步 —— claim 后进入 in_progress,用 TaskGet 拿完整 description 再干活(因为 List 只返回摘要,为省 token)
  • TaskUpdate 幂等 —— 重复 mark completed 不会出错,方便 error recovery 场景重放

核心工作流程:
1、需要将一个复杂任务拆解。并确认 DAG (有向无环图)依赖。使用 DAG 意味着任务可并行, 可拓扑排序,无死锁。

为什么 DAG 而不是树/线性队列?真实开发依赖不是树形的:任务 D 可能同时依赖 B 和 C(菱形),线性队列表达不了并行,树形结构表达不了合并。DAG 是最小的能表达”部分序”的结构。
2、任务拆解以后,将任务持久化,方便 agent 认领提交。
3、Agent 会通过 tools,调用TaskList()获取 pending 和 无 blockedBy 的任务。
4、LLM 挑选一个 task 执行。
5、调用 TaskUpdate 更新 task 的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Agent tick N]
TaskList() → 拿到 pending / owner="" / blockedBy=[] 的任务

LLM 挑一个(通常最小 ID)

TaskUpdate({taskId, owner: "self", status: "in_progress"})

(可选)同一 tick 内立即开始第一步执行 —— 发起 Read/Bash 等工具调用

[后续多个 tick] 反复执行工具调用推进任务

[某个 tick] TaskUpdate({taskId, status: "completed"})

回到 TaskList 找下一个可 claim 的任务

注意:claim 和”开始执行”通常发生在同一个 tick 里,harness 不会在 TaskUpdate 之后强制切换 —— LLM 一轮可以发多个 tool call。

Background Task - 后台执行耗时任务

**让 LLM 调慢工具(./mvnw testdocker buildpip install)时,Agent 立即返回 placeholder 让 LLM 继续工作,后台 daemon 线程跑完后通过 <task_notification> 文本块注入下一轮 — 取消同步等待

为什么需要 Background Task

如果 agent loop 是完全同步的:

1
2
3
4
5
LLM tool_use(bash, "./mvnw test")

agent loop 进程同步等子进程(直到默认 2 分钟超时)

返回 ToolResult,继续 loop

LLM 这段时间里完全失去主动权 —— 不能并行去做别的小任务,不能主动决定”要不要 abort 重新调”,甚至连”我此刻在等什么”都看不到。当单线程 RPC 客户端用。

我们需要:

1
2
3
4
5
6
7
8
9
10
11
12
13
LLM tool_use(bash, "./mvnw test", run_in_background=true)

agent 派 daemon thread → 立即返回 "[Background task bg_0001 started]"

LLM 看到 placeholder,可以:
- 想"我先去看下其他文件"
- 调别的工具(读文件、跑快命令)
- 安排接下来的步骤

后台 thread 完成,harness 在下一轮 tool_results 时
追加 <task_notification id="bg_0001">...</task_notification>

LLM 看到通知,自然消费结果("噢 test 跑完了,结果是 X")

LLM 重新拿回主导权:它决定什么时候等、等多久、要不要先做别的事。

相关 API

API 作用
Bash(run_in_background=true) 派后台任务,立即返回 task_id
TaskOutput(task_id, block=true) 阻塞等结果(等价于同步)
TaskOutput(task_id, block=false) 立即返回当前状态(异步 peek)
TaskStop(task_id) 主动取消跑飞的后台任务

<task_notification> 不是特殊的 IPC 中断机制,而是 harness 在下一次 API 请求时拼在 user 消息里的一段文本。所以 LLM 是”在下一次思考中读到”通知,不是”被中断”—— 这就是它保持主导权的技术根源。

github 开源地址 https://github.com/diaozxin007/jooj