从零搭建 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
2
3
4
取消当前 Agent Run
保留已经写入的 Session 历史
回到 claw> 提示符
不退出整个程序

因此中断的作用域应该是一次 runTurn,而不是整个 REPL。当前 REPL.Run 创建了一个信号 Channel:

1
2
3
signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt)
defer signal.Stop(signals)

os.Interrupt 通常对应用户按下 Ctrl-C。signal.Notify 把信号交给 Go Channel,而不是让进程立即采用默认动作退出。

二、runTurn 是中断边界

当前 internal/cli/repl.go 中的 runTurn 是关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func (r *REPL) runTurn(
parent context.Context,
signals <-chan os.Signal,
) error {
runCtx, cancel := context.WithCancel(parent)
defer cancel()

done := make(chan error, 1)

go func() {
done <- r.engine.Run(
runCtx,
r.session,
r.reporter,
)
}()

select {
case err := <-done:
return err

case <-signals:
fmt.Fprintln(r.out, "\n正在取消当前任务...")
cancel()

err := <-done
if errors.Is(err, context.Canceled) {
fmt.Fprintln(r.out, "当前任务已取消。")
return nil
}

return err
}
}

这段代码可以拆成三个角色:

1
2
3
runCtx:当前任务的取消信号
done:Run 完成后返回 error 的结果通道
select:同时等待任务结束和 Ctrl-C

三、为什么要启动 Goroutine

如果直接在 REPL 中调用:

1
err := r.engine.Run(runCtx, r.session, r.reporter)

当前 Goroutine 会一直阻塞在 Run 中,无法同时读取 signals。把 Run 放入 Goroutine 后,当前 Goroutine 才能使用 select 等待两个事件:

1
2
3
4
5
6
7
8
9
10
11
go func() {
done <- r.engine.Run(runCtx, r.session, r.reporter)
}()

select {
case err := <-done:
return err
case <-signals:
cancel()
return <-done
}

done 使用容量为 1 的 Channel:

1
done := make(chan error, 1)

这样即使主 Goroutine已经先收到 Ctrl-C 并开始取消,Run Goroutine 也能把最终错误写入 done,不会因为消费者暂时没有接收而卡住。

cancel() 不会把 context.Canceled 直接写入 donedone 中的值是 r.engine.Run 的返回值;只有当 Run 内部的下游操作响应取消并逐层返回后,Run 才可能返回 context.Canceled 或包含它的包装错误。

四、Engine 如何感知取消

流式 Engine 在 internal/engine/stream.go 中同时监听 Context 和事件 Channel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
for {
select {
case <-ctx.Done():
return nil, false, ctx.Err()

case event, ok := <-events:
if !ok {
if finalMessage == nil {
return nil, false, fmt.Errorf("流式响应未返回最终消息")
}
return finalMessage, emittedText, nil
}

switch event.Type {
case provider.StreamTextDelta:
if emitText && canStream {
streamReporter.OnTextDelta(ctx, event.Text)
emittedText = true
}
case provider.StreamCompleted:
finalMessage = event.Message
case provider.StreamError:
if event.Err == nil {
return nil, false, fmt.Errorf("流式响应失败")
}
return nil, false, event.Err
}
}
}

取消后,Engine 不应该继续等待 StreamCompleted。否则用户虽然已经按了 Ctrl-C,UI 却还要等模型流结束。

五、Provider 发送事件时也必须响应 Context

internal/provider/openai.go 没有直接把事件写入 Channel,而是使用:

1
2
3
4
5
6
7
8
9
10
11
12
func sendStreamEvent(
ctx context.Context,
events chan<- StreamEvent,
event StreamEvent,
) bool {
select {
case events <- event:
return true
case <-ctx.Done():
return false
}
}

这解决的是“消费者已经退出,但生产者还在发送”的阻塞问题。流式 Goroutine 本身也使用了同一个 Context:

1
2
stream := p.client.Chat.Completions.NewStreaming(ctx, params)
defer stream.Close()

因此取消链路是:

1
2
3
4
5
6
7
8
Ctrl-C

cancel()

