Files
HomeAgent/internal/agent/core/workertool_test.go
JianFeeeee 637a3a3d3f refactor(agent): R4 工作式子语义落地 + 清除 R1–R3 遗留的死代码
## 1. 工作式子的工具禁令收成单一事实来源

禁令此前写了两遍:定义侧(把工具从 childTools 滤掉)与执行侧
(模型硬调时拒绝)。两处名单必须逐字一致,任何一边落后就是真实缺口:

  定义侧漏滤 ⇒ 模型看得见但不该能用(白耗 token,还会反复试)
  执行侧漏拒 ⇒ 模型绕过定义直接调用**就能生效**

现在只有 workerForbidden 一张表,每条禁令把"匹配方式 + 类别 + 解释"
绑在一个结构体里;定义侧 workerToolAllowed、执行侧 workerToolReject
都从它派生。

上一版我用 reason 字符串 + 下标猜分类 + hint 查表做了三处间接,
属于新造的技术债,已重写为单表直查。

判据(均经变异验证):
  TestWorkerToolDenyListIsSingleSource  长出第二份名单 ⇒ 红(实测确认)
  TestWorkerToolAllowedMatchesReject    两侧判据漂移 ⇒ 红
  TestWorkerToolRejectExplainsWhy       拒绝文案不可执行 ⇒ 红

## 2. 删除零使用的抽象(不留技术债)

R1–R3 引入了 ChildKind / Host / Deliverer / DeviceResolver / ToolSpec /
ToolResult / DeclaredKinds / KindWorker / State / SetState / BumpRound /
ChannelDisposition,实现过程中发现它们是假抽象:

  · 各类型要用载体 Agent 的大半能力,要满足接口就得在实现里类型断言回 *Agent
  · 接口比被抽象的东西还大 ⇒ 只是把复杂度换个地方放
  · 实测:26 个导出符号里 20 个零使用、零实现

已删除 host.go 与 kind.go,并把"四种类型清单"和"转投驻留子为何不是类型"
这两个有价值的结论移进 kinds.go 的文档注释(知识保留,死代码删除)。

child 包现在只剩实际被用的 9 个符号:Kind / Child / Entry / Registry /
StopDeps / ErrChildExists / ErrChildNotFound(+ 2 个 Kind 常量)。

## 3. 修掉误导性签名

Entry.Dispose(policy ResidualPolicy) 里 policy **两个实现都不读** ——
残余处置实际走 ApplyResidual / FinishResidentWith,在 Dispose 之前就
已经完成。看签名会以为这里处理残余,是误导。已去掉该参数,
并删掉 child 包里重复定义的 ResidualPolicy(core 包已有一份,且多一个 stash)。

## 验证

  go build ./...                                  PASS
  go vet ./...                                    PASS
  gofmt -l                                        干净
  go test ./internal/agent/core/ -count=1         ok 15.300s
  go test ./internal/agent/core/ -count=1 -race   ok 18.040s
  干净检出(git worktree)单独 build + vet + test  PASS
2026-11-04 08:35:23 +08:00

