Peri 的 Ctrl+C 中断有过五个独立的 bug。用户按下 Ctrl+C 想中断当前操作,有时 Agent 停了但下一轮读不到上一轮的改动记录,不知道刚才改过哪些文件;有时界面卡在中断状态,输入框回不来;有时 Agent 派出去的子 Agent 还在后台跑,对 Ctrl+C 完全无反应。

五个症状看起来像同一个问题的不同表现,但调试下来发现不是。取消信号从用户按下 Ctrl+C 到 Agent 真正停下来,要穿过 Peri 的五层架构。每一层都有自己的取消处理逻辑,每一层都独立地犯了错。修一层不解决其他层,因为根因各不相同。
Agent 被用户中断后,执行结果返回最外层的服务端——ACP Server,负责管理 Agent 的执行和对话状态。服务端看到执行失败,做了一个直觉上合理的操作,把对话历史截断回这一轮开始之前的长度。
直觉是,既然这一轮被取消了,就当它没发生过。但这个直觉忽略了 Agent 在被取消之前已经做了事。Agent 在这一轮里可能调了三个工具、改了两个文件,然后用户按了 Ctrl+C。工具调用的结果已经写进了 Agent 的对话记录,改动有据可查。截断之后,这些记录全没了——下一轮用户发新消息,Agent 从截断后的历史继续,完全不知道上一轮做过什么。界面上显示一切正常,但从 Agent 的对话历史来看这些操作从没发生过。
不能简单地保留所有历史,因为 Agent 也可能因为真正的错误(比如模型报错)而返回失败,那种情况下部分写入的状态可能不完整。修复的方式是检查 Agent 在被取消前是否有有效产出——对比返回的消息数量和这一轮开始时的历史长度。如果消息数量超出了一条(用户消息本身),说明 Agent 确实在这一轮里写了新内容,这些内容保留下来,持久化到数据库。
Agent 执行中断后,框架发出一个事件通知界面层。这个事件的类型是「执行失败」,和 Agent 真正报错时发出的事件一模一样。界面层收到「执行失败」事件后,走了错误处理分支——显示错误信息,清理状态。
中断和错误需要完全不同的处理。错误处理只是显示一条报错,然后等用户下一步操作。中断处理要做更多——撤回用户上一条消息,让它回到输入框里方便修改后重发,同时恢复界面到可输入状态。因为中断事件走了错误处理分支,这套专门为中断设计的恢复机制从未生效。
区分两者的依据是事件里携带的消息内容。中断事件的消息固定是 Interrupted by user,真正的错误事件携带具体错误信息。界面层在收到执行失败事件时检查这个消息内容,走对应的处理分支。
这里还有一个互斥问题。中断或错误事件到达后,框架还会收到一个「完成」事件——因为 Agent 执行流程的正常结束就是发出完成通知。如果中断先到达并执行了恢复操作,后续的完成事件不能重复执行这些操作,否则会覆盖中断已经设好的界面状态。修复的方式是用一个标记——中断或错误处理时设为 true,完成事件到达时检查这个标记,已经处理过就跳过重建步骤,只做渲染。
中断处理走到正确的分支后,还要找到用户上一条消息在界面对话列表里的位置,才能执行撤回。用户每次发送消息时,界面层缓存一个索引,记录这一轮对话从界面列表的第几条消息开始。中断恢复时直接读这个缓存索引去定位用户消息。
问题在于,界面在 Agent 执行过程中会不断收到状态更新,每次更新都会重建对话列表。重建之后,列表里消息的数量和顺序可能变了——新消息加进来了,或者消息的内部结构变了。缓存的索引没有跟着更新,还指向重建之前的位置。位置偏了一两条,撤回操作就找错了消息。
重建后重新缓存索引也不可靠,因为重建发生多少次、每次偏移多少都是不确定的。修复的方式是不缓存,改用实时搜索——每次中断恢复时从头遍历界面对话列表,找到最后一条用户消息。搜索基于消息类型而非位置索引来定位,不受重建影响。
前面三层修完,Agent 单独运行时的 Ctrl+C 工作了。但 Agent 派出子 Agent 去执行任务时,Ctrl+C 又冒出了两个新问题。
Peri 的 Agent 可以派出子 Agent 去执行独立的子任务。子 Agent 有自己的执行上下文,独立运行。取消信号通过取消令牌传播——令牌是一个可以被设置和检查的信号标记,父 Agent 的令牌被触发时,所有从它派生的子令牌也会被触发。
问题出在同步子 Agent 的执行路径上。同步子 Agent 会阻塞父 Agent——父 Agent 派出子 Agent 后,等子 Agent 跑完再继续。但父 Agent 的取消令牌没有传递到子 Agent 的执行上下文。用户按 Ctrl+C 触发了父 Agent 的令牌,父 Agent 停了,子 Agent 在自己的上下文里运行,检查的是自己的令牌,父令牌被触发跟它无关。子 Agent 继续跑直到自己完成,Ctrl+C 对它完全无效。
不能用一个全局取消标记替代令牌树,因为令牌的父子关系保证了级联取消——取消父 Agent 时自动取消所有子 Agent,全局标记做不到这种层级传播。修复的方式是确保所有 Agent 执行路径共享同一棵取消令牌树。子 Agent 的令牌从父 Agent 的令牌派生,父令牌被触发时自动级联到所有子令牌。无论是同步、异步还是 Fork 模式,取消信号都沿着调用链传播。
取消令牌的问题修好后,信号到达界面层时又被最后一道关卡挡住了。界面层有一个安全守卫,检查当前是否在子 Agent 执行期间。守卫的意图是合理的——子 Agent 自己被取消时产生的中断事件,不应该当作用户中断来处理,因为用户中断的是父 Agent 的整体任务,不是单个子 Agent。守卫发现当前在子 Agent 执行期间,就忽略中断事件,直接返回。
问题在于,父 Agent 在等待同步子 Agent 完成时被用户中断,也满足「在子 Agent 执行期间」这个条件——因为子 Agent 的界面状态是活跃的。守卫分不清两种场景,把父 Agent 的中断也一起吞了。信号从令牌传播到事件生成再到事件路由,整条链路全部正确,最后一道关卡静默丢弃了事件。用户看到的就是,按了 Ctrl+C,什么都没发生,界面卡死。
守卫不能直接删掉,因为它确实需要过滤子 Agent 自身的中断。修复的方式是移除提前返回的逻辑,让中断事件无论如何都走到后续的清理流程。清理流程能正确处理子 Agent 的中断——子 Agent 的界面状态在清理中被归档或移除,不会误触发父 Agent 的消息撤回。
五层修完,开头提到的三个症状——对话历史丢失、界面卡死、子 Agent 停不下来——都不会再出现。取消不是一个原子操作。在分层架构中,它是一组跨层的语义契约,每一层都要独立正确,任何一层断了,用户看到的就是 Ctrl+C 不灵了。
项目地址:github.com/konghayao/p…