默认 harness:agent 循环、内置工具与 compaction
30 秒导读: 你写 eve agent,只声明"用哪个模型、有哪 些额外工具、什么指令"。真正让它跑起来 的那台引擎——把模型说的话变成工具调用、把工具结果喂回模型、循环到模型不再要工具为止,并在历史 太长时自动摘要——是框架替你装好的 harness。本章拆开这台引擎。
本章只讲默认 harness:每个 agent 开箱即带的"引擎"本身。它和上一章是一组互补的: 第 2 章讲这台引擎被谁驱动、怎么持久化(session / turn / step / Workflow 的存续);本章讲引擎内部怎么转——一次 step 里模型和工具怎么来回、内置了哪些工具、 历史怎么被压缩。两章会在"step"这个词上交汇:第 2 章关心 step 之间怎么 checkpoint,本章关心 一个 step 内部发生了什么。
1. 这是什么(零基础也能懂)
1.1 一句话定义
harness = agent 的运行时引擎。 你给它"模型 + 工具 + 历史",它负责跑那个谁都绕不开的循环:
问模型 → 模型要调工具 → 执行工具 → 把结果交回模型 → 再问模型 → …… → 模型给出最终回复
这个"模型↔工具来回直到收敛"的循环,业内叫 agentic loop / tool loop。eve 把它做成框架自带、 你一行都不用写的东西,叫"默认 harness"。
1.2 它替你扛了什么
如果没有 harness,你得自己手写一堆又脏又难的东西。harness 把它们全包了:
| 你本来要操心的 | harness 替你做了 |
|---|---|
| 循环什么时候停 | 模型不再要工具 / 调了"终止工具"就停;否则继续下一步 |
| 工具怎么执行、结果怎么塞回历史 | 拦截工具调用 → 执行 → 把 tool-result 追加进消息 |
| 模型要问用户问题 / 要授权时 | 停下来(park)、等回答、再从原地续上 |
| 历史撑爆上下文窗口 | 自动摘要旧历史(compaction),保留近期与待办 |
| 内置一套基本能力(跑命令、读写文件、搜网) | 开箱即用的内置工具,无需 import |
| 把每一步发生的事广播出去 | 发一串生命周期事件(step.started、action.result …) |
1.3 用起来什么样
作者侧极简——只 defineAgent,harness 全是隐式的:
// 示意,非源码:一个最小 eve agent
export default defineAgent({
model: "anthropic/claude-opus-4.8",
// 不写工具也行:bash / read_file / write_file / glob / grep / web_* / todo …… 全自带
compaction: { thresholdPercent: 0.75 }, // 只调一个数,就改了压缩触发点
});
你没写任何循环、没写任何工具执行代码。当用户发来"帮我把这个仓库里所有 var 改成 let",
模型会自己 grep → read_file → write_file,harness 在背后跑完整个多步循环。
1.4 一句话直觉
把 harness 当成一台"模型的呼吸机": 模型只会"说话"(产出文本和工具调用意图),它自己不会 真的去执行命令、不会记得读没读过文件、不会在上下文满时自救。harness 就是那层把模型的"说" 变成真实"做"、并维持它长时间存活的机械装置。
2. 顶层全景(它大概怎么转)
2.1 一次 step 的数据流
eve 的循环建在 AI SDK 的 ToolLoopAgent 之上,但有个关键设计:harness 不让 ToolLoopAgent
自己跑多步,而是把它钉死成"每次只跑一步模型调用",多步循环由 harness 自己驱动。
这张图从上到下是一个 step 内的处理顺序;最底下的分叉决定"再来一步 / 停下等人 / 收尾"。
一次 runStep 调用
│
┌───────────────────┼───────────────────────────┐
│ ① 解析待续输入 │
│ deferred 输入 / 运行时动作结果 / HITL 回答 │ ← 任一未到 ⇒ park(返 回 next:null)
├───────────────────┼───────────────────────────┤
│ ② compaction(模型调用前) │ ← 历史超阈值才摘要,见 §5
├───────────────────┼───────────────────────────┤
│ ③ 组装工具集 + 系统指令 + 附件 hydrate │
├───────────────────┼───────────────────────────┤
│ ④ 一次模型调用(ToolLoopAgent,stopWhen=1 步) │
│ 消费 fullStream → 边流边发事件 → 执行工具 │
├───────────────────┼───────────────────────────┤
│ ⑤ handleStepResult:看这一步的产物决定下一步 │
└───────────────────┼───────────────────────── ──┘
▼
┌───────────────┴───────────────┐
有 tool 结果? 没有工具、是纯文本?
要问用户 / 要授权? │
│ │
继续(next=runStep) 收尾(park 或 done)
或 park 等回答
2.2 主要部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
createToolLoopHarness | 工厂:吃下注入的依赖,返回一个 runStep 函数 | packages/eve/src/harness/tool-loop.ts:347 |
runStep / executeStepBody | 一次 step 的主流程(上图) | tool-loop.ts:356、:393 |
ToolLoopAgent(AI SDK) | 底层做"一次模型调用 + 本地工具执行" | tool-loop.ts:731(new ToolLoopAgent) |
buildToolSetWithProviderTools | 把 harness 工具定义编译成 AI SDK ToolSet | harness/tools.ts:267 |
emitStreamContent | 消费模型流,边流边发增量与动作事件 | harness/emission.ts:351 |
handleStepResult | 看完一步产物,决定 continue / park / done | tool-loop.ts:1419 |
maybeCompact / compactMessages | 模型调用前压缩历史 | tool-loop.ts:1988、harness/compaction.ts:107 |
resolvePendingInput | 解析 HITL / 审批的回答,或宣告"还没到、park" | harness/input-requests.ts:128 |
2.3 主线走一遍(高层)
- 一条用户消息进来,runtime 调
runStep。 - harness 先看"有没有在等什么"(上一步 park 下来的审批/问题/运行时动作)。没等 ⇒ 往下走。
- 历史太长就先 compaction。
- 组装这一步能用的工具,发
step.started,做一次模型调用。 - 模型边产出边被
emitStreamContent转成事件;模型要调的本地工具被ToolLoopAgent执行。 handleStepResult看结果:- 这步以工具结果收尾 ⇒
next = runStep,再来一步(这就是"循环")。 - 模型要问用户 / 要授权 ⇒ park,返回
next: null,等下一条输入。 - 模型给了纯文本最终回复 ⇒ 收尾(conversation 模式 park 等下一轮;task 模式
done)。
- 这步以工具结果收尾 ⇒
3. 核心机制一:每次只跑一步的 tool loop
3.1 它要解决的小问题
AI SDK 的 ToolLoopAgent 本身能自己跑完整的多步循环(一直跑到模型不要工具)。那 eve 为什么
不直接用?因为 eve 的每个"步"必须是可持久化、可中断、可恢复的最小单元(见
第 2 章):一步结束就要能 checkpoint、能 park 等用户、能跨进程恢复。
如果让底层一口气跑十步,中间这些事就插不进去。
3.2 思路:把多步循环的"步进"权夺回到 harness 手里
做法只有一行,但是全章的枢纽——把 ToolLoopAgent 的停止条件设成"跑满 1 步就停":
stopWhen: isStepCount(1),
于是 ToolLoopAgent 退化成"做一次模型调用 + 执行这次产生的工具",绝不自己进第二步。第二步要不要
跑,由 harness 在 handleStepResult 里显式决定——返回 next = runStep 就等于"再调一次我自己"。
3.3 一步内部:边流边发、边执行
模型流式产出时,harness 不是等全部结束再处理,而是一边消费流一边发事件、一边让工具执行:
const streamResult = await agent.stream({ messages: callMessages });
const { /* …inline 结果… */ } = await emitStreamContent(
emit, emissionState, streamResult.fullStream, { excludedActionToolNames, tools: config.tools },
);
const stepResult = await hooks.stepResult; // onStepFinish 把这一步的完整产物 resolve 出来
这里有个巧妙的"接力":buildStepHooks 在 harness/step-hooks.ts:126 造了一个 Promise,
ToolLoopAgent 的 onStepFinish 回调一触发就把这步的 StepResult resolve 出来
(step-hooks.ts:169)。所以 harness 既能流式发增量、又能拿到结构化的整步产物。
3.4 续不续?三种结局
handleStepResult 末尾就是循环的"心脏"。只有出现工具活动这步才继续:
const continueLoop =
!calledFinalOutput &&
(continuationMessages.at(-1)?.role === "tool" || // 这步以 tool 结果收尾
normalizedProviderHistory.outcomeEndsResponse || // provider 端执行的工具收尾
hasDeferredStepInput(nextSession)); // 还有被推迟的输入要喂
if (continueLoop) {
emissionState = advanceStep(emissionState); // stepIndex + 1
return { next: runStep, session: nextSession }; // ← "再来一步"
}
三条出路对照:
| 这一步的产物 | handleStepResult 的决定 | 返回 |
|---|---|---|
| 以工具结果收尾(还要回模型) | 继续循环 | next: runStep |
| 模型在问用户 / 要授权 | park,等回答 | next: null |
| 纯文本最终回复(无工具) | 收尾 | task 模式 done;conversation 模式 park |
每继续一步,advanceStep(emission.ts:260)把 stepIndex 加一——这就是同一个 turn 内
step 0、step 1、step 2…… 递进的来源。
3.5 历史只增不改(为什么 prompt cache 一直命中)
循环里从不回头改写已有消息,只往后追加:
const updatedHistory: ModelMessage[] = [...promptMessages, ...continuationMessages];
源码注释点破了它的用意(tool-loop.ts:1608):历史"append only,没有任何东西在 turn 中途重写
更早的消息,所以 prompt 前缀稳定,provider 的 prompt cache 跨步一直命中"。唯一会重写历史的
机制是 compaction,而它发生在模型调用之前(见 §5)——绝不在一步中途插一脚。
4. 核心机制二:内置工具与"proxy 进沙箱"
4.1 它要解决的小问题
agent 要有用,得能跑命令、读写文件、搜网。eve 让这些开箱即带:你不声明任何工具,模型也已经 看得见一整套基本能力。
4.2 内置工具一览
harness 先把工具的描述符展示给模型(discovery 阶段),再只执行模型真正调用的那个——
光是"看得见"绝不会触发执行。下面是默认带的工具,以及它的副作用落在哪里:
| 工具 | 干什么 | 副作用落点 |
|---|---|---|
bash | 跑一条 shell 命令 | 沙箱 |
read_file | 读文本文件(带行号,开启读前写) | 沙箱文件系统 |
write_file | 整文件写入(强制读前写 + 旧读检测) | 沙箱文件系统 |
glob | 按 glob 找文件 | 沙箱文件系统 |
grep | 按正则搜文件内容 | 沙箱文件系统 |
web_fetch | 抓一个 URL | app runtime |
web_search | 搜网(provider 托管,没有本地 executor) | provider |
todo | 维护一份按 session 持久的待办清单 | app runtime |
ask_question | 中途问用户一个问题/选择,然后 park | app runtime |
agent | 把子任务委派给自己的一个副本(共享父沙箱,历史/状态全新) | app runtime |
load_skill | 把某个按需 skill 的指令拉进当前 turn | app runtime |
connection_search | 在声明的 connections 里发现工具,命中的变成可直接调 | app runtime |
后三个(agent / load_skill / connection_search)是条件注入的:只有当 agent 声明了
subagent / skills / connections 时才出现。静态注册表里只有前面那批
(runtime/framework-tools/index.ts:20 的 ALL_FRAMEWORK_TOOLS,经
getFrameworkToolDefinitions() 暴露,:53)。
4.3 关键设计:shell/文件工具"在 app runtime,但 proxy 进沙箱"
这是最容易看错的一点。bash、read_file、write_file、glob、grep 这几个工具的
executor 函数本身跑在 app runtime,但它做的事被**代理(proxy)进 agent 那个唯一的
沙箱**去落地。看 bash 的实现就一眼明白:
async function executeBash(input: unknown): Promise<unknown> {
// 先拿到沙箱会话,再把命令丢进沙箱里跑 —— executor 在 app 侧,副作用在沙箱里
return executeBashOnSandbox(await requireSandboxSession(), input as BashInput);
}
read_file 同构:executeReadFileOnSandbox(await requireSandboxSession(), …)
(runtime/framework-tools/read-file.ts:57)。模式是统一的——工具壳在 app 侧,真正的
读/写/执行通过 requireSandboxSession() 拿到的沙箱句柄落进沙箱。这把"模型能触达的危险面"
关进了一个隔离边界,这部分边界语义在第 5 章展开。
4.4 工具定义怎么编译成模型能用的形态
harness 内部有一套统一的 HarnessToolDefinition(harness/execute-tool.ts:21):带 name、
description、inputSchema、可选 execute / approval / toModelOutput。buildToolSet
(harness/tools.ts:54)把它们逐个转成 AI SDK 的 tool()。两个值得记住的细节:
- 没有
execute的工具 = 客户端工具:模型能调,但没有服务端执行。web_search就是这种—— 框架定义里故意不给 executor,buildToolSetWithProviderTools(tools.ts:267)在装配时检测到 这个"空缺",