15. Harness Engineering:把能力装配成可靠运行时
前面的章节已经分别实现了 Agent loop、工具、会话日志、Context Engineering、安全边界和扩展钩子。把这些对象放进同一个进程,还不等于得到可靠的 Coding Agent。系统仍要回答:运行中切换模型会影响哪一次请求?扩展在事件回调里写日志时排在什么位置?第二个 prompt 应该拒绝还是排队?什么时候才可以对外宣布 Agent 真正空闲?
Harness Engineering 处理的正是这层编排问题。Agent loop 负责推进一次“模型—工具—模型”的循环,Context Engineering 负责决定下一次请求看到什么,harness 则负责把环境、会话、模型、工具、资源、策略和事件装配成具有一致性保证的运行时。Pi Agent 也是这种分层思想的一个例子:低层 loop 与上层 harness 分开,前者执行,后者决定状态所有权、变更时机和完成语义。
失败模式:共享一组随时可变的对象
最朴素的装配方式,是让 UI、扩展和 loop 共同持有 config、messages、tools 与 session,谁需要就直接修改。它在单次演示中通常能跑,却会在时间边界上失控:
- 模型请求已经发出,另一个回调切换了模型或工具;同一个 turn 的日志、UI 和实际请求开始描述不同的事实。
- 两个
prompt()同时进入 loop,消息与 tool result 交错写进同一条会话分支。 message_end的订阅者追加一条自定义记录,它反而先于 assistant 消息落盘。- 资源热重载只改了 system prompt,没有同步激活工具;模型看到的能力与实际工具集合不一致。
- provider 流已经结束,异步 listener 仍在写日志,但
waitForIdle()提前返回,进程退出时最后几条记录丢失。 - abort 清掉了内存状态,也顺手丢掉尚未持久化的配置变更或扩展记录。
这些问题不能靠“回调尽量不要同时发生”解决。harness 必须把允许变化的状态分类,并给每类操作规定明确的 admission 与结算语义。
设计边界:Loader、Core、Harness 与 Session
先把四个容易混在一起的职责分开:
- Loader 发现项目规则、skills、prompt templates 和扩展,执行项目信任判断,处理来源、优先级、冲突与诊断,最后产出已经解析好的 concrete resources。
- Agent Core 执行一次模型请求、工具批次和后续请求,发出第六章定义的生命周期事件。
- Harness 持有运行配置和 phase,为每个 turn 建立快照,控制哪些操作拒绝、排队或延后生效,并把事件与会话写入按确定顺序结算。
- Session Store 追加持久化 entry,重建 active branch;它不决定何时开始一个 turn,也不自行加载工具或项目规则。
这条边界很重要。harness 接收 Loader 的结果,却不在 prompt() 内临时扫描目录;它调用 Agent Core,却不把 provider 协议重新实现一遍;它决定写入顺序,却不把 JSONL 细节写死在生命周期代码里。这样切换到远程 ExecutionEnv、内存 Session 或另一种产品壳时,只替换依赖,不改变一致性契约。
四类状态,不能混成一个 state
成熟 harness 至少区分四类状态:
- Live config:应用当前选择的模型、thinking level、工具、资源、system prompt builder 和 provider 请求选项。getter 返回最新值。
- Turn snapshot:某一次 provider 请求实际使用的模型、工具、资源、system prompt、会话投影和请求选项。创建后,本次请求把它视为不可变。
- Persisted session:已经成功写入事实源的 entry。恢复与下一次 context projection 只相信这些事实。
- Pending writes:运行中由配置 setter、扩展或 listener 请求的 session 写入。它们会在安全点按请求顺序持久化,而不是直接插进当前事件序列。
关键规则是:live config 可以在 turn 运行中立即更新,但不能反向修改已经发出的请求。变化在下一个安全点重建 snapshot 后生效。与变化对应的 session entry 如果此时不能安全落盘,就进入 pending writes。
live config v1
-> create snapshot v1
-> provider request 1 -----------------------+
|
callback sets live config v2 |
v
assistant/tool messages -> persisted session -> save point
pending writes -------------------------------> flush
|
v
create snapshot v2
-> provider request 2
快照隔离不是“复制整个进程”。通常只需复制数组和请求 options,并约定其中的工具定义与资源对象不可原地修改。若扩展可能修改嵌套对象,就要深拷贝、冻结,或把它们改成不可变值;浅拷贝只能保护容器,保护不了容器里的对象。
Operation admission:拒绝、排队还是在安全点生效
不要给所有 busy 操作套同一种规则。不同操作的语义不同:
- 新的
prompt、skill invocation 和 prompt template invocation 会开启结构性 run。harness 非idle时应立即返回稳定的busy错误,不能悄悄与当前 run 合并。 - compaction 和 tree navigation 会改变会话投影或 leaf,只允许在
idle执行。 - steering 与 follow-up 本来就是运行中的输入,进入各自队列,在 Agent Core 定义的边界消费。
- next-turn 消息可以提前排队,但只在下一次用户主动发起的 turn 前注入;abort 是否保留它必须有固定契约。
- model、thinking level、工具、资源和 stream options 的 setter 更新 live config;当前 snapshot 不变,下一安全点读取新值。
- abort 可以在 turn 中触发取消,但仍要经过统一清理、pending write flush 和完成屏障。
phase 应该是显式的判别联合,例如 idle | turn | compaction | branch_summary | retry。结构性方法必须在第一次 await 之前检查并切换 phase,否则两个调用仍可能同时通过 idle 检查。settled 不是 phase,而是“本次 run 的消息、listener 与必要写入已经结算”的事件。
Save point:把时间顺序变成公共契约
安全点位于一个完整 assistant turn 之后:assistant 消息和它触发的所有 tool result 已经完成。一个稳定的顺序是:
message_end先持久化该条 Agent 消息,再通知观察者;观察者看到的是 committed state。turn_end后按队列顺序 flush pending writes,只有成功写入后才从队首删除。- 发出
save_point,说明这一轮的事实与附加写入已经稳定。 - 如果 loop 还要继续,重新读取 session 与 live config,建立 fresh snapshot,再开始下一次 provider 请求。
agent_end后处理剩余写入与异步 listener,最后发出settled,完成屏障才解除。
这条顺序让一个 listener 在 assistant message_end 中追加的自定义 entry 必然排在 assistant 后面,也让运行中切换的模型只影响下一次请求。写入失败时不要一次性清空 pending 数组;逐条提交、成功后再 shift(),才能在中途失败时保留未完成的尾部。
最小实现:给现有 loop 加一层 harness
下面的代码把本章新增的契约压到一个可运行的最小核心。RunLoop 与 Session 是前面章节已经实现的依赖;faux provider 可以实现 RunLoop,因此测试不需要真实模型。
type Phase = "idle" | "turn";
type Message = {
role: "user" | "assistant" | "tool";
text: string;
};
type RuntimeConfig = Readonly<{
model: string;
tools: readonly string[];
resources: readonly string[];
}>;
type SessionEntry =
| { type: "message"; message: Message }
| { type: "config"; config: RuntimeConfig }
| { type: "custom"; value: string };
type Session = {
messages(): Promise<readonly Message[]>;
append(entry: SessionEntry): Promise<void>;
};
type TurnSnapshot = Readonly<{
config: RuntimeConfig;
messages: readonly Message[];
}>;
type HarnessEvent =
| { type: "message_end"; message: Message }
| { type: "save_point"; hadPendingWrites: boolean }
| { type: "settled" };
type RunLoop = (input: {
prompt: Message;
snapshot: TurnSnapshot;
signal: AbortSignal;
commit(message: Message): Promise<void>;
prepareNextTurn(): Promise<TurnSnapshot>;
}) => Promise<Message>;
type HarnessErrorCode = "busy" | "session" | "hook" | "unknown";
class HarnessError extends Error {
readonly code: HarnessErrorCode;
constructor(code: HarnessErrorCode, message: string, cause?: Error) {
super(message, cause ? { cause } : undefined);
this.code = code;
}
}
class Harness {
private phase: Phase = "idle";
private config: RuntimeConfig;
private pendingWrites: SessionEntry[] = [];
private controller?: AbortController;
private idlePromise?: Promise<void>;
private listeners = new Set<(event: HarnessEvent) => void | Promise<void>>();
private readonly session: Session;
private readonly runLoop: RunLoop;
constructor(initialConfig: RuntimeConfig, session: Session, runLoop: RunLoop) {
this.config = this.copyConfig(initialConfig);
this.session = session;
this.runLoop = runLoop;
}
getPhase(): Phase {
return this.phase;
}
getConfig(): RuntimeConfig {
return this.copyConfig(this.config);
}
subscribe(listener: (event: HarnessEvent) => void | Promise<void>): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
async setConfig(patch: Partial<RuntimeConfig>): Promise<void> {
const next = this.copyConfig({
model: patch.model ?? this.config.model,
tools: patch.tools ?? this.config.tools,
resources: patch.resources ?? this.config.resources,
});
const entry: SessionEntry = { type: "config", config: next };
if (this.phase === "idle") await this.session.append(entry);
else this.pendingWrites.push(entry);
this.config = next;
}
async appendCustom(value: string): Promise<void> {
const entry: SessionEntry = { type: "custom", value };
if (this.phase === "idle") await this.session.append(entry);
else this.pendingWrites.push(entry);
}
async prompt(text: string): Promise<Message> {
if (this.phase !== "idle") throw new HarnessError("busy", "Harness is busy");
this.phase = "turn";
const controller = new AbortController();
this.controller = controller;
let releaseIdle = (): void => {};
this.idlePromise = new Promise<void>((resolve) => {
releaseIdle = resolve;
});
try {
const snapshot = await this.createTurnSnapshot();
return await this.runLoop({
prompt: { role: "user", text },
snapshot,
signal: controller.signal,
commit: async (message) => {
await this.session.append({ type: "message", message });
await this.emit({ type: "message_end", message });
},
prepareNextTurn: async () => {
const hadPendingWrites = this.pendingWrites.length > 0;
await this.flushPendingWrites();
await this.emit({ type: "save_point", hadPendingWrites });
return this.createTurnSnapshot();
},
});
} catch (error) {
if (error instanceof HarnessError) throw error;
const cause = error instanceof Error ? error : new Error(String(error));
throw new HarnessError("unknown", cause.message, cause);
} finally {
try {
await this.flushPendingWrites();
} finally {
this.controller = undefined;
try {
await this.emit({ type: "settled" });
} finally {
this.phase = "idle";
releaseIdle();
this.idlePromise = undefined;
}
}
}
}
abort(): void {
this.controller?.abort();
}
async waitForIdle(): Promise<void> {
await this.idlePromise;
}
private async createTurnSnapshot(): Promise<TurnSnapshot> {
const config = this.copyConfig(this.config);
const messages = [...(await this.session.messages())];
return { config, messages };
}
private async flushPendingWrites(): Promise<void> {
while (this.pendingWrites.length > 0) {
await this.session.append(this.pendingWrites[0]!);
this.pendingWrites.shift();
}
}
private async emit(event: HarnessEvent): Promise<void> {
for (const listener of this.listeners) await listener(event);
}
private copyConfig(config: RuntimeConfig): RuntimeConfig {
return {
model: config.model,
tools: [...config.tools],
resources: [...config.resources],
};
}
}
教学版只实现 idle | turn,但已经建立了正确扩展点:compaction、branch summary 和 retry 应作为新的结构性 phase 加入,而不是再造几组布尔变量。生产版还应把 session、hook、auth 等错误归一化成稳定 code,同时保留原始 cause。
运行观察:证明变更只在边界生效
用 faux provider 脚本化两个连续响应:第一次返回 tool use;在工具开始事件中把 model、thinking level、resources、system prompt 和 active tools 全部切到第二组;第二次返回最终文本。你应该观察到:
phase=turn request=1 model=model-a tools=read prompt=v1
config_update model=model-b tools=grep prompt=v2
message_end assistant persisted=true
message_end toolResult persisted=true
save_point hadPendingWrites=true
request=2 model=model-b tools=grep prompt=v2
agent_end
settled
phase=idle
第一条 request 必须完整保持 v1,第二条 request 才使用 v2。再让 message_end listener 调用 appendCustom("listener note"),最终 entry 顺序必须是 user、assistant、tool result、listener note,而不是由 Promise 完成速度决定。
waitForIdle() 应等待异步 listener 与最后一次 flush,但不要在活动 listener 内部反向 await harness.waitForIdle():listener 本身就是当前 run 尚未完成的一部分,这会形成等待自己的死锁。若扩展需要“空闲后再做”,应提供 runWhenIdle() 一类调度 API,而不是暴露结算 promise 让回调自等待。
Typed hooks、观察事件与错误语义
Harness Engineering 不只是加一把 mutex。公共事件应该是判别联合,能改写执行的 hook 还要有事件对应的 result 类型:context hook 返回新的 messages,tool-call hook 返回 block 决策,tool-result hook 返回受限 patch;纯观察事件不接受变更结果。
控制面 hook 的失败会不会阻止操作,必须按事件声明。权限相关 hook 通常 fail closed;统计 listener 的失败则应隔离并上报。若状态已经持久化后 listener 才失败,不要假装回滚内存而留下已提交日志。公共方法应返回 busy、invalid_argument、session、hook、auth 等稳定分类,调用方不必解析错误字符串。
生产化取舍
- Loader 与 harness 分离后,资源重载要携带来源和诊断;名称冲突必须有确定 winner,不能取决于文件系统枚举顺序。
- pending writes 需要上限或背压,否则失控的扩展可以在一个长 turn 中耗尽内存。达到上限时应拒绝新写入并发出可观察错误,而不是丢掉旧事实。
- abort 的契约要逐队列说明:哪些输入清空、哪些保留、pending writes 是否继续落盘。不要用一个
queues = []隐藏语义差异。 - provider stream 通常无法从中间恢复。进程崩溃后,应从最近 durable boundary 继续或把未完成操作标成 interrupted;除非工具声明幂等或 retry-safe,不要自动重跑未完成工具。
- 事件与 session entry 都会演化,稳定 type、version 与迁移测试比把整个内存对象序列化更可靠。
- 测试要主动制造竞态:用 deferred promise 卡住 provider 或 listener,在精确时刻切配置、追加写入和 abort。只测 happy path 看不到 harness 的价值。
练习
把前面章节的 Agent Core、Session、context builder、工具与 faux provider 装配进 Harness。
验收标准:
- 两个并发
prompt()中第二个稳定返回busy,不会向 session 写入半条 user message。 - turn 运行中切换 model、tools、resources 与 system prompt 后,当前 provider request 保持旧 snapshot,下一安全点后的请求使用新 snapshot。
message_endlistener 追加的 entry 始终位于 Agent 消息之后;一次中途写入失败不会丢掉 pending 队列的其余 entry。- compaction 与 tree navigation 在非
idlephase 被拒绝,steering、follow-up 与 next-turn 按各自契约排队。 waitForIdle()等到最后一个异步 listener 和 pending write 完成才返回;测试不使用sleep猜时机。- abort 后 phase 最终回到
idle,需要保留的 next-turn 输入仍可在下一次 prompt 前注入。 - faux provider 测试记录 request 1 与 request 2 的 model、tools、system prompt 和 session entry 顺序,直接断言所有 invariant。