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 能力自校验(校验已下放,需要它们真的做)
This commit is contained in:
JianFeeeee
2026-10-22 21:05:17 +08:00
parent 881f3c34df
commit f850d754c1
3 changed files with 526 additions and 82 deletions

View File

@ -770,20 +770,40 @@ func (a *Agent) buildToolDefs() []interface{} {
desc = ch.Name + " 输出通道"
}
tools = append(tools, toolDef("output_send__"+ch.Name, desc+"。能力: "+capStr+"。payload 为消息载荷(type 默认 text,可省略),meta 为 JSON 发送元数据。用 _help 查看 meta 格式与 type 枚举。", map[string]interface{}{
"payload": map[string]interface{}{
"type": "string",
"description": "消息载荷。type=text 时填文字,type=file/image 时填 URL 或路径",
},
"meta": map[string]interface{}{
"type": "string",
"description": "JSON 对象,包含发送所需的元数据。用 output_send__" + ch.Name + "_help 查看 meta 格式",
},
"type": map[string]interface{}{
"type": "string",
"description": "载荷类型,默认 text;其它枚举用 channel._help 查看",
},
}, "payload"))
// ★ 一次可发多条、多种类型(2026-10-22)。
//
// payload / type / meta 同时接受字符串与数组 —— 单值时行为与旧版逐字相同
// (向后兼容)。描述里**不写** type 的具体枚举:那是插件自定义字段,
// 内核不该承载它的语义文档(用户明确要求技术性内容收敛到 skill 与 _help)。
tools = append(tools, toolDef("output_send__"+ch.Name,
desc+"。能力: "+capStr+"。\n"+
"payload 是消息载荷,可以是**一条字符串**,也可以是**数组**(一次发多条)。\n"+
"type 是载荷类型,meta 是发送元数据 —— 两者的格式与取值由该通道定义,"+
"用 output_send__"+ch.Name+"_help 或对应 skill 查看。\n"+
"type/meta 只给一个值时,会应用到 payload 的每一条。",
map[string]interface{}{
"payload": map[string]interface{}{
"description": "消息载荷。字符串=发一条;数组=一次发多条(按数组顺序送达)",
"oneOf": []interface{}{
map[string]interface{}{"type": "string"},
map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
},
},
"type": map[string]interface{}{
"description": "载荷类型。字符串=全部同类型;数组=与 payload 一一对应(数量必须相等)。缺省按通道默认",
"oneOf": []interface{}{
map[string]interface{}{"type": "string"},
map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
},
},
"meta": map[string]interface{}{
"description": "发送元数据(JSON 字符串)。只给一个值时广播给所有条;数组则一一对应。格式见 _help 或对应 skill",
"oneOf": []interface{}{
map[string]interface{}{"type": "string"},
map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
},
},
}, "payload"))
tools = append(tools, toolDef("output_send__"+ch.Name+"_help", "查看 "+ch.Name+" 输出通道的 meta 格式说明和 type 枚举", map[string]interface{}{}))
}