117 lines
4.3 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
// 判据:工作式子的工具禁令是**单一事实来源**。
//
// ## 这条缺陷的原貌
//
// 禁令曾写了两遍:
//
// 定义侧(spawn.go:160) 把禁的工具从 childTools 里滤掉 ⇒ 模型看不见
// 执行侧(spawn.go:224) 模型硬调时拒绝 ⇒ 模型看得见也调不动
//
// 两处的工具名单必须逐字一致。任何一边落后就是一个真实缺口:
//
// 定义侧漏滤 ⇒ 模型看得见但不该能用(白耗 token,还会反复试)
// 执行侧漏拒 ⇒ 模型绕过定义直接调用**就能生效**(模型确实会这么干 ——
// 本仓 record 过"4 次 files_write 失败后模型改参数硬试")
//
// 现在两侧都从 workerToolAllowed 派生,这条判据钉住"不许再长出第二份"。
import (
"os"
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// TestWorkerToolDenyListIsSingleSource 钉住:禁令只在 workerForbidden* 定义一次。
//
// 变红条件:有人在别处又写一份字面量名单(如 `ct.Name == "spawn_child"`)⇒ 红。
func TestWorkerToolDenyListIsSingleSource(t *testing.T) {
data, err := os.ReadFile("spawn.go")
if err != nil {
t.Fatalf("读 spawn.go: %v", err)
}
// ★ 必须**逐行剔除注释**再扫:第一版直接扫全文,结果把自己写在
// 注释里的"上一版曾在这里重列过 `== "output_list_channels"`"当成了违规。
// 判据误报会让人开始骗判据(改注释绕过),那比没有判据更糟。
var code []string
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "//") {
continue
}
code = append(code, line)
}
src := strings.Join(code, "\n")
for _, tool := range []string{"spawn_child", "plgreload", "output_list_channels"} {
needle := `== "` + tool + `"`
if strings.Contains(src, needle) {
t.Fatalf("spawn.go 里出现了第二份工具禁令:`%s`\n"+
"禁令必须在 workerForbiddenTools 里定义一次,"+
"定义侧与执行侧都从 workerToolAllowed 派生 —— "+
"两处各写一遍时,任何一边落后就是缺口", needle)
}
}
if strings.Contains(src, `HasPrefix(name, "output_send__")`) ||
strings.Contains(src, `HasPrefix(ct.Name, "output_send__")`) {
t.Fatal("spawn.go 里出现了第二份前缀禁令(output_send__)—— " +
"应统一走 workerToolAllowed")
}
}
// TestWorkerToolAllowedMatchesReject 钉住两侧**语义一致**。
//
// 这是上一条的行为版:名单同源还应满足"允许 ⇔ 不拒绝"。
// 变红条件:两边判据不一致(比如一边禁 spawn_child、另一边漏了)。
func TestWorkerToolAllowedMatchesReject(t *testing.T) {
cases := []struct {
tool string
allowed bool
}{
{"output_send__qq", false},
{"output_send__", false},
{"output_list_channels", false},
{"spawn_child", false},
{"plgreload", false},
{"cmd_run", true},
{"files_read", true},
{"knowledge_search", true},
// 边界:前缀相似但不等价的必须放行(防"过度过滤")。
{"output_list_channels_help", true},
{"spawn_child_helper", true},
}
for _, c := range cases {
got := workerToolAllowed(c.tool)
if got != c.allowed {
t.Errorf("workerToolAllowed(%q) = %v,期望 %v", c.tool, got, c.allowed)
}
rejected := workerToolReject(agentAPI.ToolCall{Name: c.tool}) != ""
if rejected == c.allowed {
t.Errorf("workerToolReject(%q) 与 workerToolAllowed 不一致:"+
"allowed=%v 但 rejected=%v(两侧判据必须同源)",
c.tool, c.allowed, rejected)
}
}
}
// TestWorkerToolRejectExplainsWhy 钉住拒绝文案**可执行**。
//
// 本仓纪律:拒绝必须说明原因,否则模型会原样重试同一条被拒的调用
// (有"4 次 files_write 失败"先例)。变红条件:返回空文案或只有工具名。
func TestWorkerToolRejectExplainsWhy(t *testing.T) {
for _, tool := range []string{"output_send__qq", "spawn_child"} {
msg := workerToolReject(agentAPI.ToolCall{Name: tool})
if msg == "" {
t.Fatalf("%s 被拒但没给文案", tool)
}
if len([]rune(msg)) < 20 {
t.Fatalf("%s 的拒绝文案太短(%q)—— 必须说明原因才能让模型换做法", tool, msg)
}
if !strings.Contains(msg, tool) {
t.Errorf("%s 的拒绝文案没提到工具名:%q", tool, msg)
}
}
}