mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
fix(outputch): 内核不再替插件判断载荷语义 —— 空 payload 是合法占位
## 实测踩到的真 bug(生产环境)
QQ 插件重装后首次实测 `sticker_index` 用法,直接失败:
output_send__qq result: 参数有误,未发送任何内容:payload 不能为空
而插件侧明明有这个放行分支:
if payload == "" && !(rawType == "sticker" && stickerIndex > 0) {
return nil, fmt.Errorf("payload 不能为空")
}
分支永远走不到 —— 内核先拒了。
## 根因:两处内核越权判断
| 位置 | 旧判断 | 问题 |
|---|---|---|
| `parseOutputItems` | `strings.TrimSpace(p) == ""` ⇒ 拒绝 | 内核不知道 payload 语义。`type=sticker` + `meta.sticker_index` 要求 payload 留空占位,真实 URL 由插件从收藏表情取 |
| `sendOneOutput` | `stageCtx.FinalText == ""` ⇒ "输出被插件清空" | 原本就空的占位被判成异常 |
**正确边界**:参数**是否存在**归内核(没传 `payload` 键 ⇒ 拒绝,结构性错误内核能判);参数**值是否合法**归插件(空串 ⇒ 交插件判断并返回 error)。
这与本轮 outputch 的另一条改动是同一条原则(`type` 的能力校验下放插件):
**内核只透传与对齐,不解释插件自定义字段的含义**。
## 连带修正:`stringOrArray("")` 不再返回 nil
原来 `payload: ""` 被当成"未提供",与"没传 payload 键"混为一谈。
现在空串**保留为一个元素**:前者是模型明确给的空占位(须走到插件),
后者才是真的没提供(由 `len(payloads) == 0` 拦下)。
## 判据调整(原有判据与正确设计冲突)
`TestOutputSend_RejectsEmptyEntries` 断言"含空条目必须整 call 拒绝" —— 那是
按旧的越权设计写的,**与正确边界冲突**。改写为三条分别钉住边界:
| 判据 | 断言 |
|---|---|
| `EmptyPayloadDeferredToPlugin` | 内核不拦空条目,交插件判断(2 条都要送到) |
| `EmptyStringIsNotMissing` | `payload: ""` 是占位而非缺失,必须走到插件且原样透传空串 |
| `MissingPayloadStillRejected` | 完全没传 payload 键仍拒绝(内核能判的结构性错误) |
变异验证(把 trim 判空加回 `parseOutputItems`)⇒ 两条同时变红:
- 「内核不该拦空条目,应交插件判断,实际只发 0 条」
- 「空字符串 payload 应交给插件(sticker_index 用法依赖它),实际 0 次调用」
## 验证
- `go test ./internal/agent/core/` 全绿(含新 3 条)
- 生产实测修复前后对照(journalctl 原文):
21:54:35 output_send__qq result: 参数有误,未发送任何内容:payload 不能为空 ← 修复前
21:56:34 output_send__qq result: ok ← 修复后
- webui / agent-core 全绿
This commit is contained in:
@ -68,11 +68,17 @@ func parseOutputItems(args map[string]interface{}) ([]outputItem, error) {
|
||||
if len(payloads) == 0 {
|
||||
return nil, fmt.Errorf("payload 不能为空")
|
||||
}
|
||||
for i, p := range payloads {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
return nil, fmt.Errorf("payload 第 %d 条为空", i+1)
|
||||
}
|
||||
}
|
||||
// ★ 空字符串元素**不在这里拒绝**(2026-10-22 实测踩到)。
|
||||
//
|
||||
// 内核不知道 payload 的语义:它可能是"要发的内容",也可能是占位 ——
|
||||
// 例如 QQ 的 `type=sticker` + `meta.sticker_index=1`,真实 URL 由插件从
|
||||
// 收藏表情里取,模型根本不知道该往 payload 填什么。内核在这里判空,
|
||||
// 等于用一个它不理解的字段把插件的合法用法挡掉(实测报
|
||||
// "payload 不能为空",而插件侧的放行分支永远走不到)。
|
||||
//
|
||||
// 正确边界:**参数是否存在归内核管,参数值是否合法归插件管**。
|
||||
// 所以只拒绝"整条数组里没有任何一项"这种结构性错误(上面那条),
|
||||
// 逐项内容交给插件判断并返回 error。
|
||||
|
||||
// type 对齐:省略 / 单值 ⇒ 广播;否则必须与 payload 等长
|
||||
if len(types) == 0 {
|
||||
@ -125,9 +131,12 @@ func parseOutputItems(args map[string]interface{}) ([]outputItem, error) {
|
||||
func stringOrArray(v interface{}) []string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
// 空字符串**保留**为一个元素,不当"未提供"处理。
|
||||
//
|
||||
// 区别很重要:`payload: ""` 是"模型明确给了一个空载荷占位"
|
||||
// (插件可能从 meta 里取真实内容,如 QQ 的 sticker_index),
|
||||
// 而"没传 payload 这个键"才是真的没提供。前者归插件判断合法性,
|
||||
// 后者由 parseOutputItems 的 len==0 拦下。
|
||||
return []string{t}
|
||||
case []string:
|
||||
return t
|
||||
@ -246,7 +255,11 @@ func (a *Agent) sendOneOutput(channel string, item outputItem) (string, bool) {
|
||||
if stageCtx.Response != nil {
|
||||
return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response), false
|
||||
}
|
||||
if stageCtx.FinalText == "" {
|
||||
// 只有"原本非空、被插件清空"才是异常(防插件静默吞掉内容却仍算成功)。
|
||||
//
|
||||
// 原本就空 ⇒ 合法占位(如 type=sticker + meta.sticker_index,真实 URL
|
||||
// 由插件自己取),不能报"被清空"——那是拿内核不理解的字段做判断。
|
||||
if item.payload != "" && stageCtx.FinalText == "" {
|
||||
return "输出被插件清空", false
|
||||
}
|
||||
args["payload"] = stageCtx.FinalText
|
||||
|
||||
@ -260,19 +260,65 @@ func TestOutputSend_KernelDoesNotInterpretType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 空 payload 与非字符串元素必须被拒(不能静默发空消息)。
|
||||
func TestOutputSend_RejectsEmptyEntries(t *testing.T) {
|
||||
// ⑦ 空 payload 交给插件判断,内核不拦(★ 实测踩到的真实缺陷)。
|
||||
//
|
||||
// 原先这里是"含空条目必须整 call 拒绝"。但内核**不知道 payload 的语义**:
|
||||
// QQ 的 `type=sticker` + `meta.sticker_index=1` 需要 payload 留空占位,
|
||||
// 真实 URL 由插件从收藏表情里取。旧实现在内核判空 ⇒ 插件侧的放行分支
|
||||
// 永远走不到 ⇒ 生产实测报 "payload 不能为空",功能完全不可用。
|
||||
//
|
||||
// 正确边界:**参数是否存在归内核,参数值是否合法归插件**。
|
||||
//
|
||||
// 变异:把 trim 判空加回去 ⇒ 红(内核又替插件做决定了)。
|
||||
func TestOutputSend_EmptyPayloadDeferredToPlugin(t *testing.T) {
|
||||
ch := newRecordingChannel()
|
||||
a := newOutputTestAgent(t, ch, agentIO.CapText)
|
||||
|
||||
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
|
||||
"payload": []interface{}{"正常", " "},
|
||||
"meta": `{"sticker_index":1}`,
|
||||
}))
|
||||
// 内核应把两条都交给插件(内容合法性由插件判)
|
||||
if len(ch.calls) != 2 {
|
||||
t.Errorf("内核不该拦空条目,应交插件判断,实际只发 %d 条(返回 %q)", len(ch.calls), out)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦b 单个空 payload 是"占位",不是"没传"。
|
||||
//
|
||||
// 区别:`payload: ""` 说明模型明确给了空占位;"没传 payload 键"才是真没提供。
|
||||
// 前者必须走到插件(否则 sticker_index 用法永远不可用)。
|
||||
func TestOutputSend_EmptyStringIsNotMissing(t *testing.T) {
|
||||
ch := newRecordingChannel()
|
||||
a := newOutputTestAgent(t, ch, agentIO.CapText)
|
||||
|
||||
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
|
||||
"payload": "",
|
||||
"type": "sticker",
|
||||
"meta": `{"user_id":1,"sticker_index":1}`,
|
||||
}))
|
||||
if len(ch.calls) != 1 {
|
||||
t.Fatalf("空字符串 payload 应交给插件(sticker_index 用法依赖它),实际 %d 次调用(返回 %q)",
|
||||
len(ch.calls), out)
|
||||
}
|
||||
if p, _ := ch.calls[0]["payload"].(string); p != "" {
|
||||
t.Errorf("payload 应原样透传空串,实际 %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦c 但"完全没传 payload 键"仍必须拒绝(结构性错误,内核能判)。
|
||||
func TestOutputSend_MissingPayloadStillRejected(t *testing.T) {
|
||||
ch := newRecordingChannel()
|
||||
a := newOutputTestAgent(t, ch, agentIO.CapText)
|
||||
|
||||
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
|
||||
"type": "text",
|
||||
}))
|
||||
if len(ch.calls) != 0 {
|
||||
t.Errorf("含空条目的批量必须整 call 拒绝,实际发了 %d 条", len(ch.calls))
|
||||
t.Errorf("完全没传 payload 必须拒绝,实际发了 %d 条", len(ch.calls))
|
||||
}
|
||||
if !strings.Contains(out, "为空") {
|
||||
t.Errorf("返回值应指出空条目,实际: %q", out)
|
||||
if !strings.Contains(out, "payload 不能为空") {
|
||||
t.Errorf("返回值应说明 payload 不能为空,实际 %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user