AI in Harness(二)
书接上文
Error Recovery - 从错误中恢复
让 agent loop 在面对 LLM 三类常见故障(输出截断 / context 太长 / 后端过载)时自我恢复,而不是把异常一路抛回出去。
对于一个 Java 程序员处理异常那就太熟悉不过了。给 LLM 的调用方法加上 try catch。
目前最常见的错误,输出太长,输入太长,调用太快,模型压力太大。
输出太长:
- 第一次:把
max_tokens从默认 8000 → 升级到 64000(escalated),丢掉截断的输出,重新跑这一轮。模型重生成,通常这次能说完 - 第二次还截断:已经 64k 还说不完 → 真的需要”分两次说”。append 截断输出 + 注入
Continue.user message,让模型续写 - 超过
maxRecoveryRetries=3次 continuation:放弃,Fatal
输入太长:
- 可以直接调用
reactive_compact压缩模式。使用比较激进的压缩模式,只保留最近的若干条消息。
调用太快、模型压力过大:
- 调用 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 | [Agent tick N] |
注意:claim 和”开始执行”通常发生在同一个 tick 里,harness 不会在 TaskUpdate 之后强制切换 —— LLM 一轮可以发多个 tool call。
Background Task - 后台执行耗时任务
**让 LLM 调慢工具(./mvnw test、docker build、pip install)时,Agent 立即返回 placeholder 让 LLM 继续工作,后台 daemon 线程跑完后通过 <task_notification> 文本块注入下一轮 — 取消同步等待
为什么需要 Background Task
如果 agent loop 是完全同步的:
1 | LLM tool_use(bash, "./mvnw test") |
LLM 这段时间里完全失去主动权 —— 不能并行去做别的小任务,不能主动决定”要不要 abort 重新调”,甚至连”我此刻在等什么”都看不到。当单线程 RPC 客户端用。
我们需要:
1 | LLM tool_use(bash, "./mvnw test", run_in_background=true) |
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