mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
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:
@ -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 {
|
||||
|
||||
294
internal/agent/core/outputbatch_test.go
Normal file
294
internal/agent/core/outputbatch_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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{}{}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user