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

@ -6,7 +6,6 @@ import (
"strings"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
@ -28,84 +27,227 @@ import (
//
// 通道插件在这个时点需要知道"这条消息是发给谁的",所以把本轮 payload
// 透传进来(与 emitResponse 用同一套 Extra 语义)。
// outputItem 是一条待发送的输出项。
//
// 内核只承载三个**插件自定义字段**,不懂它们的语义:
//
// payload 载荷内容(文本 / URL / 路径…由插件解释)
// type 载荷类型(插件自定义枚举,内核不校验)
// meta 发送元数据(JSON 字符串,插件解释)
type outputItem struct {
payload string
typ string
meta string
}
// parseOutputItems 把工具参数解析成 1..N 条待发送项。
//
// ## 一次调用发多条(2026-10-22)
//
// 模型可以传数组来实现"一条回复里发多条、多种类型":
//
// payload: ["收到,我看看", "https://.../a.png", "/tmp/report.pdf"]
// type: ["text", "image", "file"]
// meta: "{\"user_id\":123}" ← 单值:广播给每一条
// 或 ["{...}", "{...}", "{...}"] ← 数组:一一对应
//
// ## 对齐规则(用户拍板)
//
// - payload 与 type 必须等长;type 省略/单值 ⇒ 全部复用该值(缺省 text)
// - 长度不符 ⇒ **整 call 拒绝,一条都不发**(不能发一半再报错)
// - meta 允许单值广播
//
// ## 向后兼容
//
// payload 为字符串时走单条路径,行为与改动前逐字相同。
func parseOutputItems(args map[string]interface{}) ([]outputItem, error) {
payloads := stringOrArray(args["payload"])
types := stringOrArray(args["type"])
metas := stringOrArray(args["meta"])
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)
}
}
// type 对齐:省略 / 单值 ⇒ 广播;否则必须与 payload 等长
if len(types) == 0 {
types = []string{"text"}
}
if len(types) == 1 && len(payloads) > 1 {
broadcast := types[0]
types = make([]string, len(payloads))
for i := range types {
types[i] = broadcast
}
}
if len(types) != len(payloads) {
return nil, fmt.Errorf("payload 有 %d 条,type 有 %d 条 —— 数量必须一致"+
"(或只给一个 type 表示全部同类型)", len(payloads), len(types))
}
for i, ty := range types {
if strings.TrimSpace(ty) == "" {
types[i] = "text"
}
}
// meta 对齐:省略 / 单值 ⇒ 广播
if len(metas) == 1 && len(payloads) > 1 {
broadcast := metas[0]
metas = make([]string, len(payloads))
for i := range metas {
metas[i] = broadcast
}
}
if len(metas) == 0 {
metas = make([]string, len(payloads))
}
if len(metas) != len(payloads) {
return nil, fmt.Errorf("payload 有 %d 条,meta 有 %d 条 —— 数量必须一致"+
"(或只给一个 meta 表示全部同一目标)", len(payloads), len(metas))
}
items := make([]outputItem, len(payloads))
for i := range payloads {
items[i] = outputItem{payload: payloads[i], typ: types[i], meta: metas[i]}
}
return items, nil
}
// stringOrArray 把一个 JSON 参数读成 []string。
//
// 同时接受字符串、字符串数组、以及 []interface{}(encoding/json 的默认形态)。
// 空字符串视为"未提供"(返回 nil),与改动前的 `payload == ""` 判据一致。
func stringOrArray(v interface{}) []string {
switch t := v.(type) {
case string:
if t == "" {
return nil
}
return []string{t}
case []string:
return t
case []interface{}:
out := make([]string, 0, len(t))
for _, e := range t {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
return nil
}
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
channel := strings.TrimPrefix(tc.Name, "output_send__")
payload, _ := tc.Arguments["payload"].(string)
rawType, _ := tc.Arguments["type"].(string)
if channel == "" || payload == "" {
return "工具名称格式: output_send__{channel},payload 不能为空"
}
// type 缺省按 text 处理:绝大多数输出就是文本,让模型为"省略一个默认值"付一次
// 失败重试没有意义(判据该拦的是"不知道发什么",不是"没写众所周知的默认值")。
if rawType == "" {
rawType = "text"
if channel == "" {
return "工具名称格式: output_send__{channel}"
}
// 授权闸(纵深防御):模型可能凭名字直接调未授权的输出门。
if !a.IsOutputAllowed(channel) {
return fmt.Sprintf("通道 [%s] 未授权给本 agent。可用通道见 output_list_channels", channel)
}
meta, _ := tc.Arguments["meta"].(string)
caps := a.io.GetChannelCapabilities(channel)
if caps == 0 {
// 通道存在性是**内核唯一该管的校验**。type 与能力的匹配下放给插件:
// type 是插件自定义枚举,内核不认识它(写死 text/image/file 就是内核在
// 理解插件语义,且插件的自定义类型会被无端拒绝)。
if a.io.GetChannelCapabilities(channel) == 0 {
return fmt.Sprintf("通道 [%s] 不存在或不可用。可用输出工具列表见 output_list_channels", channel)
}
switch rawType {
case "text":
if !caps.Supports(agentIO.CapText) {
return fmt.Sprintf("通道 [%s] 不支持文本输出(能力: %s)", channel, caps.String())
}
case "voice", "audio":
if !caps.Supports(agentIO.CapAudio) {
return fmt.Sprintf("通道 [%s] 不支持语音输出(能力: %s)", channel, caps.String())
}
case "image":
if !caps.Supports(agentIO.CapImage) {
return fmt.Sprintf("通道 [%s] 不支持图片输出(能力: %s)", channel, caps.String())
}
case "file":
if !caps.Supports(agentIO.CapFile) {
return fmt.Sprintf("通道 [%s] 不支持文件输出(能力: %s)", channel, caps.String())
}
items, err := parseOutputItems(tc.Arguments)
if err != nil {
// 整 call 拒绝:一条都不发。发一半再报错会让模型以为部分成功,
// 而它无法知道该从第几条重试。
return fmt.Sprintf("output_send__%s 参数有误,未发送任何内容:%v", channel, err)
}
args := map[string]interface{}{
"payload": payload,
"type": rawType,
// 单条且成功 ⇒ 仍返回 "ok"(与改动前逐字一致)。
// 多条或多条里有失败 ⇒ 返回逐项结果,让模型知道哪条没发出去。
type sendResult struct {
ok bool
msg string
}
if meta != "" {
args["meta"] = meta
results := make([]sendResult, len(items))
for i := range items {
text, ok := a.sendOneOutput(channel, items[i])
results[i] = sendResult{ok: ok, msg: text}
}
if len(items) == 1 {
if results[0].ok {
return "ok"
}
return results[0].msg
}
// 多条:如实逐项汇报。失败项必须列出 —— 模型据此决定重发哪条,
// 静默吞掉会让它以为全发出去了。
okCount := 0
var b strings.Builder
for _, r := range results {
if r.ok {
okCount++
}
}
if okCount == len(items) {
// 全部成功也给出条数:多条时不报"ok"会让模型不确定发了几条。
return fmt.Sprintf("已通过 [%s] 通道发送 %d 条", channel, len(items))
}
b.WriteString(fmt.Sprintf("已通过 [%s] 通道发送 %d/%d 条:\n", channel, okCount, len(items)))
for i, r := range results {
typ := items[i].typ
if r.ok {
b.WriteString(fmt.Sprintf(" [%d] %s ok\n", i+1, typ))
} else {
b.WriteString(fmt.Sprintf(" [%d] %s 失败:%s\n", i+1, typ, r.msg))
}
}
b.WriteString("失败的条目请单独重发。")
return b.String()
}
// sendOneOutput 发送一条输出项,返回 (结果文本, 是否成功)。
//
// 这是从 executeOutputSendTool 里拆出来的**单条**路径 —— 批量只是把它循环 N 次。
// 拆出来的理由:before_output / 安全回填 / after_output 这套顺序对**每一条**
// 都必须完整走一遍,混在批量循环里极易漏掉某一环(例如只给第一条跑 stage)。
func (a *Agent) sendOneOutput(channel string, item outputItem) (string, bool) {
args := map[string]interface{}{
"payload": item.payload,
"type": item.typ,
}
if item.meta != "" {
args["meta"] = item.meta
}
// ★ Extra 必须有 output_channel:本函数是"发往某通道",而这个键是
// 插件在该时点唯一能据以判断目标通道的凭据。
// 原先这里连 Extra 都没初始化(nil)——插件读不到任何上下文。
// Extra 必须带三个键,缺一不可:
//
// output_channel 目标通道(插件据此判断"是不是发给我的")
// output_meta 本次发送的 meta 原文(QQ 的 user_id/group_id 在里面)
// output_type 输出类型(text/image/file…)
//
// ★ output_meta 是关键(2026-10-18):本函数是**真正把消息发出去**的
// 时点,通道插件在这里需要知道"这条发给谁"才能驱动「对方正在输入中」。
// 原先 stageCtx 连 Extra 都没初始化(nil)⇒ 实测 `extra_keys=0`
// ⇒ QQ 的打字指示器一次都没能触发。
stageCtx := &sdk.StageContext{
FinalText: payload,
FinalText: item.payload,
Phase: sdk.StageBeforeOutput,
Extra: map[string]interface{}{
"output_channel": channel,
"output_meta": meta,
"output_type": rawType,
"output_meta": item.meta,
"output_type": item.typ,
},
}
a.runStage(sdk.StageBeforeOutput, stageCtx)
if stageCtx.Response != nil {
return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response)
return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response), false
}
if stageCtx.FinalText == "" {
return "输出被插件清空"
return "输出被插件清空", false
}
args["payload"] = stageCtx.FinalText
@ -116,28 +258,19 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
// · 放在 stage 之前 ⇒ 插件会看到明文(不可,插件在信任边界外)
// · 放在 Execute 之后 ⇒ 设备已收到带别名的载荷(功能坏了)
// 所以只能夹在中间:插件看不到,设备拿到真的。
//
// 注意单独处理 payload:args 里的其它字段(channel 等)不需要回填。
if s := security.Global(); s != nil {
if filled, n, used := s.Fill(args["payload"].(string)); n > 0 {
if sc := security.Global(); sc != nil {
if filled, n, used := sc.Fill(args["payload"].(string)); n > 0 {
args["payload"] = filled
log.Printf("[security] 通道 %s 载荷回填 %d 处(别名 %v)", channel, n, used)
}
}
result, err := dev.Execute("output", args)
// ★ after_output:消息**已真正送达**之后触发一次。
//
// 与前面的 before_output 成对(同一份 Extra)。通道插件用它收尾
// 打字指示器之类"发出前开始、发出后结束"的状态。
//
// 为什么必须在这里:output.go 这条路径此前**没有** after_output,
// 而它才是"消息真的发出去了"的时刻。缺了它,插件只能在轮次边界
// (emitResponse 的 after_output)收尾 —— 而一轮可能发多条消息,
// 中间那些永远收不了尾(实测:状态一直挂着)。
// 与 before_output 成对(同一份 Extra)。
stageCtx.Phase = sdk.StageAfterOutput
a.runStage(sdk.StageAfterOutput, stageCtx)
if err != nil {
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err)
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err), false
}
// 通道可能回报「未确认」(已提交但超时未拿到发送确认)——此时不能对模型
// 谎报「已发送」,否则模型不会重试/核实。
@ -147,17 +280,14 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
if note == "" {
note = "发送已提交但未收到通道确认,结果未知"
}
return fmt.Sprintf("[%s] 通道发送结果未确认:%s", channel, note)
return fmt.Sprintf("[%s] 通道发送结果未确认:%s", channel, note), false
}
}
// 成功回执:只返回极简标记,不回传完整插件响应。
// 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执
// 会驱动模型继续调用 output_send(回声效应),是 output loop 的根源之一。
return "ok"
return "ok", true
}
a.io.EmitTextTo("agent_io", channel, payload)
return fmt.Sprintf("已通过 [%s] 通道发送", channel)
a.io.EmitTextTo("agent_io", channel, item.payload)
return fmt.Sprintf("已通过 [%s] 通道发送", channel), true
}
func (a *Agent) executeOutputSendHelp(tc agentAPI.ToolCall) string {