runCtx.Done()
├── engine.generate 返回 context.Canceled
├── Provider HTTP 流结束或关闭
└── sendStreamEvent 不再阻塞

六、工具进程如何被取消

模型请求结束后,Agent 可能进入工具执行。以 internal/tools/bash.go 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func (t *BashTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var input bashArgs
if err := json.Unmarshal(args, &input); err != nil {
return "", fmt.Errorf("参数解析失败: %w", err)
}

timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

cmd := exec.CommandContext(timeoutCtx, "bash", "-c", input.Command)
cmd.Dir = t.workDir

out, err := cmd.CombinedOutput()
outputStr := string(out)

if timeoutCtx.Err() == context.DeadlineExceeded {
return outputStr + "\n[警告: 命令执行超时(30s),已被系统强制终止。]", nil
}

if err != nil {
return fmt.Sprintf("执行报错: %v\n输出:\n%s", err, outputStr), nil
}

if outputStr == "" {
return "命令执行成功,无终端输出。", nil
}

const maxLen = 8000
if len(outputStr) > maxLen {
return fmt.Sprintf("%s\n\n...[终端输出过长,已截断至前 %d 字节]...", outputStr[:maxLen], maxLen), nil
}

return outputStr, nil
}

exec.CommandContext 会把 Context 的取消传给子进程。这里还额外叠加了 30 秒超时:

1
2
父 Context 取消:用户 Ctrl-C 触发
子 Context 超时:单个 bash 最多执行 30 秒

二者任何一个发生,命令都不应该继续无限运行。

七、为什么 errors.Is 能识别 context.Canceled

Engine 的错误可能被多层包装,例如:

1
return fmt.Errorf("Action 阶段失败: %w", err)

所以不应该使用字符串比较:

1
err.Error() == "context canceled"

当前 REPL 使用:

1
2
3
4
if errors.Is(err, context.Canceled) {
fmt.Fprintln(r.out, "当前任务已取消。")
return nil
}

errors.Is 会沿着 %w 包装链检查原始错误,因此即使 Run 返回的是包装后的错误,也能判断它是否由取消导致。

八、当前 Terminal Approval 的取消边界

审批 Handler 当前通过 Goroutine 等待输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
inputCh := make(chan string, 1)

go func() {
line, _ := h.reader.ReadString('\n')
inputCh <- strings.TrimSpace(line)
}()

select {
case <-ctx.Done():
return "", ctx.Err()
case input := <-inputCh:
// 解析 y、a、n。
}

主流程可以及时返回取消,但 ReadString 本身未必会因为 Context 取消而停止。这个输入 Goroutine 可能继续占用共享 Reader,后续用户输入还可能被它消费掉。

这说明“函数返回了 context.Canceled”不等于“所有 Goroutine 都已经退出”。生产级实现需要让输入层本身可取消,或者由专门的 Terminal 输入循环统一读取,再通过内部请求 Channel 分发审批答案。

九、第一版实现的边界和改进方向

当前实现适合学习 Context 传播,但还应该继续补齐:

1
2
3
4
5
6
记录当前任务状态,避免取消后误显示成功
区分用户取消、Provider 超时、工具超时和进程异常
保证取消后的工具结果不会覆盖错误状态
处理多个 Ctrl-C 的行为
避免审批输入 Goroutine 泄漏
为 Provider、Engine、Tool 分别增加取消测试

还要注意一个事实:普通 bufio.Reader.ReadString 并不是可取消的。若要像成熟 CLI 一样处理输入,需要把 stdin 读取和 Agent 执行解耦,或者使用支持终端事件的输入层。

十、验证中断功能

至少做四个实验:

  1. 在模型流式输出过程中按 Ctrl-C,确认 Run 返回且能再次看到 claw>
  2. 执行一个超过 30 秒的 bash 命令,确认 Ctrl-C 能停止命令。
  3. 在审批提示处按 Ctrl-C,确认当前任务退出等待。
  4. 取消后输入下一条任务,确认 Session 没有被锁死、REPL 还能继续工作。

中断的本质不是监听一个信号,而是让每个可阻塞组件都遵守同一个取消协议。下一篇将把工具执行前的人类确认设计成可复用的 Approval Gate。