跳到正文

Coding Agent 原理与实践

06:交互与运行控制

更新于:

作者:折腾客

约 6,164 字164 行代码

文章目录

1. Agent 正在工作时,怎样与它交互?

使用 Claude Code、Codex 这类 Coding Agent 时,一个复杂任务往往需要多次调用模型、执行工具,持续几分钟,甚至更久。在这段时间里,我们并不是只能等待最终回答,还会用到几种交互:

  • 查看进展:看到 Agent 已经做了什么,以及当前正在处理什么。
  • 提前安排后续任务:当前工作还没结束,就提交下一项要求,等 Agent 给出当前工作的回答后继续处理。
  • 追加要求、纠正方向:发现 Agent 的理解有偏差,及时补充信息,让它在后续执行中调整做法。
  • 中断执行:发现 Agent 正在进行预期之外的操作,主动要求它停止。

Ink 也支持这些交互。这些交互是怎样实现的?运行进展怎样传回界面?提前提交的下一项任务怎样排队,并在模型准备结束当前工作时接着处理?追加的信息怎样交给正在工作的 Agent,让它调整后续行动?用户要求停止时,又怎样通知正在调用的模型和工具?

本篇就沿着这些问题,解释 Agent 怎样在持续执行任务的同时,接收并响应用户的操作。 具体的界面布局与渲染,留到第 07 篇展开。

2. 这四种交互是怎样实现的?

下面用“修复求和函数,并运行相关测试”这个任务串起执行过程。先看交互能够持续进行的前提:启动 Agent 后,接收输入的逻辑仍然在工作。

异步调用让调用方在等待结果时,仍能处理其他已就绪的事件。对 Agent 来说,等待模型服务返回内容期间可以处理用户输入;内容到达后,再继续处理模型回复。

等待模型回复时,Agent 仍能处理输入

按 T1 → T2 → T3 阅读 · 每个时间点对照两列状态

本地 Agent
模型服务
T1发出模型请求
发起模型调用等待回复的函数暂停
→
收到请求开始生成回复
T2等待期间,用户输入到达
用户输入到达执行输入处理函数处理已收到的用户操作
仍在生成本次等待尚未结束
T3模型返回内容
模型内容到达继续处理模型回复等待结果的代码恢复执行
←
返回内容本次等待有了结果
T1–T3 表示先后顺序,不代表固定耗时。本地输入处理与回复处理交替执行,模型服务在远端生成内容。

图中展示了异步处理的效果:等待模型回复期间,Agent 仍然可以响应其他已就绪的事件。用户输入只是其中一种;模型返回新的文字片段、工具开始执行或返回结果时,也可以通知界面更新显示。因此,用户能够在任务尚未结束时,持续看到回答和运行进展。异步调用让等待期间可以处理这些通知,运行事件则告诉界面发生了什么变化。

异步调用让这些交互有机会在任务执行期间被处理,但具体怎样处理,还需要 Agent 作出安排。例如,同样是在执行中收到一句话,“做完后写一份修改说明”可以等模型给出当前工作的回答后再处理,“不要修改函数接口”则需要尽早交给当前运行中的模型。收到中断操作后,还要通知正在执行的模型调用或工具停止。

为了实现这些安排,需要在 Agent 循环之外增加一部分协调逻辑:根据当前运行情况处理用户输入和中断,同时接收运行进展,交给界面显示。本篇把这部分称为交互与运行控制层。下面这张图展示它怎样连接用户界面与 Agent 的执行过程。

用户操作怎样进入运行,运行进展怎样返回?

用户界面接收输入与中断操作,显示回答和工具状态
↓ 提交要求、请求中断↑ 回答、活动状态、排队提示
交互与运行控制 · 本篇重点
运行状态
现在能否开始新运行
输入队列
追加要求何时处理
取消控制
通知停止并等待收尾
↓ 启动运行、提供追加输入、传递取消信号↑ 运行事件、停止原因或异常
Agent 执行runner 准备运行 → Agent 循环调用模型与工具
职责示意。交互层可以在等待执行完成期间继续接收操作;具体界面布局与渲染见第 07 篇。

