跳到主要内容

默认 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.startedaction.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", 模型会自己 grepread_filewrite_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 ToolSetharness/tools.ts:267
emitStreamContent消费模型流,边流边发增量与动作事件harness/emission.ts:351
handleStepResult看完一步产物,决定 continue / park / donetool-loop.ts:1419
maybeCompact / compactMessages模型调用前压缩历史tool-loop.ts:1988harness/compaction.ts:107
resolvePendingInput解析 HITL / 审批的回答,或宣告"还没到、park"harness/input-requests.ts:128

2.3 主线走一遍(高层)

  1. 一条用户消息进来,runtime 调 runStep
  2. harness 先看"有没有在等什么"(上一步 park 下来的审批/问题/运行时动作)。没等 ⇒ 往下走。
  3. 历史太长就先 compaction。
  4. 组装这一步能用的工具,发 step.started,做一次模型调用。
  5. 模型边产出边被 emitStreamContent 转成事件;模型要调的本地工具被 ToolLoopAgent 执行。
  6. 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 步就停":

packages/eve/src/harness/tool-loop.ts:726
stopWhen: isStepCount(1),

于是 ToolLoopAgent 退化成"做一次模型调用 + 执行这次产生的工具",绝不自己进第二步。第二步要不要 跑,由 harness 在 handleStepResult显式决定——返回 next = runStep 就等于"再调一次我自己"。

3.3 一步内部:边流边发、边执行

模型流式产出时,harness 不是等全部结束再处理,而是一边消费流一边发事件、一边让工具执行:

packages/eve/src/harness/tool-loop.ts:746
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 出来

这里有个巧妙的"接力":buildStepHooksharness/step-hooks.ts:126 造了一个 Promise, ToolLoopAgentonStepFinish 回调一触发就把这步的 StepResult resolve 出来 (step-hooks.ts:169)。所以 harness 既能流式发增量、又能拿到结构化的整步产物。

3.4 续不续?三种结局

handleStepResult 末尾就是循环的"心脏"。只有出现工具活动这步才继续:

packages/eve/src/harness/tool-loop.ts:1622
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 一直命中)

循环里从不回头改写已有消息,只往后追加:

packages/eve/src/harness/tool-loop.ts:1613
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抓一个 URLapp runtime
web_search搜网(provider 托管,没有本地 executor)provider
todo维护一份按 session 持久的待办清单app runtime
ask_question中途问用户一个问题/选择,然后 parkapp runtime
agent把子任务委派给自己的一个副本(共享父沙箱,历史/状态全新)app runtime
load_skill把某个按需 skill 的指令拉进当前 turnapp runtime
connection_search在声明的 connections 里发现工具,命中的变成可直接调app runtime

后三个(agent / load_skill / connection_search)是条件注入的:只有当 agent 声明了 subagent / skills / connections 时才出现。静态注册表里只有前面那批 (runtime/framework-tools/index.ts:20ALL_FRAMEWORK_TOOLS,经 getFrameworkToolDefinitions() 暴露,:53)。

4.3 关键设计:shell/文件工具"在 app runtime,但 proxy 进沙箱"

这是最容易看错的一点。bashread_filewrite_fileglobgrep 这几个工具的 executor 函数本身跑在 app runtime,但它做的事被**代理(proxy)进 agent 那个唯一的 沙箱**去落地。看 bash 的实现就一眼明白:

packages/eve/src/runtime/framework-tools/bash.ts:50
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):带 namedescriptioninputSchema、可选 execute / approval / toModelOutputbuildToolSet (harness/tools.ts:54)把它们逐个转成 AI SDK 的 tool()。两个值得记住的细节:

  • 没有 execute 的工具 = 客户端工具:模型能调,但没有服务端执行。web_search 就是这种—— 框架定义里故意不给 executor,buildToolSetWithProviderTools(tools.ts:267)在装配时检测到 这个"空缺",注入真正的 provider 工具;当前模型给不了该 provider 工具时,干脆把这个 sentinel 删掉,而不是把一个不可执行的工具暴露给模型(tools.ts:288-294)。
  • ask_question 受能力门控:只有 capabilities.requestInput === true 的 session 才看得见它 (tools.ts:65);定时任务根和其下的 subagent 链没有 HITL 能力,永远看不到这个工具。

4.5 覆盖、禁用、新增(作者怎么动这套默认)

作者用文件名来动这套内置工具——这是 第 1 章 "文件系统即接口" 在 harness 上的体现:

想要怎么做效果
同名但改行为agent/tools/<slug>.tsdefineTool 重定义,spread 默认实现来包一层模型仍看到同名工具,但走你的逻辑
彻底拿掉某能力agent/tools/<slug>.ts 导出 disableTool()模型再也看不到这个工具
加一个框架没有的工具给个全新 slug加入内置集,而不是替换某个

默认实现从 eve/tools/defaults 可导入(public/tools/defaults.ts),所以你能 spread/包/打补丁。 不 spread 就拿不到框架的隐藏接线——例如 todo 工具的持久 state key,一个全新的 defineTool 不会继承它。文件名拼错会在构建期报错而不是悄悄删错工具。


5. 核心机制三:compaction(上下文压缩)

5.1 它要解决的小问题

长会话会把历史撑到模型上下文窗口塞不下。compaction 在历史快满时,把更早的轮次摘要成一小段, 保留近期消息,然后继续跑。

5.2 触发点:模型调用之前,基于 token 估算

compaction 跑在 agent.stream() 之前,这样压缩后的 messages 能顺着同一个变量流回 session.history(tool-loop.ts:511-527 的注释专门强调了这点)。判断"该不该压"靠 shouldCompact:

packages/eve/src/harness/compaction.ts:69
export function shouldCompact(messages, config): boolean {
return getInputTokenCount(messages, config) > config.threshold;
}

token 数怎么来?两条腿走路(compaction.ts:46 getInputTokenCount):上一步模型真实回报的 inputTokens(权威)+ 自那以后新追加消息的字符估算(JSON.stringify(...).length / 4, estimateTokens,compaction.ts:37)。真实回报值由 createNextCompactionConfig (tool-loop.ts:1956)在每步结束时写回 session.compaction.lastKnownInputTokens。阈值 config.threshold 由作者的 thresholdPercent(默认 0.9,即窗口的 90%)换算而来。

5.3 怎么压:摘要旧的,保留近的,丢掉工具往返

compactMessages(compaction.ts:107)把消息切成"旧 / 近"两段:

[ ──────── older(摘要掉) ──────── ][ ── recent(保留) ── ]
│ │
用摘要模型生成一段 只留"纯对话":
labeled 摘要文本 tool 结果丢掉、
│ assistant 的 tool_use 剥掉
▼ │
最终历史 = [user:"以下是目前的对话摘要"] +
[assistant: 摘要文本] + recent 尾巴 + (必要时)补一句 user

几个刻意的取舍:

  • 保留窗口大小是动态的。 selectRecentWindowSize(compaction.ts:217)从最新消息往回数, 累加 token,直到逼近阈值(还预留一段给摘要本身,COMPACTION_SUMMARY_RESERVE_TOKENS = 2048)。
  • 近期段丢掉所有工具往返。 keepNonToolResultMessages(compaction.ts:177)把 tool 消息整条 删、把 assistant 消息只留文本(剥掉 tool-call 与 reasoning)。理由:摘要已经把旧的工具活动 记下了,而剥干净能保证"绝不出现一个 tool_use 没有对应 tool_result"——那种悬空会被 provider 直接拒。
  • 结尾补一句 user: "Continue."(必要时)。 若保留尾巴为空或以 assistant 收尾,会补一条合成 user 消息(compaction.ts:148-152)——因为很多 provider 不接受"以 assistant 内容结尾"的请求。
  • 压一轮还不够就再缩。 压完若仍超阈值,keep -= 1 再压(compaction.ts:161-165),直到达标 或保留窗口归零。
  • 摘要用哪个模型? 默认复用当前 turn 模型;作者可单独配 compactionModelReference 覆盖(compaction.ts:82 resolveCompactionModel)。摘要的系统提示是固定的 "你是对话摘要器…保留目标/指令/技术决策/发现/未完成工作/相关工具结果…用对话原语言" (compaction.ts:6 COMPACTION_SYSTEM_PROMPT),temperature: 0

5.4 压完之后:框架自己的状态也得救回来

摘要会把"读过哪些文件、待办是什么"这类框架自有状态一起冲掉。所以 compaction 之后,harness 调一个回调 onCompaction() 让执行层把这些状态重新注入:

packages/eve/src/harness/tool-loop.ts:2035
if (input.onCompaction) {
for (const msg of input.onCompaction()) {
messages.push(msg);
}
}

执行层的实现就两步(execution/compaction.ts:18 preserveFrameworkStateOnCompaction):

