mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-04 16:27:28 +00:00
用户要求:内核 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 能力自校验(校验已下放,需要它们真的做)
365 lines
12 KiB
Go
365 lines
12 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
|
||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||
)
|
||
|
||
// executeOutputSendTool 执行一次 output_send__{通道}。
|
||
//
|
||
// turnExtra 是**本轮输入事件的非保留 payload 字段**(由调用方从帧上取)。
|
||
//
|
||
// ## 为什么需要它(2026-10-18)
|
||
//
|
||
// `before_output` 被两个语义共用:
|
||
//
|
||
// emitResponse(eventloop.go) = 一轮的回复,turn 边界
|
||
// 本函数(output.go) = 单次 output_send__* 的工具输出
|
||
//
|
||
// **真正把消息发出去的是后者**。而它原先构造 stageCtx 时连 Extra 都没初始化
|
||
// (实测日志 `extra_keys=0`),插件在这里读不到任何本轮身份 ——
|
||
// QQ 的「对方正在输入中」因此一次都没能触发。
|
||
//
|
||
// 通道插件在这个时点需要知道"这条消息是发给谁的",所以把本轮 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__")
|
||
if channel == "" {
|
||
return "工具名称格式: output_send__{channel}"
|
||
}
|
||
// 授权闸(纵深防御):模型可能凭名字直接调未授权的输出门。
|
||
if !a.IsOutputAllowed(channel) {
|
||
return fmt.Sprintf("通道 [%s] 未授权给本 agent。可用通道见 output_list_channels", channel)
|
||
}
|
||
// 通道存在性是**内核唯一该管的校验**。type 与能力的匹配下放给插件:
|
||
// type 是插件自定义枚举,内核不认识它(写死 text/image/file 就是内核在
|
||
// 理解插件语义,且插件的自定义类型会被无端拒绝)。
|
||
if a.io.GetChannelCapabilities(channel) == 0 {
|
||
return fmt.Sprintf("通道 [%s] 不存在或不可用。可用输出工具列表见 output_list_channels", channel)
|
||
}
|
||
|
||
items, err := parseOutputItems(tc.Arguments)
|
||
if err != nil {
|
||
// 整 call 拒绝:一条都不发。发一半再报错会让模型以为部分成功,
|
||
// 而它无法知道该从第几条重试。
|
||
return fmt.Sprintf("output_send__%s 参数有误,未发送任何内容:%v", channel, err)
|
||
}
|
||
|
||
// 单条且成功 ⇒ 仍返回 "ok"(与改动前逐字一致)。
|
||
// 多条或多条里有失败 ⇒ 返回逐项结果,让模型知道哪条没发出去。
|
||
type sendResult struct {
|
||
ok bool
|
||
msg string
|
||
}
|
||
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 必须带三个键,缺一不可:
|
||
//
|
||
// output_channel 目标通道(插件据此判断"是不是发给我的")
|
||
// output_meta 本次发送的 meta 原文(QQ 的 user_id/group_id 在里面)
|
||
// output_type 输出类型(text/image/file…)
|
||
stageCtx := &sdk.StageContext{
|
||
FinalText: item.payload,
|
||
Phase: sdk.StageBeforeOutput,
|
||
Extra: map[string]interface{}{
|
||
"output_channel": channel,
|
||
"output_meta": item.meta,
|
||
"output_type": item.typ,
|
||
},
|
||
}
|
||
a.runStage(sdk.StageBeforeOutput, stageCtx)
|
||
if stageCtx.Response != nil {
|
||
return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response), false
|
||
}
|
||
if stageCtx.FinalText == "" {
|
||
return "输出被插件清空", false
|
||
}
|
||
args["payload"] = stageCtx.FinalText
|
||
|
||
if dev := a.io.GetDevice(channel); dev != nil {
|
||
// ★ 安全模块入向回填:把 @{别名} 换回明文。
|
||
//
|
||
// 位置在**插件 stage 之后、设备 Execute 之前**是有讲究的:
|
||
// · 放在 stage 之前 ⇒ 插件会看到明文(不可,插件在信任边界外)
|
||
// · 放在 Execute 之后 ⇒ 设备已收到带别名的载荷(功能坏了)
|
||
// 所以只能夹在中间:插件看不到,设备拿到真的。
|
||
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)。
|
||
stageCtx.Phase = sdk.StageAfterOutput
|
||
a.runStage(sdk.StageAfterOutput, stageCtx)
|
||
if err != nil {
|
||
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err), false
|
||
}
|
||
// 通道可能回报「未确认」(已提交但超时未拿到发送确认)——此时不能对模型
|
||
// 谎报「已发送」,否则模型不会重试/核实。
|
||
if m, ok := result.(map[string]interface{}); ok {
|
||
if status, _ := m["status"].(string); status == "unconfirmed" || status == "queued" {
|
||
note, _ := m["note"].(string)
|
||
if note == "" {
|
||
note = "发送已提交但未收到通道确认,结果未知"
|
||
}
|
||
return fmt.Sprintf("[%s] 通道发送结果未确认:%s", channel, note), false
|
||
}
|
||
}
|
||
return "ok", true
|
||
}
|
||
|
||
a.io.EmitTextTo("agent_io", channel, item.payload)
|
||
return fmt.Sprintf("已通过 [%s] 通道发送", channel), true
|
||
}
|
||
|
||
func (a *Agent) executeOutputSendHelp(tc agentAPI.ToolCall) string {
|
||
suffix := strings.TrimPrefix(tc.Name, "output_send__")
|
||
channel := strings.TrimSuffix(suffix, "_help")
|
||
if channel == "" {
|
||
return "工具名称格式: output_send__{channel}_help"
|
||
}
|
||
|
||
dev := a.io.GetDevice(channel)
|
||
if dev == nil {
|
||
return fmt.Sprintf("通道 [%s] 不存在", channel)
|
||
}
|
||
|
||
caps := a.io.GetChannelCapabilities(channel)
|
||
capStr := "无"
|
||
if caps != 0 {
|
||
capStr = caps.String()
|
||
}
|
||
|
||
desc := dev.Description()
|
||
if desc == "" {
|
||
desc = channel + " 输出通道"
|
||
}
|
||
|
||
return fmt.Sprintf(`通道 [%s]
|
||
描述: %s
|
||
能力: %s
|
||
|
||
【参数说明】
|
||
payload — 消息载荷(必填)。type=text 时直接填文字,type=file/image 时填 URL 或路径
|
||
meta — JSON 对象,发送所需的元数据(可选,取决于通道是否需要路由信息)
|
||
type — 载荷类型(必填),枚举值见下方
|
||
|
||
【type 枚举】
|
||
- text — 文本消息
|
||
- voice — 语音消息
|
||
- image — 图片
|
||
- file — 文件
|
||
|
||
【meta JSON 格式】
|
||
由通道描述定义,通常包含:
|
||
- "group_id" 群号(群聊时必填)
|
||
- "user_id" 目标用户 QQ 号(私聊时必填)
|
||
- "reply_to" 回复某条消息 ID(可选)
|
||
|
||
示例: output_send__%s(payload="你好", meta="{\"group_id\": 123456789}", type="text")`, channel, desc, capStr, channel)
|
||
}
|
||
|
||
func (a *Agent) executeOutputListChannels() string {
|
||
channels := a.io.ListChannels()
|
||
if len(channels) == 0 {
|
||
return "没有可用通道"
|
||
}
|
||
var parts []string
|
||
parts = append(parts, "可用通道:")
|
||
for _, ch := range channels {
|
||
if ch.OutputCaps == 0 {
|
||
continue
|
||
}
|
||
if !a.IsOutputAllowed(ch.Name) {
|
||
continue
|
||
}
|
||
line := fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description)
|
||
if t, ok := a.ResolveOutputTarget(ch.Name); ok {
|
||
line += fmt.Sprintf("(目标: %s / inputch %s)", orDash(t.AgentID), orDash(t.InputCh))
|
||
}
|
||
parts = append(parts, line)
|
||
for _, t := range ch.Tools {
|
||
parts = append(parts, fmt.Sprintf(" 工具: %s - %s", t.Name, t.Description))
|
||
}
|
||
}
|
||
return strings.Join(parts, "\n")
|
||
}
|