在 Ink 中,这部分协调逻辑主要位于 repl/repl.ts 的 ReplMachine 中。先看它怎样启动一次运行,并接入进展通知、追加输入和取消信号;后面的各小节再分别展开。

其中,runner.runTurn 是启动一次 Agent 运行的入口,内部会准备上下文并调用 Agent 循环。下面保留源码的主要变量和调用,省略界面装饰、统计与其他分支,不能直接复制运行。

// 文件:src/repl/repl.ts
// 所在函数:ReplMachine.submitTurn
// 节选单个 Agent 的运行主线;省略初始化、子代理事件和统计。
this.state = "running"; // 先记下:已经有一次运行在进行
this.input.setActive(true); // 更新输入区提示,仍然允许输入

const controller = new AbortController(); // 为这次运行准备取消通知
this.controller = controller;
try {
  const result = await this.runner.runTurn({
    userMessage: line,
    signal: controller.signal, // 2.4:向模型接入层和工具传递取消通知
    onEvent: event => {
      // 省略并发工具显示事件的提前返回分支,2.1 节展开。
      this.trackActivity(event);
      this.renderer.event(event);
      this.showResultFold(event);
    },
    getSteeringMessages: () => this.steeringMessages(), // 2.3:让循环取走追加要求
    getFollowUpMessages: () => this.followUpMessages(), // 2.2:准备结束时取走后续要求
  });
  await this.settleSuccess(result); // 运行返回后收尾;中断时交还输入(第 3 节)
} catch (err) {
  this.settleFailure(err); // 出错后收尾,让用户能够继续操作
}

这里的 await 等待的是一次 Agent 运行,其中可能包含多次模型调用。等待期间,新输入由另一个输入处理函数接收;它通过队列和取消控制器与这次运行配合。接下来就看这些连接为什么需要、在哪里起作用。

2.1 模型回复和工具状态怎样传回界面?

Agent 开始修复求和函数时,模型可能先回答“我先查看函数和相关测试”,随后要求读取文件。界面既要逐段显示这句话,也要在处理读取调用时显示工具状态。它们都发生在整次运行结束之前,不能只靠 runTurn 最后返回的结果来更新。

先看模型文字怎样逐段显示。

第 02 下篇已经介绍过,模型服务会陆续返回内容,接入层负责解析这些片段。以 openai-completions.ts 为例,拿到一段文字后,接入层会向外产出一个对象:

// 文件:src/provider/openai-completions.ts
// 所在位置:createOpenAICompletionsProvider 返回对象的 stream 方法。
// 节选响应片段中的文字处理,省略文字累积和其他内容类型。
const content = choice?.delta?.content;
if (typeof content === "string" && content !== "") {
  // 此处省略将文字累积到完整回复的代码。
  yield { type: "text_delta", text: content };
}

假设这次收到的是“我先查看函数”,对象中的 text 就是这段文字;type: "text_delta" 表示新收到了一段文字片段。yield 将这个对象交给读取接入层输出的代码,不必等整条回复生成完。

Agent 循环中的 streamAssistant 逐个接收这些对象,再通过 onEvent 回调向外传递:

// 文件:src/core/loop.ts
// 所在函数:streamAssistant
// 节选接收与转发;省略 message_end 的完整消息返回及用量统计。
for await (const event of provider.stream(request)) {
  if (request.signal?.aborted) return null;
  onEvent?.(event);
  // 此处省略 message_end 的处理。
}

onEvent 是调用方传入的函数。调用它并传入这个对象,就把收到文字的消息通知给了上层。对于 text_delta,经过 runner 转发后,第二节总览中的回调调用 renderer.event(event),把文字交给渲染器。渲染器负责把文字展示到界面上,具体逻辑见第 07 篇。下一段文字到达时,再走一遍这个过程,回答就能逐步显示。

再看工具状态怎样更新。

