mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 06:17:25 +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)。
103 lines
4.0 KiB
Go
103 lines
4.0 KiB
Go
package main
|
||
|
||
// 判据:内部委派的工具调用**不得向群聊发送消息**。
|
||
//
|
||
// ## 现场事故(2026-10-18)
|
||
//
|
||
// offload 子 agent 的「分诊汇报」把本该私聊给用户的**进度播报发到了群里**:
|
||
//
|
||
// 17:15:00 interrupt from offload-1792313969/child: 分诊汇报
|
||
// 17:15:41 executing tool: output_send__qq
|
||
// 17:15:41 handleChannelOutput meta={"group_id": 979911915}
|
||
//
|
||
// 根因:子 agent 被授予全部输出通道(AllowedOutputs=nil,用户明确要求
|
||
// "持有全部输出通道"),于是它能自己选目标 —— 而它只从上下文里看到一个群号,
|
||
// 就选了群。
|
||
//
|
||
// ## 为什么必须由此处硬拦(提示词不够)
|
||
//
|
||
// 提示词是"软"的;而群的发言**公开可见**。子 agent 看不到群里的上下文、
|
||
// 分不清每句话是回给谁的 —— 它只有一句任务描述。让它自由向群发言,
|
||
// 等于把私聊级别的判断拿到公开场合执行。
|
||
//
|
||
// 真需要群里回时,由**父**(有完整上下文与用户身份)来发 ——
|
||
// 父的 comefrom 是 user,不受这条约束。
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/JianFeeeee/homeagentsdk/sdk"
|
||
)
|
||
|
||
func mkOutputCall(name, meta string) *sdk.StageContext {
|
||
return &sdk.StageContext{
|
||
Extra: map[string]interface{}{"comefrom": "internal"},
|
||
ToolCalls: []sdk.ToolCall{{
|
||
Name: name,
|
||
Arguments: map[string]interface{}{"payload": "x", "type": "text", "meta": meta},
|
||
}},
|
||
}
|
||
}
|
||
|
||
// ★★★ 群目标必须被识别出来。
|
||
func TestOutputSendGroupTargetDetectsGroup(t *testing.T) {
|
||
ctx := mkOutputCall("output_send__qq", `{"group_id": 979911915}`)
|
||
if got := outputSendGroupTarget(ctx, "qq"); got != "979911915" {
|
||
t.Fatalf("★ 没识别出群目标,got=%q —— 进度播报会照旧发到群里", got)
|
||
}
|
||
}
|
||
|
||
// 私聊目标不算(那是允许的)。
|
||
func TestOutputSendGroupTargetIgnoresPrivate(t *testing.T) {
|
||
ctx := mkOutputCall("output_send__qq", `{"user_id": 1456128421}`)
|
||
if got := outputSendGroupTarget(ctx, "qq"); got != "" {
|
||
t.Fatalf("★ 私聊目标被误判成群目标:%q", got)
|
||
}
|
||
}
|
||
|
||
// 别的通道的 output_send 不管(本插件只约束自己)。
|
||
func TestOutputSendGroupTargetIgnoresOtherChannel(t *testing.T) {
|
||
ctx := mkOutputCall("output_send__webui", `{"group_id": 1}`)
|
||
if got := outputSendGroupTarget(ctx, "qq"); got != "" {
|
||
t.Fatalf("★ 别的通道被误拦:%q", got)
|
||
}
|
||
}
|
||
|
||
// 非 output_send 工具不管。
|
||
func TestOutputSendGroupTargetIgnoresOtherTools(t *testing.T) {
|
||
ctx := &sdk.StageContext{
|
||
ToolCalls: []sdk.ToolCall{{
|
||
Name: "qq_send_private_msg",
|
||
Arguments: map[string]interface{}{"meta": `{"group_id": 1}`},
|
||
}},
|
||
}
|
||
if got := outputSendGroupTarget(ctx, "qq"); got != "" {
|
||
t.Fatalf("★ 非 output_send 工具被误拦:%q", got)
|
||
}
|
||
}
|
||
|
||
// ★★★ 接线判据:beforeToolcall 必须**真的**在 internal 放行前调用它。
|
||
//
|
||
// 这一类“函数写了但没接上”编译器不报、运行时不报错 ——
|
||
// 事故还会照旧发生。必须按代码行验证调用存在且位置正确。
|
||
func TestInternalGroupSendBlockIsWired(t *testing.T) {
|
||
lines := codeLines(t, "plugin.go")
|
||
body := funcBody(t, lines, "func (p *Plugin) beforeToolcall(")
|
||
if !strings.Contains(body, "outputSendGroupTarget(ctx, p.name)") {
|
||
t.Fatal("★ beforeToolcall 没调用 outputSendGroupTarget —— " +
|
||
"内部子 agent 仍可把消息发到群里(事故会复发)")
|
||
}
|
||
// 必须在 isInternalToolCall 的分支里(内部委派专用约束)
|
||
iInt := strings.Index(body, "if isInternalToolCall(ctx) {")
|
||
iCall := strings.Index(body, "outputSendGroupTarget(ctx, p.name)")
|
||
if iInt < 0 || iCall < 0 || iCall < iInt {
|
||
t.Fatal("★ outputSendGroupTarget 不在 isInternalToolCall 分支内 —— " +
|
||
"约束会错误地作用到用户请求上")
|
||
}
|
||
// 拦截必须落到 setDenial(写拒绝指令),而不是 return error(会让整轮失败)
|
||
if !strings.Contains(body, "p.setDenial(ctx, msg)") {
|
||
t.Fatal("★ 拦截没写 denial —— 模型不会知道自己被拒,可能反复重试")
|
||
}
|
||
}
|