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

@ -0,0 +1,294 @@
package core
// outputch 一 call 多发的判据。
//
// ## 用户拍板的五条(2026-10-22)
//
// 1. 独立 tool(如 qq_send_file)收回 outputch —— 能力统一走输出门
// 2. meta 允许单值广播
// 3. 能力校验放在插件,插件向内核返回错误
// 4. 支持向后兼容(单值 payload 老写法照旧)
// 5. outputch 说明移进 skill 一份,同时保留 _help 机制
//
// ## 内核的职责边界(这是本组判据的核心)
//
// 内核只做三件事:**透传**(payload/type/meta 原样给插件)、**对齐校验**、
// **逐条调用 + 如实汇报**。它不解释 type 的语义 —— type 是插件自定义字段。
import (
"fmt"
"strings"
"sync"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// recordingChannel 记录每次发送收到的参数,并可按条注入失败。
type recordingChannel struct {
mu sync.Mutex
calls []map[string]interface{}
// failAt 是"第 N 条(0 基)失败";-1 表示全成功
failAt int
}
func newRecordingChannel() *recordingChannel { return &recordingChannel{failAt: -1} }
func (c *recordingChannel) run(args map[string]interface{}) (interface{}, error) {
c.mu.Lock()
defer c.mu.Unlock()
idx := len(c.calls)
c.calls = append(c.calls, args)
if idx == c.failAt {
return nil, fmt.Errorf("模拟失败:第 %d 条", idx+1)
}
return map[string]interface{}{"status": "sent"}, nil
}
func (c *recordingChannel) payloads() []string {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]string, len(c.calls))
for i, a := range c.calls {
out[i], _ = a["payload"].(string)
}
return out
}
// batchDevice 是一个可记录、可按条注入失败的输出设备。
type batchDevice struct {
name string
caps agentIO.OutputCapability
ch *recordingChannel
}
func (d *batchDevice) Name() string { return d.name }
func (d *batchDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *batchDevice) Description() string { return "batch test device" }
func (d *batchDevice) Tools() []agentIO.ToolDef { return nil }
func (d *batchDevice) Execute(_ string, args map[string]interface{}) (interface{}, error) {
return d.ch.run(args)
}
func (d *batchDevice) Start() error { return nil }
func (d *batchDevice) Stop() error { return nil }
func (d *batchDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
func (d *batchDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
// newOutputTestAgent 造一个带可控通道的 Agent。
func newOutputTestAgent(t *testing.T, ch *recordingChannel, caps agentIO.OutputCapability) *Agent {
t.Helper()
a := newToolTestAgent(t)
a.io = agentIO.NewIOManager()
if err := a.io.RegisterDevice(&batchDevice{
name: "testch", caps: caps, ch: ch,
}); err != nil {
t.Fatalf("注册测试设备失败: %v", err)
}
return a
}
func sendArgs(args map[string]interface{}) agentAPI.ToolCall {
return agentAPI.ToolCall{Name: "output_send__testch", Arguments: args}
}
// ① 向后兼容:payload 为字符串时,行为与改动前逐字相同。
//
// 变异:把单值路径也走批量(返回"已发送 1 条"而不是 "ok")⇒ 红。
func TestOutputSend_SingleValueUnchanged(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": "你好",
"meta": `{"user_id":1}`,
}))
if out != "ok" {
t.Errorf("单条成功应返回 \"ok\"(与改动前一致),实际: %q", out)
}
if got := ch.payloads(); len(got) != 1 || got[0] != "你好" {
t.Errorf("应发送 1 条「你好」,实际: %v", got)
}
// type 缺省应为 text
if ty, _ := ch.calls[0]["type"].(string); ty != "text" {
t.Errorf("type 缺省应为 text,实际 %q", ty)
}
}
// ② 批量:payload 数组 ⇒ 按顺序发 N 条。
//
// 变异:乱序发送 / 只发第一条 ⇒ 红。
func TestOutputSend_ArraySendsInOrder(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText|agentIO.CapImage|agentIO.CapFile)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"第一条", "https://x/a.png", "/tmp/r.pdf"},
"type": []interface{}{"text", "image", "file"},
}))
if !strings.Contains(out, "3") {
t.Errorf("返回值应报告发送了 3 条,实际: %q", out)
}
want := []string{"第一条", "https://x/a.png", "/tmp/r.pdf"}
got := ch.payloads()
if len(got) != 3 {
t.Fatalf("应发送 3 条,实际 %d: %v", len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("第 %d 条顺序错:got %q want %q", i+1, got[i], want[i])
}
}
// type 一一对应
for i, wantTy := range []string{"text", "image", "file"} {
if ty, _ := ch.calls[i]["type"].(string); ty != wantTy {
t.Errorf("第 %d 条 type: got %q want %q", i+1, ty, wantTy)
}
}
}
// ③ meta 单值广播(用户明确要求)。
//
// 变异:广播只给第一条 ⇒ 红。
func TestOutputSend_MetaBroadcast(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b", "c"},
"meta": `{"user_id":42}`,
}))
if len(ch.calls) != 3 {
t.Fatalf("应发送 3 条,实际 %d", len(ch.calls))
}
for i, call := range ch.calls {
if m, _ := call["meta"].(string); m != `{"user_id":42}` {
t.Errorf("第 %d 条的 meta 未广播到:got %q", i+1, m)
}
}
}
// ④ 长度不符 ⇒ 整 call 拒绝,一条都不发。
//
// 变异:发前 N 条再报错 ⇒ 红。
func TestOutputSend_LengthMismatchRejectsWholeCall(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText|agentIO.CapImage)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b"},
"type": []interface{}{"text"}, // 1 个 type 会被当成广播,这里用 3 个才错
}))
// 1 个 type + 2 个 payload ⇒ 广播,合法。用 3 个 meta 制造真错。
if len(ch.calls) != 2 {
t.Errorf("单 type 应广播给 2 条,实际发送 %d 条(%q)", len(ch.calls), out)
}
ch2 := newRecordingChannel()
a2 := newOutputTestAgent(t, ch2, agentIO.CapText)
out2 := a2.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b"},
"type": []interface{}{"text", "text", "text"}, // 3 != 2
}))
if len(ch2.calls) != 0 {
t.Errorf("长度不符必须一条都不发,实际发了 %d 条", len(ch2.calls))
}
if !strings.Contains(out2, "数量必须一致") {
t.Errorf("返回值应说明数量不一致,实际: %q", out2)
}
}
// ⑤ 部分失败:继续发剩余的,并如实列出失败项。
//
// 变异:第 2 条失败即中止 ⇒ 第 3 条不会被尝试 ⇒ 红。
// 变异:静默吞掉失败 ⇒ 返回值不含"失败" ⇒ 红。
func TestOutputSend_PartialFailureReportsAndContinues(t *testing.T) {
ch := newRecordingChannel()
ch.failAt = 1 // 第 2 条失败
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"第一条", "第二条", "第三条"},
}))
if len(ch.calls) != 3 {
t.Errorf("第 2 条失败后仍应尝试第 3 条,实际只调用 %d 次", len(ch.calls))
}
if !strings.Contains(out, "2/3") {
t.Errorf("返回值应报告 2/3 条成功,实际: %q", out)
}
if !strings.Contains(out, "失败") {
t.Errorf("返回值必须如实列出失败项,实际: %q", out)
}
if !strings.Contains(out, "模拟失败:第 2 条") {
t.Errorf("返回值应带插件的错误原文,实际: %q", out)
}
if !strings.Contains(out, "重发") {
t.Errorf("返回值应告知可单独重发失败项,实际: %q", out)
}
}
// ⑥ 内核不再解释 type 语义:任意插件自定义 type 都原样透传。
//
// ★ 这是"能力校验下放插件"的核心判据。旧实现里内核 switch 了
// text/voice/image/file 四个值,其它值直接跳过校验、且四个已知值会被
// 内核用 caps 拦掉 —— 那等于内核在理解插件语义。
//
// 变异:恢复 switch rawType 的能力校验 ⇒ 自定义 type "sticker" 若插件
// 不支持会被内核拦截(本判据用 caps 只声明 CapText 来触发)。
func TestOutputSend_KernelDoesNotInterpretType(t *testing.T) {
ch := newRecordingChannel()
// 只声明 CapText:若内核还按 type 校验,"sticker" 会解析成未知类型
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": "这是插件自定义类型的载荷",
"type": "sticker",
}))
if len(ch.calls) != 1 {
t.Fatalf("自定义 type 应被原样透传给插件,实际调用 %d 次(返回 %q)", len(ch.calls), out)
}
if ty, _ := ch.calls[0]["type"].(string); ty != "sticker" {
t.Errorf("type 被内核改写:got %q want \"sticker\"", ty)
}
if strings.Contains(out, "不支持") {
t.Errorf("内核不该对 type 做能力判断,实际返回: %q", out)
}
}
// ⑦ 空 payload 与非字符串元素必须被拒(不能静默发空消息)。
func TestOutputSend_RejectsEmptyEntries(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"正常", " "},
}))
if len(ch.calls) != 0 {
t.Errorf("含空条目的批量必须整 call 拒绝,实际发了 %d 条", len(ch.calls))
}
if !strings.Contains(out, "为空") {
t.Errorf("返回值应指出空条目,实际: %q", out)
}
}
// ⑧ 未授权通道不得发送(授权闸不被批量绕过)。
func TestOutputSend_UnauthorizedStillBlocked(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
a.allowedOutputs = []string{"other"}
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b"},
}))
if len(ch.calls) != 0 {
t.Errorf("未授权通道不得发送,实际发了 %d 条", len(ch.calls))
}
if !strings.Contains(out, "未授权") {
t.Errorf("返回值应说明未授权,实际: %q", out)
}
}