packages/eve/src/execution/compaction.ts:18
export function preserveFrameworkStateOnCompaction(): readonly ModelMessage[] {
clearReadFileState(); // ① 重置读前写追踪
const todo = getTodoCompactionMessage(); // ② 把当前待办重新拼成一条消息
return todo === undefined ? [] : [todo];
}
  • ①重置读前写:读文件的"证据"被摘要掉后,如果还认为"读过",写入会用一个旧指纹去校验。 清掉追踪,强制压缩之后的写入先重读那个文件(下一节讲读前写)。
  • ②重新注入待办:把待办清单拼回一条消息追加进历史,模型跨摘要仍记得自己的任务列表。

整个 compaction 还会发一对事件 compaction.requestedcompaction.completed (tool-loop.ts:2014:2041),让观测端看得见这次压缩。


6. 核心机制四:读前写与旧读防护

6.1 它要解决的小问题

模型很容易"凭印象覆写"文件:它以为文件还是上次看到的样子,直接 write_file 整文件覆盖,把别人 (或它自己早先)的改动冲掉。eve 在 write_file 里硬性拦这两类事故:

  • 读前写(read-before-write):没读过的已存在文件,不许覆写。
  • 旧读检测(stale-read):读过、但文件在那之后被改过了,也不许覆写。

6.2 思路:每次读都留指纹,每次写都核对

机制是一对"盖戳/核戳":read_file 成功后给文件留一个指纹(内容哈希 + 字节数), write_file 写之前先核对当前文件指纹和留存指纹是否一致。

read_file(f) ──盖戳──▶ state.byTarget[f] = { contentHash, byteLength }

write_file(f) ──核戳──▶ ┌─ 没戳(没读过) ⇒ 报错:先 read_file
├─ 有戳但当前哈希 ≠ 留存哈希 ⇒ 报错:文件被改过,请重读
└─ 一致 ⇒ 写入,并刷新戳

6.3 真实实现

写入执行器把三种情况判得很干净(execution/sandbox/write-file-tool.ts:44 executeWriteFileOnSandbox):

packages/eve/src/execution/sandbox/write-file-tool.ts:81
const storedStamp = state.byTarget[targetKey];
if (storedStamp === undefined) {
throw new Error(`You must read file ${filePath} before overwriting it. Use the read_file tool first.`);
}
// …旧读检测…
if (currentStamp.contentHash !== storedStamp.contentHash ||
currentStamp.byteLength !== storedStamp.byteLength) {
throw new Error(`File ${filePath} has been modified since it was last read. Please read the file again before modifying it.`);
}

几个细节:

  • 新文件直接放行。 文件不存在时无需先读,写完盖一个新戳(write-file-tool.ts:63-74)。
  • 每次写都整文件读一遍来算哈希。 源码注释承认这是已知成本:旧读检测要哈希当前内容,所以 写前必读全文(write-file-tool.ts:55-61)。
  • 指纹存在哪。 存在 ReadFileState 这份按 session 持久的上下文状态里 (runtime/framework-tools/file-state.ts:26,ReadFileStateKey),按规范化路径索引。
  • 和 compaction 的联动。 上一节的 clearReadFileState() 之所以重要,就是因为这些戳是 "读过"的唯一证据;摘要冲掉证据后必须清戳,否则模型会拿一个失效的"读过"去过读前写门—— 清掉就退化成"压缩后第一次写,先重读"。

7. 核心机制五:park 与续上(中断怎么不丢)

7.1 它要解决的小问题

agent 跑到一半可能需要外部输入才能继续:问用户一个澄清问题、等一个工具审批、等一个授权 (OAuth)、等一个被委派出去的子动作的结果。这时不能死等,也不能丢——要停在原地、把状态存住、 回答到了从原地续上。这个"停下等"在 eve 里叫 park

7.2 park 的统一信号:next: null

不管为什么停,harness 都返回同一个信号:{ next: null, session }。runtime 看到 null 就知道 "这一步停了,等下一条输入再调 runStep"。executeStepBody 一开头就按顺序检查三类待续输入, 任一没到就 park:

packages/eve/src/harness/tool-loop.ts:414
const resolvedRuntimeActions = await resolvePendingRuntimeActions({ /* … */ });
if (resolvedRuntimeActions.outcome === "unresolved") {
return { next: null, session: resolvedRuntimeActions.session }; // 运行时动作结果没到 ⇒ park
}
// …随后 resolvePendingInput 同理处理 HITL / 审批…

7.3 怎么从原地续上:把"问题"和"已产出"都存进 session

park 时不是简单返回——handleStepResult 把这一步已经产出的助手消息、待回答的请求(问题/审批)、 以及发事件用的"坐标"(turnId/stepIndex/sequence)一起存进 session,等回答到了重建现场:

packages/eve/src/harness/tool-loop.ts:1540
if (inputRequests.length > 0) {
let parkedSession = setPendingInputBatch({
event: { sequence, stepIndex, turnId }, // 续上时用同一套坐标,turn 不断
requests: inputRequests, // 待回答的问题 / 审批
responseMessages, // 这一步已产出的助手消息
session: { ...baseSession, history: [...promptMessages] },
});
// …发 input.requested 事件,conversation 模式补 turn 收尾…
return { next: null, session: parkedSession };
}

回答到了,下一次 runStepresolvePendingInput(input-requests.ts:128)把回答拼成 tool 结果消息接回历史,清掉 pending batch,循环继续。还有个细节:审批回答和新用户消息不能塞进 同一次请求(AI SDK 限制),所以 harness 会把新消息推迟(defer),下一步再 replay (input-requests.ts:205-221)。授权(OAuth)走类似路径,但用 setPendingAuthorization 存挑战、 发 authorization.required 事件(tool-loop.ts:1573-1604)。

这些 park/resume 之所以能跨进程存活,靠的是第 2 章的持久化执行 模型——本章只负责"决定何时 park、存什么";"存到哪、怎么恢复"是第 2 章的事。


8. 一步内的事件流(emission)

harness 每走一步都把发生的事广播成生命周期事件,观测端、UI、eval runner 都靠它。事件携带一组 坐标(HarnessEmissionState:turnId / stepIndex / sequence),持久在 session.state 上, 所以跨 step 边界(甚至跨进程恢复)还能续号(emission.ts:71)。

一个普通 turn 的事件序列:

session.started(仅首次) → turn.started → message.received
→ step.started → [流式增量 / actions.requested / action.result] → step.completed
→ (要工具就回到 step.started,stepIndex+1) …
→ turn.completed → session.waiting(conversation) / session.completed(task)

几个锚点:

  • turn 序幕:emitTurnPreamble(emission.ts:136)发 session.started(只首次)、 turn.startedmessage.received,并把 turnId 定为 turn_<sequence>
  • 每步开头:emitStepStarted(emission.ts:171)。注意它在装配工具集之前发,好让订阅 step.started 的动态工具解析器能改这一步的有效工具集(tool-loop.ts:830-834)。
  • 续步:advanceStep(emission.ts:260)只把 stepIndex 加一,turnId 不变——所以"同一 turn 多步"在事件里表现为相同 turnId、递增 stepIndex
  • turn 收尾:emitTurnEpilogue(emission.ts:271)发 turn.completed,再按 mode 发 session.waiting(conversation,可续)或 session.completed(task,终结);并把 turnId 清成 ""——这个空串就是"turn 间隙"的哨兵,isHarnessBetweenTurns(emission.ts:108)靠它判断 "是新一轮投递还是同一 turn 的续跑"。
  • 失败:emitFailedStep(终结:step.failedturn.failedsession.failed)与 emitRecoverableFailedTurn(可恢复:尾巴是 session.waiting)两条级联,分别对应"这 session 死了" 和"这步崩了但还能等用户重试"(emission.ts:228:241)。

9. 巧妙之处(可借鉴的技术)

  • 把底层 agent 钉成"一步",自己当循环驱动者。 stopWhen: isStepCount(1)(tool-loop.ts:726) 一行,就把"何时进下一步"的权力从 SDK 夺回到 harness——换来每步可 checkpoint / park / 恢复。 借鉴点:当你需要在循环的每一拍插入持久化与中断,就别让底层库替你跑完整个循环。

  • 历史 append-only,compaction 是唯一的重写者且只在调用前动手。(tool-loop.ts:1608 注释 + :511 注释)前缀稳定 ⇒ provider prompt cache 跨步常命中;重写集中到一个明确时点 ⇒ 不会在一步中途 破坏缓存或制造悬空 tool_use。

  • 客户端工具 + provider 工具的"空缺注入"。 web_search 框架定义故意不带 executor,装配时检测 到空缺再注入真 provider 工具;模型给不了就直接删掉,绝不暴露一个不可执行的工具 (tools.ts:286-295)。

  • compaction 把工具往返整段剥干净,只靠摘要承载旧工具活动。 既省 token,又从结构上杜绝"孤儿 tool_use 被 provider 拒"(compaction.ts:142 注释)。

  • 读前写/旧读用"内容指纹"而非时间戳。 哈希 + 字节数比 mtime 抗误判;compaction 一清戳就自动 退化为"先重读",两个机制干净地咬合(write-file-tool.ts:81-101 + execution/compaction.ts:19)。

  • park 用同一个 next: null 信号统一了四种中断(运行时动作 / 问题 / 审批 / 授权),把"为什么停" 的差异收进 session 状态,而对 runtime 暴露一个极简契约。