模型回复中包含工具调用后,Agent 循环开始安排执行。先看 bash 运行测试这样的串行调用:executeToolBatchInner 在处理前和取得结果后,分别发出通知:

// 文件:src/core/loop.ts
// 所在函数:executeToolBatchInner,串行执行分支。
// 节选一次工具调用前后的通知,省略遍历和取消检查等代码。
onEvent?.({
  type: "tool_start",
  toolCallId: call.id,
  name: call.name,
  args: call.arguments,
});

const result = await executeToolCall(
  call.id,
  call.name,
  call.arguments,
  toolMap,
  signal,
  onToolCall,
  grace
);
results.push(persistableResult(result));
onEvent?.({ type: "tool_end", result });

例如,name 是 bash,args 包含测试命令,toolCallId 标识这次调用。结束通知中的 result 保存输出或错误,也带着对应的调用标识,交互层据此更新同一次调用的状态。tool_start 出现在参数检查与执行前回调之前,因此它表示开始处理调用,不能单凭这个事件断定命令已经运行。

对于连续允许并发的调用,还要区分“已经登记,但正在等待”和“取得执行位置,真正开始工作”。当前 Ink 的工具事件有以下分工:

事件表示什么用途
tool_start开始处理一次调用,尚未保证通过检查或开始执行登记名称、参数和调用 ID;并发调用先显示排队状态
tool_running并发调用通过检查并取得执行位置,即将执行将排队状态改为运行状态,从此时开始计算执行耗时
tool_settled并发调用已经取得用于收尾的结果立即结束该调用的运行显示,并显示结果,不必等待前面的慢调用
tool_end按调用顺序交付结果与有序结果收集对应,也供扩展监听调用结果

tool_running 和 tool_settled 来自并发执行路径。串行调用仍使用 tool_start、tool_end;参数检查失败或被执行前回调拒绝的并发调用,也不会发出 tool_running、tool_settled,而是按顺序交付错误结果。调用怎样分组、何时取得执行位置,见第 03 篇的工具调度。

并发执行时,为什么完成显示与有序交付要分开? 假设模型按 A、B 的顺序提出两次文件读取调用。这两次 read 调用都允许并发,通过检查后同时开始执行。A 需要 3 秒,B 只需要 1 秒:

时点事件与结果收集用户看到什么
1 秒时,B 先完成发出 B 的 tool_settled;A 尚未完成,B 的 tool_end 暂不发出B 的运行提示结束,结果可以查看;A 仍在运行
3 秒时,A 完成发出 A 的 tool_settled,随后按 A、B 顺序收集结果并发出 tool_endA 也显示完成;B 不会因此重复显示一份结果

这里提前更新的是界面。Agent 循环仍会等这一条模型回复中的工具调用全部处理完,再把收集的结果写入 history,供下一次模型请求使用。界面已经显示 B 的结果,不代表模型已经收到它。 并发结果中的 durationMs 则按各自实际开始到取得结果的时间计算,不把排队或等待有序交付的时间算进去。

“取得结果”也可能是取得错误结果,包括第 2.4 节所述的取消后放弃等待;它不能证明操作成功,或底层进程已经停止。这些事件也没有传递执行期间的输出片段,不能据此认为测试日志会实时逐行显示。

两类信息经过同一事件回调,再按用途分流。

前面这些对象都在描述 Agent 运行期间发生的变化,本篇将它们称为运行事件。type 区分变化类型,其余字段携带对应信息:文字片段放在 text,调用状态带上调用 ID,结果事件包含输出或错误。它们是 Ink 的内部事件,并非模型服务统一规定的格式。

第二节总览省略了事件回调中的分流。当前 ReplMachine.submitTurn 会先处理仅用于并发工具显示的两个事件,再把普通文字和工具事件交给 Renderer:

// 文件:src/repl/repl.ts
// 所在函数:ReplMachine.submitTurn,传给 runner.runTurn 的 onEvent 回调。
// 节选:省略当前运行检查、健康提示和状态栏刷新。
onEvent: (event, info) => {
  if (event.type === "tool_running") {
    if (info === undefined) this.trackActivity(event, info);
    return;
  }
  if (event.type === "tool_settled") {
    if (info === undefined) {
      this.trackActivity(event, info);
      this.toolSink?.end(event.result);
    }
    return;
  }
  this.trackActivity(event, info);
  if (info === undefined) {
    this.renderer.event(event);
    this.showResultFold(event);
  }
},

info 用来区分事件来源;这里两个提前返回的分支只处理顶层 Agent 的显示事件。trackActivity 根据 tool_running 更新排队状态和开始时间,收到 tool_settled 时移除相应的活动记录;toolSink.end 则更新工具完成状态与结果展示。稍后 tool_end 再到达时,工具展示层会按调用 ID 识别已经结束的记录,避免重复添加结果。

普通 text_delta 仍由 Renderer.event 追加文字,tool_start、tool_end 也继续走渲染器的工具分支。打印模式和旧式输出依赖这条有序的 tool_end 路径,不会因为新增显示事件就改成按完成顺序输出。具体怎样布局、刷新和折叠,留到第 07 篇。

2.2 怎样提前提交下一项任务?

假设 Agent 正在修复求和函数,你已经想好下一步:“修复后,再写一份修改说明。” 修改尚未结束,现在就开始写说明还太早;但也不必让用户一直等着。Agent 可以先保存这条输入,等模型给出当前工作的回答、不再请求工具时,再带上这条要求继续调用模型。

在 Ink 中,这种输入称为 followUp 模式,用户通过 Alt+Enter 提交。它等待的是模型准备结束当前工作的时点;后续要求仍由同一次 Agent 运行处理,此时 runTurn 还没有返回。

收到输入时,交互层先检查当前是否空闲:空闲就立即启动运行;否则先把输入加入队列。

// 文件:src/repl/repl.ts
// 所在函数:ReplMachine.handleLine
// 节选普通对话输入;省略空输入、退出检查和命令分支。
if (this.state === "idle") {
  void this.submitTurn(line); // 空闲时直接开始
  return;
}
this.queue.push({ text: line, mode }); // 忙碌时先保存输入
this.syncQueue();
this.input.refresh();

queue 保存尚未处理的输入,每项记录输入文字 text 和提交模式 mode。本例的 mode 就是 followUp。syncQueue() 更新界面上的排队列表,让用户知道任务已经接收。

一次模型回复结束,并不一定会取出 follow-up:回复中如果还有工具调用,循环会先执行工具,再继续请求模型。只有回复不含工具调用、循环原本准备结束时,才检查是否接收 follow-up。这里的顺序是:先取 steer 补充要求;没有待处理的 steer,再取 followUp。 两边都没有消息,才返回 completed。

前面的 getFollowUpMessages 回调连接了 Agent 循环和输入队列。它调用 ReplMachine.followUpMessages,从队列中取出符合条件的普通对话输入,转换为用户消息。默认每次只取一条;以 ! 开头的直接执行命令会留在队列中,不作为模型的 follow-up 输入。

// 文件:src/core/loop.ts
// 所在函数:runAgentLoop,toolCalls.length === 0 分支。
// 前面已检查 steer;若取到了补充要求,就已加入历史并继续循环。
// 此处节选没有待处理 steer 时的 follow-up 路径,省略返回值中的截断标记。
const followUps = (await getFollowUpMessages?.()) ?? [];
if (followUps.length === 0) {
  return { stopReason: "completed", turns, usage };
}
for (const message of followUps) {
  history.push(message);
  onMessage?.(message);
}
skipSteeringPoll = true;
continue; // 留在同一次 runAgentLoop 中,准备下一次模型调用。

在本例中,“修复后,再写一份修改说明”被加入当前历史,模型可以利用此前的修改与测试结果继续工作。这里通过 continue 回到循环开头,沿用同一份历史、取消信号和累计用量;不会重新调用 submitTurn。循环开头仍会检查中断并处理上下文长度。

