从零搭建 Agent Harness 系列(十二)用 Context 和 Ctrl-C 中断当前任务
连续对话只有在“当前任务可以被停止”时才真正适合 Terminal。Coding Agent 可能正在等待模型响应,也可能正在执行 go test、搜索大量文件或等待用户审批。如果 Ctrl-C 只能等任务自然结束,Terminal 的交互体验仍然是不完整的。
本文源码版本对应提交:4a9be525a8e0f5ef24014c7c178f7ac8a8704d2b(add interrupt)。
本篇只讨论中断,不讨论 Unix raw mode 或复杂 TUI。我们先理解当前实现,再指出它距离生产级取消还差哪些边界。
一、Ctrl-C 到底应该取消什么
用户在 Terminal 中按下 Ctrl-C 时,期望的是:
1 | 取消当前 Agent Run |
因此中断的作用域应该是一次 runTurn,而不是整个 REPL。当前 REPL.Run 创建了一个信号 Channel:
1 | signals := make(chan os.Signal, 1) |
os.Interrupt 通常对应用户按下 Ctrl-C。signal.Notify 把信号交给 Go Channel,而不是让进程立即采用默认动作退出。
二、runTurn 是中断边界
当前 internal/cli/repl.go 中的 runTurn 是关键代码:
1 | func (r *REPL) runTurn( |
这段代码可以拆成三个角色:
1 | runCtx:当前任务的取消信号 |
三、为什么要启动 Goroutine
如果直接在 REPL 中调用:
1 | err := r.engine.Run(runCtx, r.session, r.reporter) |
当前 Goroutine 会一直阻塞在 Run 中,无法同时读取 signals。把 Run 放入 Goroutine 后,当前 Goroutine 才能使用 select 等待两个事件:
1 | go func() { |
done 使用容量为 1 的 Channel:
1 | done := make(chan error, 1) |
这样即使主 Goroutine已经先收到 Ctrl-C 并开始取消,Run Goroutine 也能把最终错误写入 done,不会因为消费者暂时没有接收而卡住。
cancel() 不会把 context.Canceled 直接写入 done。done 中的值是 r.engine.Run 的返回值;只有当 Run 内部的下游操作响应取消并逐层返回后,Run 才可能返回 context.Canceled 或包含它的包装错误。
四、Engine 如何感知取消
流式 Engine 在 internal/engine/stream.go 中同时监听 Context 和事件 Channel:
1 | for { |
取消后,Engine 不应该继续等待 StreamCompleted。否则用户虽然已经按了 Ctrl-C,UI 却还要等模型流结束。
五、Provider 发送事件时也必须响应 Context
internal/provider/openai.go 没有直接把事件写入 Channel,而是使用:
1 | func sendStreamEvent( |
这解决的是“消费者已经退出,但生产者还在发送”的阻塞问题。流式 Goroutine 本身也使用了同一个 Context:
1 | stream := p.client.Chat.Completions.NewStreaming(ctx, params) |
因此取消链路是:
1 | Ctrl-C |
六、工具进程如何被取消
模型请求结束后,Agent 可能进入工具执行。以 internal/tools/bash.go 为例:
1 | func (t *BashTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { |
exec.CommandContext 会把 Context 的取消传给子进程。这里还额外叠加了 30 秒超时:
1 | 父 Context 取消:用户 Ctrl-C 触发 |
二者任何一个发生,命令都不应该继续无限运行。
七、为什么 errors.Is 能识别 context.Canceled
Engine 的错误可能被多层包装,例如:
1 | return fmt.Errorf("Action 阶段失败: %w", err) |
所以不应该使用字符串比较:
1 | err.Error() == "context canceled" |
当前 REPL 使用:
1 | if errors.Is(err, context.Canceled) { |
errors.Is 会沿着 %w 包装链检查原始错误,因此即使 Run 返回的是包装后的错误,也能判断它是否由取消导致。
八、当前 Terminal Approval 的取消边界
审批 Handler 当前通过 Goroutine 等待输入:
1 | inputCh := make(chan string, 1) |
主流程可以及时返回取消,但 ReadString 本身未必会因为 Context 取消而停止。这个输入 Goroutine 可能继续占用共享 Reader,后续用户输入还可能被它消费掉。
这说明“函数返回了 context.Canceled”不等于“所有 Goroutine 都已经退出”。生产级实现需要让输入层本身可取消,或者由专门的 Terminal 输入循环统一读取,再通过内部请求 Channel 分发审批答案。
九、第一版实现的边界和改进方向
当前实现适合学习 Context 传播,但还应该继续补齐:
1 | 记录当前任务状态,避免取消后误显示成功 |
还要注意一个事实:普通 bufio.Reader.ReadString 并不是可取消的。若要像成熟 CLI 一样处理输入,需要把 stdin 读取和 Agent 执行解耦,或者使用支持终端事件的输入层。
十、验证中断功能
至少做四个实验:
- 在模型流式输出过程中按 Ctrl-C,确认 Run 返回且能再次看到
claw>。 - 执行一个超过 30 秒的 bash 命令,确认 Ctrl-C 能停止命令。
- 在审批提示处按 Ctrl-C,确认当前任务退出等待。
- 取消后输入下一条任务,确认 Session 没有被锁死、REPL 还能继续工作。
中断的本质不是监听一个信号,而是让每个可阻塞组件都遵守同一个取消协议。下一篇将把工具执行前的人类确认设计成可复用的 Approval Gate。