Commit Graph

2 Commits

Author SHA1 Message Date
8fa6f36982 fix(agent): 插件崩溃熔断从不生效 —— 冷却锚点用了从未赋值的字段
## 缺陷(写上一个提交的判据时撞出来的)

recordCrash 标记 Unhealthy=true,但 isHealthy 的冷却判据是:

    time.Since(r.LastReload) > reloadCooldown

而 LastReload **只在 markReloaded 里被赋值**(即"重载成功"之后)。
插件刚崩、从未重载时它是**零值**:

    time.Since(零值) ≈ 2000 年 >> 30s
    ⇒ 判"冷却期早已过去" ⇒ 立刻把 Unhealthy 置回 false

实测(连打 3 次 recordCrash):

    crash #1 → needReload=false healthy=true
    crash #2 → needReload=false healthy=true
    crash #3 → needReload=true  healthy=true   ← 刚标完就自愈

## 影响

三处「插件不健康则跳过执行」全是**死分支**:

    task.go:1065        stepToolBegin 中的跳过
    task.go:1135        (上一提交刚补的那个分支的另一处同构)
    toolbatch.go:155    并发批次中的跳过

即:一个正在崩溃循环的插件会被**持续调用**,每次调用再崩一次。
recordCrash 返回的 needReload=true 也没意义 —— 下一句 isHealthy 就说它健康了。

pendingReloads 用同一个零值字段 ⇒ autoReloadPlugins 每次都会尝试重载
**刚崩的**插件,很可能立刻再崩(崩溃循环)。

## 修法:冷却锚点改为 LastCrash

语义上「熔断」是"刚崩完先别用它,等 30s 再看" —— 观察窗口应锚在
**最近一次崩溃**,而不是"最近一次重载成功"。

    isHealthy      锚点 LastReload → LastCrash
    pendingReloads 锚点 LastReload → LastCrash

## 顺带删掉冗余

    unhealthyPlugins()      零生产调用方(全仓 grep 只有定义本身)
    record.FirstCrash       只写不读(写它的唯一目的是被下面这个判断用,
                            而那个判断早就改成只看 LastCrash 了)

## 判据(5 条,3 次变异全部实测变红)

  TestCircuitBreakerActuallyOpens                   ★ 主判据
      变异:锚点改回 LastReload ⇒ "★ 连续 3 次崩溃后插件仍被判健康"

  TestCircuitBreakerWaitsForCooldownBeforeReload
      变异:pendingReloads 锚点改回 LastReload ⇒
      "★ 刚崩完就进了重载队列(应等冷却期):[p]"

  TestCircuitBreakerRecoversAfterCooldown
      变异:删掉恢复分支 ⇒ "★ 冷却期过后仍未恢复健康 ——
      插件会被永久停用(比不熔断更糟)"

  TestCircuitBreakerUnknownPluginIsHealthy    从未崩过的插件不记账
  TestCircuitBreakerWindowResets              窗口外的旧崩溃不该累加

## 判据本身的两个坑(已避开)

① 只断言 recordCrash 的返回值 —— 它本来就返回 true,测了等于没测。
   必须断言 **isHealthy 的结论**,那才是被熔断逻辑消费的东西。

② 不测"冷却之后会恢复" —— 那样把代码改成恒 return false 也能过,
   而那就成了永久禁用插件(比不熔断更糟)。

## 连带:deniedpair_test 的前置条件改回真实状态机

上一提交里,第二条判据只能**手工置 Unhealthy**(因为 recordCrash 标完
立刻被自愈撤销)。现在熔断真生效了,改回用 recordCrash 打满阈值 ——
判据依赖公开状态机而不是私有字段。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + TestCircuitBreaker/TestDenied 全绿
2026-11-04 16:34:46 +08:00
c9be04fdac fix(agent): 被拒绝/被跳过的工具没落 ToolResults —— call 与 result 失配
## 根因(4ab888c 的自检抓出来的)

