mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 缺陷(写上一个提交的判据时撞出来的)
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 全绿
214 lines
8.6 KiB
Go
214 lines
8.6 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|