diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go index 1a363e3..6ae8aba 100644 --- a/internal/agent/core/output.go +++ b/internal/agent/core/output.go @@ -1,6 +1,7 @@ package core import ( + "encoding/json" "fmt" "strings" @@ -170,3 +171,94 @@ 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 new file mode 100644 index 0000000..8391122 --- /dev/null +++ b/internal/agent/core/output_meta_test.go @@ -0,0 +1,131 @@ +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) + } +}