从零构建 Coding Agent
English
本章目录

15. Harness Engineering:把能力装配成可靠运行时

前面的章节已经分别实现了 Agent loop、工具、会话日志、Context Engineering、安全边界和扩展钩子。把这些对象放进同一个进程,还不等于得到可靠的 Coding Agent。系统仍要回答:运行中切换模型会影响哪一次请求?扩展在事件回调里写日志时排在什么位置?第二个 prompt 应该拒绝还是排队?什么时候才可以对外宣布 Agent 真正空闲?

Harness Engineering 处理的正是这层编排问题。Agent loop 负责推进一次“模型—工具—模型”的循环,Context Engineering 负责决定下一次请求看到什么,harness 则负责把环境、会话、模型、工具、资源、策略和事件装配成具有一致性保证的运行时。Pi Agent 也是这种分层思想的一个例子:低层 loop 与上层 harness 分开,前者执行,后者决定状态所有权、变更时机和完成语义。

失败模式:共享一组随时可变的对象

最朴素的装配方式,是让 UI、扩展和 loop 共同持有 configmessagestoolssession,谁需要就直接修改。它在单次演示中通常能跑,却会在时间边界上失控:

  • 模型请求已经发出,另一个回调切换了模型或工具;同一个 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 已经完成。一个稳定的顺序是:

  1. message_end 先持久化该条 Agent 消息,再通知观察者;观察者看到的是 committed state。
  2. turn_end 后按队列顺序 flush pending writes,只有成功写入后才从队首删除。
  3. 发出 save_point,说明这一轮的事实与附加写入已经稳定。
  4. 如果 loop 还要继续,重新读取 session 与 live config,建立 fresh snapshot,再开始下一次 provider 请求。
  5. agent_end 后处理剩余写入与异步 listener,最后发出 settled,完成屏障才解除。

这条顺序让一个 listener 在 assistant message_end 中追加的自定义 entry 必然排在 assistant 后面,也让运行中切换的模型只影响下一次请求。写入失败时不要一次性清空 pending 数组;逐条提交、成功后再 shift(),才能在中途失败时保留未完成的尾部。

最小实现:给现有 loop 加一层 harness

下面的代码把本章新增的契约压到一个可运行的最小核心。RunLoopSession 是前面章节已经实现的依赖;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 才失败,不要假装回滚内存而留下已提交日志。公共方法应返回 busyinvalid_argumentsessionhookauth 等稳定分类,调用方不必解析错误字符串。

生产化取舍

  • 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_end listener 追加的 entry 始终位于 Agent 消息之后;一次中途写入失败不会丢掉 pending 队列的其余 entry。
  • compaction 与 tree navigation 在非 idle phase 被拒绝,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。