mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
## 1. Qzone 说说(send_qzone_msg / delete_qzone_msg)
一次真实的误判,值得记下来:我曾断言「生产 NapCat 不支持发说说」,
依据是探测返回「不支持的Api SendQzoneMsg」——**是我的调用名错了**。
router.ts 里声明的是下划线式 `send_qzone_msg`,我用的是源码**类名**
`SendQzoneMsg`。换对名字后一次成功。
教训:「API 不存在」这个结论必须用真实 action 名验证;源码里的
类名/文件名/方法名都不是调用名。判据 TestQzoneActionNamesAreSnakeCase
把这条钉住了。
本地校验 ugc_right 白名单(1/4/16/64/128)与 16/128 必带 target_uins,
省一次跨进程往返、给模型更直接的反馈。
## 2. 内部委派不得向群聊发送(现场事故)
offload 子 agent 的「分诊汇报」把本该私聊给用户的进度播报**发到了群里**:
17:15:41 executing tool: output_send__qq
17:15:41 handleChannelOutput meta={"group_id": 979911915}
17:15:42 群友质问「又没@你,你就这么确定是找你的?」
根因:子 agent 被授予全部输出通道(AllowedOutputs=nil,用户明确要求
"持有全部输出通道"),于是它能自己选目标——而它只从上下文里看到一个
群号,就选了群。
修法:`beforeToolcall` 在 internal 放行前调用 outputSendGroupTarget,
识别出群目标就写 denial。为什么不放在 handleChannelOutput:ToolHandler
只收 args,拿不到帧上的 comefrom(SDK 契约如此),只有 stage 帧能看到。
## 3. @全体成员不算 @bot
原实现 `if v == botIDStr || v == "all" { return true }` —— 任何人
@全体都会拉起 bot。那不是"在叫 bot",是"在叫所有人":一个是针对性请求,
一个是广播,语义完全不同。
群里误响应的代价不是"多回一句",而是**在未被邀请的场合发言**。
宁可漏响应(用户会再 @ 一次),不能误响应。
## 4. 状态语义按用户定义重排
学习中 = 等待 LLM 搬砖中 = 执行 toolcall 在线 = 空闲
pre_action → 学习中(stepLLM 之前,唯一能提前标记"要等模型"处)
before_toolcall → 搬砖中
after_toolcall → 学习中(工具回来了,又要等 LLM)
before_output → 不改(LLM 已回、即将输出,不是等模型)
after_output → 在线(force,绕防抖)
原先「学习中」挂在 before_output 是错的:那个时刻 LLM 已经返回。
## 5. 「对方正在输入中」按轮驱动
起点从 HTTP 入口(消息刚到)改到 before_output(每轮只触发一次),
终点从 output_send(任何发送都停,含群聊/发给别人)改到 after_output。
身份由 InjectOptions.Payload 携带 `qq_uid`,内核透传到 stageCtx.Extra。
不用全局 p.auth:并发私聊会串号(A 的指示打到 B 身上)。
## 6. 启动日志不再硬编码工具数
`log.Printf("... (%d tools)", p.name, 15)` 硬编码了 15,工具加到 24 后
仍报 15 —— 排查"qzone 装上没"时被误导了好几轮。改成真实计数 p.toolCount。
## 判据(新增 13 条,变异 8/8 咬)
Qzone 5 条、群发拦截 5 条、@门 4 条、状态映射 7 条、打字 7 条。
★ 两个判据自身缺陷被变异抓出并修掉:
· @门判据用字符串找 `}\n\t}` 截分支,而 joined 已 strip 缩进 ⇒ 找不到
⇒ 分支延伸到文件末尾 ⇒ "文件里任何别处的 return"都让它通过。
改用括号计数 ifBlockAt。
· 状态判据只查常量名,`_ = qqStatusStudy` 就蒙混过关。改查真实调用
applyStatus(qqStatusStudy)。
152 lines
6.7 KiB
Go
152 lines
6.7 KiB
Go
package main
|
||
|
||
// 状态映射的判据。
|
||
//
|
||
// ## 用户定义的语义(2026-10-18)
|
||
//
|
||
// 学习中 = agent 正在**等待 LLM**
|
||
// 搬砖中 = 真正**执行 toolcall**
|
||
// 在线 = **空闲**
|
||
//
|
||
// ## 内核时序(task.go,权威)
|
||
//
|
||
// pre_action ──→ stepLLM() ──→ ★等 LLM★ ──→ post_action
|
||
// ↓
|
||
// 工具循环: before_toolcall → ★跑工具★ → after_toolcall
|
||
// ↓
|
||
// 再次 stepLLM(第二轮工具)或 before_output → after_output
|
||
//
|
||
// 所以正确映射是:
|
||
//
|
||
// pre_action → 学习中 (即将调 LLM,接下来就是等)
|
||
// before_toolcall → 搬砖中 (即将/正在跑工具)
|
||
// after_toolcall → 学习中 (工具回来了,又要等 LLM 决定下一步)
|
||
// before_output → 不改状态 (LLM 已回、即将输出;不是等 LLM 也不是跑工具)
|
||
// after_output → 在线 (一轮真正结束,空闲)
|
||
//
|
||
// ## 曾经错在哪
|
||
//
|
||
// 「学习中」被挂在 `before_output` 上 —— 那个时刻 LLM **已经返回**了,
|
||
// 等的是输出而不是等模型。后果:等待 LLM 的整段时间状态是错的
|
||
// (停在上一轮的「搬砖中」或「在线」),用户看不到 agent 在想。
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// ★★★ 判据 1:pre_action 必须切「学习中」(这是唯一的"即将等 LLM"时点)。
|
||
func TestStatusStudyOnPreAction(t *testing.T) {
|
||
lines := codeLines(t, "status.go")
|
||
body := funcBody(t, lines, "func (p *Plugin) stagePreAction(")
|
||
if !strings.Contains(body, "applyStatus(qqStatusStudy)") {
|
||
t.Fatal("★ pre_action 没有切「学习中」——\n" +
|
||
" 内核时序里 pre_action 在 stepLLM() **之前**,这是唯一能提前标记\n" +
|
||
" “接下来要等模型”的位置。不在这里切,等待 LLM 的整段时间状态都是错的")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 2:before_toolcall 必须切「搬砖中」。
|
||
func TestStatusWorkingOnBeforeToolcall(t *testing.T) {
|
||
lines := codeLines(t, "status.go")
|
||
body := funcBody(t, lines, "func (p *Plugin) stageBeforeToolcall(")
|
||
if !strings.Contains(body, "applyStatus(qqStatusWorking)") {
|
||
t.Fatal("★ before_toolcall 没有切「搬砖中」")
|
||
}
|
||
// 权限门必须仍在这里(合并后不能丢)
|
||
if !strings.Contains(body, "p.beforeToolcall(ctx)") {
|
||
t.Fatal("★ before_toolcall 里权限门丢了 —— 合并时把安全逻辑吞掉了")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 3:after_toolcall 必须回到「学习中」。
|
||
//
|
||
// 不回到学习中,状态会在整个工具循环里挂着「搬砖中」,看起来像工具卡住。
|
||
func TestStatusStudyOnAfterToolcall(t *testing.T) {
|
||
lines := codeLines(t, "status.go")
|
||
body := funcBody(t, lines, "func (p *Plugin) stageAfterToolcall(")
|
||
if !strings.Contains(body, "applyStatus(qqStatusStudy)") {
|
||
t.Fatal("★ after_toolcall 没回到「学习中」——\n" +
|
||
" 工具回来了,接下来又要等 LLM 决定下一步。不切会让状态\n" +
|
||
" 在整个工具循环里挂着「搬砖中」,看起来像卡住")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 4:before_output **不**改在线状态。
|
||
//
|
||
// 那个时刻 LLM 已返回、即将输出 —— 既不是等 LLM 也不是跑工具。
|
||
// 在那里写「学习中」会让状态在收尾时继续挂着,看起来像永远在想。
|
||
func TestStatusNotSetOnBeforeOutput(t *testing.T) {
|
||
lines := codeLines(t, "status.go")
|
||
body := funcBody(t, lines, "func (p *Plugin) stageBeforeOutput(")
|
||
// 必须检查**真实调用**:`_ = qqStatusStudy` 这种引用不该算作"改了状态",
|
||
// 但 `applyStatus(qqStatusStudy)` 必须算。反过来也一样——
|
||
// 只查常量名会让"引用了但没调用"的变异漏网(实测)。
|
||
for _, bad := range []string{"applyStatus(qqStatusStudy)", "applyStatus(qqStatusWorking)", "applyStatusForce(qqStatusOnline)"} {
|
||
if strings.Contains(body, bad) {
|
||
t.Fatalf("★ before_output 改了在线状态(%s)——\n"+
|
||
" 这个时刻 LLM 已返回、即将输出,既不是等 LLM 也不是跑工具;\n"+
|
||
" 在这里写「学习中」会让状态在收尾时继续挂着,看起来像永远在想", bad)
|
||
}
|
||
}
|
||
// 但它必须仍负责打字指示
|
||
if !strings.Contains(body, "p.startTypingForTurn(ctx)") {
|
||
t.Fatal("★ before_output 没有发起打字指示")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 5:after_output 必须是「在线」(force,绕防抖)。
|
||
//
|
||
// 一轮结束时是唯一的"归位"机会;被防抖拦住就会永远停在错误状态。
|
||
func TestStatusOnlineOnAfterOutput(t *testing.T) {
|
||
lines := codeLines(t, "status.go")
|
||
body := funcBody(t, lines, "func (p *Plugin) stageAfterOutput(")
|
||
if !strings.Contains(body, "applyStatusForce(qqStatusOnline)") {
|
||
t.Fatal("★ after_output 没有 force 归位「在线」——\n" +
|
||
" 一轮结束是唯一的归位机会;用 applyStatus(带防抖)会被\n" +
|
||
" 2 秒间隔拦住,进而永远停在错误状态")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 6:pre_action / after_toolcall 必须真的注册了。
|
||
//
|
||
// 光有函数不够 —— 不注册就等于没写。这类“函数在、注册忘”的漏洞
|
||
// 编译器不报、运行时不报错。
|
||
func TestStatusStagesRegistered(t *testing.T) {
|
||
lines := codeLines(t, "plugin.go")
|
||
joined := strings.Join(lines, "\n")
|
||
for _, s := range []string{
|
||
"sdk.StagePreAction, p.stagePreAction",
|
||
"sdk.StageAfterToolcall, p.stageAfterToolcall",
|
||
} {
|
||
if !strings.Contains(joined, s) {
|
||
t.Fatalf("★ 没有注册 stage:%s(函数写了但没注册 = 没写)", s)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 7:启动日志里的工具数必须是**真实计数**,不能写死。
|
||
//
|
||
// 这里原本是 `log.Printf("... (%d tools)", p.name, 15)` —— 硬编码。
|
||
// 后来工具从 15 加到 24,日志仍报 15。
|
||
//
|
||
// 后果不是"日志难看",而是**排查时被误导**:我查"qzone 工具装上没",
|
||
// 看到日志说 15 tools,以为装的是旧版,白折腾了好几轮。
|
||
// 日志说谎比不日志更坏 —— 因为它看起来像证据。
|
||
func TestToolCountLoggedIsNotHardcoded(t *testing.T) {
|
||
for _, ln := range codeLines(t, "plugin.go") {
|
||
if !strings.Contains(ln, "[qq] plugin started:") {
|
||
continue
|
||
}
|
||
if !strings.Contains(ln, "p.toolCount") {
|
||
t.Fatalf("★ 启动日志没报真实工具数:%q\n"+
|
||
" 硬编码的数字会随工具增减而失真,进而误导排查", ln)
|
||
}
|
||
}
|
||
// 计数必须真的在注册时累加(否则 p.toolCount 恒为 0,比硬编码更糟)
|
||
body := funcBody(t, codeLines(t, "plugin.go"), "func (p *Plugin) regTool(")
|
||
if !strings.Contains(body, "p.toolCount++") {
|
||
t.Fatal("★ regTool 没有累加 p.toolCount —— 计数恒为 0,日志会报 `0 tools`")
|
||
}
|
||
}
|