Files
HomeAgent/internal/agent/core/deniedpair_test.go
JianFeeeee 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

214 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
// 判据:「被拒绝 / 被跳过」的工具**也必须落 ToolResults**。
//
// ## 守的是什么(2026-11-04 生产故障,根因)
//
// `stepToolBegin` 的顺序是「先记 call,再判能不能执行」:
//
// f.appendToolCallRecord(tc) // ← 先落 ToolCalls
// if a.runStage(sdk.StageBeforeToolcall, …) {
// f.Msgs 补一条 role=tool // ← 模型能看到
// return // ← 但没写 ToolResults
// }
//
// 于是 `ContextEvent{ToolCalls: 2, ToolResults: 1}`,渲染出
// assistant(tool_calls=[c1,c2]) 却只有 c1 的结果 ⇒ 上游硬拒绝:
//
// An assistant message with 'tool_calls' must be followed by tool messages
// responding to each 'tool_call_id'. (insufficient tool messages…)
//
// 而且脏数据**持久化进 context.json**,之后每一轮都带着它。
//
// 实测那一次:qq 插件的来源权限策略拒绝了 `qq_call_state`。
//
// [42] tool_calls=[qq_call_state, output_send__qq]
// tool_results=[output_send__qq] ← call_state 从未落盘
//
// ## 为什么这个判据必须走真实链路
//
// 只测 `appendToolResultRecord` 本身毫无意义 —— 它当然会 append。
// 要钉的是「stepToolBegin 的拒绝分支有没有调用它」。所以这里注册一个
// **真的会拒绝**的 before_toolcall 钩子,跑完整一轮,再检查落进 context 的
// 那条事件。
import (
"context"
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// deniedProvider 第一轮要求调用一个工具,之后给最终回复。
type deniedProvider struct {
calls int
tc agentAPI.ToolCall
}
func (p *deniedProvider) Name() string { return "denied" }
func (p *deniedProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.calls++
if p.calls == 1 {
return &agentAPI.CompletionResponse{ToolCalls: []agentAPI.ToolCall{p.tc}}, nil
}
return &agentAPI.CompletionResponse{Content: "done"}, nil
}
func (p *deniedProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
return nil, context.Canceled
}
func (p *deniedProvider) MaxContextTokens() int { return 8192 }
// newDeniedAgent 建一个「工具会被 before_toolcall 拒绝」的 agent。
//
// 与被拒绝工具同时提供一个正常工具,用以断言**不能因噎废食**:
// 拒绝分支修好之后,同批的另一个工具仍需正常落 result。
func newDeniedAgent(t *testing.T, tc agentAPI.ToolCall) *Agent {
t.Helper()
a := New(AgentConfig{
ID: "deniedagent",
Provider: &deniedProvider{tc: tc},
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
})
dev := &mockOutputDevice{
name: "denydev",
caps: agentIO.CapText,
tools: []agentIO.ToolDef{
{Name: tc.Name, Description: "会被拒绝"},
},
toolFn: func(tool string, args map[string]interface{}) (interface{}, error) {
t.Fatalf("★ 被拒绝的工具不该真的执行(%s)", tool)
return nil, nil
},
}
if err := a.io.RegisterDevice(dev); err != nil {
t.Fatalf("注册测试设备: %v", err)
}
return a
}
// TestDeniedToolStillRecordsResult 是主判据。
//
// 变异:删掉 stepToolBegin 拒绝分支里的 f.appendToolResultRecord(...) ⇒ 本例红。
func TestDeniedToolStillRecordsResult(t *testing.T) {
tc := agentAPI.ToolCall{ID: "call_deny_1", Name: "denied_tool", Arguments: map[string]interface{}{}}
a := newDeniedAgent(t, tc)
// ★ 真的会拒绝的 before_toolcall 钩子(拒绝 = ctx.Response != nil)。
a.stageHost.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
reason := "被来源权限策略拦下(非所有者私聊)"
ctx.Lock()
ctx.Response = &reason
ctx.Unlock()
return nil
})
f := a.newTaskFrame("跑一下被拒绝的工具", a.stageCtxFromInput("跑一下", "", ""))
if out := a.runTaskSteps(f); out != outcomeDone {
t.Fatalf("runTaskSteps = %v, err=%v", out, f.Err)
}
// 前置条件:确认这个工具**确实**被记成了一次调用(否则本判据空转)。
if len(f.ToolCalls) == 0 {
t.Fatal("★ 没有记录任何 ToolCall —— 前置条件不成立,判据会空转")
}
answered := map[string]bool{}
for _, r := range f.ToolResults {
answered[r.CallID] = true
}
for _, c := range f.ToolCalls {
if !answered[c.CallID] {
t.Fatalf("★ call %s (%s) 没有对应的 ToolResults ——\n"+
"ContextEvent 会以 ToolCalls=%d / ToolResults=%d 落盘,\n"+
"渲染出 assistant(tool_calls) 却没有它的结果 ⇒ 上游 400。\n"+
"实测同型故障:qq_call_state 被权限策略拒绝后每一轮都触发。",
c.CallID, c.Name, len(f.ToolCalls), len(f.ToolResults))
}
}
// 被拒绝的结果必须带上**拒绝原因**(模型看到什么,落盘就该是什么)。
for _, r := range f.ToolResults {
if r.CallID == tc.ID && !strings.Contains(r.Output, "权限策略") {
t.Fatalf("★ 落盘的拒绝原因丢了(Output=%q)——\n"+
"模型从 f.Msgs 看到的是原因,context 里却是别的,两者必须一致",
r.Output)
}
}
}
// TestUnhealthyPluginSkipStillRecordsResult 钉住第二条同构分支:
// 插件处于崩溃态、工具被跳过时,同样必须落 result。
//
// 变异:删掉该分支的 f.appendToolResultRecord(...) ⇒ 本例红。
func TestUnhealthyPluginSkipStillRecordsResult(t *testing.T) {
tc := agentAPI.ToolCall{ID: "call_unhealthy_1", Name: "denied_tool", Arguments: map[string]interface{}{}}
a := newDeniedAgent(t, tc)
// 把该工具所属的插件标成崩溃态。
//
// 用 recordCrash 打到阈值(maxPluginCrashes=3)而不是直接改私有字段 ——
// 这样判据依赖的是**公开状态机**,字段改名不会让它变成"编译不过"以外的
// 沉默失效。插件名必须与 stepToolBegin 里 resolveToolPlugin 的结果一致,
// 否则 isHealthy 永远返回 true,判据会空转。
// 显式登记工具归属,否则 resolveToolPlugin 会把它归到 "core"
// (builtinToolOwner 的兜底),而 health tracker 是按插件名记账的。
const pluginName = "denyplug"
if err := a.stageHost.RegisterTool(tc.Name, sdk.ToolDef{
Name: tc.Name, Description: "x", Plugin: pluginName,
}, func(map[string]interface{}) (interface{}, error) {
t.Fatalf("★ 被跳过的工具不该执行")
return nil, nil
}); err != nil {
t.Fatalf("登记工具归属: %v", err)
}
if got := a.resolveToolPlugin(tc.Name); got != pluginName {
t.Fatalf("★ resolveToolPlugin=%q,期望 %q —— 前置条件不成立", got, pluginName)
}
// ★ 直接置 Unhealthy,不用 recordCrash 打满阈值。
//
// 为什么不走 recordCrash:实测发现它标记 Unhealthy 之后,isHealthy **立刻**
// 判回健康 —— 因为 recordCrash 从不设置 LastReload,而 isHealthy 的冷却
// 判据是 `time.Since(r.LastReload) > reloadCooldown`,零值的 Since 是巨大的
// 正数 ⇒ 冷却期"早已过去" ⇒ 熔断自我解除。
//
// 这是**另一个独立缺陷**(插件崩溃熔断实际从不生效),不在本次修复范围
// (本次只修 tool 配对)。这里直达状态,把判据聚焦在被测分支上;
// 熔断那个缺陷应单独修并单独钉。
// ★ 走**真实状态机**,不直接改字段:
// 现在 isHealthy 的冷却锚点是 LastCrash(见 plugin_health.go),
// 连续 recordCrash 达到阈值就会进入真正的熔断态。
//
// 这条判据曾经只能手工置 Unhealthy —— 因为当时 recordCrash 标完
// 立刻被 isHealthy 的自愈逻辑撤销(那是另一个缺陷,已单独修)。
// 现在熔断真生效了,就用它自己的入口构造前置条件。
for i := 0; i < maxPluginCrashes; i++ {
a.pluginHealth.recordCrash(pluginName)
}
if a.pluginHealth.isHealthy(pluginName) {
t.Fatalf("★ 置 Unhealthy 后 %s 仍被判健康,判据前置条件不成立", pluginName)
}
f := a.newTaskFrame("跑一下", a.stageCtxFromInput("跑一下", "", ""))
if out := a.runTaskSteps(f); out != outcomeDone {
t.Fatalf("runTaskSteps = %v, err=%v", out, f.Err)
}
if len(f.ToolCalls) == 0 {
t.Fatal("★ 没有记录 ToolCall —— 前置条件不成立")
}
answered := map[string]bool{}
for _, r := range f.ToolResults {
answered[r.CallID] = true
}
for _, c := range f.ToolCalls {
if !answered[c.CallID] {
t.Fatalf("★ 被跳过的工具 %s (%s) 没落 ToolResults —— 同型失配,上游 400",
c.CallID, c.Name)
}
}
}