skipSteeringPoll 只跳过紧接着的那一次循环开头的 steer 检查,避免刚在结束前取过输入,又立即取第二批。特别是在一次只取一条的配置下,这能避免连续两次检查把两条消息一起送入下一次请求。它不会跳过中断检查或上下文整理。

整个过程是:提交后续要求 → 加入队列 → 模型不再请求工具 → 先检查 steer,再取 follow-up → 在同一次运行中继续调用模型。

runTurn 真正返回后,交互层仍会通过 settleSuccess 收尾,并由 flushQueue 处理仍留在队列中的输入,例如直接执行命令或单独排队的提示词。这是运行结束后的处理路径;上面的普通 follow-up 已经在循环内接续处理。

注意,模型不再请求工具,只说明它准备停下来,不能据此确认修复已经成功。如果后续要求依赖测试通过,仍要依据测试结果判断。中断或失败时不会自动接续未处理输入,具体处理见第 3 节。达到轮次上限时的处理与中断不同,下一节会说明这一边界。

2.3 怎样在执行中补充要求、纠正方向?

上一节的修改说明可以等模型准备结束当前工作时再处理。但如果你看到 Agent 正准备改动函数接口,补充一句“不要修改函数接口”,这条限制等修复结束后才送进去,就可能太晚了。

这句话应该影响当前修复过程。但当前模型调用使用的是请求发出时的上下文,无法看到后来提交的补充。要让模型知道新要求,需要在下一次模型调用之前,把这句话加入历史。

因此,这条输入也先进入上一节的 queue,但标记为 steer,供当前 Agent 循环取走。两种模式共用一个队列,通过 mode 区分处理时机。steer 可以在后续模型调用前进入历史,不必等到模型不再请求工具。在当前终端界面中,Enter 提交的普通输入使用这个模式;Agent 不会仅凭“不要修改”这几个字自动中断操作。

假设补充要求在读取代码期间到达,过程如下:

时刻Agent 正在做什么补充要求在哪里
用户提交“不要修改函数接口”当前读取操作继续执行暂存在输入队列
当前批次的工具调用结束工具结果写入历史,循环准备继续从队列取出这句话,加入历史
再次调用模型发送更新后的上下文模型同时看到读取结果与新要求

代码中的 getSteeringMessages 就是这两个位置之间的连接。默认配置下,每次调用它时,交互层会按提交顺序取出当前所有标记为 steer 的普通对话输入,将它们分别转换为用户消息;中间的 followUp 会被跳过,继续留在队列里。

// 文件:src/repl/repl.ts
// 所在函数:ReplMachine.steeringMessages
// 节选查找与取出逻辑;省略空结果提前返回、界面回显和类型标注。
const picked = [];
while (true) {
  const index = this.queue.findIndex(
    entry =>
      "text" in entry && entry.mode === "steer" && !isBangLine(entry.text)
  );
  if (index === -1) break;
  const entry = this.queue.splice(index, 1)[0];
  if (entry === undefined || !("text" in entry)) break;
  picked.push(entry.text);
  if (this.modes.steering === "one-at-a-time") break;
}
this.syncQueue();
return picked.map(text => ({ role: "user", content: text }));

每次 findIndex 找到一条,splice(index, 1) 将它移出队列;外层循环继续查找,因此可以一次取出多条。isBangLine 排除以 ! 开头、需要直接执行的命令,避免将它当作给模型的补充要求。

Agent 循环通常在每次调用模型前调用这个回调,将返回的消息写入 history。如果刚刚已经在准备结束的位置取过输入,就通过上一节的 skipSteeringPoll 跳过一次重复检查:

// 文件:src/core/loop.ts
// 所在函数:runAgentLoop,while 循环开头的取消检查之后。
if (skipSteeringPoll) {
  skipSteeringPoll = false;
} else {
  const steering = (await getSteeringMessages?.()) ?? [];
  for (const message of steering) {
    history.push(message);
    onMessage?.(message);
  }
}
// 随后处理上下文,再通过 streamAssistant 调用模型。

