Commit Graph

2 Commits

Author SHA1 Message Date
d3590192e7 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 全绿
2026-10-22 22:42:02 +08:00
f850d754c1 feat(outputch): 一 call 多发 + 多类型 —— 内核级通用能力(非某插件专属)
用户要求:内核 outputch 是通用基础,传入 meta/payload/type(三者均为**插件自定义
字段**)。要支持一次调用发多条、多种类型消息,通过 payload 数组 + type 数组实现。

## 内核原有形态的限制

output_send__<ch> 每次只能发一条单类型:
  payload: string / type: string / meta: string
带附件或多段内容的通道只能额外注册独立 tool(如 QQ 的 qq_send_file),
而这不该是插件的职责 —— "发一条消息"本来就是输出门的语义。

## 改法

### ① 工具 schema:oneOf [string, array]

payload / type / meta 三者的 schema 改为同时接受字符串与数组。
payload 是字符串时走单条路径,**行为与改动前逐字相同**(向后兼容,用户拍板)。

### ② 解析规则(parseOutputItems)

- payload 与 type 必须等长;type 省略或单值 ⇒ 广播
- meta 允许**单值广播**(用户拍板)
- 长度不符 ⇒ **整 call 拒绝,一条都不发**
- 含空条目 ⇒ 同样整 call 拒绝(不能静默发空消息)

### ③ 能力校验下放插件(用户拍板)

删掉内核里的 `switch rawType { case text/voice/image/file: 查 caps }`:

  · type 是**插件自定义枚举**,内核不认识它。写死四个值 = 内核在理解插件语义;
  · 且插件的自定义类型(如 QQ 的 sticker)会被内核无端拒绝。

内核只保留两件它确实该管的校验:**授权闸**(IsOutputAllowed)与
**通道存在性**。type 与能力的匹配由插件在 handler 里判断并**返回 error**。

### ④ 部分失败:继续发剩余的,如实逐项汇报

用户要求"部分失败向 agent 返回失败项"。实现选择"继续发完"而不是"中止":
第 2 条图片失败不该把第 3 条文字也丢掉,否则"一次发多条"失去意义。

返回形态:
  全部成功(多条)  已通过 [qq] 通道发送 3 条
  部分失败          已通过 [qq] 通道发送 2/3 条:
                      [1] text ok
                      [2] image 失败:<插件返回的错误原文>
                      [3] text ok
                    失败的条目请单独重发。

单条成功仍返回 "ok"(与旧版完全一致,避免富回执驱动回声效应 ——
output.go 里有实测依据)。

### ⑤ 拆出 sendOneOutput

before_output / 安全回填 / after_output 这套顺序对**每一条**都必须完整走一遍。
混在批量循环里极易漏环(例如只给第一条跑 stage),所以把单条路径独立成函数,
批量只是循环调用它。

## 判据(8 条,全部有变异自证)

| 判据 | 覆盖 |
|---|---|
| SingleValueUnchanged | 单值 payload 行为与旧版逐字相同(返回 "ok") |
| ArraySendsInOrder | 数组按顺序发 N 条,type 一一对应 |
| MetaBroadcast | meta 单值广播给所有条 |
| LengthMismatchRejectsWholeCall | 长度不符 ⇒ 一条都不发 |
| PartialFailureReportsAndContinues | 第 2 条失败仍尝试第 3 条 + 如实列出失败项与插件错误原文 |
| KernelDoesNotInterpretType | 自定义 type "sticker" 原样透传,内核不拦 |
| RejectsEmptyEntries | 含空条目整 call 拒绝 |
| UnauthorizedStillBlocked | 授权闸不被批量绕过 |

变异全部被抓:
- 第 2 条失败即中止 → 「实际只调用 2 次」
- 恢复内核 type 能力校验 → 「内核不该对 type 做能力判断」
- meta 不广播 → 「meta 未广播到」

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- 现有 output 相关测试(output_grant / output_rules / resident_output)全绿

## 未做(下一步)

- example 插件(qq)收回 qq_send_file / qq_send_sticker,改走 outputch
- outputch 说明迁入各插件 skill(保留 _help 机制)
- 插件侧补 type 能力自校验(校验已下放,需要它们真的做)
2026-10-22 21:05:17 +08:00