From 8bd8271f49182a536c4da8d417a86281a457bc90 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 19:11:51 +0800 Subject: [PATCH] =?UTF-8?q?Revert=20"fix(agent):=20output=5Fsend=20?= =?UTF-8?q?=E7=BC=BA=E6=94=B6=E4=BB=B6=E4=BA=BA=E6=97=B6=E4=BB=8E=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E4=BA=8B=E4=BB=B6=E8=87=AA=E5=8A=A8=E8=A1=A5"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6b74862. ## 为什么撤 方案本身是错的,三点: 1. **假设 meta 的收件人字段跨通道通用。** 只实现了 qq(user_id/group_id), 而 wechat、群聊各有各的字段名。逐通道补全会变成一张靠猜的映射表, 每加一个通道就得重猜一遍。 2. **假设「回复来源 = 收件人」。** 线上实测直接推翻(2026-09-26 19:05): 一次请求从 webui 会话发起、却要发到 QQ(input source=webui, output channel=qq)。收件人与来源根本不是一回事 —— 用户在 WebUI 里说"发个 QQ 给我",收件人只能来自上下文或用户明说。 按 channel 名补,等于用错误的假设去覆盖真实场景。 3. **没解决问题,反而引入新风险。** 19:05:46 那次照样报同样的错 ("meta 中需要 group_id 或 user_id"),补全逻辑对跨通道场景无效。 而一旦补错,消息会发给错的人 —— 那比报错坏得多。 ## 保留的部分 现象描述与排查结论留在这个 revert 的说明里,便于后续重新设计时 不再重复踩:各通道 meta 结构差异极大,要让插件自己声明收件人来源 (qq 插件知道自己的 user_id 从哪来),内核只转发不猜。 不采用"把格式写进工具描述"这类方案:那只缓解症状, 且各通道格式仍需逐个核实。 --- internal/agent/core/output.go | 92 ----------------- internal/agent/core/output_meta_test.go | 131 ------------------------ 2 files changed, 223 deletions(-) delete mode 100644 internal/agent/core/output_meta_test.go diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go index 6ae8aba..1a363e3 100644 --- a/internal/agent/core/output.go +++ b/internal/agent/core/output.go @@ -1,7 +1,6 @@ package core import ( - "encoding/json" "fmt" "strings" @@ -171,94 +170,3 @@ func (a *Agent) executeOutputListChannels() string { } 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。 -// - 输入事件里没��件人信息 ⇒ 留空,让下游按原逻辑报"需要 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 -} diff --git a/internal/agent/core/output_meta_test.go b/internal/agent/core/output_meta_test.go deleted file mode 100644 index 8391122..0000000 --- a/internal/agent/core/output_meta_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package core - -import ( - "encoding/json" - "strings" - "testing" - - agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" -) - -// ===== output_send 自动补收件人 ===== -// -// 线上现象(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 ← 28 秒后靠重试成功 -// -// 即:信息内核**本来就有**(输入事件里带着 user_id),却要模型从 -// qq_get_message 的返回里手抄进 meta。抄错就失败,失败才去查 _help, -// 一次本该 5 秒的回复花了 74 秒;运气差就不重试(17:15 / 17:21 那两次 -// tools=[] 压根没调发送,回复静默丢失)。 -// -// 修法:meta 缺收件人时,内核从**本轮输入事件**自动补。显式传 meta 时 -// 不干预(主动 DM 别人等场景行为不变)。 - -// ---- 判据:meta 补全逻辑 ---- - -// 私聊:补 user_id。 -func TestOutputSendAutoFillsUserIDForPrivateChat(t *testing.T) { - evt := &agentIO.InputEvent{ - Source: "qq/qq", - Payload: map[string]interface{}{"user_id": "2198972886", "message_type": "private"}, - } - meta := autoFillOutputMeta("", "qq", evt) - if !strings.Contains(meta, "2198972886") { - t.Errorf("私聊应自动补 user_id,实际 meta=%q", meta) - } - // 必须是真的 JSON,不能是拼字符串 - var m map[string]interface{} - if err := json.Unmarshal([]byte(meta), &m); err != nil { - t.Fatalf("补出的 meta 不是合法 JSON: %v (meta=%q)", err, meta) - } - if m["user_id"] != "2198972886" { - t.Errorf("user_id 不对: %v", m["user_id"]) - } -} - -// 群聊:补 group_id(且不该同时补 user_id —— 群消息有 user_id 是发送者, -// 不是收件人)。 -func TestOutputSendAutoFillsGroupIDForGroupChat(t *testing.T) { - evt := &agentIO.InputEvent{ - Source: "qq/qq", - Payload: map[string]interface{}{"user_id": "111", "group_id": "999", "message_type": "group"}, - } - meta := autoFillOutputMeta("", "qq", evt) - var m map[string]interface{} - if err := json.Unmarshal([]byte(meta), &m); err != nil { - t.Fatalf("不是合法 JSON: %v (meta=%q)", err, meta) - } - if m["group_id"] != "999" { - t.Errorf("群聊应补 group_id,实际 %v (meta=%q)", m["group_id"], meta) - } - if _, ok := m["user_id"]; ok { - t.Errorf("群聊不该补 user_id(那是发送者不是收件人): %q", meta) - } -} - -// 显式传了 meta 就不动它(主动 DM 等场景必须保持原行为)。 -func TestOutputSendKeepsExplicitMeta(t *testing.T) { - evt := &agentIO.InputEvent{ - Source: "qq/qq", - Payload: map[string]interface{}{"user_id": "2198972886"}, - } - explicit := `{"user_id":"someone_else"}` - if got := autoFillOutputMeta(explicit, "qq", evt); got != explicit { - t.Errorf("显式 meta 不该被改写: got=%q want=%q", got, explicit) - } -} - -// meta 里已有 group_id(群聊)时也不该覆盖。 -func TestOutputSendDoesNotOverwriteExistingRecipient(t *testing.T) { - evt := &agentIO.InputEvent{ - Source: "qq/qq", - Payload: map[string]interface{}{"user_id": "111", "group_id": "999"}, - } - explicit := `{"group_id":"777"}` - if got := autoFillOutputMeta(explicit, "qq", evt); got != explicit { - t.Errorf("已有收件人时不该覆盖: got=%q want=%q", got, explicit) - } -} - -// meta 是坏 JSON(模型写了错的)时不该崩,也不该当成"没传"而静默覆盖。 -func TestOutputSendBadJSONMetaIsNotSilentlyReplaced(t *testing.T) { - evt := &agentIO.InputEvent{ - Source: "qq/qq", - Payload: map[string]interface{}{"user_id": "2198972886"}, - } - broken := `{"user_id": ` // 截断的 JSON - got := autoFillOutputMeta(broken, "qq", evt) - if got != broken { - t.Errorf("坏 JSON 应原样保留(让下游报格式错,而不是被静默替换成别的东西): got=%q", got) - } -} - -// 非 qq 通道(如 webui)不该被补 —— webui 回复走 ResponseCh, -// 不经过 output_send 的 meta。 -func TestOutputSendNoAutoFillForWebUI(t *testing.T) { - evt := &agentIO.InputEvent{ - Source: "webui", - Payload: map[string]interface{}{"user_id": "123"}, - } - if got := autoFillOutputMeta("", "webui", evt); got != "" { - t.Errorf("webui 通道不该补 meta: %q", got) - } -} - -// 输入事件里没有收件人信息时,保持空(让下游按原逻辑报"需要 user_id", -// 而不是编一个错的收件人)。 -func TestOutputSendNoRecipientInfoStaysEmpty(t *testing.T) { - evt := &agentIO.InputEvent{Source: "qq/qq", Payload: map[string]interface{}{}} - if got := autoFillOutputMeta("", "qq", evt); got != "" { - t.Errorf("无收件人信息时应留空让下游报错,不能编: %q", got) - } - // evt 为 nil 也不能崩 - if got := autoFillOutputMeta("", "qq", nil); got != "" { - t.Errorf("nil evt 应留空: %q", got) - } -}