所以,界面显示“已排队”和模型真正收到要求之间,可能还有一段时间。已经交给工具执行的操作,也不会因为队列里多了一句话而撤回。如果必须阻止当前操作,应使用中断,而不能只追加“先别改了”。

模型回复不含工具调用时,还会再检查一次 steer。如果等待这次回复期间收到了补充,就先把补充加入历史,继续请求模型;不会直接转去处理 follow-up。因此,steer 的接收位置有两个:通常的模型调用前,以及模型原本准备结束时。

混合排队时,先处理哪些输入?

假设修复任务正在等待模型回复,用户依次提交下面四条消息;这些消息全部入队后,没有新的输入:

顺序输入模式
1修复后写一份修改说明followUp
2不要修改函数接口steer
3再补一份使用文档followUp
4保持原来的返回类型steer

按当前默认配置,处理顺序如下:

  1. 先取第 2、4 条 steer。 两条约束按提交顺序一起加入历史,让模型在下一次请求中同时看到。若当前回复还要求执行工具,就等这一批工具处理完,再在下次请求前取出;若回复没有工具调用,就在准备结束的位置取出。
  2. 带着约束继续当前工作。 模型仍可能请求工具,循环照常执行。到下一次不再请求工具、且没有新 steer 的时点,才取第 1 条 follow-up,接着写修改说明。
  3. 再取第 3 条 follow-up。 修改说明的回复结束、不再请求工具且没有新 steer 时,再加入“补使用文档”。这些模型调用仍处于同一次 runTurn 中。
  4. 没有待处理输入时结束。 回复不含工具调用,两种输入检查也都返回空,循环才以 completed 返回。

两种输入每次取出的数量都可以配置:steeringMode 默认为 all,一次取出所有 steer;followUpMode 默认为 one-at-a-time,一次取出一条 follow-up。两者都支持 all 和 one-at-a-time,配置在每次运行开始时读取。若将 follow-up 改成 all,多个后续要求会一起进入下一次模型请求,不再逐条等待回答。

这些顺序保证了什么,又没有保证什么?

这套机制决定的是输入何时交给模型,不会撤回已发出的模型请求或已执行的工具操作,也不会把某条 steer 固定绑定到一个任务。若限制在处理 follow-up 时才到达,它就只能影响之后的模型请求。持续到来的 steer 还可能推迟 follow-up,因为每次准备结束时都会先检查 steer。

运行上限也需要单独看:当前 maxIterations 在模型返回工具调用后检查,不会限制纯文字回复之间的 follow-up 接续。即使因达到该上限返回,settleSuccess 仍会交给 flushQueue 处理剩余输入;不能把它当作与中断等价的“停止所有排队任务”。中断或异常才会交还未处理输入,具体见第 3 节。

2.4 怎样中断正在进行的工作?

如果你发现 Agent 正在改动不该修改的文件,单纯追加一句限制可能来不及。此时可以要求中断当前工作。

只把界面上的进行中提示去掉,后台操作仍可能继续;只让循环不再调用下一次模型,也不能立即结束正在等待的操作。取消通知必须传到实际工作的那一层。

因此,启动运行时创建一个 AbortController。交互层持有它,用来发出取消通知;它的 signal 传给 runner,再传入 Agent 循环、模型接入层和工具。收到中断操作后,交互层调用 abort()。

// 文件:src/repl/repl.ts
// 所在函数:ReplMachine.handleInterrupt,running 分支。
// 省略界面提示;第一次中断通知取消,再次中断强制退出。
this.interruptCount++;
if (this.interruptCount === 1) {
  this.controller?.abort();
} else {
  this.forceExit(130);
}

各层接到通知后,负责停止自己的工作:

  1. 模型接入层取消请求或停止读取响应,让等待回复的调用结束。
  2. 正在执行的工具尝试停止操作。例如,bash 会尝试终止命令进程组,并直接结束进程输出的等待,整理中断原因和已有输出。
  3. Agent 循环检查取消状态,停止推进后续调用,并返回中断原因。

