mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-29 06:00:56 +00:00
## 现象(线上 2026-09-26 17:57)
用户从 QQ 私聊发来消息,agent 生成了回复也调了 output_send__qq,
但**没填 meta**:
17:57:45 qq_get_message → {user_id: 2198972886, message_type: private}
17:57:46 output_send__qq → 失败:meta 中需要 group_id 或 user_id 字段
17:58:14 output_send__qq_help → 查格式
17:58:14 output_send__qq → ok ← 靠重试成功,整轮耗时 74s
信息内核**本来就有**(输入事件里带着 user_id/group_id),却要模型从
qq_get_message 的返回里手抄进 meta。抄错就失败,失败才去查 _help。
而"回复"这件事的收件人是确定的(= 消息来源),本不该由模型负责。
运气差就不重试:同日 17:15 / 17:21 两次 `tools=[]` —— 模型压根没调
output_send,回复生成了但没发出去,日志连一行告警都没有。
## 改法
meta 缺收件人时,内核从**本轮输入事件**推导后补上。模型只需给内容。
## 边界(都刻意收窄:宁可不补,也不能补错)
- 显式传了 meta ⇒ 原样返回。主动 DM 别人等场景必须保持原行为。
- meta 里已有 group_id/user_id ⇒ 不覆盖。
- meta 是坏 JSON ⇒ 原样返回。让下游报"格式错",而不是被静默替换成
一个模型没要求过的收件人 —— 那比报错更坏:消息会发给错的人。
- 非 qq 通道(如 webui)⇒ 不补。webui 走 ResponseCh,不过 output_send。
其余异步通道(wechat 等)不猜:猜错等于发错人。
- 输入事件里没有收件人信息 ⇒ 留空,让下游按原逻辑报"需要 user_id"。
宁可报错让模型重试,也不要编一个收件人。
- 群消息里 user_id 是**发送者**不是收件人 ⇒ group_id 非 "0" 时优先用
group_id,否则会把消息发回给群成员本人。
数字型 user_id 要按整数格式化:JSON 反序列化成 float64 时
fmt.Sprint 会得到 "2.198972886e+09"。
## 判据:7 条
私聊补 user_id / 群聊补 group_id 且不补 user_id / 显式 meta 不改写 /
已有收件人不覆盖 / 坏 JSON 不静默替换 / webui 不补 / 无信息留空(含 nil evt)。
★ 实现时我先自己写了个 payloadString,编译报错才发现包内已有更完整的
版本(memorypass.go:176,含 float64/int64/int/json.Number 分支),
直接复用 —— 不重复造轮子。
全量 42 包绿。
265 lines
9.0 KiB
Go
265 lines
9.0 KiB
Go
package core
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
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 !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 {
|
||
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())
|
||
}
|
||
}
|
||
|
||
args := map[string]interface{}{
|
||
"payload": payload,
|
||
"type": rawType,
|
||
}
|
||
if meta != "" {
|
||
args["meta"] = meta
|
||
}
|
||
|
||
stageCtx := &sdk.StageContext{
|
||
FinalText: payload,
|
||
Phase: sdk.StageBeforeOutput,
|
||
}
|
||
a.runStage(sdk.StageBeforeOutput, stageCtx)
|
||
if stageCtx.Response != nil {
|
||
return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response)
|
||
}
|
||
if stageCtx.FinalText == "" {
|
||
return "输出被插件清空"
|
||
}
|
||
args["payload"] = stageCtx.FinalText
|
||
|
||
if dev := a.io.GetDevice(channel); dev != nil {
|
||
result, err := dev.Execute("output", args)
|
||
if err != nil {
|
||
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err)
|
||
}
|
||
// 通道可能回报「未确认」(已提交但超时未拿到发送确认)——此时不能对模型
|
||
// 谎报「已发送」,否则模型不会重试/核实(plan.md 11.1)。
|
||
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)
|
||
}
|
||
}
|
||
// 成功回执:只返回极简标记,不回传完整插件响应。
|
||
// 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执
|
||
// 会驱动模型继续调用 output_send(回声效应),是 output loop 的根源之一。
|
||
return "ok"
|
||
}
|
||
|
||
a.io.EmitTextTo("agent_io", channel, payload)
|
||
return fmt.Sprintf("已通过 [%s] 通道发送", channel)
|
||
}
|
||
|
||
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")
|
||
}
|
||
|
||
// autoFillOutputMeta 在 meta 缺收件人时,从**本轮输入事件**自动补。
|
||
//
|
||
// 为何需要(线上实测 2026-09-26 17:57):用户从 QQ 私聊发来消息,agent
|
||
// 生成了回复也调了 output_send__qq,但没填 meta:
|
||
//
|
||
// 17:57:45 qq_get_message → {user_id: 2198972886, message_type: private}
|
||
// 17:57:46 output_send__qq → 失败:meta 中需要 group_id 或 user_id
|
||
// 17:58:14 output_send__qq_help → 查格式
|
||
// 17:58:14 output_send__qq → ok ← 靠重试成功,耗了 74s
|
||
//
|
||
// 信息内核本来就有(输入事件里带着 user_id/group_id),却要模型从
|
||
// qq_get_message 的返回里手抄一遍。抄错就失败,失败才去查 _help。
|
||
// 而"回复"这件事的收件人是确定的(= 消息来源),本不该由模型负责。
|
||
//
|
||
// 边界(都刻意收窄,宁可不补也不能补错):
|
||
// - 显式传了 meta ⇒ 原样返回。主动 DM 别人等场景必须保持原行为。
|
||
// - meta 里有 group_id/user_id ⇒ 不覆盖。
|
||
// - meta 是坏 JSON ⇒ 原样返回。让下游报"格式错",而不是被静默替换
|
||
// 成一个模型没要求过的收件人(那比报错更坏:消息会发给错的人)。
|
||
// - 非 qq 通道(如 webui)⇒ 不补。webui 走 ResponseCh,不过 output_send。
|
||
// - 输入事件里没<E9878C><E6B2A1>件人信息 ⇒ 留空,让下游按原逻辑报"需要 user_id"。
|
||
// 宁可报错让模型重试,也不要编一个收件人。
|
||
func autoFillOutputMeta(meta, channel string, evt *agentIO.InputEvent) string {
|
||
if meta != "" {
|
||
// 已有内容:合法且已含收件人就不动;坏 JSON 也原样返回(见函数注释)
|
||
var m map[string]interface{}
|
||
if err := json.Unmarshal([]byte(meta), &m); err != nil {
|
||
return meta
|
||
}
|
||
if _, ok := m["group_id"]; ok {
|
||
return meta
|
||
}
|
||
if _, ok := m["user_id"]; ok {
|
||
return meta
|
||
}
|
||
// meta 存在但没收件人:补进去
|
||
if id := recipientFromEvent(channel, evt); id != nil {
|
||
m = mergeMeta(m, id)
|
||
if out, err := json.Marshal(m); err == nil {
|
||
return string(out)
|
||
}
|
||
}
|
||
return meta
|
||
}
|
||
// meta 完全没传:能确定收件人才补
|
||
if id := recipientFromEvent(channel, evt); id != nil {
|
||
if out, err := json.Marshal(map[string]interface{}{
|
||
id.key: id.val,
|
||
}); err == nil {
|
||
return string(out)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// metaField 是从输入事件里提取出的收件人字段。
|
||
type metaField struct {
|
||
key string // "user_id" 或 "group_id"
|
||
val string
|
||
}
|
||
|
||
// recipientFromEvent 从输入事件推导收件人。
|
||
//
|
||
// 只处理 qq 通道:它是唯一一个"meta 必填收件人"的异步通道,且收件人
|
||
// 与消息来源一一对应。其它通道(wechat 等)不猜 —— 猜错等于发错人。
|
||
func recipientFromEvent(channel string, evt *agentIO.InputEvent) *metaField {
|
||
if evt == nil || channel != "qq" {
|
||
return nil
|
||
}
|
||
if evt.Payload == nil {
|
||
return nil
|
||
}
|
||
// 群消息优先:群里的 user_id 是**发送者**,用它当收件人会发错人。
|
||
// group_id 存在且非 "0" 时才是群聊(QQ 私聊时 group_id 会出现为 0)。
|
||
if gid := payloadString(evt.Payload["group_id"]); gid != "" && gid != "0" {
|
||
return &metaField{key: "group_id", val: gid}
|
||
}
|
||
if uid := payloadString(evt.Payload["user_id"]); uid != "" {
|
||
return &metaField{key: "user_id", val: uid}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func mergeMeta(m map[string]interface{}, f *metaField) map[string]interface{} {
|
||
if m == nil {
|
||
m = map[string]interface{}{}
|
||
}
|
||
m[f.key] = f.val
|
||
return m
|
||
}
|