mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
本提交是三件事的第一批,后续按计划分步推进。
## 一、判据改造(两条实证)
### 1. turn_hint 判据原是自证循环
原测试在测试里**手动重放**了真实注入逻辑:
f.Msgs = a.buildMessages(...)
hint := stageHint(f.StageCtx, "turn_hint")
f.Msgs = append(f.Msgs, Message{Role:"system", Content:"[Turn hints] "+hint})
// 然后断言"最后一条是 system、内容含 [Turn hints]"
它断言的是**它自己刚 append 的那一行** —— 把 task.go:656-661 抄进测试
再验证抄得对不对。真实代码那段删掉,它照样绿。
改为:把 turn_hint 放进 StageCtx,跑**真实的 stepPrepare**
(newTaskFrame 起始就是 StepPrepare),检查它产出的 Msgs。
变异验证:删掉 task.go 里 `if hint := stageHint(...)` 那段 ⇒ 实测红。
### 2. webui 文件下发判据原是白盒
原测试直接调未导出的 `stageWebFile(src, isImage)`、断言 `webFilesDir`
包级变量 —— 那都是**实现方式**。
改为从**输出通道 handler** 走(`RegisterOutputChannel("webui", …)`,
agent 发图/发文件时走的就是它):
输入:{payload: "<路径或URL>", type: "image"|"file"}
输出:{status:"ok", url:"/files/<name>.<ext>", size:N}
4 条判据(新版 handler_files_contract_test.go):
· 图片 → 可下载 URL(前缀/扩展名/size)
· 远端 URL 原样透传(不下载)
· 危险扩展名被中和(.html 保留会在 webui 域执行脚本)
· 空 payload 被拒
变异验证:丢扩展名 ⇒ 红;远端也拷贝 ⇒ 红。
## 二、文档立规:engineering-lessons 新增第五节
原来只有「判据会骗人」,没有「判据本身形状错了」。新增 6 小节:
5.1 判据必须把被测模块当黑箱(含"如果重写内部实现、接口不变,
这条判据还会绿吗"这个可操作判据)
5.2 禁止读源码做断言(附正确替代表)
5.2.1 判据不得以文档为依据
5.3 测试文件与业务代码分离(现状:521 文件 / 11.2 万行,
agent/core 一个包 196 文件里 144 个是测试)
5.4 CI 必须覆盖每个独立 module
5.5 CI 与本地必须跑同一套门
§5.2.1 的直接触发:`interrupt_invariant_test.go` 里有一条判据写着
「事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)」
—— 被引用方自己标着「状态:计划(未实施)」,而那条判据的实际断言
只是"入队后队列里 1 条",与声称的"反向依赖现状快照"毫无关系。
该判据已删除(入队行为已由 scheduler_preempt_test 的黑箱断言覆盖)。
## 三、CI 补两个缺口
### gofmt 门
提交纪律要求 `gofmt -l` 干净,但三个 workflow 里 `gofmt` 出现 **0 次**
—— 纪律只靠人自觉。补上后未格式化的代码进不了主干。
只查本仓自管目录:SDK 是独立仓,格式由它自己的 CI 管。
### SDK 判据步骤
`third_party/hologramagentsdk/` 有自己独立的 go.mod:
go list ./... | grep third_party → 0 个
主仓 `go test ./...` **永远不包含** SDK 任何测试。实测 SDK 有 30 个判据
文件,主仓 CI 一次都没跑过。
补的步骤只跑主仓 git 跟踪的文件(CI 上只有 22 个 vendored 文件,
工作机上是完整独立仓)—— 实测 `git archive` 出 22 个文件后
`go test ./sdk/ ./meta/` 全绿。
## 四、SDK 仓测试迁移(独立仓,已单独提交 3a38ef5)
`sdk/{plugin,proxy,stress}_test.go` → `sdk/test/`,包名 `sdk` → `test`。
关键:原测试直接写 `&PluginSDK{regStage: X, name: "test"}` —— **私有字段**。
而 `New(name, sett, regTool, regStage, regAPI, regOutput)` 本来就把所有
registrar 作为参数,等价且是生产路径。共改 19 处,0 处剩余。
主仓同步更新了跟踪路径(这三个文件主仓也 vendor 了一份)。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + 改造后的判据全绿
## 后续(按计划分步)
步骤 2 webui 29 个测试分类迁移(已实测出精确分类)
步骤 3 agent/core 739 处白盒引用先分类再动
步骤 4 SDK tools/hmapdev(package main,无法迁出,只加说明)
步骤 5 CI 补 coverage + staticcheck
步骤 6 文档双语(需定术语口径)
131 lines
4.9 KiB
Go
131 lines
4.9 KiB
Go
package core
|
||
|
||
// 工具执行超时的判据(2026-10-17 生产实测缺陷)。
|
||
//
|
||
// ## 缺陷
|
||
//
|
||
// `toolcall.go` 里写死 `time.After(60 * time.Second)`,与工具自己的超时
|
||
// **互相矛盾**且让内层形同虚设:
|
||
//
|
||
// 23:13:38 执行 cmd_run(agent 传了 130s 级 timeout 参数)
|
||
// 23:14:38 ★ 内核 60s 硬超时掐断 → 「执行超时(60秒)」
|
||
//
|
||
// 同一份日志里还有:
|
||
// · healthcheck 声明默认 120s ⇒ 被砍掉一半,天生跑不完
|
||
// · output_send__commondev_* 两次 60s 超时(投递到子 pipeline 的长任务)
|
||
//
|
||
// ## 判据覆盖
|
||
//
|
||
// 1. 未声明 → 60s(与改动前逐字一致,存量插件不变)
|
||
// 2. 声明 → 用它(healthcheck 的 5m、output_send 的 3m)
|
||
// 3. 参数推导 → cmd_run 传 timeout="2m" 时给 2m(取 max)
|
||
// 4. 非法声明 → 回退默认(不静默接受、不 panic)
|
||
// 5. 超硬上限 → 夹到 10m(防插件声明成无限把队列堵死)
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||
)
|
||
|
||
// toolDefWithTimeout 造一个带 Timeout 声明的 ToolDef。
|
||
//
|
||
// 原先它单独放在 zz_timeout_helper_test.go —— 文件名前缀 `zz_` 是
|
||
// 临时调试残留的惯例,放一个**长期使用**的辅助函数在那种命名的文件里,
|
||
// 会让后来人以为它可删。辅助函数跟着唯一的调用方走。
|
||
func toolDefWithTimeout(name, timeout string) sdk.ToolDef {
|
||
return sdk.ToolDef{Name: name, Description: "test", Timeout: timeout}
|
||
}
|
||
|
||
// fakeStageHost 提供可控的 ToolDef 查询。
|
||
type fakeStageHost struct {
|
||
*StageHost
|
||
defs map[string]string // name → Timeout
|
||
}
|
||
|
||
func newFakeAgent(defs map[string]string) *Agent {
|
||
h := NewStageHost()
|
||
for name, to := range defs {
|
||
h.RegisterTool(name, toolDefWithTimeout(name, to), func(map[string]interface{}) (interface{}, error) {
|
||
return nil, nil
|
||
})
|
||
}
|
||
return &Agent{stageHost: h}
|
||
}
|
||
|
||
// ★★★ 判据:未声明超时的工具沿用 60s(存量行为不变)。
|
||
func TestToolTimeoutDefaultsTo60s(t *testing.T) {
|
||
a := newFakeAgent(nil)
|
||
if got := a.resolveToolTimeout("no_such_tool", nil); got != 60*time.Second {
|
||
t.Fatalf("未声明应得 60s,实际 %v —— 存量插件的行为必须不变", got)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:声明了就用声明的值。
|
||
func TestToolTimeoutUsesDeclaredValue(t *testing.T) {
|
||
a := newFakeAgent(map[string]string{"healthcheck": "5m"})
|
||
if got := a.resolveToolTimeout("healthcheck", nil); got != 5*time.Minute {
|
||
t.Fatalf("healthcheck 声明 5m,实际 %v", got)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:调用参数里的 timeout 参与取 max(cmd_run 场景)。
|
||
//
|
||
// 这是原 bug 的核心:模型传 timeout=2m,内核却只给 60s。
|
||
func TestToolTimeoutHonorsCallArgument(t *testing.T) {
|
||
a := newFakeAgent(nil) // cmd_run 没有静态声明
|
||
args := map[string]interface{}{"timeout": "2m"}
|
||
got := a.resolveToolTimeout("cmd_run", args)
|
||
if got != 2*time.Minute {
|
||
t.Fatalf("★ 参数 timeout=2m 应得 2m,实际 %v —— "+
|
||
"这正是原 bug:模型要 2 分钟,内核 60s 就掐断", got)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:参数比声明**小**时用声明(不因模型说了个短值就少给)。
|
||
func TestToolTimeoutTakesMaxOfDeclaredAndArg(t *testing.T) {
|
||
a := newFakeAgent(map[string]string{"slowtool": "3m"})
|
||
args := map[string]interface{}{"timeout": "10s"}
|
||
if got := a.resolveToolTimeout("slowtool", args); got != 3*time.Minute {
|
||
t.Fatalf("声明 3m、参数 10s 应取 3m,实际 %v", got)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:非法声明回退默认(不静默、不 panic)。
|
||
func TestToolTimeoutFallsBackOnInvalidDeclaration(t *testing.T) {
|
||
a := newFakeAgent(map[string]string{
|
||
"bad_unit": "130", // 生产日志里真实出现过(少了单位)
|
||
"zero": "0s", // 非正数
|
||
"negative": "-5s", // 负数
|
||
})
|
||
for _, name := range []string{"bad_unit", "zero", "negative"} {
|
||
if got := a.resolveToolTimeout(name, nil); got != defaultToolTimeout {
|
||
t.Errorf("%s 声明非法,应回退 %v,实际 %v", name, defaultToolTimeout, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:超硬上限被夹住(防声明成无限堵死队列)。
|
||
func TestToolTimeoutClampedToMax(t *testing.T) {
|
||
a := newFakeAgent(map[string]string{"greedy": "2h"})
|
||
if got := a.resolveToolTimeout("greedy", nil); got != maxToolTimeout {
|
||
t.Fatalf("2h 应被夹到 %v,实际 %v", maxToolTimeout, got)
|
||
}
|
||
// 参数路径同样要夹
|
||
if got := a.resolveToolTimeout("cmd_run", map[string]interface{}{"timeout": "2h"}); got != maxToolTimeout {
|
||
t.Fatalf("参数 2h 应被夹到 %v,实际 %v", maxToolTimeout, got)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:output_send__* 必须声明足够长的超时。
|
||
//
|
||
// 实测两次 60s 超时:它们是投递到 commondev 开发线(子 pipeline 在
|
||
// 克隆/构建/跑测试),天然超过 60s。
|
||
func TestOutputSendDeclaresLongTimeout(t *testing.T) {
|
||
to := builtinToolTimeout("output_send__commondev_br-xxx")
|
||
if to == "" {
|
||
t.Skip("output_send__* 的名字是动态的,见下一条判据")
|
||
}
|
||
}
|