循环开头就有这样一个检查:

// 文件:src/core/loop.ts
// 所在函数:runAgentLoop,while 循环开头。
if (signal?.aborted) return { stopReason: "aborted", turns, usage };

这个检查防止循环继续推进,但已经开始的模型调用和工具操作,仍然需要各自响应信号。对于忽略取消的工具执行,Ink 还提供了 10 秒的等待宽限:取消后到期仍未返回,就生成错误结果并放弃等待,迟到的结果不再写入历史。这个机制不会终止底层操作,也不覆盖执行前回调和模型请求;详细流程见第 03 篇的工具取消说明。

所以,“已请求停止”“Agent 已完成收尾”和“底层操作确实停止”需要分开判断。 Ink 发出取消通知后仍保持 running,直到取得正常返回或用于收尾的错误结果、完成收尾,才回到空闲状态;若工具结果被放弃,后台操作仍可能存在。如果尚未结束时再次中断,当前实现会强制退出进程;这不等同于正常收尾。

中断也不会撤销已经完成的修改。停止时,项目文件可能已经改过;此时结束的是这次 Agent 运行,修复任务是否完成,还要依据代码和测试结果判断。

3. 中断或失败后,怎样处理尚未执行的输入?

前面已经解释过,普通 follow-up 可以在同一次运行中接续处理,运行返回后也可能还有剩余队列需要处理。但如果用户刚刚要求停止,队列里还留着“写一份修改说明”,此时马上继续执行,就没有尊重这次中断的意思。

因此,中断后需要交还尚未处理的输入,让用户重新决定下一步;如果是运行失败,还需要说明出了什么问题。

3.1 用户中断后,交还剩余输入

runTurn 被中断时,可以正常返回 stopReason: "aborted"。因此,settleSuccess 的名字只表示调用没有抛出异常,内部仍然需要判断是否中断:

// 文件:src/repl/repl.ts
// 所在函数:ReplMachine.settleSuccess
// 省略退出检查、统计与状态栏更新。
this.controller = null;
this.interruptCount = 0;
this.renderer.endRun();

if (result.stopReason === "aborted") {
  this.restoreQueueToEditor(); // 将未处理的输入交还用户
  this.returnToIdle();
  return; // 不进入下面的队列续跑
}
await this.flushQueue();

在当前终端界面中,restoreQueueToEditor 把未处理的输入合并为可编辑文本,等待用户修改或重新提交;已经加入历史的要求不会退回。returnToIdle 清理活动显示,并将运行状态恢复为空闲,此后新的输入才会启动下一次运行。

3.2 运行失败后,报告错误并交还剩余输入

如果模型请求等环节抛出异常,第二节开头的 catch 会进入 settleFailure。它结束运行提示、报告错误,然后采用同样的收尾方式:

// 文件:src/repl/repl.ts
// 所在函数:ReplMachine.settleFailure,普通错误路径。
// 前面已清理运行显示,并单独处理 AbortError;省略恢复提示。
this.reportError(err);
this.restoreQueueToEditor();
this.returnToIdle();

AbortError 表示调用因取消而结束,源码会单独识别,避免将它显示成模型服务故障。普通错误则说明执行没有按预期完成,先交还剩余输入,用户才能根据错误决定重试、调整要求或停止。

中断和失败都可能发生在部分操作已经完成之后。已经产生的工具结果和会话记录,由第 01、05 篇介绍的机制保留;恢复到可以输入的状态,不表示这次执行过程已被清空或撤销。

4. 从交互行为,到界面呈现

沿着这次修复任务,运行事件让用户看见进展;follow-up 在模型准备结束当前工作时接入同一次运行;steer 则尽早进入后续模型请求,并在准备结束时优先处理;取消通知让各层停止工作,并在收尾后把控制交还给用户。

接下来再看怎样把这些行为做成好用的界面:回答不断增长时,输入区怎样保持稳定?工具输出很长时,怎样保留重点又能展开查看?第 07 篇继续讨论界面设计与实现。