10. 边界与局限(诚实)

  • 每次 write_file 都整文件读+哈希一遍,大文件有成本;源码明说这是为换取一个 exists() 原语 而做的取舍(write-file-tool.ts:55-61)。

  • token 估算是粗的。 compaction 触发判断里"新增消息"那部分用 字符数 / 4 估 (compaction.ts:37),只在权威的 inputTokens 之上叠加——估算偏差可能让压缩触发偏早或偏晚。

  • web_search 没有本地实现,完全依赖 provider。 当前模型 provider 给不了,这个工具就消失 (tools.ts:288-291);要自带实现得用 defineTool() 覆盖。

  • HITL(ask_question)受能力门控。 定时任务根及其下的 subagent 链没有 requestInput 能力, 这些上下文里模型根本看不到问用户的工具(tools.ts:65),设计上不能向人发问。

  • compaction 之后的连续性依赖固定摘要提示。 摘要质量受那段固定系统提示与摘要模型左右 (compaction.ts:6);极端长会话里"被摘掉的细节"无法事后找回(除非它落在保留窗口里)。


11. 横向对比

把本章三件套放到 shelf 同类里看(各家在"循环 / 内置工具 / 上下文压缩"上的取舍):

关切eve 的取法常见替代取法
循环步进底层 agent 钉成 1 步,harness 自己驱动多步,每步可持久化/park直接让 SDK 跑完整多步循环,牺牲细粒度中断
危险工具边界shell/文件工具壳在 app、副作用 proxy 进单一沙箱直接在宿主进程执行,或每工具各自起隔离
上下文压缩调用前摘要 + 动态保留窗口 + 框架状态再注入固定保留最近 N 条 / 滑动窗口截断,无状态再注入
写文件安全内容指纹的读前写 + 旧读检测无校验直接覆写,或仅靠 diff/patch 工具

更细的对照见同组其它章:01 文件系统即接口讲工具/skill 怎么被发现编译、 04 上下文控制讲 instructions / skills / subagents、 05 前门与边界讲沙箱与安全模型;02 持久化执行模型 讲 step 之间怎么 checkpoint。


12. 代码地图(导航索引)

主题文件路径符号名
harness 工厂packages/eve/src/harness/tool-loop.tscreateToolLoopHarness
一步主流程packages/eve/src/harness/tool-loop.tsrunStep / executeStepBody
钉成一步packages/eve/src/harness/tool-loop.tsstopWhen: isStepCount(1)
续/停/终决策packages/eve/src/harness/tool-loop.tshandleStepResult / continueLoop
步进 hook(onStepFinish/prepareStep)packages/eve/src/harness/step-hooks.tsbuildStepHooks
工具集装配packages/eve/src/harness/tools.tsbuildToolSet / buildToolSetWithProviderTools
工具定义类型packages/eve/src/harness/execute-tool.tsHarnessToolDefinition
内置工具注册packages/eve/src/runtime/framework-tools/index.tsALL_FRAMEWORK_TOOLS / getFrameworkToolDefinitions
bash proxy 进沙箱packages/eve/src/runtime/framework-tools/bash.tsexecuteBash / executeBashOnSandbox
作者侧默认工具导出packages/eve/src/public/tools/defaults.tsbash / readFile / writeFile
compaction 是否触发packages/eve/src/harness/compaction.tsshouldCompact / getInputTokenCount / estimateTokens
compaction 主体packages/eve/src/harness/compaction.tscompactMessages / keepNonToolResultMessages / selectRecentWindowSize
调用前压缩packages/eve/src/harness/tool-loop.tsmaybeCompact / createNextCompactionConfig
压缩后框架状态再注入packages/eve/src/execution/compaction.tspreserveFrameworkStateOnCompaction
读前写 / 旧读检测packages/eve/src/execution/sandbox/write-file-tool.tsexecuteWriteFileOnSandbox
读戳状态packages/eve/src/runtime/framework-tools/file-state.tsReadFileState / ReadFileStateKey
流式发事件packages/eve/src/harness/emission.tsemitStreamContent / emitStepStarted / advanceStep
park/resume 输入packages/eve/src/harness/input-requests.tsresolvePendingInput / setPendingInputBatch