4ab888c 在发送前加了配对自检,生产日志立刻指出真正来源:

    ★ dropped 1 unpaired tool message(s) before send (names: [qq_call_state])

顺着工具名查 context.json,脏帧是决定性的:

    [42] tool_calls   = [qq_call_state, output_send__qq]
         tool_results = [output_send__qq]        ← call_state 从未落盘

该帧自己的记录写了原因:qq_call_state 被 QQ 插件的来源权限策略拦下
(判定为"非所有者私聊")。

## 缺陷位置

stepToolBegin 的顺序是「先记 call,再判能不能执行」:

    f.appendToolCallRecord(tc)                // ← 先落 ToolCalls
    if a.runStage(sdk.StageBeforeToolcall, …) {
        f.Msgs 补一条 role=tool                 // ← 模型能看到
        f.ToolIdx++
        return outcomeContinue                  // ← 但没写 ToolResults
    }

同一函数里还有一处同构分支(插件崩溃态跳过执行),同样只补 f.Msgs。

task.go 顶部的注释写着「ToolCalls 与 ToolResults 通过 CallID 配对;
两者都由 appendToolCallRecord 同步追加」—— 这个不变量在**提前返回路径**
上被违反了。

## 为什么这会变成会话级故障

    ContextEvent{ToolCalls: 2, ToolResults: 1}
    ⇒ 渲染出 assistant(tool_calls=[c1,c2]) 但只有 c1 的结果
    ⇒ 上游硬拒绝(HTTP 400):
       An assistant message with 'tool_calls' must be followed by tool messages
       responding to each 'tool_call_id'. (insufficient tool messages…)

关键在于脏数据**持久化进 context.json**,之后每一轮都带着它,直到自检剔掉。

## 修法

新增 appendToolResultRecord(与 appendToolCallRecord 成对的公开入口),
在两处提前返回各补一行。

判定"被拒绝/被跳过"≠"没有结果":拒绝原因本身就是结果,而且模型确实
从 f.Msgs 看到了它。落 context 的那份必须与发给模型的一致。

并发路径(stepToolBatch)没有这个问题 —— runOneTool 只返回结果,
join 后的顺序收尾段无条件写 ToolResults(含被拒绝项)。

## 判据(2 条,2 次变异全部实测变红)

  TestDeniedToolStillRecordsResult
      注册一个真的会拒绝的 before_toolcall 钩子,跑完整一轮,
      断言落进 context 的每个 call 都有 result,且拒绝原因文本一致。
      变异:删掉拒绝分支那行 ⇒ "★ call call_deny_1 (denied_tool) 没有对应的
      ToolResults"(实测红)

  TestUnhealthyPluginSkipStillRecordsResult
      钉住第二条同构分支。
      变异:删掉跳过分支那行 ⇒ "★ 被跳过的工具 call_unhealthy_1 没落
      ToolResults —— 同型失配,上游 400"(实测红)

两条都走真实链路(真的调 runStage / 真的置崩溃态),不是只测
appendToolResultRecord 这个函数本身。

## 顺带发现一个独立缺陷(未修,不在本次范围)

写第二条判据时发现:插件崩溃熔断**实际从不生效**。

    recordCrash 标记 Unhealthy=true,但从不设置 LastReload
    ⇒ isHealthy 的冷却判据 time.Since(r.LastReload) > reloadCooldown
      在 LastReload 为零值时恒为真(Since 是巨大的正数)
    ⇒ 判定"冷却期早已过去" ⇒ 立刻自愈

实测:连续 3 次 recordCrash 后 needReload=true、Unhealthy=true,
紧接着 isHealthy 返回 true。

后果:task.go:1065/1135 的「插件崩溃态跳过执行」分支实际走不到。
这条应单独修并单独钉,本提交的判据用直达状态绕过它(注释已写明原因)。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + TestDenied/TestUnhealthy 全绿
2026-11-04 16:24:49 +08:00