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:
JianFeeeee
2026-10-22 22:42:02 +08:00
parent 55dfa959aa
commit d3590192e7
2 changed files with 73 additions and 14 deletions

View File

@ -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