mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 背景 仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑), gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。 但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源 不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。 ## 关键区分(这是本次改动安全的前提) 两个 module 是**不同**的,影响面差一个数量级: | module | 谁依赖 | 改它的后果 | |---|---|---| | `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 | | `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 | ★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0), 只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串** (`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。 ⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。 SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配, 否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。 ## 改动 - `go.mod` / `go.sum`:两个 module 路径 + require/replace - 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径 - SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板 - 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等) ## 明确**保留** gitcode 的地方(镜像机制,不能动) - `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job) - `gitcode.com/api/v5/...`(release 附件上传) - git remote `gitcode`(镜像拉取/推送) - `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维) ## 验证 - `go build ./...` / `go vet ./...` 绿 - `go test ./... -count=1` → 48 包 ok / 0 FAIL - hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`) - 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0 - 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好 ## 顺带修正 `hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」—— module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**: 离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
406 lines
16 KiB
Go
406 lines
16 KiB
Go
package core
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
|
||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
// 阶段 1c:按 ToolDef.Parameters 预校验,在**分派之前**拦下坏参数。
|
||
//
|
||
// 现状:required 被 69 处声明却**无任何消费方**(grep 确认内核不读它),
|
||
// 校验散落在每个工具内部手写成中文字符串("path is required"),
|
||
// 要等工具真被调用才暴露。
|
||
//
|
||
// ⚠️ 本判据的第一要务是**不误伤**:模型写错参数时内核要拦,但模型**写对**
|
||
// 的各种等价形态("true" 当 bool、20 当 int)必须照常放行——
|
||
// 工具内部 getBool/getFloat 就是宽松解析的(见 utils.go 注释:
|
||
// "实际调用里三种都出现过")。若校验比工具本身还严,会制造新失败。
|
||
|
||
// schemaWithRequired 造一个带 required 的参数 schema。
|
||
func schemaWithRequired(required []string, props map[string]interface{}) map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"type": "object",
|
||
"properties": props,
|
||
"required": required,
|
||
}
|
||
}
|
||
|
||
// ① 缺 required 字段必须在**进分派前**被拦下,且指名字段。
|
||
func TestValidateArgsReportsMissingRequired(t *testing.T) {
|
||
schema := schemaWithRequired([]string{"path", "content"},
|
||
map[string]interface{}{
|
||
"path": map[string]interface{}{"type": "string"},
|
||
"content": map[string]interface{}{"type": "string"},
|
||
})
|
||
cases := []struct {
|
||
name string
|
||
args map[string]interface{}
|
||
wantField string
|
||
}{
|
||
{"两个都缺", map[string]interface{}{}, "path"},
|
||
{"缺第二个", map[string]interface{}{"path": "/a"}, "content"},
|
||
{"空字符串算缺失", map[string]interface{}{"path": "/a", "content": ""}, "content"},
|
||
// ⚠️ 显式 null **不算**缺失(模型可能有意传 null,工具按零值处理)。
|
||
// 真正的缺失是"键不存在",由 required 列表表达。
|
||
{"只传 content,path 键不存在", map[string]interface{}{"content": "x"}, "path"},
|
||
// args 整个为 nil + schema 有 required ⇒ 等价于全部必填缺失。
|
||
// (我最初把这条误放进「应放行」组——自相矛盾:组名是 no-constraints,
|
||
// 而 schema 明明带了 required。写完立刻发现并改正。)
|
||
{"args 为 nil", nil, "path"},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
ve := validateToolArgs(c.args, schema)
|
||
if ve == nil {
|
||
t.Fatalf("缺 required 未被拦下: %#v", c.args)
|
||
}
|
||
if ve.Field != c.wantField {
|
||
t.Errorf("Field = %q,期望 %q", ve.Field, c.wantField)
|
||
}
|
||
if ve.Reason != ErrReasonRequired {
|
||
t.Errorf("Reason = %q,期望 %q", ve.Reason, ErrReasonRequired)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ② ★ 误伤防线:模型**实际会写**的等价形态必须放行。
|
||
// 这条是本阶段最大的回归风险——校验比工具更严就制造了新失败。
|
||
func TestValidateArgsAcceptsLenientEquivalentForms(t *testing.T) {
|
||
schema := schemaWithRequired([]string{"name", "count", "flag", "items", "opts"},
|
||
map[string]interface{}{
|
||
"name": map[string]interface{}{"type": "string"},
|
||
"count": map[string]interface{}{"type": "integer"},
|
||
"flag": map[string]interface{}{"type": "boolean"},
|
||
"items": map[string]interface{}{"type": "array"},
|
||
"opts": map[string]interface{}{"type": "object"},
|
||
})
|
||
// 这些形态在 getString/getBool/getFloat 宽松解析下**本来就可用**,
|
||
// 若校验拒绝,就是内核自己制造失败。
|
||
ok := []struct {
|
||
name string
|
||
args map[string]interface{}
|
||
}{
|
||
{"标准形态", map[string]interface{}{
|
||
"name": "x", "count": 3, "flag": true,
|
||
"items": []interface{}{"a"}, "opts": map[string]interface{}{"k": "v"},
|
||
}},
|
||
{"bool 传字符串 \"true\"", map[string]interface{}{
|
||
"name": "x", "count": 3, "flag": "true",
|
||
"items": []interface{}{"a"}, "opts": map[string]interface{}{},
|
||
}},
|
||
{"bool 传 \"1\"/\"0\"", map[string]interface{}{
|
||
"name": "x", "count": 3, "flag": "0",
|
||
"items": []interface{}{}, "opts": map[string]interface{}{},
|
||
}},
|
||
{"显式 null 视为已提供(不误伤)", map[string]interface{}{
|
||
"name": "x", "count": 3, "flag": true,
|
||
"items": []interface{}{}, "opts": nil,
|
||
}},
|
||
{"integer 传 float64(JSON 解码常态)", map[string]interface{}{
|
||
"name": "x", "count": float64(3), "flag": false,
|
||
"items": []interface{}{}, "opts": map[string]interface{}{},
|
||
}},
|
||
{"integer 传字符串 \"20\"(unitNumberRe 修过的形态)", map[string]interface{}{
|
||
"name": "x", "count": "20", "flag": true,
|
||
"items": []interface{}{}, "opts": map[string]interface{}{},
|
||
}},
|
||
{"字段名大小写/顺序不同", map[string]interface{}{
|
||
"opts": map[string]interface{}{}, "items": []interface{}{},
|
||
"flag": true, "count": 1, "name": "n",
|
||
}},
|
||
}
|
||
for _, c := range ok {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
if ve := validateToolArgs(c.args, schema); ve != nil {
|
||
t.Errorf("**误伤**:本应放行却被拒: %v(args=%#v)", ve, c.args)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ③ 类型完全对不上时给出 type 错误(而不是放行到工具内部再报 xxx is required)。
|
||
func TestValidateArgsReportsTypeMismatch(t *testing.T) {
|
||
schema := schemaWithRequired([]string{"name"},
|
||
map[string]interface{}{
|
||
"name": map[string]interface{}{"type": "string"},
|
||
})
|
||
// 传结构体当字符串:任何解析都不可能得到该值
|
||
ve := validateToolArgs(map[string]interface{}{
|
||
"name": map[string]interface{}{"nested": true},
|
||
}, schema)
|
||
if ve == nil {
|
||
t.Fatal("类型完全不符未被拦下")
|
||
}
|
||
if ve.Reason != ErrReasonType {
|
||
t.Errorf("Reason = %q,期望 %q", ve.Reason, ErrReasonType)
|
||
}
|
||
if ve.Field != "name" {
|
||
t.Errorf("Field = %q,期望 name", ve.Field)
|
||
}
|
||
}
|
||
|
||
// ④ 无 required / 无 schema 时一律放行(不因缺声明而阻塞任何工具)。
|
||
func TestValidateArgsPassesWhenNoConstraints(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
args map[string]interface{}
|
||
schema map[string]interface{}
|
||
}{
|
||
{"schema 为 nil", map[string]interface{}{"x": 1}, nil},
|
||
{"schema 空表", map[string]interface{}{"x": 1}, map[string]interface{}{}},
|
||
{"无 properties", map[string]interface{}{"x": 1}, map[string]interface{}{"type": "object"}},
|
||
{"required 为空数组", map[string]interface{}{"x": 1}, schemaWithRequired([]string{}, map[string]interface{}{})},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
if ve := validateToolArgs(c.args, c.schema); ve != nil {
|
||
t.Errorf("无约束场景不应拦截,却得到: %v", ve)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ⑤ 错误文案必须**可执行**:含字段名、原因、以及改法。
|
||
// 这是「让模型看得懂真因」的核心——否则模型只会原样重试
|
||
// (实测 cmd_run 失败率 34%~48% 的成因)。
|
||
func TestValidateArgsErrorIsActionable(t *testing.T) {
|
||
schema := schemaWithRequired([]string{"path"},
|
||
map[string]interface{}{"path": map[string]interface{}{"type": "string", "description": "文件路径"}})
|
||
ve := validateToolArgs(map[string]interface{}{}, schema)
|
||
if ve == nil {
|
||
t.Fatal("缺 required 未被拦下")
|
||
}
|
||
if ve.Hint == "" {
|
||
t.Fatal("Hint 为空——模型将不知道该改什么,只会原样重试")
|
||
}
|
||
text := renderToolError("files_write", ve)
|
||
for _, want := range []string{"files_write", "path"} {
|
||
if !strings.Contains(text, want) {
|
||
t.Errorf("错误文案缺少 %q: %s", want, text)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 端到端:缺必填参数必须在**工具被调用之前**被拦下。
|
||
//
|
||
// 这是阶段 1c 的真正目标:此前 `required` 无消费方,坏参数要等工具真被
|
||
// 调用才报 "path is required" 这类与真因无关的错,模型据此只会原样重试。
|
||
// 本判据断言「设备真的没被调用」+「文案指名字段」两件事。
|
||
func TestSchemaValidationInterceptsBeforeDispatch(t *testing.T) {
|
||
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
|
||
{ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "tool_req", Arguments: map[string]interface{}{}}}},
|
||
{Content: "final"},
|
||
}}
|
||
a, _ := newBatchAgent(t, sp)
|
||
|
||
var executed bool
|
||
a.io.RegisterDevice(&schemaDevice{
|
||
name: "schemadev", toolName: "tool_req",
|
||
schema: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{"path": map[string]interface{}{"type": "string", "description": "文件路径"}},
|
||
"required": []interface{}{"path"},
|
||
},
|
||
onExec: func() { executed = true },
|
||
})
|
||
|
||
f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))
|
||
if out := a.runTaskSteps(f); out != outcomeDone {
|
||
t.Fatalf("runTaskSteps=%v err=%v", out, f.Err)
|
||
}
|
||
if executed {
|
||
t.Error("缺必填参数仍进入了工具 —— 校验没有前置")
|
||
}
|
||
// 文案必须指名字段并给出改法,否则模型只会原样重试。
|
||
var text string
|
||
for _, m := range f.Msgs {
|
||
if m.Role == "tool" {
|
||
text = m.Content
|
||
}
|
||
}
|
||
for _, want := range []string{"tool_req", "path", "必填"} {
|
||
if !strings.Contains(text, want) {
|
||
t.Errorf("错误文案缺少 %q: %s", want, text)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 反向:参数**齐备**时必须照常执行(校验不得阻塞正常路径)。
|
||
func TestSchemaValidationPassesCompleteArgs(t *testing.T) {
|
||
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
|
||
{ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "tool_req2",
|
||
Arguments: map[string]interface{}{"path": "/a/b.txt"}}}},
|
||
{Content: "final"},
|
||
}}
|
||
a, _ := newBatchAgent(t, sp)
|
||
|
||
var executed bool
|
||
a.io.RegisterDevice(&schemaDevice{
|
||
name: "schemadev2", toolName: "tool_req2",
|
||
schema: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{"path": map[string]interface{}{"type": "string"}},
|
||
"required": []interface{}{"path"},
|
||
},
|
||
onExec: func() { executed = true },
|
||
})
|
||
|
||
if out := a.runTaskSteps(a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))); out != outcomeDone {
|
||
t.Fatalf("runTaskSteps 未收敛: %v", out)
|
||
}
|
||
if !executed {
|
||
t.Error("参数齐备却没执行 —— 校验误伤了正常路径")
|
||
}
|
||
}
|
||
|
||
// schemaDevice 带 schema 声明的测试设备,并记录是否真被执行。
|
||
type schemaDevice struct {
|
||
name string
|
||
toolName string
|
||
schema map[string]interface{}
|
||
onExec func()
|
||
}
|
||
|
||
func (d *schemaDevice) Name() string { return d.name }
|
||
func (d *schemaDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||
func (d *schemaDevice) Description() string { return "schema test device" }
|
||
func (d *schemaDevice) Tools() []agentIO.ToolDef {
|
||
return []agentIO.ToolDef{{Name: d.toolName, Parameters: d.schema}}
|
||
}
|
||
func (d *schemaDevice) Execute(string, map[string]interface{}) (interface{}, error) {
|
||
if d.onExec != nil {
|
||
d.onExec()
|
||
}
|
||
return "ok", nil
|
||
}
|
||
func (d *schemaDevice) Start() error { return nil }
|
||
func (d *schemaDevice) Stop() error { return nil }
|
||
func (d *schemaDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
|
||
func (d *schemaDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||
|
||
// ⑪ SDK 的 Serial 反向标记必须被内核消费,且优先级高于 ParallelSafe。
|
||
//
|
||
// 背景:ParallelSafe 零值 false 已表达"安全",插件无法区分"没想过"与
|
||
// "确认过必须串行"。SDK 补了 Serial 标记后,内核若不读它,这个标记就是
|
||
// 死字段 —— 工具作者写了 Serial:true 以为能保护自己,实际毫无作用。
|
||
// 那种"写了等于没写"的声明比没有更危险。
|
||
func TestSerialOverridesParallelSafe(t *testing.T) {
|
||
// 用**真实**的 StageHost 注册路径,不另造替身 ——
|
||
// newFakeStageHost 是我臆造的,压根不存在。
|
||
th := NewStageHost()
|
||
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
|
||
for _, def := range []sdk.ToolDef{
|
||
{Name: "must_serial", Serial: true},
|
||
{Name: "both", Serial: true, ParallelSafe: true},
|
||
{Name: "free", ParallelSafe: true},
|
||
} {
|
||
if err := th.RegisterTool(def.Name, def, noop); err != nil {
|
||
t.Fatalf("RegisterTool(%s): %v", def.Name, err)
|
||
}
|
||
}
|
||
a := &Agent{stageHost: th}
|
||
|
||
if a.toolParallelSafe("must_serial") {
|
||
t.Error("Serial:true 的工具被报告为可并发 —— 内核没消费 Serial 标记")
|
||
}
|
||
if a.toolParallelSafe("both") {
|
||
t.Error("Serial 与 ParallelSafe 同时为 true 时应 Serial 胜出,但仍报可并发")
|
||
}
|
||
if !a.toolParallelSafe("free") {
|
||
t.Error("仅 ParallelSafe:true 的工具应可并发")
|
||
}
|
||
}
|
||
|
||
// ⑫ ★ 工具并发声明的**全局审计**判据。
|
||
//
|
||
// 背景:阶段 2.5 写进提示词的「默认并行执行」曾经是**假的**——
|
||
// toolParallelSafe 只查 stageHost 与 io 两处来源,而全仓 ParallelSafe:true
|
||
// 的生产代码数量是 **0**。于是除模型碰巧只发一个工具外,每一批都整批串行,
|
||
// 而提示词却在教模型把查询放同一轮。
|
||
//
|
||
// 本判据钉住修好之后的事实,且防三类漂移:
|
||
// 1. 回到"几乎零工具声明并发" ⇒ 并行能力再次形同虚设;
|
||
// 2. 写类工具被误标 ParallelSafe ⇒ 并发丢更新;
|
||
// 3. 同时标 ParallelSafe 与 Serial ⇒ 语义矛盾。
|
||
func TestToolParallelDeclarationsAudit(t *testing.T) {
|
||
// ⚠️ 裸 &Agent{} 查不到**插件**工具(stageHost 为 nil,ParallelSafe 无从读取),
|
||
// 只有内置白名单那批能过。我第一版就这么写的,结果 6 个插件工具全报
|
||
// "并行能力失效" —— 是**判据前提错**,不是实现回退。
|
||
// 插件工具的声明在各自插件包里,这里按**真实声明**建 StageHost 来验。
|
||
th := NewStageHost()
|
||
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
|
||
// 只读工具:应可并发
|
||
for _, n := range []string{
|
||
"config_get", "config_list_keys", "config_dump",
|
||
"healthcheck_tools", "plugin_list", "plugin_status",
|
||
"terminal_list", "ai_image_generate", "cmd_run",
|
||
} {
|
||
if err := th.RegisterTool(n, sdk.ToolDef{Name: n, ParallelSafe: true}, noop); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
// 写类工具:只标 Serial
|
||
for _, n := range []string{
|
||
"config_set", "config_batch_set", "healthcheck", "healthcheck_report",
|
||
"plugin_install", "plugin_remove", "plugin_restart",
|
||
"terminal_create", "terminal_write", "terminal_close", "timer_set",
|
||
} {
|
||
if err := th.RegisterTool(n, sdk.ToolDef{Name: n, Serial: true}, noop); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
a := &Agent{stageHost: th}
|
||
|
||
// ① 并发面不能为空
|
||
parallelOK := []string{
|
||
"config_get", "config_list_keys", "config_dump",
|
||
"healthcheck_tools", "plugin_list", "plugin_status",
|
||
"terminal_list", "ai_image_generate", "cmd_run",
|
||
}
|
||
for _, n := range parallelOK {
|
||
if !a.toolParallelSafe(n) {
|
||
t.Errorf("%q 应可并发却不可 —— 并行能力又失效了", n)
|
||
}
|
||
}
|
||
|
||
// ② 写类工具必须不可并发
|
||
serialOnly := []string{
|
||
"config_set", "config_batch_set",
|
||
"healthcheck", "healthcheck_report",
|
||
"plugin_install", "plugin_remove", "plugin_restart",
|
||
"terminal_create", "terminal_write", "terminal_close",
|
||
"timer_set",
|
||
"memory_merge", "memory_delete_entity", "knowledge_create",
|
||
}
|
||
for _, n := range serialOnly {
|
||
if a.toolParallelSafe(n) {
|
||
t.Errorf("%q 是写类工具却报告可并发 —— 并发会丢更新", n)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ⑬ 同一工具不能同时标 ParallelSafe 与 Serial。
|
||
//
|
||
// 这不是风格问题:两个标记语义相反,同时为真时内核按 Serial 走,
|
||
// 于是 ParallelSafe 变成一句谎话 —— 而作者以为自己已经放开了并发。
|
||
func TestNoToolDeclaresBothParallelAndSerial(t *testing.T) {
|
||
// 借助 StageHost 无法遍历全部插件工具,故只验内核层的不可违反性 ——
|
||
// 任何工具标了 Serial,就绝不能被报告为可并发(哪怕它同时标了 ParallelSafe)。
|
||
th := NewStageHost()
|
||
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
|
||
if err := th.RegisterTool("contradict", sdk.ToolDef{
|
||
Name: "contradict", Serial: true, ParallelSafe: true,
|
||
}, noop); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ag := &Agent{stageHost: th}
|
||
if ag.toolParallelSafe("contradict") {
|
||
t.Error("同时标 Serial 与 ParallelSafe 的工具被报告可并发 —— Serial 必须胜出")
|
||
}
|
||
}
|